Boulderdash Game Logic
Wed, Oct 26, 2011Yesterday, 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 the good stuff…
The Game Logic
While this javascript implementation is all new, much of the information that defines how a Boulderdash game should behave has already been established, and a lot of it has been collected on Martijn’s Boulderdash Fan Site. I highly recommend you check out the information he has gathered there, it contains actual documented specifications for the game, including:
Game Objects
Before we can get into the meat of the game, we need to declare the game OBJECT
s that can occupy each block in a
Boulderdash cave:
Each OBJECT
can have a handful of attributes:
- code - an identifier for this object from the original c64 game.
- rounded - if a falling object hits me, will it roll to the side? or will it stop?
- explodable - does this object explode when hit by a falling object?
- consumable - can this object be consumed by a nearby explosion?
For Boulderdash1, the OBJECTs can be defined as follows:
var OBJECT = {
SPACE: { code: 0x00, rounded: false, explodable: false, consumable: true },
DIRT: { code: 0x01, rounded: false, explodable: false, consumable: true },
BRICKWALL: { code: 0x02, rounded: true, explodable: false, consumable: true },
MAGICWALL: { code: 0x03, rounded: false, explodable: false, consumable: true },
PREOUTBOX: { code: 0x04, rounded: false, explodable: false, consumable: false },
OUTBOX: { code: 0x05, rounded: false, explodable: false, consumable: false },
STEELWALL: { code: 0x07, rounded: false, explodable: false, consumable: false },
FIREFLY1: { code: 0x08, rounded: false, explodable: true, consumable: true },
FIREFLY2: { code: 0x09, rounded: false, explodable: true, consumable: true },
FIREFLY3: { code: 0x0A, rounded: false, explodable: true, consumable: true },
FIREFLY4: { code: 0x0B, rounded: false, explodable: true, consumable: true },
BOULDER: { code: 0x10, rounded: true, explodable: false, consumable: true },
BOULDERFALLING: { code: 0x12, rounded: false, explodable: false, consumable: true },
DIAMOND: { code: 0x14, rounded: true, explodable: false, consumable: true },
DIAMONDFALLING: { code: 0x16, rounded: false, explodable: false, consumable: true },
EXPLODETOSPACE0: { code: 0x1B, rounded: false, explodable: false, consumable: false },
EXPLODETOSPACE1: { code: 0x1C, rounded: false, explodable: false, consumable: false },
EXPLODETOSPACE2: { code: 0x1D, rounded: false, explodable: false, consumable: false },
EXPLODETOSPACE3: { code: 0x1E, rounded: false, explodable: false, consumable: false },
EXPLODETOSPACE4: { code: 0x1F, rounded: false, explodable: false, consumable: false },
EXPLODETODIAMOND0: { code: 0x20, rounded: false, explodable: false, consumable: false },
EXPLODETODIAMOND1: { code: 0x21, rounded: false, explodable: false, consumable: false },
EXPLODETODIAMOND2: { code: 0x22, rounded: false, explodable: false, consumable: false },
EXPLODETODIAMOND3: { code: 0x23, rounded: false, explodable: false, consumable: false },
EXPLODETODIAMOND4: { code: 0x24, rounded: false, explodable: false, consumable: false },
PREROCKFORD1: { code: 0x25, rounded: false, explodable: false, consumable: false },
PREROCKFORD2: { code: 0x26, rounded: false, explodable: false, consumable: false },
PREROCKFORD3: { code: 0x27, rounded: false, explodable: false, consumable: false },
PREROCKFORD4: { code: 0x28, rounded: false, explodable: false, consumable: false },
BUTTERFLY1: { code: 0x30, rounded: false, explodable: true, consumable: true },
BUTTERFLY2: { code: 0x31, rounded: false, explodable: true, consumable: true },
BUTTERFLY3: { code: 0x32, rounded: false, explodable: true, consumable: true },
BUTTERFLY4: { code: 0x33, rounded: false, explodable: true, consumable: true },
ROCKFORD: { code: 0x38, rounded: false, explodable: true, consumable: true },
AMOEBA: { code: 0x3A, rounded: false, explodable: false, consumable: true }
};
Coordinates and Directions
A Boulderdash cave is a simple 2 dimensional grid, and within a single update
, any given object
can move, at most, to one of its neighbouring cells.
Therefore, it is going to be useful for us to be able to specify directions, and construct a
coordinate Point
based on an existing Point
plus a direction:
var DIR = { UP: 0, UPRIGHT: 1, RIGHT: 2, DOWNRIGHT: 3, DOWN: 4, DOWNLEFT: 5, LEFT: 6, UPLEFT: 7 };
var DIRX = [ 0, 1, 1, 1, 0, -1, -1, -1 ];
var DIRY = [ -1, -1, 0, 1, 1, 1, 0, -1 ];
var Point = function(x, y, dir) {
this.x = x + (DIRX[dir] || 0);
this.y = y + (DIRY[dir] || 0);
}
The Game and The Cave
During the game loop we constructed a fairly abstract game
object in
order to call its update()
method:
var game = new Game();
...
game.update();
...
Now we get to define that Game
class, which will need a typical javascript constructor and prototype:
var Game = function() {
...
}
Game.prototype = {
...
}
All of the subsequent methods to be discussed in this article are instance methods defined in the Game.prototype…
… starting with the ability to reset
our state at the start of each new cave:
Mostly this consists of initializing state based on the attributes of the supplied cave. The important data
structure is initialized at the end where we construct a 2 dimensional array of cave cells, each containing one
of the OBJECT
s described earlier:
reset: function(cave) {
this.width = cave.width; // cave cell width
this.height = cave.height; // cave cell height
this.cells = []; // will be built up into 2 dimensional array below
this.frame = 0; // game frame counter starts at zero
this.fps = 10; // how many game frames per second
this.step = 1/this.fps; // how long is each game frame (in seconds)
this.timer = cave.caveTime; // seconds allowed to complete this cave
this.flash = false; // trigger white flash when rockford has collected enought diamonds
this.won = false; // set to true when rockford enters the outbox
this.diamonds = {
collected: 0, // how many diamonds collected so far
needed: cave.diamondsNeeded, // how many diamonds needed to exit the cave
value: cave.initialDiamondValue, // how many points for each required diamond
extra: cave.extraDiamondValue // how many points for each additional diamond
};
this.amoeba = {
max: cave.amoebaMaxSize, // how large can amoeba grow before it turns to boulders
slow: cave.amoebaSlowGrowthTime/this.step // how long before amoeba growth rate speeds up
};
this.magic = {
active: false, // are magic walls active
time: this.magicWallMillingTime/this.step // how long do magic walls stay active
};
var x, y;
for(y = 0 ; y < this.height ; ++y) {
for(x = 0 ; x < this.width ; ++x) {
this.cells[x] = this.cells[x] || [];
this.cells[x][y] = { object: OBJECT[cave.map[x][y]], frame: 0, p: new Point(x,y) };
}
}
}
Cave Cells
Having initialized a 2 dimensional ‘cave’ of OBJECT
s we can provide a few core helper methods to get and
set each cell:
get: function(p,dir) { return this.cells[p.x + (DIRX[dir] || 0)][p.y + (DIRY[dir] || 0)].object; },
set: function(p,o,dir) { var cell = this.cells[p.x + (DIRX[dir] || 0)][p.y + (DIRY[dir] || 0)]; cell.object = o; cell.frame = this.frame; },
clear: function(p,dir) { this.set(p,OBJECT.SPACE,dir); },
move: function(p,dir,o) { this.clear(p); this.set(p,o,dir); },
Each method above works on the cell defined by point p
and (optionally) offset by 1 cell in the direction dir
.
We can build further on these helper methods:
isempty: function(p,dir) { return this.get(p,dir) === OBJECT.SPACE; },
isdirt: function(p,dir) { return this.get(p,dir) === OBJECT.DIRT; },
isboulder: function(p,dir) { return this.get(p,dir) === OBJECT.BOULDER; },
isrockford: function(p,dir) { return this.get(p,dir) === OBJECT.ROCKFORD; },
isdiamond: function(p,dir) { return this.get(p,dir) === OBJECT.DIAMOND; },
// ... etc
isexplodable: function(p,dir) { return this.get(p,dir).explodable; },
isconsumable: function(p,dir) { return this.get(p,dir).consumable; },
isrounded: function(p,dir) { return this.get(p,dir).rounded; },
And finally, we can provide an iterator that will call a provided function (fn
) once for each cell in the cave:
eachCell: function(fn, thisArg) {
for(var y = 0 ; y < this.height ; y++) {
for(var x = 0 ; x < this.width ; x++) {
fn.call(thisArg || this, this.cells[x][y]);
}
}
},
Updating the Cave
Using the eachCell
helper, we can now implement our game.update()
function to simply loop through
eachCell
and call an appropriate update***
method.
The beginFrame
and endFrame
calls will examine our state each frame in order to check for level ending
events such as ROCKFORD
dying or reaching the OUTBOX
.
update: function() {
this.beginFrame();
this.eachCell(function(cell) {
if (cell.frame < this.frame) {
switch(cell.object) {
case OBJECT.ROCKFORD: this.updateRockford(cell.p, moving.dir); break;
case OBJECT.BOULDER: this.updateBoulder(cell.p); break;
case OBJECT.DIAMOND: this.updateDiamond(cell.p); break;
// ... etc
}
}
});
this.endFrame();
},
NOTE: in the
set
helper method described previously, we stored the current frame (this.frame
) in the cell (cell.frame
) - This information is used here to avoid updating the same object twice, e.g. when a boulder falls down one cell we will meet that same boulder on the next row of cells and we should ignore it the 2nd time around.
Updating Boulders and Diamonds
Updating boulders (and diamonds) gives us a good insight into the general pattern for updating all of the different types
of OBJECT
s that might be in the cave - so lets take a closer look:
Boulders and stored in 2 separate forms, as a stationary BOULDER
or as a moving BOULDERFALLING
.
When we meet a stationary BOULDER
we update
it by performing 1 of the following actions:
- if the cell below is empty then change to a
BOULDERFALLING
- if the cell below has a rounded
OBJECT
and there is empty space then roll off - otherwise do nothing
When we meet a moving FALLINGBOULDER
we update
it by checking:
- if the cell below is empty then move into it
- if the cell below is an explodable
OBJECT
then explode it - if the cell below is a
MAGICWALL
then fall through it - if the cell below has a rounded
OBJECT
and there is empty space then roll off - otherwise change it to a stationary
BOULDER
updateBoulder: function(p) {
if (this.isempty(p, DIR.DOWN))
this.set(p, OBJECT.BOULDERFALLING);
else if (this.isrounded(p, DIR.DOWN) && this.isempty(p, DIR.LEFT) && this.isempty(p, DIR.DOWNLEFT))
this.move(p, DIR.LEFT, OBJECT.BOULDERFALLING);
else if (this.isrounded(p, DIR.DOWN) && this.isempty(p, DIR.RIGHT) && this.isempty(p, DIR.DOWNRIGHT))
this.move(p, DIR.RIGHT, OBJECT.BOULDERFALLING);
},
updateBoulderFalling: function(p) {
if (this.isempty(p, DIR.DOWN))
this.move(p, DIR.DOWN, OBJECT.BOULDERFALLING);
else if (this.isexplodable(p, DIR.DOWN))
this.explode(p, DIR.DOWN);
else if (this.ismagic(p, DIR.DOWN))
this.domagic(p, OBJECT.DIAMOND);
else if (this.isrounded(p, DIR.DOWN) && this.isempty(p, DIR.LEFT) && this.isempty(p, DIR.DOWNLEFT))
this.move(p, DIR.LEFT, OBJECT.BOULDERFALLING);
else if (this.isrounded(p, DIR.DOWN) && this.isempty(p, DIR.RIGHT) && this.isempty(p, DIR.DOWNRIGHT))
this.move(p, DIR.RIGHT, OBJECT.BOULDERFALLING);
else
this.set(p, OBJECT.BOULDER);
},
Updating diamonds is implemented almost identically to updating boulders.
Updating Fireflies and Butterflies
Updating a firefly will check:
- if any adjacent cell contains
ROCKFORD
then explode - if any adjacent cell contains
AMOEBA
then explode - try to rotate 90 degrees left, if empty, move into a new cell in that direction
- otherwise try moving into the next cell in the original direction
- otherwise rotate to the right and wait for the next update
updateFirefly: function(p, dir) {
var newdir = rotateLeft(dir);
if (this.isrockford(p, DIR.UP) || this.isrockford(p, DIR.DOWN) || this.isrockford(p, DIR.LEFT) || this.isrockford(p, DIR.RIGHT))
this.explode(p);
else if (this.isamoeba(p, DIR.UP) || this.isamoeba(p, DIR.DOWN) || this.isamoeba(p, DIR.LEFT) || this.isamoeba(p, DIR.RIGHT))
this.explode(p);
else if (this.isempty(p, newdir))
this.move(p, newdir, FIREFLIES[newdir]);
else if (this.isempty(p, dir))
this.move(p, dir, FIREFLIES[dir]);
else
this.set(p, FIREFLIES[rotateRight(dir)]);
},
Updating butterflies is implemented almost identically to updating fireflies except they rotate the other direction.
Updating Rockford
Updating Rockford will check:
- if the cave timer has expired then explode
- if moving in a direction that is empty or
DIRT
then move 1 cell in that direction - if moving in the direction of a
DIAMOND
then collect the diamond - if moving in the direction of a
BOULDER
then try to push the boulder - if moving in the direction of the
OUTBOX
then the level is complete
updateRockford: function(p, dir) {
if (this.timer === 0) {
this.explode(p);
}
else if (this.isempty(p, dir) || this.isdirt(p, dir)) {
this.move(p, dir, OBJECT.ROCKFORD);
}
else if (this.isdiamond(p, dir)) {
this.move(p, dir, OBJECT.ROCKFORD);
this.collectDiamond();
}
else if (horizontal(dir) && this.isboulder(p, dir)) {
this.push(p, dir);
}
else if (this.isoutbox(p, dir)) {
this.move(p, dir, OBJECT.ROCKFORD);
this.winLevel();
}
},
Exploding Objects
When an explosion occurs, we want to consume the neighbouring cells, and possibly set off a chain explosion:
explode: function(p, dir) {
var p2 = new Point(p.x, p.y, dir);
var explosion = (this.isbutterfly(p2) ? OBJECT.EXPLODETODIAMOND0 : OBJECT.EXPLODETOSPACE0);
this.set(p2, explosion);
for(dir = 0 ; dir < 8 ; ++dir) { // for each of the 8 directions
if (this.isexplodable(p2, dir))
this.explode(p2, dir);
else if (this.isconsumable(p2, dir))
this.set(p2, explosion, dir);
}
},
Summary
There is obviously a few details left out here such as how we collectDiamond
or winLevel
. If you view
the source code you will find they are fairly small auxiliary methods that increase counters or set some additional
game state to trigger an end game.
But now we have our game loop and our game logic the only major topic left to talk about next time is rendering in the browser…
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…