fertig-classic-games/docs/worms-build-plan.md

12 KiB
Raw Blame History

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 01 done (2026-07-31). node tools/verifyWorms.js207 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; 091 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 19 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 15 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 19.

  • Seeded RNG + integer-hash value noise, no Math.random anywhere 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 independence
  • raycast, 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 + generateValidMap retry loop

Wave 0 findings

  1. 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.

  2. 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.

  3. 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. snapToGround now rounds to integers on landing.

  4. resolveOverlap has 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.

  5. 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 than MAX_STEP.

  6. 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.01.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 1012 added.

  • WormsWeapons.js — 16 weapons + surrender, crate table, charge curve
  • WormsLogic.js — teams, turn rotation, timer, retreat, wind, crates, mines, barrels, sudden death + rising water, drowning, corpse blasts, chain reactions, win check
  • WormsThemes.js — 8 palettes with named art slots
  • WormsRender.js — terrain RenderTexture, procedural worms, water, explosions
  • WormsGame.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

  1. pendingDeath must 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.

  2. 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.

  3. 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.

  4. Phaser Containers swallow scrollFactor the 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. makeBackdrop returns loose root objects with explicit depths instead. (Same family as the container-depth gotcha already burned into the repo.)

  5. "Leave match" leaves this.state set. Tearing down the graphics while the state object survives means the next update() draws into destroyed objects. A single matchLive flag set by teardownMatch/buildWorld guards every path — rematch, menu, scene shutdown — instead of null-checking six fields.

  6. 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

  • target kind: air strike, homing missile, girder, teleport (click-to-place flow)
  • utility kind: 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.