Boulderdash Game Logic

Wed, Oct 26, 2011

Yesterday, 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 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 OBJECTs 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 OBJECTs 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 OBJECTs 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 OBJECTs 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

More information…

Game specs…