Boulderdash Rendering

Thu, Oct 27, 2011

Previously, I released an experimental javascript Boulderdash game and promised to write up some articles about how the game works:


The topics I wanted to cover include:

So, lets continue on by talking about rendering the game…

Decoupling the game logic from the rendering code

It is general good practice to try to minimize coupling between components of a software system.

For Boulderdash we instantiated 2 primary components

var game   = new Game(),           // provides the game logic
var render = new Render(game);     // provides the rendering engine

The Render object needs to have quite an intimate knowledge of the Game object if it is going to render its state to the HTML5 <canvas>, but it would be nice if this was a one way dependency - there should be no reason the Game object needs to have any knowledge of how it is rendered.

In order to avoid the Game having to call into the Render object when its state changes we can implement a very simplified publish/subscribe pattern:

Game.prototype = {

  subscribe: function(event, callback, target) {
    this.subscribers = this.subscribers || {};
    this.subscribers[event] = this.subscribers[event] || [];
    this.subscribers[event].push({ callback: callback, target: target });
  },

  publish: function(event) {
    if (this.subscribers && this.subscribers[event]) {
      var subs = this.subscribers[event];
      var args = [].slice.call(arguments, 1);
      var n, sub;
      for(n = 0 ; n < subs.length ; ++n) {
        sub = subs[n];
        sub.callback.apply(sub.target, args);
      }
    }
  },

  // ...
}

Now, for example, when the Game decides to increase the score it can publish this event:

increaseScore: function(n) {
  this.score += n;
  this.publish('score', this.score);
},

… and the Render object can subscribe to that and trigger a re-render of the scoreline in the header:

function Render(game) {
  // ...
  game.subscribe('score', this.invalidateScore, this);
}

This is a very useful pattern that is simple to achieve with Javascript and means our Game object can stay completely unaware of our…

Render Object

Our Render object constructor simply subscribes to various Game events, and initializes the rest of its state when the main game loop asks it to reset

  • this.canvas - the primary rendering surface
  • this.ctx - the 2d context needed to draw on the canvas
  • this.sprites - the sprites.png image loaded by the main game loop
  • this.fps - the desired fps that the rendering loop should be run at
  • this.step - 1/fps
  • this.frame - incremental frame counter
function Render(game) {
  game.subscribe('level', this.invalidateCave,  this);
  game.subscribe('flash', this.invalidateCave,  this);
  game.subscribe('cell',  this.invalidateCell,  this);
  game.subscribe('score', this.invalidateScore, this);
  game.subscribe('timer', this.invalidateScore, this);
}

Render.prototype = {

  reset: function(sprites) {
    this.canvas   = document.getElementById('canvas');
    this.ctx      = this.canvas.getContext('2d');
    this.sprites  = sprites;
    this.fps      = 30;
    this.step     = 1/this.fps;
    this.frame    = 0;
  },

  // ... etc
}

Optimized Rendering

Although Boulderdash is not a graphically complex game, we should still endeavour to minimize the amount of rendering we do in order to maintain acceptable frame rates in older browsers.

We can do this by only re-rendering changed elements, using a fairly common invalidate pattern to record the need to re-render either:

  • an individual cave cell (e.g. when a boulder moves)
  • the entire cave (e.g. when starting a new level)
  • the scoreline (e.g the score increase, or the timer decreases)
invalid: { cave: true, score: true },
invalidateCell:  function(cell) { cell.invalid       = true;  },
invalidateCave:  function()     { this.invalid.cave  = true;  },
invalidateScore: function()     { this.invalid.score = true;  },
validateCell:    function(cell) { cell.invalid       = false; },
validateCave:    function()     { this.invalid.cave  = false; },
validateScore:   function()     { this.invalid.score = false; },

Then in our render.update function (below) we can use this information to only re-render the items that have changed.

Rendering the Cave

The render.update function called by the game loop becomes very simple,

  • increment the frame counter
  • for eachCell render an appropriate sprite ONLY IF:
    • the cave is invalid, OR
    • the cell is invalid, OR
    • the sprite has multiple frames (an animation loop)
update: function() {
  this.frame++;
  game.eachCell(this.cell, this);
  this.validateCave();
},

cell: function(cell) {
  var sprite = cell.object.sprite;
  if (this.invalid.cave || cell.invalid || (sprite.f > 1)) {
    this.sprite(sprite, cell);
    this.validateCell(cell);
  }
},

Rendering a Sprite

Rendering a sprite is a simple <canvas> drawImage function call. The tricky part is getting the offsets correct and animating the sprite at the correct frame rate (if it has multiple frames)

sprite: function(sprite, cell) {

  // calculate the correct animation frame (if any) based on desired sprite.fps
  var f = sprite.f ? (Math.floor((sprite.fps/this.fps) * this.frame) % sprite.f) : 0;

  // draw the sprite frame
  this.ctx.drawImage(
    this.sprites,                               // source image
    (sprite.x+f) * 32, sprite.y * 32,           // source x,y
    32, 32,                                     // source w,h
    cell.p.x * this.dx, (1+cell.p.y) * this.dy, // destination x,y
    this.dx, this.dy);                          // destination w,h

},

Some points to note:

  • dx and dy represent the pixel size of a cave cell calculated during a browser resize event (code not shown here)
  • destination cell.p.y is incremented by 1 to account for the scoreline at the top of the game
  • it is bad practice to hard code the 32x32 size of the source sprite images but I was being lazy
  • it is also a performance hit to rely on drawImage to scale the image

Time permitting, we should be recreating the source sprites at load-time and scaling them to the current target dimension just once, then using this dynamic spritesheet as the source of our drawImage calls.

Summary

The game rendering is surprisingly simple - that comes from the power of sprite sheets and well defined data - rendering simply becomes iterating over a 2 dimensional grid drawing the correct sprite in the correct cell. Of course it also helps that Boulderdash is a simple game!

There is obviously a few details left out here such as how we render.score, but you can view the source code for the gritty details.

Now we are almost complete. We have our game loop, our game logic, and now our game rendering, the only topic left to talk about next time is decoding the c64 cave data

More information…

Game specs…