Javascript Gauntlet - Game Logic

Sun, May 19, 2013

Now its time to lift our heads up out of the weeds and take a final, short look at our high level game logic.

Previously we talked about:

For this final article I want to talk a little more about the Finite State Machine and the Publish-Subscribe model that keeps the game logic clean and decoupled.

Finite State Machine

Using an FSM can be a great way to manage the high level transitions within the game. On first glance, for a simple game like Gauntlet, you might think there are really only 2 states, the menu, and playing the game, but it also helps to have smaller, transitional states, for example, while we are loading each game level.

The following diagram shows the top level state machine for Gauntlet:

This is declared in code with the following events and states:

state: {
  initial: 'booting',
  events: [
    { name: 'ready',  from: 'booting',               to: 'menu'     }, // initial page loads images and sounds then transitions to 'menu'
    { name: 'start',  from: 'menu',                  to: 'starting' }, // start a new game from the menu
    { name: 'load',   from: ['starting', 'playing'], to: 'loading'  }, // start loading a new level (either to start a new game, or next level while playing)
    { name: 'play',   from: 'loading',               to: 'playing'  }, // play the level after loading it
    { name: 'help',   from: ['loading', 'playing'],  to: 'help'     }, // pause the game to show a help topic
    { name: 'resume', from: 'help',                  to: 'playing'  }, // resume playing after showing a help topic
    { name: 'lose',   from: 'playing',               to: 'lost'     }, // player died
    { name: 'quit',   from: 'playing',               to: 'lost'     }, // player quit
    { name: 'win',    from: 'playing',               to: 'won'      }, // player won
    { name: 'finish', from: ['won', 'lost'],         to: 'menu'     }  // back to menu
  ]
},

We can now use the state machine’s callback mechanism to run additional code whenever different types of state transition occur. These state machine callbacks are usually very short and simple.

For example, when booting has finished and the game is ready we can hide the progress indicator:

onready: function() {
  $('booting').hide();
},

… or, when entering the menu state, we can play some music:

onmenu: function(event, previous, current) {
  this.sounds.playMenuMusic();
},

… or, when a new game starts, we can join the game, and load the first level:

onstart: function(event, previous, current, type, nlevel) {
  this.player.join(type);
  this.load(1);
},

… or, we can ask for confirmation before the player quits the game:

onbeforequit: function(event, previous, current) {
  if (!confirm('Quit Game?'))
    return false;
},

onquit: function(event, previous, current) {
  this.finish();
},

… and so on, and so forth.

Using a declarative model to define our game states and the events that transition between them, along with short, simple callbacks when those transitions occur, allows us to keep the main game object clean and avoids long, complicated, spaghetti-like conditional methods.

Publish - Subscribe

In addition to the high level state machine, within the single playing state we should also be able to publish that certain events have occurred. Any other objects within the game can subscribe to be notified of events that are relevent to them, and ignore those events that they don’t care about.

This keeps our game objects decoupled and minimizes their need to know about each other. The main objects that subscribe to events are:

  • game - controls the primary game logic by responding to events
  • scoreboard - updates itself when key events occur.
  • sounds - are played for many key events.
  • player - needs to reset on every START_LEVEL event

The biggest consumer of events is the primary game object. It uses event handlers to coordinate the game logic itself. The handlers are declared using a small data structure:

pubsub: [
  { event: EVENT.MONSTER_DEATH,      action: function(monster, by, nuke) { this.onMonsterDeath(monster, by, nuke);     } },
  { event: EVENT.GENERATOR_DEATH,    action: function(generator, by)     { this.onGeneratorDeath(generator, by);       } },
  { event: EVENT.DOOR_OPENING,       action: function(door, speed)       { this.onDoorOpening(door, speed);            } },
  { event: EVENT.DOOR_OPEN,          action: function(door)              { this.onDoorOpen(door);                      } },
  { event: EVENT.TREASURE_COLLECTED, action: function(treasure, player)  { this.onTreasureCollected(treasure, player); } },
  { event: EVENT.WEAPON_COLLIDE,     action: function(weapon, entity)    { this.onWeaponCollide(weapon, entity);       } },
  { event: EVENT.PLAYER_COLLIDE,     action: function(player, entity)    { this.onPlayerCollide(player, entity);       } },
  { event: EVENT.MONSTER_COLLIDE,    action: function(monster, entity)   { this.onMonsterCollide(monster, entity);     } },
  { event: EVENT.PLAYER_NUKE,        action: function(player)            { this.onPlayerNuke(player);                  } },
  { event: EVENT.PLAYER_FIRE,        action: function(player)            { this.onPlayerFire(player);                  } },
  { event: EVENT.MONSTER_FIRE,       action: function(monster)           { this.onMonsterFire(monster);                } },
  { event: EVENT.PLAYER_EXITING,     action: function(player, exit)      { this.onPlayerExiting(player, exit);         } },
  { event: EVENT.PLAYER_EXIT,        action: function(player)            { this.onPlayerExit(player);                  } },
  { event: EVENT.FX_FINISHED,        action: function(fx)                { this.onFxFinished(fx);                      } },
  { event: EVENT.PLAYER_DEATH,       action: function(player)            { this.onPlayerDeath(player);                 } }
],

And, similar to the finite state machine, the event handler methods are small, simple methods that coordinate what should happen in response to that event:

For example, when the PLAYER_FIRE event occurs, a new weapon should be created:

onPlayerFire: function(player) {
  this.map.addWeapon(player.x, player.y, player.type.weapon, player.dir, player);
},

… or, when the PLAYER_EXIT event occurs we should start the next level:

onPlayerExit: function(player) {
  if (!this.map.last)
    this.nextLevel();
},

… or, when the DOOR_OPEN event occurs, the door should be removed from the map:

onDoorOpen: function(door) {
  this.map.remove(door);
},

… or, when the WEAPON_COLLIDE event occurs, something should get hurt:

onWeaponCollide: function(weapon, entity) {
  var x = weapon.x + (entity.x ? (entity.x - weapon.x)/2 : 0),
      y = weapon.y + (entity.y ? (entity.y - weapon.y)/2 : 0);

  if (weapon.type.player && (entity.monster || entity.generator))
    entity.hurt(weapon.type.damage, weapon);
  else if (weapon.type.monster && entity.player)
    entity.hurt(weapon.type.damage, weapon);
  else if (weapon.type.monster && entity.monster)
    entity.hurt(1, weapon);

  this.map.addFx(x, y, FX.WEAPON_HIT);
  this.map.remove(weapon);
},

… or, when the PLAYER_COLLIDE event occurs, the player should get hurt or rewarded, depending on what it collided with:

onPlayerCollide: function(player, entity) {
  if (entity.monster || entity.generator)
    entity.hurt(player.type.damage, player);
  else if (entity.treasure)
    player.collect(entity);
  else if (entity.door && player.keys && entity.open())
    player.keys--;
  else if (entity.exit)
    player.exit(entity);
},

… and so on, and so forth.

Conclusion

The finite state machine, and the publish-subscribe model, are 2 great patterns to help tidy up any game code base. It helps to decouple object dependencies, and avoid complex conditional logic which might otherwise end up as spaghetti-code.


… and, that’s about all there is for Gauntlet… at least for this single player version. The game works pretty well, its fairly fun (at least for a short while) and feels (to me at least) like a Gauntlet game. There is certainly plenty that could be added or improved:

  • More maps
  • More monster types (e.g. lobbers)
  • Teleporters
  • Secret doors
  • Special potion types
  • Better graphics
  • Better gameplay balancing
  • Speech Synthesis
  • Mobile Support
  • Performance (particularly loading sounds over a slow network)

But of course, the biggest ommision is multiplayer support. I hope to work on a multiplayer version later in the year, time permitting, so we will have to wait to see what happens with that.

I haven’t said much about how the game is rendered, but its pretty straight forward <canvas> sprite rendering using drawImage api calls. I’ve spoken in more detail about these in the past with boulderdash and sprite rendering with outrun. I think the code for the Gauntlet render object mostly speaks for itself. Finally, the sound is all managed by the AudioFx library, which I’ve spoken about in a past article.

Until then, thats about all I have to say on the single player version.

Enjoy!