Spielwerk 10: Hau den Maulwurf

2026-11-01
Spielwerk 10: Hau den Maulwurf - Teaser

Twelve holes, moles that peek out for a moment, and a hammer on the pointer: a game where the hand counts, not the decision. The new toolbox module is Spawner, the schedule every "something appears every so often" game needs, without setInterval, seeded, and with a safe catch-up after a long frame.

Play it · Source: collection-spielwerk/10-hau-den-maulwurf · Toolbox 5.5.0 or later

The game

A round lasts 45 seconds. Moles pop up from random holes, at most three at a time, and sink again after about a second; towards the end they come twice as often and stay shorter. A hit scores 10 and raises the combo, a blow on an empty hole costs 5, and a mole that gets away resets the combo. Now and then a golden mole (+30, gone quickly) or a bomb (−50) comes up instead. After "Runde vorbei" (round over) the score and the session's best stay up, and the next round plays the next seed.

New in the toolbox: Spawner

The obvious way to send up a mole every so often is setInterval, and it is the wrong one. It runs on the browser's clock, not the game's: a background tab gets throttled, a paused game keeps spawning, a test cannot say "now 45 seconds have passed" but has to wait, and the same seed gives a different round every time because the intervals drift against the frames. Everything in this game should come out of update(delta_seconds), like the Timer from step 05.

The idea: a countdown that the frame loop feeds. When it reaches zero, call a factory, hand back what it made, and roll the next interval. A gate decides whether a spawn may happen right now.

const spawner = new Spawner(() => make_mole(), {
    interval: { min: 0.45, max: 0.9 },   // seconds: a fixed number or a range
    can_spawn: () => moles_up < 3,       // gate, asked at the moment of the spawn
    random: new SeededRandom(seed),      // rolls the ranges; default SeededRandom(0)
    max_per_update: 3,                   // catch-up limit (the default)
});
const spawned = spawner.update(delta_seconds);   // every frame: 0..n new items
spawner.rate_multiplier = 0.5;                   // twice as fast
spawner.seconds_until_next;
spawner.stop(); spawner.start(); spawner.reset();

The core:

public update(delta_seconds: number): T[] {
    const spawned: T[] = [];
    if (!this._is_running || !(delta_seconds > 0)) {
        return spawned;
    }
    this._seconds_until_next -= delta_seconds;
    // A microsecond of tolerance: 60 frames of 1/60 s add up to "almost" one second.
    while (this._seconds_until_next <= 1e-6) {
        if (spawned.length >= this.max_per_update) {
            this._seconds_until_next = this.roll_interval();   // drop the backlog
            break;
        }
        if (this.can_spawn()) {
            spawned.push(this.factory());
            this._spawned_count += 1;
        }
        this._seconds_until_next += this.roll_interval();
    }
    return spawned;
}

Two decisions are in there. Catch-up: while instead of if means a 2.5-second frame at a 1-second interval yields two spawns and keeps 0.5 seconds, like a repeating Timer. But only up to max_per_update; beyond that the backlog is dropped and the next spawn is a full interval away. A tab that returns after a minute in the background gets three moles, not sixty. The element's frame loop caps the delta at 0.1 seconds anyway, but the spawner does not know who calls it.

The gate skips, it does not queue. When can_spawn says no, that tick is lost and the next try comes one interval later, not the moment a mole sinks. The spawner never has a waiting list, which keeps its behaviour easy to predict.

rate_multiplier scales every interval, including the running one: a spawn one second away is half a second away after rate_multiplier = 0.5.

The game builds its whole rhythm from that:

// src/game/Game.ts
this.spawner = new Spawner<Spawn | null>(() => this.spawn(), {
    interval: { min: 0.45, max: 0.9 },
    can_spawn: () => this.moles_up < MAX_MOLES_UP && this.free_holes().length > 0,
    random: this.random.fork(),
});

// in update():
this.round.update(delta_seconds);
// the ramp: half the interval at the end of the round
this.spawner.rate_multiplier = 1 - 0.5 * this.progress;
this.spawner.update(delta_seconds);
if (this.is_over) {
    this.spawner.stop();
}

The same file exports weighted_pick, the weighted choice every spawner needs sooner or later. Each entry's chance is its weight over the sum; one roll walks through the weights until it drops below zero:

const kind = weighted_pick(this.random, KIND_WEIGHTS);   // normal 80, golden 8, bomb 12

// inside weighted_pick:
let roll = random.next() * total;
for (const entry of entries) {
    if (!(entry.weight > 0)) {
        continue;
    }
    last = entry.item;
    roll -= entry.weight;
    if (roll < 0) {
        return entry.item;
    }
}

The tests (src/flow/spawner/Spawner.test.ts) drive time directly, no waiting:

it("catches up after a long frame, at most max_per_update", () => {
    const spawner = new Spawner(() => "x", { interval: 1 });
    expect(spawner.update(2.5)).toEqual(["x", "x"]);
    expect(spawner.seconds_until_next).toBeCloseTo(0.5);
    // 10 s at once: 3 by default, the backlog is dropped, a full interval follows
    expect(spawner.update(10)).toEqual(["x", "x", "x"]);
    expect(spawner.seconds_until_next).toBeCloseTo(1);
});

Another test pins the gate: two closed ticks spawn nothing, and after opening it the next mole still comes a full interval later. The module is src/flow/spawner/Spawner.ts.

From earlier steps

Scaffold, start screen, intro and scaling are explained in Spielwerk 00: The workbench. The system cursor is hidden over the canvas: the painted hammer is the pointer, and a key press moves it over the hole it hits. What this step first wrote by hand for the "next round" pill 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-hau-den-maulwurf"></script>
<tce-hau-den-maulwurf init-seed="4711"></tce-hau-den-maulwurf>
Attribute Values Default
init-seed whole number; the first round, every next round is seed + 1 random
init-intro true / false; play the GO logo first true

Next

Previous: Spielwerk 09: Farb-Picker · Next: Spielwerk 11: Flappy Bird · All steps

Comments

captcha