Spielwerk 04: Mastermind

2026-11-01
Spielwerk 04: Mastermind - Teaser

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.

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

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

captcha