Spielwerk 02: Galgenraten

2026-11-01
Spielwerk 02: Galgenraten - Teaser

Galgenraten is hangman in German: one secret word, eight wrong guesses, a gallows that grows with every miss. It is also the first step with real keyboard input, and the place to learn how a game loop should read keys: once per frame, from a state, not inside event handlers. The new toolbox module is KeyboardState.

Play it · Source: collection-spielwerk/02-galgenraten · Toolbox 5.4.0 or later

The game

The element picks a German noun (5 to 12 letters, umlauts allowed, no ß) and shows one empty box per letter. Every hit fills all matching boxes, every miss draws the next part of the gallows and is listed under "Falsch geraten" (wrong guesses). Find the word for "Gewonnen!" (won); the eighth miss ends with "Verloren – das Wort war …" (lost, the word was …) and reveals it. "Neues Wort" (new word) or "Nächstes Wort" (next word) continues with the next seed.

New in the toolbox: KeyboardState

The first idea for keyboard input is a keydown listener that decides on the spot: a letter? guess it. Enter? new word. That works for Galgenraten, but it is the wrong pattern for games:

The idea: turn it around. DOM events go in and are only recorded; the loop asks plain questions out, at the one place it decides things, and then closes the frame so the next one starts empty.

export class KeyboardState {
    attach(target: EventTarget, signal?: AbortSignal): void;  // keydown, keyup, blur
    detach(): void;
    is_down(key: string): boolean;       // held right now
    was_pressed(key: string): boolean;   // went down since end_frame()
    was_released(key: string): boolean;  // went up since end_frame()
    typed(): string[];                   // printable characters, in order
    end_frame(): void;                   // once per frame, after reading
    clear(): void;                       // forget everything, held keys included
}

Keys are KeyboardEvent.key values: "ArrowLeft", "a", "Ä", " ", "Enter". The difference between is_down and was_pressed is the core of it. is_down("ArrowLeft") is true in every frame the key is held: movement. was_pressed(" ") is true in exactly one frame: jump, shoot, confirm. The recording side is three small handlers:

private readonly on_key_down = (event: Event): void => {
    if (!is_keyboard_event(event)) {
        return;
    }
    const key = event.key;
    if (!event.repeat && !this.down_keys.has(key)) {
        this.pressed_keys.add(key);              // an edge: only the first keydown counts
    }
    this.down_keys.add(key);
    if (is_printable(event)) {
        this.typed_characters.push(key); // auto-repeat types, as expected
    }
};

private readonly on_key_up = (event: Event): void => {
    if (!is_keyboard_event(event)) {
        return;
    }
    const key = event.key;
    if (this.down_keys.delete(key)) {
        this.released_keys.add(key);
    }
};

private readonly on_blur = (): void => {
    this.clear();                                // keyup outside the focus is never seen
};

public end_frame(): void {
    this.pressed_keys.clear();                   // edges and characters belong to one frame,
    this.released_keys.clear();                  // held keys stay
    this.typed_characters = [];
}

is_printable accepts single-character keys without Ctrl or Meta; Ctrl together with Alt still counts, because that is AltGr on Windows keyboards. attach uses an internal AbortController, so detach() or an aborted signal removes all listeners at once, and attaching twice moves them to the new target. Only EventTarget is used, never document or window, so the class runs in jsdom.

Galgenraten reads the keyboard in one method, called from the tick:

// src/TceGalgenratenLogic.ts
protected override update(): boolean {
    const keyboard = this.properties.keyboard;
    for (const character of keyboard.typed()) {
        this.guess(character);
    }
    if (keyboard.was_pressed('Enter')) {
        this.restart();
    }
    return false;                                // guess() marks the screen dirty itself
}

The shell calls keyboard.end_frame() right after update().

Keys and the on-screen buttons end in the same guess(), and the DOM-free Game normalizes the character ("ö" becomes "Ö", a digit is ignored). While the intro runs, the tick only calls keyboard.clear(), so a key pressed during the logo never becomes a guess.

The test (src/input/KeyboardState.test.ts) dispatches synthetic events and checks the two kinds of question:

it("reports a key as down while it is held and as pressed once", () => {
    const { keyboard, element } = setup();
    key_down(element, "ArrowLeft");
    expect(keyboard.is_down("ArrowLeft")).toBe(true);
    expect(keyboard.was_pressed("ArrowLeft")).toBe(true);
    keyboard.end_frame();
    expect(keyboard.is_down("ArrowLeft")).toBe(true);
    expect(keyboard.was_pressed("ArrowLeft")).toBe(false);
});

it("forgets held keys on blur", () => {
    const { keyboard, element } = setup();
    key_down(element, "ArrowUp");
    element.dispatchEvent(new Event("blur"));
    expect(keyboard.is_down("ArrowUp")).toBe(false);
});

The blur case is the trap: without it, a player who holds a key and clicks into another window comes back to a key that is still "down" forever.

The module is src/input/KeyboardState.ts.

From earlier steps

The scaffold, the GO logo intro, the start screen and the scaling are explained once in Spielwerk 00: The workbench. The alphabet is a toolbox UiPage of Buttons; a CSS-like class plus a theme rule recolors a letter once it has been tried, as a hit or a miss. What this step first wrote by hand for the key handling, the start picture and a status line that follows the game 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-galgenraten"></script>
<tce-galgenraten init-seed="4711"></tce-galgenraten>
Attribute Values Default
init-seed whole number; picks the word, every new word continues with seed + 1 random
init-intro true / false; play the GO logo first true

Next

Previous: Spielwerk 01: Zahlen raten · Next: Spielwerk 03: Tic-Tac-Toe · All steps

Comments

captcha