Spielwerk 01: Zahlen raten

2026-11-01
Spielwerk 01: Zahlen raten - Teaser

The computer picks a number between 1 and 100, I guess, and it answers "Zu klein" (too low) or "Zu groß" (too high). The smallest game I know, and the right place to fix the first thing every game gets wrong: randomness you cannot repeat. The new toolbox module is SeededRandom.

Play it · Source: collection-spielwerk/01-zahlen-raten · Toolbox 5.7.0 or later

The game

Type a number and press "Raten" (guess). The game says whether the secret is higher or lower, and a bar shows the range that is still possible. Every guess appears as a tile with an arrow. Hit the number and the pad gives way to "Neues Spiel" (new game). A binary search never needs more than seven guesses for 1 to 100.

New in the toolbox: SeededRandom

Math.random() is a poor source of randomness for a game. Not because its numbers are badly distributed, but because you cannot get the same numbers twice. That breaks three things:

The idea: a generator whose whole state is one number. Each call scrambles that number into the next one and turns it into a fraction. Same starting number, same sequence, forever, on every machine.

The algorithm is mulberry32, a 32-bit generator that fits in four lines and is good enough for games:

export class SeededRandom {
    private _state: number;

    public constructor(seed: number) {
        this._state = Math.floor(Math.abs(seed)) >>> 0;   // any number becomes an unsigned 32-bit state
    }

    /** A number in [0, 1), like Math.random(). */
    public next(): number {
        this._state = (this._state + 0x6d2b79f5) >>> 0;   // step the state by a fixed odd constant
        let t = this._state;
        t = Math.imul(t ^ (t >>> 15), t | 1);              // then mix its bits until the output
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);        // looks uniformly distributed
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    }

    /** A whole number between min and max, both inclusive. */
    public int(min: number, max: number): number {
        return min + Math.floor(this.next() * (max - min + 1));
    }
}

>>> 0 keeps every intermediate value an unsigned 32-bit integer, and Math.imul multiplies like C would, without JavaScript's floating-point rounding. That is what makes the sequence identical in every browser.

Around that core sits a small API:

random.int(1, 6)              // a die
random.float(-2, 2)           // [min, max)
random.chance(0.25)           // true one time in four
random.pick(words)            // one element of a list
random.shuffle(cards)         // Fisher-Yates, in place
random.fork()                 // a second, independent stream
random.state                  // save this ...
SeededRandom.from_state(s)    // ... and continue exactly there

fork() matters as soon as two systems draw from randomness. If the enemy AI and the card deck share one stream, one extra AI decision shifts every card that follows. Give each system its own fork and they stop disturbing each other.

The game uses one line of it. Each game draws its secret from its own seed:

// src/game/Game.ts
this.secret = new SeededRandom(options.seed).int(this.min, this.max);

and the shell seeds game n of an element with init-seed + n, so every "Neues Spiel" gets the next seed. The same init-seed therefore replays the same series of games, not only the first one, and "the third game of seed 4711" names exactly one game.

The test next to the module (src/random/SeededRandom.test.ts) pins down exactly the promises above:

it("yields the same sequence for the same seed", () => {
    const a = new SeededRandom(4711);
    const b = new SeededRandom(4711);
    expect(Array.from({ length: 20 }, () => a.next())).toEqual(Array.from({ length: 20 }, () => b.next()));
});

it("forks into an independent stream and restores from state", () => {
    const parent = new SeededRandom(77);
    const saved = parent.state;
    const expected = parent.next();
    expect(SeededRandom.from_state(saved).next()).toBe(expected);
});

The rule for the rest of the series: no Math.random in game code. Only the element may roll once, when no init-seed is set, and that single roll becomes the seed.

From earlier steps

This is the first game step. The scaffold (the five classes) and the toolbox's canvas game shell are explained once in Spielwerk 00: The workbench. Zahlen raten implements create_game() and overrides read_attributes() for init-max, on_key_down() for typed digits and sync(), which shows the pad during a game and "Neues Spiel" after the win; the shell does the intro, the fitting and the input. It gives the stage two layouts, 960x640 and an upright 600x960 for phones, and every key is placed by a rule, so the pad rearranges itself when the stage switches. The number pad is twelve toolbox Buttons on the shell's UiPage. What this step first wrote by hand for placing widgets by rule, the header, the seed series, the button classes, the progress bar and the key grid 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-zahlen-raten"></script>
<tce-zahlen-raten init-seed="4711"></tce-zahlen-raten>
Attribute Values Default
init-seed whole number; decides every secret of this element random
init-max whole number 10..9999; the largest possible secret 100
init-intro true / false; play the GO logo first true

Next

Next: Spielwerk 02: Galgenraten · All steps

Comments

captcha