Mastermind: the computer hides a code of four colors, I have ten rows to crack it, and every row is answered with black and white pegs. A game with phases (entering, checking, won, lost) should not be held together by boolean flags. The new toolbox module is StateMachine.
Play it · Source: collection-spielwerk/04-mastermind · Toolbox 5.4.0 or later
The game
Six colors, four places, repeats allowed: 1296 possible codes, drawn from the seed. I fill the active row and press "Prüfen" (check). A black peg means a right color in the right place, a white peg a right color in the wrong place; the pegs appear one after another. Four black pegs within ten rows is "Geknackt in n Versuchen!" (cracked in n tries), otherwise the code is revealed. "Neues Spiel" (new game) starts over with a new code.
- Mouse/touch: a palette color fills the next free place, a click on a place of the active row cycles its color, "Prüfen", "Neues Spiel".
- Keyboard: 1 to 6 fill the next free place, Backspace clears the last, Enter checks (or starts over after the end), Space starts over.
New in the toolbox: StateMachine
Mastermind has four phases. While I enter colors, clicks may change the row. While the pegs appear, half a second, nothing may change. Afterwards the game goes back to entering, or it is won or lost. With flags this becomes is_checking, is_over, has_won plus a timer, and every input has to test all of them. Four flags allow sixteen combinations, most of which must never happen, and nothing in the code says which.
The idea: name the phases and let exactly one be current. Each phase says what happens when it is entered, when it is left and on every update, all in one place. An update may return the next phase, and the machine performs the change.
export interface StateHandlers<S extends string, C> {
enter?(context: C, from: S | null): void; // from is null for the initial state
exit?(context: C, to: S): void;
update?(context: C, delta_seconds: number): S | void; // the next state, or nothing to stay
}
export class StateMachine<S extends string, C> {
constructor(states: Record<S, StateHandlers<S, C>>, initial: S, context: C);
get current(): S;
get previous(): S | null;
get seconds_in_state(): number;
is(state: S): boolean;
transition(to: S): void;
update(delta_seconds: number): void;
on_transition(listener: (from: S | null, to: S) => void): () => void; // returns the unsubscribe
}
States are plain strings, so S is a union type like 'entering' | 'checking' | 'won' | 'lost' and a typo is a compile error. The context C is whatever the handlers work on; here it is the game itself. The core is two methods:
public transition(to: S): void {
if (!(to in this.states)) {
throw new Error(`StateMachine: unknown state "${to}"`);
}
const from = this._current;
this.handlers(from).exit?.(this.context, to); // leave the old state first
this._previous = from;
this._current = to;
this._seconds_in_state = 0; // every state has its own clock
this.handlers(to).enter?.(this.context, from);
this.notify(from, to); // listeners see completed transitions only
}
public update(delta_seconds: number): void {
this._seconds_in_state += delta_seconds;
const next = this.handlers(this._current).update?.(this.context, delta_seconds);
if (next !== undefined) {
this.transition(next); // exactly one; the new state's update waits
}
}
The "exactly one" rule means no frame can skip a phase. Transitioning to the current state re-enters it: exit, enter, clock reset. The initial state's enter runs in the constructor, with from === null, and an unknown state name throws instead of leaving the machine somewhere undefined.
Why does update return the next state instead of calling transition itself? Because the handler then stays a pure decision: it reads the context and the clock and names a phase, and the machine owns the order of exit, enter and notification. notify iterates over a copy of the listener list, so a listener may unsubscribe itself while it is being called.
Mastermind's phases, from the DOM-free Game:
// src/game/Game.ts
this.phases = new StateMachine<Phase, Game>({
entering: {
enter: (game) => game.current.fill(null),
},
checking: {
enter: (game) => {
const code = game.current.map((slot) => slot ?? 0);
game.rows.push({ code, feedback: count_pegs(game.secret, code) });
game.current.fill(null);
},
update: (game) => {
if (game.phases.seconds_in_state < game.reveal_seconds) {
return;
}
const last = game.rows[game.rows.length - 1];
if (last.feedback.black === CODE_LENGTH) {
return 'won';
}
if (game.rows.length >= MAX_ROWS) {
return 'lost';
}
return 'entering';
},
},
won: {},
lost: {},
}, 'entering', this);
Every input then asks one question: set_slot and clear_last_slot return early unless this.phases.is('entering'), and check() is a guard plus this.phases.transition('checking'). The peg animation needs no timer of its own; it reads seconds_in_state / reveal_seconds. The element subscribes with game.phases.on_transition(...) to repaint and update the screen-reader status line, and the game knows nothing about the element.
The test (src/flow/state/StateMachine.test.ts) checks the order of calls and the one-transition rule:
it("runs exit before enter on a transition and tracks previous", () => {
machine.transition("checking");
expect(context.log).toEqual([
"exit entering to checking",
"enter checking from entering",
]);
expect(machine.previous).toBe("entering");
});
it("follows exactly one transition per update", () => {
machine.transition("checking");
machine.update(0.1);
expect(machine.current).toBe("won");
// "won" has no update: the machine stays.
machine.update(0.1);
expect(machine.current).toBe("won");
});
The module is src/flow/state/StateMachine.ts.
From earlier steps
SeededRandomfrom step 01: draws the secret code, so the sameinit-seedis always the same code.
The scaffold, the GO logo intro, the start screen and the scaling are explained once in Spielwerk 00: The workbench. "Prüfen" and "Neues Spiel" are toolbox Buttons on a UiPage; a DOM-free Layout class maps a click to a place or a palette color with Rect.contains. What this step first wrote by hand for wrapped text is part of the canvas game shell now; the workbench lists it under "Extracted along the way".
Embed it
<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-mastermind"></script>
<tce-mastermind init-seed="4711"></tce-mastermind>
| Attribute | Values | Default |
|---|---|---|
init-seed |
whole number; decides the secret codes, "Neues Spiel" continues with seed + 1 | random |
init-intro |
true / false; play the GO logo first |
true |
Next
Previous: Spielwerk 03: Tic-Tac-Toe · Next: Spielwerk 05: Lock Picker · All steps
Comments