Model menu, play, pause and game-over as a finite state machine with a pure transition function.
Why this matters
A prototype quickly grows more than one "mode": a title screen, the game itself, a pause screen, a game-over screen with a restart button. Without structure this becomes a nest of flags (isPaused, isDead, hasStarted, showMenu) that can contradict each other. Players then find the bug where pressing pause on the game-over screen revives them. A finite state machine (FSM) removes that whole class of bugs by making the modes explicit.
The idea
A finite state machine has:
- a fixed set of states (exactly one is current),
- a set of events (things that happen: a key press, a hit, a timer),
- transitions: rules saying "in state S, event E moves you to state T".
Anything not listed is ignored. For a typical arcade game:
| from | event | to | side effect |
|---|---|---|---|
| menu | START | playing | score = 0, lives = 3 |
| playing | PAUSE | paused | |
| paused | RESUME | playing | |
| playing | HIT | playing or gameover | lives - 1; gameover at 0 |
| playing | SCORE | playing | score + points |
| gameover | RESTART | menu |
The table is the design. Write it before any code, and show it to teammates: holes ("what does Escape do on the game-over screen?") become obvious.
A pure transition function
The cleanest implementation is a function that takes the current game and an event and returns the next game, without changing the input:
function createGame() {
return { state: "menu", score: 0, lives: 3 };
}
function transition(game, event) {
switch (game.state) {
case "menu":
if (event.type === "START") return { state: "playing", score: 0, lives: 3 };
return game;
case "playing":
if (event.type === "PAUSE") return { ...game, state: "paused" };
if (event.type === "SCORE") return { ...game, score: game.score + event.points };
if (event.type === "HIT") {
const lives = game.lives - 1;
return { ...game, lives, state: lives <= 0 ? "gameover" : "playing" };
}
return game;
// paused and gameover follow the same pattern
default:
return game;
}
}
Events are objects ({ type: "SCORE", points: 10 }) so they can carry data. The spread { ...game, ... } copies the old fields and overrides a few, so the original is untouched.
Why bother with purity?
- Testing:
transition(createGame(), { type: "START" })is a one-line test with no canvas, timer or keyboard. - Replays and debugging: a list of events plus the starting state reproduces any bug exactly.
- Undo, rewind, networking: keeping old states is free when nothing mutates them.
Ignoring invalid events
Notice the return game; lines. A HIT in the menu, a PAUSE on the game-over screen, a SCORE while paused: all ignored. This is the main safety property of an FSM. Instead of asking "which flags must I check here?", each state lists what it accepts and everything else is a no-op.
Wiring it to the loop
The game loop asks the state what to do:
let game = createGame();
function update(dt) {
if (game.state === "playing") updateWorld(dt); // physics only while playing
}
function draw() {
drawWorld();
if (game.state === "menu") drawTitle();
if (game.state === "paused") drawOverlay("Paused");
if (game.state === "gameover") drawOverlay(`Game over: ${game.score}`);
}
window.addEventListener("keydown", (e) => {
if (e.key === "Enter") game = transition(game, { type: game.state === "gameover" ? "RESTART" : "START" });
if (e.key === "Escape") game = transition(game, { type: game.state === "paused" ? "RESUME" : "PAUSE" });
});
Input handlers translate keys into events; the transition function decides what those events mean right now.
When states get bigger
- Enter/exit actions: run code once when entering a state (play a jingle on game over).
- Hierarchical states: "playing" can contain sub-states like "alive", "respawning", "level-complete".
- Per-entity FSMs: enemies use the same pattern: idle, patrol, chase, attack. The NesHacker video shows how classic games used state machines for almost everything.
- Behaviour trees replace FSMs for complex AI, but an FSM is the right first tool for game flow.
Common mistakes
- Boolean soup: several flags that can be true at once instead of one
statevalue. - Mutating the input in a function meant to be pure, so tests pass or fail depending on order.
- Forgetting to reset score and lives on START, so a new run keeps the old score.
- Handling events everywhere: pause logic scattered across input, physics and UI code.
- Using
<instead of<=for lives, so the player gets an extra life at zero.
Quick recap
- One
statevalue, a fixed list of events, and a transition table. - A pure
transition(game, event)returns a new game and ignores invalid events. - The loop updates and draws based on the current state.
- Write the table first; it is the design.
Try it
Open the Do tab to implement createGame and a pure transition function for menu, playing, paused and game over.