Learn

/

State Management

State Management

2 patterns

Finite state machines, scene transitions, save systems, and entity lifecycle. You'll hit this when adding a new enemy behavior breaks three others, or when your pause menu doesn't actually pause everything.

Avoid

Boolean flags (no validation)


Prefer

Finite state machine (validated transitions)

Why avoid

Boolean flags create a combinatorial explosion: four flags means 16 possible combinations, most of which are invalid. Nothing prevents isJumping and isStunned from both being true, and every update tick must navigate a nest of conditionals that grows with each new ability. Bugs from invalid flag combinations are subtle and hard to test.

Why prefer

A discriminated union (tagged state) makes it impossible to be in two conflicting states at once. Each state carries only the data it needs, and the switch enforces that every state handles its own transitions explicitly. Adding a new state is a compile-time checklist: the exhaustiveness check tells you exactly which transitions to define.

Game Programming Patterns: State
Avoid
let currentScene = "menu";

function update(dt: number) {
  if (currentScene === "menu") {
    updateMenu(dt);
    if (startClicked) currentScene = "game";
  } else if (currentScene === "game") {
    updateGame(dt);
    if (playerDied) currentScene = "gameover";
    if (paused) currentScene = "pause";
  } else if (currentScene === "pause") {
    updatePause(dt);
    if (resumed) currentScene = "game";
  } else if (currentScene === "gameover") {
    updateGameOver(dt);
    if (retryClicked) currentScene = "game";
  }
}
let currentScene = "menu";

function update(dt: number) {
  if (currentScene === "menu") {
    updateMenu(dt);
    if (startClicked) currentScene = "game";
  } else if (currentScene === "game") {
    updateGame(dt);
    if (playerDied) currentScene = "gameover";
    if (paused) currentScene = "pause";
  } else if (currentScene === "pause") {
    updatePause(dt);
    if (resumed) currentScene = "game";
  } else if (currentScene === "gameover") {
    updateGameOver(dt);
    if (retryClicked) currentScene = "game";
  }
}

Prefer
interface Scene {
  enter(): void;
  exit(): void;
  update(dt: number): void;
  render(ctx: CanvasRenderingContext2D): void;
}

class SceneManager {
  private stack: Scene[] = [];

  get active(): Scene {
    return this.stack[this.stack.length - 1];
  }

  push(scene: Scene) {
    scene.enter();
    this.stack.push(scene);
  }

  pop() {
    this.stack.pop()?.exit();
    // Previous scene is still on the stack
  }

  replace(scene: Scene) {
    this.stack.pop()?.exit();
    scene.enter();
    this.stack.push(scene);
  }
}

// Pause pushes on top, resume pops back
manager.push(new PauseScene());
interface Scene {
  enter(): void;
  exit(): void;
  update(dt: number): void;
  render(ctx: CanvasRenderingContext2D): void;
}

class SceneManager {
  private stack: Scene[] = [];

  get active(): Scene {
    return this.stack[this.stack.length - 1];
  }

  push(scene: Scene) {
    scene.enter();
    this.stack.push(scene);
  }

  pop() {
    this.stack.pop()?.exit();
    // Previous scene is still on the stack
  }

  replace(scene: Scene) {
    this.stack.pop()?.exit();
    scene.enter();
    this.stack.push(scene);
  }
}

// Pause pushes on top, resume pops back
manager.push(new PauseScene());
Why avoid

A string-based scene variable with a giant if/else chain mixes all scene logic in one place and provides no lifecycle management. Transitioning from 'game' to 'pause' and back requires manually saving and restoring state. Adding new scenes means touching the central update function, and nothing enforces cleanup when leaving a scene.

Why prefer

A scene stack with enter/exit lifecycle hooks gives each scene a clear boundary for setup and teardown. Pushing a pause screen on top of the game scene preserves the game state underneath. Each scene is self-contained: it manages its own input, rendering, and transitions without knowing about other scenes.

Game Programming Patterns: State