Go to Top

How The Game Works

This serves as the documentation for the game and also a tutorial you can follow along to make the game yourself, or make an entirely different game with its mechanics

Skip to a Section:

01. The Canvas
02. Drawing
03. Keyboard Input
04. Game Loop & Objects
05. Spaceship Physics
06. Screen Wrapping
07. Shooting
08. Asteroids
09. Collision
10. Title/Game/End Screens

The Canvas

Everything rendered in the game is done using the HTML5 Canvas. Canvases can be drawn to and cleared with JavaScript. You can create a canvas using the canvas HTML tag as shown below:

<canvas id="can" width="0" height="0">
 Your browser does not support the HTML5 canvas
</canvas>

The text inside the tag is shown if the client's browser is outdated and does not support the canvas. The width and height attributes specify the dimensions of the canvas in pixels. Notice that there is a distinction between these values and the CSS width and height of the canvas. In this example, the dimensions are set to 0x0 because they will be changed later with JS.

To interact with the canvas in JS, we must reference it with document.getElementById(). To draw to it, we must get the 2D context of the canvas.

var cv = document.getElementById("can");
cv.width = GAME_WIDTH;
cv.height = GAME_HEIGHT;
var c = cv.getContext("2d", {alpha: false});

The context of the canvas is obtained with cv.getContext("2d", {alpha: false}). Passing {alpha: false} removes the transparent background of the canvas to improve performance. It is optional. I named the context variable c so it can be typed easily, because we will be using it to draw to the canvas often.

Drawing

Drawing to the canvas is done using the 2D context we obtained earlier. Image files can be drawn by passing an Image variable along with the x and y coordinates to draw to. Example:

var sImage = new Image();
sImage.src = "path/to/image.png";

c.drawImage(sImage, 0, 0);

This will draw an image to the top left corner of the canvas (position 0,0). If you run this code unaltered, however, you likely won't see anything appear, because you must wait for images to load before drawing them. This can be done by setting the onload value of the image to a function that checks if all images you plan on using have loaded. This is how images are loaded in the game:

var imagesLoaded = 0;
var imagesNeeded = 7;
var imagesToLoad = [sSpaceship, sBullet, sAsteroid, sAlert1, sAlert2, sBg1, sBg2];

function imageLoaded()
{
 if ((++imagesLoaded) >= imagesNeeded)
  gmTitle();
}

for (var i = 0; i < imagesToLoad.length; ++i)
{
 imagesToLoad[i].onload = imageLoaded;
}

This code waits for all images to load, then calls gmTitle() which starts the game. Feel free to optimize this code if you plan on reusing it. I haven't optimized the code in this case because it has little impact on performance. What does have impact on performance, however, is drawing the images themselves.

To draw a rotated image (which is done with the spaceship), you must translate and rotate the canvas context before calling drawImage. In the game, this is done with this function:

function oSpaceshipDraw(x, y)
{
 c.save();
 c.translate(x, y);
 c.rotate(this.dir);
 c.drawImage(sSpaceship, -this.w / 2, -this.h / 2);
 c.restore();
}

c.save() saves the current state of the canvas context. Then, the context is translated by the x and y coordinates of the ship, and rotated. The x and y coordinates used in drawImage are relative to the x and y coordinates we translated the context by. In this case, the negatives of the half of the width and height of the ship are passed to position the ship in the center of the x and y coordinates we are drawing to. Finally, c.restore() is called, which undoes all of the translations and rotations, so that next time we draw something, it isn't translated and rotated somewhere random.

To avoid rotated image bluriness, as done in the game, the canvas context variable imageSmoothingEnabled must be set to false.

c.imageSmoothingEnabled = false;

Keyboard Input

To detect keyboard input, two event listeners are added to the document that check for key presses and releases. These are added with the document function addEventListener which takes two parameters. These include a string specifying the type of event to listen for, in this case "keydown" or "keyup", and a function to call when the event is activated, which an event object is automatically passed to.

The keyCode variable of the event object contains an integer that represents the ID of the key being pressed or released. A switch statement is used to check which key code corresponds to which key variable. They key variables are set to 1 when activated in the keydown event and 0 in the keyup event.

To find the key code of a desired key, you can find a table online containing all key codes, or if you prefer to not visit any other sites, write a function yourself that prints the key code of the last pressed key.

// Keyboard Input
var kl = 0, kr = 0, ku = 0, kd = 0, k1 = 0, k2 = 0;

// Listen for keyboard input
document.addEventListener("keydown", function(e)
{
 switch (e.keyCode)
 {
  case 37:{kl=1;e.preventDefault();break;}
  case 39:{kr=1;e.preventDefault();break;}
  case 40:{kd=1;e.preventDefault();break;}
  case 38:{ku=1;e.preventDefault();break;}

  case 90:{k1=1;break;}
  case 13:{k2=1;break;}
 }
});
document.addEventListener("keyup", function(e)
{
 switch (e.keyCode)
 {
  case 37:{kl=0;break;}
  case 39:{kr=0;break;}
  case 40:{kd=0;break;}
  case 38:{ku=0;break;}

  case 90:{k1=0;break;}
 }
});

Key codes 37 through 40 represent the arrow keys. When they are activated in the keydown event, the function preventDefault of the event object is called to prevent the default action of these keys, which scroll around the page. Without calling this function, the player would unintentionally scroll around the webpage when trying to play, which makes games nearly unplayable.

With these two keydown and keyup events we can now detect when keys are held down, but not when they are initially pressed. To implement key press detection, the function stopKeyRepeat is used, which simply sets the keys we only want to detect initial presses on to 0.

function stopKeyRepeat()
{
 k2 = 0;
}

This function is called every game loop after objects are done updating. When the keys it affects are pressed and set to 1, the game objects will run their update function once before the key is set back to 0, only activated upon another initial press of the key. The game loop is a function that evaluates every game frame, and it will be explained in the next section.

Game Loop & Objects

In the game, the game loop is contained in the function gmLoop. This function executes a single frame of the game, and then calls the function requestAnimationFrame to call itself again in 1/60 of a second, resulting in game frames being drawn at 60 frames per second.

To render a frame, the gmLoop function clears the canvas, draws the background, executes the update function of all objects that may draw themselves, and finally executes more code that isn't tied to any object, including the spawning of asteroids and drawing of the current score.

function gmLoop()
{
 // Request another frame
 requestAnimationFrame(gmLoop);

 // Clear Screen
 c.clearRect(0,0,cv.width,cv.height);

 // Draw Background
 drawBg();

 // Update Instances
 for (var i = 0; i < MAX_INSTANCES; ++i)
  if (inst[i] != null)
   inst[i].ud();

 // Spawn Asteroids
 if (spawning && (--spawnTime) <= 0)
 {
   // Spawn Asteroid
   var x = Math.random() * GAME_WIDTH;
   var y = Math.random() * GAME_HEIGHT;
   new makeInst(x, y, 3);
  // Reset Spawn Time
   spawnTimeReset -= SPAWN_TIME_DEC;
   if (spawnTimeReset < SPAWN_TIME_MIN)
    spawnTimeReset = SPAWN_TIME_MIN;
   spawnTime = spawnTimeReset;
 }

 // Keyboard Input
 stopKeyRepeat();

 // Show Framerate & Score
 updateFramerate();

 c.fillStyle = TEXT_COLOR_GREEN;
 c.font = "16px Courier";
 c.textAlign = "left";
 c.fillText("FPS: " + fps, 6, 16);
 c.fillText("SCORE: " + score, 6, 32);
 c.fillText("HISCORE: " + highScore, 6, 48);
}

Object instances are kept in the inst array. To update them, the inst array is looped through, and the update function of each instance, ud, is executed. The update functions contain the code executed by each instance every frame. These usually include moving the instance, checking for collisions, and drawing the instance.

New object instances are created using the function makeInst as a constructor. You can see this in action in the code example above, where asteroids are spawned with this line:

new makeInst(x, y, 3);

The third argument of makeInst is the ID of the object type being created. 0 is used for the creation of the player's ship, 1 is used for bullets, 2 is used for asteroids, and so forth. 3 is passed to this makeInst in this example because it creates the alert objects that flash for a few seconds before spawning an asteroid.

Let's take a deeper look into the code of makeInst.

function makeInst(x, y, o)
{
 // Skip past instance indexes that are full or have persistent objects
 while (inst[instNext] != null && inst[instNext].ps != null)
 {
  if ((++instNext) >= MAX_INSTANCES)
  instNext = 0;
 }

 // Add instance to instance array
 inst[instNext] = this;
 this.id = instNext;
 if ((++instNext) >= MAX_INSTANCES)
  instNext = 0;

 // Set x & y position
 this.x = x;
 this.y = y;

 this.o = o;

 // Execute object constructor
 switch (o)
 {
  case 0:
  {
   this.xforces = new Array(PLAYER_FORCE_COUNT).fill(0);
   this.yforces = new Array(PLAYER_FORCE_COUNT).fill(0);
   this.force = 0;
   this.forceSpeed = PLAYER_SPEED_FORCE;
   this.turnSpeed = PLAYER_SPEED_TURN;
   this.bulletSpeed = PLAYER_SPEED_BULLET;
   this.shootCooldownReset = 10;
   this.shootCooldown = 0;
   this.dir = 0;
   this.w = sSpaceship.width;
   this.h = sSpaceship.height;
   
   this.worldWrap = oWorldWrap;
   this.shoot = oSpaceshipShoot;
   this.draw = oSpaceshipDraw;
   this.drawWrap = oDrawWrap;
   this.hit = oCollideWithLarger;
   
   this.ps = true;
   this.ud = oSpaceshipUD;
   break;
  }
  // More cases are in the source code but not shown here
 }
}

Object instances are added to the inst array as soon as the makeInst function is called. If there is no room left in the array for new instances, the instance being created will overwrite another instance as long as the instance's ps variable, which indicates persistence, is not set to true. The object-specific variables and update function are determined by the switch statement.

Sometimes different objects execute the same sets of code, such as collision checking and screen wrapping. These are stored in functions so that they don't have to be written over and over again for each object. For an object instance to call these functions properly, a reference to one of them must be stored in an instance variable, and then called from that reference. This is demonstrated in the following code:

// Get a reference to the function
this.shoot = oSpaceshipShoot;

// Call the function
this.shoot();

Calling the function without a reference causes the keyword this to not reference the object instance. It is valid to call functions without a reference that do not need to reference the instance calling the object, such as gmEnd, which simply ends the game without accessing any instance variables, though this is rarely done.

To summarize, gameLoop is called 60 times a second to draw a new game frame and update object instances by calling their ud function.

Spaceship Physics

The spaceship object keeps track of the direction it points in with its dir variable, and keeps track of its momentum in two arrays, xforces and yforces, both of equal length. When the up key is held, the object adds the amount of pixels it should move horizontally and vertically in xforces and yforces respectively, this is calculated with sine and cosine waves with the direction of the ship as input.

// Move ship
if (ku)
{
 this.xforces[this.force] = Math.cos(this.dir) * this.forceSpeed;
 this.yforces[this.force] = Math.sin(this.dir) * this.forceSpeed;

 ++this.force;
 if (this.force >= PLAYER_FORCE_COUNT)
  this.force = 0;
}

The more the direction is turned to 0 or pi radians, the more the ship will move horizontally, and the more the direction is turned to pi/2 or 3pi/2 radians, the more the ship will move vertically. The forces added are multipled by forceSpeed to control the speed that the ship picks up each frame.

Indexes of the xforces and yforces arrays are accessed with the variable force. Each time forces are added, force increments to move to the next index, and loops back to 0 when the end of the arrays, stored in PLAYER_FORCE_COUNT, are reached.

The forces are finally applied to the ship and move it in the loop below, which is executed regardless of whether or not the up key is held down.

// Apply movement
for (var i = 0; i < PLAYER_FORCE_COUNT; ++i)
{
 this.x += this.xforces[i];
 this.y += this.yforces[i];
}

In the end, this creates a system where forces applied in past frames still move the ship until the ship either moves for long enough that the forces are overwritten, or the ship moves in the opposite direction to balance the force. This is why the ship "drifts" ingame instead of staying still or immediately changing the direction of movement once any amount of force is added.

Screen Wrapping

To keep the player and asteroids within the bounds of the screen, the x and y values of the objects are reset once they reach the edge of the screen.

function oWorldWrap()
{
 if (this.x > GAME_WIDTH)
  this.x = GAME_WIDTH - this.x;
 else if (this.x < 0)
  this.x = GAME_WIDTH + this.x;

 if (this.y > GAME_HEIGHT)
  this.y = GAME_HEIGHT - this.y;
 else if (this.y < 0)
  this.y = GAME_HEIGHT + this.y;
}

When the x and y values are reset, they are positioned relative to how far the object has move past the bounds of the screen. For example, if the screen with is 600 pixels and the object's x position is at 650, the x value will be reset to 0 plus 50 because it traveled 50 pixels beyond the screen. This results in no loss of movement when wrapping around the screen.

The code above properly handles the coordinates of objects, but we still need to draw objects while they are wrapping from one side of the screen to the next to make wrapping entirely seamless. This is accomplished by drawing the objects in all 8 directions around them with the following code:

function oDrawWrap()
{
 var xstop = this.x + GAME_WIDTH;
 var ystop = this.y + GAME_HEIGHT;

 for (var x = this.x - GAME_WIDTH - 1; x <= xstop; x += GAME_WIDTH)
 {
  for (var y = this.y - GAME_HEIGHT - 1; y <= ystop; y += GAME_HEIGHT)
  {
   this.draw(x, y);
  }
 }
}

Each object is spaced horizontally by the screen width, and spaced vertically by the screen height. This effect causes objects to appear on top of the screen when wrapping from the bottom, on the left of the screen when wrapping from the right, and so forth.

This draws a total of 9 images, so it should be used sparingly, as drawing is a computationally expensive operation. It also has room for optimization, as it can be edited to only draw objects based on the direction they are moving in.

Shooting

This section covers the mechanics of bullet firing by the spaceship object that the player controls. Bullets, like the spaceship itself, are objects created with makeInst that have speeds initialized upon their creation which go unchanged until the object is destroyed. The following function is used by spaceship instances to create them.

function oSpaceshipShoot()
{
 var bullet = new makeInst(this.x, this.y, 1);
 bullet.hsp = Math.cos(this.dir) * this.bulletSpeed;
 bullet.vsp = Math.sin(this.dir) * this.bulletSpeed;
}

Bullets use two variables to move each frame, hsp and vsp which control the bullets' horizontal speed and vertical speed respectively. These are used instead of a direction and speed variable to control bullet movement because they only need to be calculated once, while direction calculations must be performed every frame to move a bullet. This improves performance, especially when many bullets objects are in action at once.

The rate at which bullets are fired are dependent on two variables in the spaceship object, shootCooldown and shootCooldownReset. shootCooldown is decremented each frame in the spaceship's ud function until it reaches 0. When the Z key is pressed or held at this point, a bullet will be fired, and shootCooldown will be set to shootCooldownReset. The player must wait until shootCooldown reaches 0 to fire another bullet, or let go of the Z key to immediately set the cooldown to 0. Below is the code that the spaceship runs to perform all of this:

// Shooting
if (this.shootCooldown == 0)
{
 if (k1)
 {
  this.shoot();
  this.shootCooldown = this.shootCooldownReset;
 }
}
else
{
 --this.shootCooldown;
 if (!k1)
  this.shootCooldown = 0;
}

Asteroids

Asteroids are spawned by alert objects, which are spawned by an if statement found in gmLoop shown below. The spawning is influenced by three global variables: spawning, spawnTime, and spawnTimeReset.

spawning is a boolean that controls whether or not asteroids can spawn.

spawnTime is an integer that decrements each frame until it reaches 0, at which point an alert object will spawn.

spawnTimeReset is the value that spawnTime is reset to upon reaching 0, and it decreases by SPAWN_TIME_DEC each time an alert object is created. This shortens the amount of time between alert spawns until spawnTimeReset reaches the minimum spawn time, SPAWN_TIME_MIN.

// Spawn Asteroids
if (spawning && (--spawnTime) <= 0)
{
 // Spawn Asteroid
 var x = Math.random() * GAME_WIDTH;
 var y = Math.random() * GAME_HEIGHT;
 new makeInst(x, y, 3);

 // Reset Spawn Time
 spawnTimeReset -= SPAWN_TIME_DEC;
 if (spawnTimeReset < SPAWN_TIME_MIN)
  spawnTimeReset = SPAWN_TIME_MIN;
 spawnTime = spawnTimeReset;
}

Alert objects draw a blinking exclamation mark symbol onto the screen before spawning an asteroid. This is done to give the player time to react before each asteroid spawns. Their update function, shown below, is very simple:

function oAlertUD()
{
 // Duration
 if ((--this.dur) == 0)
 {
  // Spawn an asteroid, then delete self
  new makeInst(this.x, this.y, 2);
  killInst(this);
 }

 // Animation
 if ((--this.frames) == 0)
 {
  this.frames = ALERT_FRAMES;
  this.frame1 = !this.frame1;
 }

 // Drawing
 c.drawImage(this.frame1 ? sAlert1 : sAlert2, this.x, this.y);
}

Asteroids are also very simple. They wrap around the screen with the same functions as spaceships, and move similarly to bullets with hsp and vsp values, however, these variables are initialized in makeInst rather than being set by another instance, i.e., spaceship instances setting bullet speeds. Here is their update function:

function oAsteroidUD()
{
 // Move
 this.x += this.hsp;
 this.y += this.vsp;

 // Wrap around world
 this.worldWrap();

 // Check for collision with a bullet
 var hitby;
 if ((hitby = this.hit(1, this.x - this.halfWidth, this.y - this.halfHeight, 64, 64, 0, 0, 10, 10)) != null)
 {
  if ((--this.hp) == 0)
  {
   ++score;
   killInst(this);
  }
  killInst(hitby);
 }

 // Draw
 this.drawWrap();
}

The only complex piece of code is the collision checking for bullets, which will be explained in the next section.

Collision

Collision between object instances is done with the function checkRect, which checks for a collision between two rectangles. It takes 8 parameters: the x, y, width, and height of two different rectangles, and returns a boolean. The only precondition is that the variables for the smaller rectangle is passed first.

function checkRect(x1,y1,w1,h1,x2,y2,w2,h2)
{
 var x12 = x1 + w1;
 var y12 = y1 + h1;
 var x22 = x2 + w2;
 var y22 = y2 + h2;

 if (x1 >= x2 && x1 <= x22)
 {
  //Left Side Hit
  if (y1 >= y2 && y1 <= y22)
  {
   //Top Side Hit
   return true;
  }
  if (y12 >= y2 && y12 <= y22)
  {
   // Bottom Side Hit
   return true;
  }
 }
 else if (x12 >= x2 && x12 <= x22)
 {
  // Right Side Hit
  if (y1 >= y2 && y1 <= y22)
  {
   //Top Side Hit
   return true;
  }
  if (y12 >= y2 && y12 <= y22)
  {
   // Bottom Side Hit
   return true;
  }
 }
 return false;
}

This function is used by another two functions: oCollideWithSmaller and oCollideWithLarger. These functions are called by object instances to check for collisions between all instances of another type of object. This is done by looping through inst to find all instances of the object type and calling checkRect with their variables. If a collision is found, the instance collided with is returned, otherwise null is returned. Here is oCollideWithSmaller:

function oCollideWithSmaller(o, x1, y1, w1, h1, s1, s2, w2, h2)
{ 
 var other;
 for (var i = 0; i < MAX_INSTANCES; ++i)
 {
  other = inst[i];
  if (other != null && other.o == o)
  {
   if (checkRect(other.x + s1, other.y + s2, w2, h2, x1, y1, w1, h1))
    return other;
  }
 }
 return null;
}

The function oCollideWithLarger is identical to oCollideWithSmaller except the order of rectangles passed to checkRect is reversed. This is because the functionality of checkRect depends on the smaller rectangle being passed first. The two "collide with" functions exist to check for collisions with objects that are smaller than the calling instance or larger.

The reason two "collide with" functions exist despite much of their code being the same is to improve performance. If there was only one "collide with" function that required an extra if statement to determine which set of objects was smaller, performance would suffer, because that if statement would be run for every single instance of the object type being searched for.

Title/Game/End Screens

This section covers the code behind the multiple "screens" of the game, those being the title screen, gameplay screen, and game over screen.

The title screen is run by calling the function gmTitle. This simply creates a title screen object and starts the game loop.

function gmTitle()
{
 new makeInst(0, 0, 4);
 gmLoop();
}

The update function of the title object draws the title text and starts the game when enter is pressed by calling gmStart.

function oTitleUD()
{
 c.textAlign = "center";
 c.font = "20px Courier";
 c.fillStyle = TEXT_COLOR_GREEN;
 c.fillText("the game finished loading. yay.", GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 120);
 c.fillStyle = TEXT_COLOR_WHITE;
 c.fillText("Bad Asteroid Game", GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 60);
 c.fillStyle = TEXT_COLOR_GREEN;
 c.fillText("by Luke Lawlor", GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 30);
 c.fillText("Press Enter to Play", GAME_WIDTH_HALF, GAME_HEIGHT_HALF + 20);

 if (k2)
  gmStart();
}

The function gmStart destroys all object instances by filling inst with null values, starts the spawning of asteroids, sets the score to 0, and creates a spaceship object.

function gmStart()
{
 inst.fill(null);
 spawning = true;
 spawnTimeReset = SPAWN_TIME_START;
 spawnTime = 10;
 score = 0;
 new makeInst(100, 80, 0);
}

The final screen changing function is gmEnd. This destroys all object instances, records the high score, stops the spawning of asteroids, and creates a game over screen object.

function gmEnd()
{
 if (score > highScore)
  highScore = score;
 inst.fill(null);
 spawning = false;
 new makeInst(0, 0, 5);
}

The game over screen object draws the game over text and restarts the game by calling gmStart, just as the title screen object does.

function oEndUD()
{
 c.textAlign = "center";
 c.font = "20px Courier";
 c.fillStyle = TEXT_COLOR_WHITE;
 c.fillText("G A M E O V E R", GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 60);
 c.fillStyle = TEXT_COLOR_GREEN;
 c.fillText("Score: " + score, GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 30);
 c.fillText("High Score: " + highScore, GAME_WIDTH_HALF, GAME_HEIGHT_HALF - 12);
 c.fillText("Press Enter to Replay", GAME_WIDTH_HALF, GAME_HEIGHT_HALF + 20);

 if (k2)
  gmStart();
}
You've reached the end of this tutorial-ish thing!
Hopefully you learned something!

Go back to the game

Vim good Linux good Firefox somewhat good