Making <canvas> Games

In my last post, Making <canvas> Art, I introduced the idea of using the HTML5 <canvas> API to create a randomly-generated “northern lights” animation. The effect of the resulting animation is entirely aesthetic in nature. It provides no interactivity beyond the generation of new colors and shapes. In this follow-up post, we’ll use the same technique introduced in the original post, but extend our example to introduce keyboard input and manipulation in order to create a fully interactive game.

We’ll be creating a game I’m calling Space. It’s a simple game in which you use the keyboard to navigate a two-dimensional map of stars and planets. For the sake of brevity, the full script will be made available at the bottom of this post, and I’ll use snippets from the script to explain what’s what.

The game “loop”

The most important element of game development is the game “loop.” This is essentially a function that will continuously repeat as long as the game is being played. Our game loop will run much like our animation loop that we used in the previous post, with a few key additions. Without getting into too much detail, game loop script will look something like this:

<canvas id="space" width="400" height="300"></canvas>
// game
function game(){
  
  // configuration
  var game = this;
  game.canvas = document.getElementById('space');
  game.ctx = game.canvas.getContext('2d');
  game.time = false;
  
  // initialize
  game.init = function(){
    
    // start the game loop
    game.loop();
    
  };
  
  // game loop
  game.loop = function(){
    
    // timing
    var now = new Date().getTime();
    var d = now - (game.time || now);
    game.time = now;
    
    // update positions, view, etc.
    game.update(d);
    
    // render
    game.render(d);
    
    // request next frame
    requestAnimationFrame(game.loop);
    
  };
  
  // update game
  game.update = function(d){
    
    // 1. update player position
    // 2. update view
    
  };
  
  // render game
  game.render = function(d){
    
    // 1. clear the canvas
    // 2. draw background
    // 3. draw stars, planets
    // 4. draw player
    
  };
  
}

// begin game
var space = new game();
space.init();

With this basic structure in place, we’ll start to add more specific features to our script to handle movement, animation, and player interaction.

The game view

The first challenge is creating a scrollable map on which players can move. Our map will contain objects like planets and stars, but not every star will be visible at any given time. Only those stars that are close to the player will be drawn on the canvas. This is made possible by creating a game view. Because this is a two-dimensional game, we can consider the game view according to x and y axes. It looks something like this:

game-view

As you can see, stars 1 and 2 are visible within the game view, but star 3 is not. As the player moves around the map on the x and y axes, the view will be updated to show new map content, based on the location of the player. In our script, we can create the game view with the following additions to our game() function.

// configuration
game.width = 0;
game.height = 0;
game.view = { x: 0, y: 0 };

// resize canvas
game.resize = function(){
  game.ctx.canvas.width = game.canvas.width;
  game.ctx.canvas.height = game.canvas.width;
  game.width = game.canvas.width;
  game.height = game.canvas.width;
};

Using a combination of game.width, game.height, game.view.x, and game.view.y, we can keep track of our view and always draw the correct objects within the game canvas. As we add movement and objects to our map, we’ll refer back to our game view every time an object is rendered.

Stars, planets, and ships, oh my

With a basic animation and view structure in place, it’s time to populate our game world with the stars, planets, and ships that will make up the final game. It’s best to create a generic entity object that we can use to place all objects in our map. Our entities will store information for each map object, including its position (x and y), size (width and height), orientation, and movement vector.

// translate coordinates
game.translate = function(x, y){
  return {
    x: (game.width / 2) - game.view.x + x,
    y: (game.height / 2) - game.view.y + y
  }
};

// entity
game.entity = function(options){
  
  // settings
  var entity = this;
  entity.settings = {
    x: 0, // x position
    y: 0, // y position
    w: 20, // width
    h: 20, // height
    o: 0, // orientation
    v: { x: 0, y: 0 }, // vector
    f: 0.0004, // friction
    speed: 1,
    color: { red: 0, green: 0, blue: 0, alpha: 0 }
  };
  entity.settings = merge(entity.settings, options);
  
  // update
  entity.update = function(d){
    
    // update position
    entity.settings.x += (entity.settings.v.x / 10) * d;
    entity.settings.y += (entity.settings.v.y / 10) * d;
    
    // friction
    entity.settings.v.x -= entity.settings.v.x * entity.settings.f * d;
    entity.settings.v.y -= entity.settings.v.y * entity.settings.f * d;
    
  };
  
  // draw
  entity.draw = function(d){
    
    // only draw when in view
    if(entity.settings.x - (entity.settings.w / 2) <= game.view.x + (game.width / 2) && entity.settings.x + (entity.settings.w / 2) >= game.view.x - (game.width / 2) && entity.settings.y - (entity.settings.h / 2) <= game.view.y + (game.height / 2) && entity.settings.y + (entity.settings.h / 2) >= game.view.y - (game.height / 2)){
      
      // get translated coordinates
      var t = game.translate(entity.settings.x, entity.settings.y);
      
      // orientation
      game.ctx.save();
      game.ctx.translate(t.x, t.y);
      game.ctx.rotate(entity.settings.o * Math.PI / 180);
      
      // color
      game.ctx.fillStyle = 'rgba(' + entity.settings.color.red + ', ' + entity.settings.color.green + ', ' + entity.settings.color.blue + ', ' + entity.settings.color.alpha + ')';
      
      // draw entity
      game.ctx.beginPath();
      game.ctx.rect(0 - (entity.settings.w / 2), 0 - (entity.settings.h / 2), entity.settings.w, entity.settings.h);
      game.ctx.fill();
      
      // reset orientation
      game.ctx.restore();
      
    }
      
  };
    
};

// create player
game.player = new game.entity({
  x: 0,
  y: 0,
  w: 32,
  h: 40
});

// create a star at a random location
var star = new space.entity({
  x: Math.floor(Math.random()*5000)*(Math.round(Math.random())*2-1),
  y: Math.floor(Math.random()*5000)*(Math.round(Math.random())*2-1),
  w: 5,
  h: 5
});

The entity object will store all information we’ll need to draw objects on the game map. Entities will support position, orientation, and even movement using the v (vector) property. I’ve also included a friction property that will slowly reduce the movement vector over time, so that when players move around the map they don’t float away indefinitely.

To draw game entities in the correct position, we’re using a translate() function to convert our map-relative x and y coordinates into view-relative coordinates. Using this in conjunction with an if statement that checks to see if the entity is currently “in view,” we can draw our game objects only when they appear near the player.

Images and animation

Currently, our game entities can be rendered only as simple rectangles on our game map. Instead of rectangles, we want to use images and even animations to bring our game content to life. We can add this support to our game entities using animation sprites, a common technique used in game development. Our game objects will be rendered using images such as the following image of our player ship.

ship

This single image contains frames that make up the different states of our object. From left to right, the first frame is the static state of our ship, followed by two forward thrust frames, followed by two reverse thrust frames, and finally followed by two turbo frames. We can improve our script by adding support for these sprite animations.

// load images
game.resources = [];
game.load = function(images){
  
  // load image from url
  var loadFromUrl = function(url){
    var img = new Image();
    img.src = '/path/to/images/' + url + '.png';
    game.resources[url] = { image: img, loaded: false };
    img.onload = function(){
      game.resources[url].loaded = true;
    };
  };
  
  // accept array or single resource
  if(images instanceof Array){
    for(var i = 0; i < images.length; i++){
      loadFromUrl(images[i]);
    }
  }
  else{
    loadFromUrl(images);
  }
  
};

// sprites
game.sprite = function(options){
  
  // settings
  var sprite = this;
  sprite.settings = {
    image: false,
    alpha: 1,
    x: 0,
    y: 0,
    w: 0,
    h: 0,
    speed: 0.02, // .001 = 1 frame/second
    frames: [],
    index: 0,
    dir: 'horizontal',
    loop: true
  };
  sprite.settings = merge(sprite.settings, options);
  
  // update
  sprite.update = function(d){
    sprite.settings.index += sprite.settings.speed * d;
  };
  
  // draw
  sprite.draw = function(x, y, w, h){
     
    // determine which frame to draw
    var frame = 0;
    if(sprite.settings.speed > 0){
      var max = sprite.settings.frames.length;
      var idx = Math.floor(sprite.settings.index);
      frame = sprite.settings.frames[idx % max];
      if(!sprite.settings.loop && idx > max){
        var frame = sprite.settings.frames[sprite.settings.frames.length - 1];
      }
    }
        
    // set new position
    if(sprite.settings.dir == 'vertical'){
      sprite.settings.y = frame * sprite.settings.h;
    }
    else{
      sprite.settings.x = frame * sprite.settings.w;
    }
    
    // render
    game.ctx.drawImage(sprite.settings.image, sprite.settings.x, sprite.settings.y, sprite.settings.w, sprite.settings.h, x, y, w, h);
    
  };
  
};

// load images
game.load([
  'ship',
  'star-small',
  'star-large'
]);

// add sprites to player
game.player.settings.sprites['rest'] = new game.sprite({
  image: game.resources['ship'].image,
  w: 32,
  h: 40,
  frames: [0],
  speed: 0
});
game.player.settings.sprites['forward'] = new game.sprite({
  image: game.resources['ship'].image,
  w: 32,
  h: 40,
  frames: [1, 2]
});
game.player.settings.sprites['reverse'] = new game.sprite({
  image: game.resources['ship'].image,
  w: 32,
  h: 40,
  frames: [3, 4]
});
game.player.settings.sprites['boost'] = new game.sprite({
  image: game.resources['ship'].image,
  w: 32,
  h: 40,
  frames: [5, 6]
});

The game.sprite() object allows us to quickly specify a width, height, speed, and direction of our sprite image. Using this function, we can extend our entity() object to include support for sprite images. The game.load() function handles image loading, so that we can preload images before the game begins.

Keyboard interaction

The final addition to our game script is keyboard interaction. With game development, it’s much easier to keep track of both the keyup and keydown events independently. I prefer to create an array of keycodes that can be checked at any time in our script to see if that particular key is currently pressed. We can do this with an addition to our game script:

// keyboard
game.keys = [];
game.keydown = function(e){
  game.keys[e.keyCode] = true;
};
game.keyup = function(e){
  game.keys[e.keyCode] = false;
};

// listen
window.addEventListener('keydown', game.keydown, false);
window.addEventListener('keyup', game.keyup, false);

This function allows us to keep track of every keyboard event. For example, if we want to know at any time if the left-arrow-key is depressed, we can check game.keys[37], which will return true when depressed, and false or undefined when otherwise. We can put this to good use by running a new function every time our game.update() is executed.

game.keypress = function(d){
  
  // boost
  var boost = 1;
  if(game.keys[16]){
    boost = 3;
  }
  
  // thrust
  if(game.keys[40]){
    game.player.settings.v.x += Math.cos((game.player.settings.o - 270) * Math.PI / 180) * 0.002 * game.player.settings.speed * d;
    game.player.settings.v.y += Math.sin((game.player.settings.o - 270) * Math.PI / 180) * 0.002 * game.player.settings.speed * d;
    game.player.settings.status = 'reverse';
  }
  else if(game.keys[38]){
    game.player.settings.v.x += Math.cos((game.player.settings.o - 90) * Math.PI / 180) * 0.004 * (game.player.settings.speed * boost) * d;
    game.player.settings.v.y += Math.sin((game.player.settings.o - 90) * Math.PI / 180) * 0.004 * (game.player.settings.speed * boost) * d;
    game.player.settings.status = 'forward';
    if(game.keys[16]){
      game.player.settings.status = 'boost';
    }
  }
  
  // rotate
  if(game.keys[37]){
    game.player.settings.o -= (0.15 / boost) * d;
    if(game.player.settings.o < 0){ game.player.settings.o = 360 - game.player.settings.o; } else if(game.player.settings.o > 360){
      game.player.settings.o = 0 + game.player.settings.o;
    }
  }
  if(game.keys[39]){
    game.player.settings.o += (0.15 / boost) * d;
    if(game.player.settings.o < 0){ game.player.settings.o = 360 - game.player.settings.o; } else if(game.player.settings.o > 360){
      game.player.settings.o = 0 + game.player.settings.o;
    }
  }
  
};

This last piece of our script allows users to manipulate the player object’s orientation, movement vector, and speed using the arrow keys. This allows users to rotate their ship and move about the game world. We’ve even added a speed boost when the shift key and the up arrow are pressed at the same time.

The final product

With a bit of tweaking, we’ve added hundreds of randomly placed stars and planets to our game, creating a small two-dimensional world that we can explore in our ship. Try it out using the arrow keys on your keyboard.

You can download the source code and play the game full screen at http://oldrivercreative.com/space/.

One comment

  1. Thomas says:

    Hello Kyle,

    Stumbled upon your website while gathering webdev-knowledge.
    Thanks you for the rich source of knowledge you have put on here!

    Best regards,
    Thomas

Post a comment

Your email address will not be published. Required fields are marked *