What you'll learn
- Build menu, play and game-over states
- Describe game flow as a transition table of states and events
- Write a pure transition function that ignores invalid events
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 machineA model with a fixed set of states, exactly one current, that changes state only through listed transitions. (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 eventsThings that happen, such as a key press or a hit, which may trigger a transition. (things that happen: a key press, a hit, a timer),
- transitionsRules of the form "in state S, event E moves you to state T".: 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 treesA structure used for complex AI that replaces state machines when behaviour gets large. replace FSMs for complex AI, but an FSM is the right first tool for game flow.
Common mistakes
- Boolean soupSeveral flags that can be true at once and contradict each other, instead of one state value.: 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.
Key terms (5)
- finite state machine
- A model with a fixed set of states, exactly one current, that changes state only through listed transitions.
- events
- Things that happen, such as a key press or a hit, which may trigger a transition.
- transitions
- Rules of the form "in state S, event E moves you to state T".
- Boolean soup
- Several flags that can be true at once and contradict each other, instead of one state value.
- Behaviour trees
- A structure used for complex AI that replaces state machines when behaviour gets large.