My first version of a mini-game tracked its phase with a pile of booleans: isLoading, isShowingStimulus, hasResponded, isComplete. It worked until it didn't. A slow network plus a fast click gave me isShowingStimulus && hasResponded && isLoading all true at once — a state that makes no sense but that my code happily rendered. I was writing defensive if checks to paper over combinations that should never exist.
The fix was to stop tracking facts about the state and start naming the state itself.
A game has phases, so model phases. Each mini-game moves through a fixed lifecycle: idle → stimulus → awaiting_response → feedback → complete. That's a finite state machine. There is exactly one current phase, and only certain transitions are legal.
const machine = {
idle: { START: "stimulus" },
stimulus: { SHOWN: "awaiting_response" },
awaiting_response: { RESPOND: "feedback", TIMEOUT: "feedback" },
feedback: { NEXT: "stimulus", DONE: "complete" },
complete: {},
};
function transition(state, event) {
return machine[state][event] ?? state; // ignore illegal events
}
Impossible states become unrepresentable. A click that arrives during feedback sends a RESPOND event that the machine simply ignores, because feedback has no RESPOND transition. No guard clauses, no flag juggling. The bug I was patching couldn't occur.
You get testing and visualization for free. Because transitions are data, I can assert "from stimulus, only SHOWN does anything" in a unit test, and I can literally draw the graph. When I outgrew the hand-rolled version I moved to XState, but the reducer above carried me a long way and is worth reaching for before adding a dependency.
The general rule: if you find yourself writing if (a && !b && c) to describe your UI, you probably have one state variable with a handful of legal values, not four independent booleans. Name the states.
I use this pattern across every game in CogniPrep, a practice platform for game-based psychometric assessments: https://cogniprep.app
Top comments (0)