Boulderdash Rendering
Thu, Oct 27, 2011Previously, I released an experimental javascript Boulderdash game and promised to write up some articles about how the game works:
- play the game now
- view the source code
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 surfacethis.ctx
- the 2d context needed to draw on the canvasthis.sprites
- thesprites.png
image loaded by the main game loopthis.fps
- the desired fps that the rendering loop should be run atthis.step
- 1/fpsthis.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
anddy
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…
Related Links
- play the game now
- view the source code
- read more about how it works:
More information…
- The original publishers - First Star
- Martijn’s Boulderdash Fan Site
- Arno’s Boulderdash Fan Site
- Boulderdash Common File Format BDCFF
- Boulderdash on the c64
- Boulderdash on Wikipedia
Game specs…