12 KiB
Worms (Worms Armageddon) — build plan
Living plan doc. Survives context clears: read this file first, pick the next unchecked item, update the checkboxes and the Status line as work lands.
Status: Waves 0–1 done (2026-07-31). node tools/verifyWorms.js → 207 checks green
(~4 min; --quick ≈ 30 s). The game is wired into the menu and a full match is playable
with 9 of the 16 weapons. Never opened in a browser — Brian playtests, and nothing here
is signed off until he has.
Decisions taken up front
| Question | Answer |
|---|---|
| Slug / name / category | worms / "Worms" / arcade-console-pc ("Video Games") |
| iconFrame | 92 — freed by the Angry Birds removal; 0–91 were contiguous with no gaps |
| Physics | Bespoke deterministic solver. Matter.js ships inside the CDN Phaser build but is not importable in bare Node, which would forfeit the verifier and the AI's shot replay |
| Weapons | 16 core: Bazooka, Grenade, Cluster, Shotgun, Uzi, Fire Punch, Dynamite, Mine, Air Strike, Homing Missile, Holy Hand Grenade, Blowtorch, Girder, Teleport, Ninja Rope, Skip Go (+ Surrender) |
| Single-player | 20-match campaign ladder fronted by the shared opponent roster, plus free Quick Match |
| 2 player | Hotseat round-robin, one shared control set (turns never overlap) |
| Themes | 8 — Forest, Desert, Farm, Hell, Arctic, Jungle, Construction, Space |
| Art | Terrain shape procedural; worms procedural with a drop-in hook. Brian supplies theme backdrops, terrain fill/surface textures and background-object sheets |
| Map shapes | Island (open sky) and Cavern (indestructible frame) |
| Look | Clean late-90s PC. No CRT overlay, no m6x11 pixel font |
| Rules | 45 s turns, 5 s retreat, fall damage, wind, sudden death + rising water, crates, mines, oil drums |
Files
| File | Role | State |
|---|---|---|
src/games/worms/WormsTerrain.js |
Mask terrain: generation, destruction, queries, spawn lint | ✅ Wave 0 |
src/games/worms/WormsPhysics.js |
Worm locomotion, projectiles, rope, blast geometry | ✅ Wave 0 |
tools/verifyWorms.js |
Headless verifier, sections 1–9 | ✅ Wave 0 (152 checks) |
src/games/worms/WormsWeapons.js |
The 16-weapon table | ✅ Wave 1 |
src/games/worms/WormsLogic.js |
Match rules, turn order, event list | ✅ Wave 1 |
src/games/worms/WormsThemes.js |
8 theme palettes + art slot names | ✅ Wave 1 |
src/games/worms/WormsRender.js |
Procedural worms, terrain painting, FX | ✅ Wave 1 |
src/games/worms/WormsGame.js |
Phaser scene | ✅ Wave 1 |
src/games/worms/tutorial.md |
Player-facing rules and controls | ✅ Wave 1 |
data/worms-artwork.json |
Drop-in art slots, all path: null |
✅ Wave 1 |
src/games/worms/WormsAI.js |
Skill 1–5 opponent | ⬜ Wave 3 |
data/worms-campaign.json |
20 ladder rungs | ⬜ Wave 3 |
src/games/worms/sprites.md |
Art spec for Brian | ⬜ Wave 4 |
Wiring touchpoints — all done in Wave 1: src/data/gamesRegistry.js (iconFrame 92),
src/main.js (import + scene array), src/scenes/GameRoomScene.js:26 (slugDispatch),
src/data/assetManifest.js (wormsArt resolver), src/scenes/PreloadScene.js
(worms-artwork JSON). The ?worms-maplab=1 entrance is still Wave 4.
Deviation from the original plan: the weapon table lives in WormsWeapons.js, not
data/worms-rules.json. The verifier, the AI and the scene must agree on those numbers
exactly, and a plain ESM import gives that in both the browser and bare Node with no fetch
and no async boot ordering. Theme palettes are in WormsThemes.js for the same reason;
only the art paths are data (data/worms-artwork.json).
Wave 0 — terrain + physics core, headless — ✅ DONE 2026-07-31
Landed: WormsTerrain.js, WormsPhysics.js, tools/verifyWorms.js sections 1–9.
- Seeded RNG + integer-hash value noise, no
Math.randomanywhere in the sim - Island generation — heightfield crust, coastline envelope, noise caves, arches, speck prune
- Cavern generation — ceiling/floor band, interior pillars and ledges, largest-void filter, ROCK frame
carveCircle/addCircle/addGirder, ROCK immunity, order independenceraycast,normalAt,surfaceY,firstSolidBelow,hashMask- Worm locomotion — step-up walking, jump, backflip, ballistic flight, fall damage
- Projectiles — swept collision, bounce, fuse,
maxAge, wind, homing - Ninja rope — raycast anchor, pendulum, corner push/pop wrapping
- Blast geometry — linear falloff, impulse direction
- Fairness lint +
generateValidMapretry loop
Wave 0 findings
-
A plain noise threshold cannot generate a cavern. The base octave decides huge regions at once, so thresholding fBm gives one usable chamber and welds two thirds of the map shut — measured 70 % solid with the right-hand 60 % of the map unreachable. The fix is to carve the void as a band between a wavy ceiling and a wavy floor, which is connected across the full width by construction, and get the interest from what is put back: pillars, stalactites and floating ledges from a second noise field. Verifier pins this: "cavern void spans the full width" requires >98 % of columns to contain air.
-
Only the ROOT rope anchor is attached to terrain. Every later anchor is a corner the rope merely bends around, and by construction it sits in AIR (it is the last free point before the raycast contact). Validating those the same way as the root made the rope release itself the instant it wrapped anything — 14 of 35 swings dropped mid-flight. Bends are geometry, not attachments.
-
Sub-pixel residue from a landing rides along forever. The mask is only sampled at integers, so a worm that settles at y=290.625 stands a visible pixel higher than one at y=291 and reports a different standing height for the rest of the match — it made a correct 6px step-climb look 1.4px wrong.
snapToGroundnow rounds to integers on landing. -
resolveOverlaphas to be a per-frame safety net, not just a landing fixup. Backing off 1.5px along the surface normal after a bounce is not enough in a concave corner, and terrain can be built or destroyed around an airborne worm. Without the net, a randomised walk had a worm inside terrain on 14 of 14 400 frames. The invariant worth having is absolute: a worm is never inside terrain at the end of a step. -
A 6px drop is a step, not a fall — and that is load-bearing.
MAX_STEP(10px) governs both climbing up and walking down. Terrain features below it are invisible to movement; features above it are walls. This is what makes terrain shape tactical rather than incidental, and it means "walked off a ledge" tests need a drop taller thanMAX_STEP. -
Explosion falloff divides by the radius. A zero-radius blast (which a weapon table typo will produce sooner or later) put NaN into worm velocities and from there into the whole sim. Guarded at the source.
Wave 0 baselines
| Measurement | Result |
|---|---|
| Map generation, medium island / cavern | 93 ms / 199 ms |
| Map generation, large island / cavern | 148 ms / 266 ms |
generateValidMap retries |
1.0–1.2 mean, ≤8 worst |
carveCircle r=30 / r=75 / r=120 |
0.011 / 0.033 / 0.080 ms |
| 8 worms × 120 substeps (1 s of sim) | 6.0 ms |
| 500 AI shot replays | 40 ms |
| Bazooka range, full power, flat ground | ~1030 px (matches 2v²sin θ cos θ / g to 2 %) |
The AI budget risk in the original plan is resolved: at 0.08 ms per shot replay the AI can evaluate a few thousand candidates inside a 200 ms turn, so no coarse-to-fine sweep is needed. Terrain repaint is a non-issue on the logic side; the CanvasTexture upload still wants measuring in the browser during Wave 1.
Wave 1 — rules engine + one playable match — ✅ DONE 2026-07-31
Landed: the 16-weapon table, the match rules engine, the 8 theme palettes, the renderer, the scene, the tutorial, and all five wiring touchpoints. Verifier sections 10–12 added.
WormsWeapons.js— 16 weapons + surrender, crate table, charge curveWormsLogic.js— teams, turn rotation, timer, retreat, wind, crates, mines, barrels, sudden death + rising water, drowning, corpse blasts, chain reactions, win checkWormsThemes.js— 8 palettes with named art slotsWormsRender.js— terrain RenderTexture, procedural worms, water, explosionsWormsGame.js— mode select, quick-match setup, camera, HUD, weapon panel, keyboard + slingshot mouse aim, 2-player hotseat- Wiring: registry (iconFrame 92),
main.js,slugDispatch, manifest resolver, preload - Verifier §10 weapon table, §11 match rules, §12 scripted match soak
9 of 16 weapons are live (bazooka, grenade, cluster, shotgun, uzi, fire punch, dynamite,
mine, holy hand grenade, plus skip/surrender). The target and utility kinds — air strike,
homing missile, blowtorch, girder, teleport, ninja rope — deliberately refuse to fire
rather than silently eating the ammo and the turn; the verifier pins that. The rope already
works in physics and is reachable from the scene, it just has no ammo accounting yet.
Wave 1 findings
-
pendingDeathmust not block the settle phase. "Is the world quiet?" and "is anything waiting to be resolved?" are different questions. Treating a worm at 0 HP as still moving stranded every kill until the 14-second settle timeout fired, turning a two-second turn into a sixteen-second one. Deaths are resolved by the phase that comes after settle. -
A body has to be seated by its own radius. Barrels were being placed 8 px above the surface with a 13 px radius, so they started half-buried — and a half-buried body is never "grounded" by the contact test, so it fell through its own first frame.
-
A two-shot weapon costs one ammo for the pair. Spending per trigger pull halves the shotgun. The fix is to spend only on the pull that starts the pair, which needs the decision made before the switch, not inside the multi-shot bookkeeping.
-
Phaser Containers swallow
scrollFactorthe same way they swallow depth. Parenting the parallax layers to one backdrop container made every layer move at the container's rate — the backdrop looked painted onto the terrain.makeBackdropreturns loose root objects with explicit depths instead. (Same family as the container-depth gotcha already burned into the repo.) -
"Leave match" leaves
this.stateset. Tearing down the graphics while the state object survives means the nextupdate()draws into destroyed objects. A singlematchLiveflag set byteardownMatch/buildWorldguards every path — rematch, menu, scene shutdown — instead of null-checking six fields. -
Craters must be smaller than the damage radius. Otherwise every hit that hurts also opens a hole big enough to fall through, and matches devolve into everyone drowning. Pinned by the verifier across the whole weapon table.
Wave 1 baselines
| Measurement | Result |
|---|---|
| Scripted match, 4v4 | terminates 30/30, 0 NaN, 0 draws, mean 50 turns |
| Wall time per scripted match | ~5.7 s (~2.5 s with retreatSeconds: 0) |
| Whole-match determinism | same seed → same hashState |
The soak cost matters for Wave 3: 400 AI matches at ~5.7 s is 38 minutes. The soak config
sets retreatSeconds: 0 (the retreat wait is dead time headlessly), which is why §12 runs
24 matches by default and --games=400 is the long run.
Wave 2 — full arsenal and rule extras — ⬜ NEXT
targetkind: air strike, homing missile, girder, teleport (click-to-place flow)utilitykind: blowtorch, ninja rope ammo + turn integration- Verifier §13: a fixture per weapon pinning damage, blast and behaviour
Wave 3 — AI and campaign ladder — ⬜
Wave 4 — presentation, themes, 2-player — ⬜
Wave 5 — retune after Brian's playtest — ⬜
Sources for original-game behaviour
Worms Armageddon (Team17, 1999): 45 s default turn with 5 s retreat, 100 HP worms, wind re-rolled each turn and affecting only unpowered arcing weapons, fall damage from the peak of the fall, sudden death dropping every worm to 1 HP with the water rising per turn, crates parachuting in between turns, and the ninja rope wrapping terrain corners rather than passing through them.