Spielwerk 19: Schleim-Abwehr

2026-11-01
Spielwerk 19: Schleim-Abwehr - Teaser

A castle in a meadow, slimes in waves that grow, and all you do is spend gold. Everything walks somewhere and then stands in a crowd, so the new module is Steering: stop at a point without shaking, and don't stand on each other.

Play it · Source: collection-spielwerk/19-schleim-abwehr · Toolbox 5.7.0 or later

The game

Castle, towers and units act by themselves. A worker ("Arbeiter") fetches gold. A swordsman ("Schwert") hits all it touches, which answers a swarm, but a thick hide takes most of that damage, which "Turmtempo" and "Turmkraft" answer. A wave comes every 60 seconds, and your score is the wave at which the castle falls. "Heim" shelters the workers: the towers shoot faster, but no gold comes in.

The old version had 1,900 lines. I cut what changed no decision: the growing world, trees you chop, its physics engine, the menu. About 1,300 lines are left, 250 of them numbers and layouts.

New in the toolbox: Steering

The problem. The obvious way to walk is one line:

slime.x += direction_x * speed * delta_seconds;

Close to the target that step is longer than the way left, so the slime overshoots, turns round and overshoots again. At 3x it shakes three times as far. On the exact spot the direction divides by zero, and a NaN position quietly removes the slime from the game. And thirty slimes with one target end up on one pixel, where they look like one slime.

The idea. Two small rules, one for each body and one for all of them. A step is never longer than the way left. And an overlap isn't corrected at once: each step takes away a share of it, with all pushes added up first and applied together, so the order of the list doesn't matter. A push out and a slow walk in then cancel, and a crowd comes to rest.

The API. Two plain functions over anything with an x and a y:

function steer_towards(body: Vector2I, target: Readonly<Vector2I>, delta_seconds: number, options: SteerOptions): boolean;
function separate_bodies(bodies: readonly SeparatedBody[], delta_seconds: number, stiffness?: number): void;

interface SteerOptions {
    speed: number;
    stop_distance?: number; // how close counts as there
    slow_distance?: number; // the last stretch, slowing down
}

interface SeparatedBody extends Vector2I {
    radius: number;
    is_fixed?: boolean; // pushes others, never moves: a building
}

The core of the walk:

const distance = Math.hypot(to_x, to_y);
const left = distance - (options.stop_distance ?? 0);
if (left <= 0) {
    return true; // on the spot too: this is where the NaN would have been
}
// Never below the floor: slowing in proportion alone would halve the way forever.
const speed = left < slow_distance
    ? options.speed * Math.max(MIN_SLOW_SHARE, left / slow_distance)
    : options.speed;
const step = Math.min(left, speed * delta_seconds);
body.x += (to_x / distance) * step;
body.y += (to_y / distance) * step;
return step === left;

separate_bodies compares all pairs and adds up the pushes in two arrays. Two bodies on the very same point part in a direction made from their places in the list, so there are no random numbers in it and a seed replays exactly. It is the plain version on purpose: the game's limit is 251 bodies, about 31,000 pairs, and a whole game step at that limit costs about half a millisecond. A grid would be for a number that asked for one.

In the game. A slime goes for the castle, or for a unit that comes into sight, and everything is separated once per step, with the buildings fixed:

const prey = nearest(slime, units, slime.stats.sight);
const target = prey ?? castle;
steer_towards(slime, target, STEP_SECONDS, {
    speed: slime.stats.speed,
    stop_distance: slime.radius + target.radius,
    slow_distance: SLOW_DISTANCE,
});
if (is_touching(slime, target) && slime.bite.try_use()) {
    this.bite(slime, prey);
}
// after everybody has moved:
separate_bodies([...this.towers, ...this.slimes, ...this.swordsmen, ...workers_outside], STEP_SECONDS);

The crowd does work here: only the front row reaches the castle, so a hundred slimes don't bite a hundred times.

The test and the trap. The test that counts walks 40 bodies to one point for 30 seconds and then looks at one more step: nobody moves more than 0.05 units, and nobody stands in another's middle.

for (let step = 0; step < 900; step++) {
    step_all();
}
const before = crowd.map((one) => ({ x: one.x, y: one.y }));
step_all();
const moved = crowd.map((one, index) => Math.hypot(one.x - before[index].x, one.y - before[index].y));
expect(Math.max(...moved)).toBeLessThan(0.05);

The trap got me twice while building this. First, my slow-down had no floor, and steer_towards never said "there". Then three workers shared a coin spot with a stop distance smaller than they fit into: they kept each other out, none ever arrived, and the income was zero. Bodies that share a target need a stop_distance with room for all of them. The module is src/physics/steering/Steering.ts.

From earlier steps

The rest is in Spielwerk 00: The workbench, now with FixedStepClock: step 16 and step 17 had the same loop, and this would have been the third. "Zu wenig Gold" is a TimedNotice from there, and the end card that a click meant for "Schwert" can't skip is settings.round_end_hold_seconds: this game wrote both by hand first, and step 20 moved them.

Over 500 seeded runs per bot, the median wave is 3 buying nothing, 5 with only towers, 6 with only swordsmen, 11 with workers and one of those, 20 with workers first, then everything, calling home in time. Every run ended, at wave 22 at most.

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-schleim-abwehr"></script>
<tce-schleim-abwehr init-seed="4711"></tce-schleim-abwehr>
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 18: Rundenkampf · Next: Spielwerk 20: Stellwerk · All steps

Comments

captcha