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.
- Mouse/touch: the number pad, "Löschen" (delete), "Raten".
- Keyboard: digits, Backspace, Enter guesses (or starts the next game after a win), Escape clears the entry.
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:
- Reproducing. A player reports "the number was right on my first guess" and I cannot replay it. With a seed I ask for their
init-seedand see the same game. - Testing. This step's browser check knows that seed 4711 hides 37 and checks that guessing 50 really answers "Zu groß". With
Math.randomit could only check that some answer came. - Saving. A world, a deck, the next wave of enemies: with a seed they are a few bytes. Without one, I have to save the result.
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