Handling state management in a browser-based game

One of the first concerns in developing rail shoot (working title) was state management. The original plan was to have user-created maps, which can be selected from in the game’s initial menus. The map gets loaded, the user plays, they might pause the game, or they could fail. What happens when they succeed? How do we navigate these states reliably, keeping track of all the content in memory, and managing auxiliary systems, like audio or user input?

state flow

One way to handle this is by defining these states as controllers in and of themselves. Roughly speaking, each state is now an explicit component of the game, defined in code. A given state has logic that gets executed when it is entered, logic that executes on every iteration of the game loop if it is active, and logic that executes as it is left.

For example, the main menu state class currently looks like this:

import { Game } from '../index';
import { MainMenu } from '../ui';
import { GameState } from './base';

export class MainMenuState extends GameState {

  private game: Game;

  constructor() {
    super();
    this.game = Game.retrieve();
  }

  enter(): void {
    if (!this.game.ui.systems.has(MainMenu)) {
      this.game.ui.addSystem(MainMenu);
    } else {
      this.game.ui.enableSystem(MainMenu);
    }

    document.exitPointerLock();
  }

  run(): void {
    super.run();
  }

  exit(): void {
    this.game.ui.disableSystem(MainMenu);
  }

}

In the UI code for the main menu, we’ve got the state change code:

  // ...

  this.buttons.gotoMapSelect.addEventListener('click', () => {
    this.game.moveToState(MapSelectState);
  });

  // ...

Now, state management can literally follow the process flow as we outline it, and a given state can easily be tracked, and maintained, since it has a clear place in our codebase.

The boilerplate itself is super easy to set up:

export class GameState {

  protected _elapsedTime: number;
  protected _previousState?: GameState;

  constructor() {
    this._elapsedTime = 0;
  }

  enter(): void {}

  exit(): void {}

  run(): void {
    this._elapsedTime++;
  }

  set previousState(prevState: GameState | null) {
    this._previousState = prevState;
  }

}

Once that’s set up, extending and implementing new states is a breeze, and arbitrary changes to the flow of the game are easy to reason about and implement. For example, if you wanted to implement a leaderboard page for each map, or perhaps an ‘all-time highest players’ leaderboard, you can set that up as its own game state.


373 Words

2020-04-12 03:45 +0000