A night on a car park, three minutes of zombie waves that grow from thirty to thousands, and three ways to shoot back. It puts the physics world from step 27 under a real crowd, which needed two small things in the toolbox: a world that only reports the contacts a game listens to, and a way to draw a thousand turned sprites without a save() and restore() for each.
Play it · Source: collection-spielwerk/29-horde · Toolbox 6.0.0 or later
The game
You are the survivor in the middle of a car park at night. Every twenty seconds a new wave comes out of the dark at the edges and walks at you: walkers, quick runners from the second wave on, heavy brutes that take five hits from the third. A touch pushes you back and costs 25 points, and nothing else happens, so there is no way to lose: the night always lasts three minutes and ends on a score card ("Die Nacht ist vorbei"). The panel shows what the device is doing: frames per second, zombies, bullets, pair tests, milliseconds for the physics step and for painting, and the most zombies this device carried at 60 FPS.
- Mouse: WASD or the arrows walk, the mouse aims, holding the button fires. 1, 2 and 3 pick the pattern ("Fächer", a fan of five; "Spirale", three arms turning round you; "Abpraller", a shot that comes back from walls three times). G switches the grid off and on.
- Touch: hold a finger where you want to go; the survivor walks there and aims at the nearest zombie by itself. The buttons pick the pattern and the grid.
New in the toolbox: a crowd in the world
The problem. Step 27 dropped a few hundred shapes into a box. Here it is 5,000 bodies that never rest: every zombie walks, every zombie leans on its neighbours, and bullets fly through the lot. Three things went wrong at that size. Sleeping, which saved 27, does nothing for a crowd that always moves (the game switches it off: sleep_seconds: Infinity). Every one of the 17,000 contacts a step sent a ContactEvent that nobody read, because the game only cares about bullets and the survivor. And painting a turned sprite the usual way, with save(), translate(), rotate(), drawImage() and restore(), is five calls and a saved state per zombie.
The idea. Let the world know which contacts matter, and let the painter keep only the numbers until it draws.
const world = new PhysicsWorld<Thing>({
rule: AABB_SWEEP, // measured: 8.8 ms a step at 5,000 against 12.4 with SAT_SWEEP
broad_phase: { kind: "grid", cell_size: 40 },
restitution: 0.9,
bounce_speed: 250, // only bullets close this fast: they bounce, the crowd doesn't
sleep_seconds: Infinity, // nobody ever stands still
contact_event_layers: LAYER.bullet | LAYER.player, // new: events only for these layers
});
contact_event_layers takes the same bits as a body's layer. A contact still pushes the two bodies apart either way; it only sends an event when one of them is on those layers. The default is every layer, so older games behave as before. With bullets and the survivor on it, a step at 5,000 zombies went from 10.8 to 9.0 ms. The masks do the rest: bullets meet zombies and walls, never other bullets, so a spiral of a hundred bullets adds no pairs among themselves.
The crowd walks with seek_velocity, new in physics/steering next to steer_towards from step 19. It gives the velocity instead of moving the body, because the world moves it:
seek_velocity(body.position, target, { speed: definition.speed }, desired); // written into `desired`
// then the zombie gets there at its grip: a push carries it a little way first
For painting there is SpriteBatch (src/canvas/SpriteBatch.ts):
const batch = new SpriteBatch();
batch.add(image, x, y, angle, width, height); // only numbers, kept per image
batch.draw(context); // image by image, one setTransform each, then the transform as it was
draw() takes the context's transform once and works out each sprite's matrix on top of it, so the camera's translate() still applies and nothing is saved or restored per sprite. Sprites are grouped by image, which means all walkers are drawn before all runners; for a crowd that doesn't matter.
What the measurement said. I expected the batch to be the big win. It isn't: in headless Chromium, which paints the 2D canvas in software, 2,000 turned sprites take 34 ms with the batch and 36 ms with save() and restore(). The time goes into putting turned pixels on the canvas, not into the calls: the same 2,000 sprites unturned take 11 ms, and painted circles 7 ms. The game draws only what is in view (a few hundred of the thousands), and FrameBudget, moved into the shell from step 27, holds the crowd at what the device carries: below 50 FPS new zombies stay in the dark, below 40 the furthest go back into it.
| Zombies | step, grid | step, every pair | painting (calls) | FPS, grid / every pair |
|---|---|---|---|---|
| 500 | 0.6 ms | 2.2 ms | 0.5 ms | 60 / 60 |
| 2,000 | 2.0 ms | 12.0 ms | 0.7 ms | 60 / 42 |
| 5,000 | 7.9 ms | 74.9 ms | 1.4 ms | 27 / 10 |
A test worth copying. That bullets never test bullets is easy to claim and easy to break with one wrong mask, so the test asks the rule itself:
const spy: CollisionRule = {
...AABB_SWEEP,
overlap: (a, b) => {
asked += 1;
if (role(a) === "bullet" && role(b) === "bullet") {
bullet_pairs += 1;
}
return AABB_SWEEP.overlap(a, b);
},
};
game.world.rule = spy;
// ... a ring of brutes, four seconds of the fan pattern ...
expect(asked).toBeGreaterThan(100);
expect(bullet_pairs).toBe(0);
With LAYER.bullet added to the bullets' mask it fails at once.
Path: src/physics/world/PhysicsWorld.ts (contact_event_layers), src/physics/steering/Steering.ts (seek_velocity), src/canvas/SpriteBatch.ts, src/game-element/FrameBudget.ts.
The pictures
The survivor, the three zombies, the ground tiles, blood and casings are placeholders, drawn by a script on white and cut out by another one that keeps white inside a figure (the eyes) and leaves no white halo. They are in dll-tce-assets 0.2.1 under their final names with the tag placeholder, so the real art will only change what is behind each name. The game asks find_assets({ tags: ["horde"] }) and loads them with AssetManager.request() from step 28 before the first wave. On a page with an older library it plays with painted shapes, and the loading card says so. Ground, blood and casings are stamped into one picture the size of the car park, so a thousand pools of blood cost one drawImage() a frame.
From earlier steps
Camera2Dfrom step 12: the view follows the survivor over a car park larger than the screen.PhysicsWorldfrom step 27: every zombie, bullet and wall is a body, with layers, masks and the grid.AssetManager.request()from step 28: the sprites come off the network with a loading bar.Cooldownfrom step 05: one shot sound and one hit sound at a time, however many there are.SeededRandomfrom step 01: the same seed sends the same waves.
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-horde"></script>
<tce-horde></tce-horde>
| Attribute | Values | Default |
|---|---|---|
init-seed |
whole number; the night's waves (where and what comes) | random |
init-intro |
true / false: play the GO logo intro at the first connection |
true |
Next
Previous: Spielwerk 28: Artbook · All steps · Where the series is heading: Civilization
Comments