Spielwerk 24: Stellschrauben

2026-11-01
Spielwerk 24: Stellschrauben - Teaser

An island workshop: an order comes in for land, mountains and islands, and you turn four screws until the map fits. Each map is made from scratch, cell by cell, and costs about 12 milliseconds. A slider asks for one on every move of the pointer. This step is about the toolbox's Throttler, which has been there since before this series and was never used: it makes sure the map keeps up without the sliders stuttering.

Play it · Source: collection-spielwerk/24-stellschrauben · Toolbox 5.7.0 or later

The game

Three orders per round. Each asks for a share of land, a share of mountains and a number of islands, each within a range. The screws are the sea level, the mountain line, how rugged the coast is, and the scale. When all three conditions are met, the map says "Passt!" and the next order comes. Your score is the time for all three. There's no clock running out, so you can try things.

New in the toolbox: the Throttler

The problem. A map of 480 by 360 cells is 172,800 cells, each from up to six layers of noise, then a flood fill to count the islands. That's about 12 ms in Chrome on my machine. A slider fires on every pointer move, and a held arrow key repeats. Make a map for every one of those and every move waits for the last map, and the slider drags behind the pointer. Turn "Drosseln" off in the game and you can feel it.

The idea. Don't make a map for every turn, but always make one for where the screw stops. Throttler wraps an async callback and runs it at most once per delay_ms, however often it's asked to:

private readonly throttler = new Throttler<[Game, Screws], Terrain>({
    delay_ms: this.settings.throttle_ms, // 120
    callback: async (game, screws) => this.make_map(game, screws),
});

The first call after a rest runs at once, so a single click answers right away. Calls that come while it runs, or within the delay after it, wait and share one more run, with the last call's arguments:

game.turn(key, value);
this.throttler.run(game, game.screws).then((terrain) => {
    // A map for a game that has been restarted since belongs to nobody.
    if (properties.game === game) {
        game.show(terrain);
    }
});

The browser check presses the arrow fifteen times in about 25 ms. With the throttle that gives 2 maps, the first and the last. Without it, every press makes one.

What using it found. The old Throttler ran the calls made during the delay once it ended, and the first one's arguments won. A slider let go during the delay ended on an old value, which in this game means a map that doesn't match its screws. Its only preset also dropped calls made while a run was going, and it never went back to rest, so wake_up_ms never applied. It had no tests. It's rewritten behind the same run() and settings, with tests first: the new default STRATEGIES.latest always runs the last call, combine keeps its documented behavior, and is_busy says whether anything is running or waiting.

One thing to know. Every call that shared a run gets the same promise. Fifteen presses gave two maps, but fifteen then()s, and the game first counted fifteen maps. Showing the same map again now counts once:

public show(terrain: Terrain): void {
    // Calls that waited for the same throttled run all get its map; it is one map.
    if (this.is_over || terrain === this.terrain) {
        return;
    }
    // ...
}

A test worth copying. Fake timers make a burst exact, and the slider test reads like what it checks:

it('the last value of a burst always runs: a slider ends where it was let go', async () => {
    const { calls, callback } = recorder(30);
    const throttler = new Throttler<[number], number>({ callback, delay_ms: 50 });

    for (let value = 1; value <= 20; value++) {
        void throttler.run(value);
        await vi.advanceTimersByTimeAsync(7);
    }
    await vi.advanceTimersByTimeAsync(500);

    expect(calls[calls.length - 1]).toBe(20);
    expect(calls.length).toBeLessThan(8);
});

The map

The terrain is plain code in the game, not a toolbox module: smooth value noise from the seed, a few layers of it added up, lower toward the edge so the map ends in sea. A cell below the sea level is water, above the mountain line it's mountain, and a thin band above the sea is beach. A flood fill then counts the pieces of land, and anything under 96 cells is a rock, not an island. The renderer turns the cells into an ImageData once per new map, as step 14 does for its pixels, and draws that picture scaled up every frame.

Every order is made from screws the game picks and doesn't show, so every order can be met. An order the start screws already meet is thrown away.

From earlier steps

The scaffold, start screen, intro and scaling are explained once in Spielwerk 00: The workbench.

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-stellschrauben"></script>
<tce-stellschrauben init-seed="4711"></tce-stellschrauben>
Attribute Values Default
init-seed whole number; the first run, every next run is seed + 1 random
init-intro true / false: play the GO logo intro at the first connection true

Next

Previous: Spielwerk 23: Diaabend · Next: Spielwerk 25: Wochenmarkt · All steps · Where the series is heading: Civilization

Comments

captcha