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

268 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Goo Tower (World of Goo) — 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 03 complete (2026-07-26).** All nine goo types and every hazard are
live, with a 6-level sandbox tier on top of the 6-level teaching chapter —
`node tools/verifyGooTower.js`**303 checks green**, including a reference player that
beats all **12** levels headlessly. **Never opened in a browser** — Brian playtests, and
Waves 13 are not signed off until he has.
---
## Decisions taken up front
| Question | Answer |
|---|---|
| Scope | Full campaign — 5 named chapters, ~75 levels. **No** Goo Corporation sandbox tower. |
| Level authoring | **Both** — editor for hand-tuned levels, generator for drill/sandbox tiers |
| Art | **Procedural, zero art dependency**; optional drop-in sprite hooks later |
| Name | **Goo Tower**, slug `gootower`, iconFrame **90** (089 all taken) |
## Files
| File | Role | State |
|---|---|---|
| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 01 done |
| `src/games/gootower/GooTowerGame.js` | Phaser scene: level select, board, HUD | Wave 1 done |
| `src/games/gootower/tutorial.md` | `hasTutorial: true` | done |
| `src/games/gootower/GooTowerEditor.js` | Level editor, `?gootower-editor=1` | Wave 2 done |
| `src/games/gootower/GooTowerAuto.js` | Greedy reference player = the winnability gate | done |
| `tools/verifyGooTower.js` | Regression harness, **303 checks** | Waves 03 done |
| `tools/genGooTower.js` | Level bank writer (12 hand-tuned levels) | Wave 3 done |
| `assets/gamedata/gootower/levels.json` | Manifest + per-level `level-NNN.json` | 12 levels |
`GooTowerAuto.js` lives in `src/`, not `tools/`, because the editor's "Test Winnable"
button must run exactly the gate the verifier runs.
Wiring is in place: `gamesRegistry.js` (iconFrame 90), `main.js` import + scene array,
`GameRoomScene.js` slugDispatch, `PreloadScene.js` manifest load.
Run the harness after **every** change to the sim. The physics is the game: a subtly wrong
solver produces structures that look plausible and are unbuildable, and there is no way to
tell by reading the code.
---
## Wave 0 — the solver — ✅ DONE 2026-07-26
Landed: Verlet integration with XPBD distance constraints, terrain collision and pushout,
ballball collision with a uniform-grid broadphase, tension-based strand breaking with a
fatigue window, structure flood-fill, seeded RNG, and a 69-check harness.
- [x] Verlet integrator with global drag and a per-substep travel cap
- [x] XPBD distance constraints (compliance + axial damping)
- [x] Terrain: point-in-poly, closest-point pushout, friction/restitution, slopes
- [x] Ballball collision (uniform grid, rebuilt once per substep)
- [x] Strand breaking on load, with a shock path and a length backstop
- [x] Structure flood-fill; `rooted` components; adrift chunks fall as chunks
- [x] Hazards: spikes, fire, falling out of the world
- [x] Determinism: identical replay, frame-rate independence, ragged pacing, `cloneState`
- [x] `hashState` fingerprint for the generator and verifier
### Five findings worth not rediscovering
1. **Plain PBD cannot break strands.** A near-rigid solver yanks every strand back to its
rest length each substep, so `length/rest` can never report load and *nothing ever
breaks*. XPBD's accumulated multiplier λ **is** the constraint force — that is why the
solver is XPBD and not simple relaxation. `tension = -λ/dt²` now reads the true static
load (verified: 1.00 / 2.01 / 4.08 ball-weights for 1/2/4-ball chains).
2. **Break on force, not on stretch.** At the stiffness a buildable truss needs
(k ≈ 8333), a `BREAK_RATIO` threshold would demand ~350 ball-weights and nothing would
ever collapse. `BREAK_FORCE` is expressed in ball-weights (25) and is therefore
independent of any later stiffness retune. The length ratio survives only as a backstop.
3. **Break on a *filtered* load.** A stiff lattice rings at ~15Hz and any impact spikes to
~2× static, so judging raw tension snaps strands on the first frame of a level that is
merely settling. `LOAD_TAU` (0.12s) is the fatigue window; `SHOCK_FACTOR` (3×) still
lets a genuinely violent hit cut through it.
4. **`isSettled()` is instantaneous, and that is a trap.** An oscillating structure passes
through zero speed at every turning point, so "step until settled" stops at the moment
of *maximum stretch* and reports the peak of a decaying oscillation as the static
equilibrium. This produced a completely bogus, non-monotonic tuning sweep before it was
spotted. `settle()` now requires a sustained quiet window.
5. **Constraint damping cannot settle a tower.** It resists a strand *stretching*, but the
slowest mode is a pendulum swing that barely stretches anything — it rang for 13s.
Global `DRAG` (0.995/substep ≈ 0.30/s) is what actually settles structures.
Also: `addStrand` takes its rest length from the distance it is **born** at. Clamping to a
fixed 62 meant a level author's 87px diagonal started 1.41× pre-stressed and ripped the
lattice apart on frame one.
### Baselines
```
node tools/verifyGooTower.js # 69 passed, 0 failed
```
Perf (1s of sim, full 240Hz substepping):
| Structure | ms/frame | Budget |
|---|---|---|
| 30 balls / 69 strands | 0.73 | 16.7 |
| 100 balls / 261 strands | 2.18 | 16.7 |
| 196 balls / 533 strands | 4.53 | 16.7 |
### Known gaps, deliberate
- Rigidity is purely emergent from triangulation — there are **no angular constraints**.
That makes `MIN_ANGLE_DEG` in the attachment rule (Wave 1) the most important number in
the game: it is the only thing forcing the player to build real trusses.
- `GOO_TYPES` carries all nine varieties already, but only mass/buoyancy/stiffness/strength
and `spikeProof` are wired. Detachability, terrain-sticking and explosions are Wave 3.
- `refreshStructure` runs whenever a ball touches terrain. Fine at current scale (see perf
table); revisit if a level ever needs 400+ balls.
---
## Wave 1 — playable core — ✅ DONE 2026-07-26 (pending playtest)
- [x] `chooseAttachments()` — reach, line of sight, nearest-first, **MIN_ANGLE rejection**
- [x] Drag a ball out of the pile; place or spring back; detachable types can be lifted off
- [x] Crawling goo: `{onStrand, t, dir}`, deterministic node choice, seeded wander
- [x] Pipe: opens on contact; BFS over the strand graph; crawlers path in and are drunk
- [x] Win at `collected >= required`; OCD tracked as a separate award
- [x] `GooTowerGame.js` — accumulator + events array, procedural rendering, `D` depth map
- [x] Wiring (all four files) + `tutorial.md`
- [x] Progress via `/puzzles/gootower/{progress,complete,reset}` + `/history/single-player`
- [x] Six hand-built levels in `assets/gamedata/gootower/`
- [x] Greedy reference player; all six levels beaten headlessly
- [ ] **First browser playtest — NOT DONE.** `MIN_ANGLE_DEG`, `ATTACH_R`, `GRAVITY` and
`WANDER_SPEED` get tuned by feel here. No amount of Node testing substitutes.
### Wave 1 findings
1. **Loose goo has to walk.** The pipe would open and then collect *nothing*, because the
heap sat where it was authored and never touched the structure. In the original,
unattached goo mills around the tower; `wanderLoose()` reproduces that. Without it a
level whose heap is not already against the structure is simply unwinnable, and no
amount of building fixes it. Re-targeting is throttled to `WANDER_HZ` because it is
O(loose x attached).
2. **A settling level is not a winnable level.** The bank lint (levels settle, keep their
strands, lose no goo) passed on all six while two of them could not actually be beaten.
Only playing them found it — hence `tools/lib/gooAutoPlay.js`.
3. Two real level bugs it caught: **L4** handed out exactly enough goo to *reach* the pipe
and none to feed it; **L3** asked for a 677px diagonal traverse around an obstacle as the
third teaching level.
4. The greedy gate is **one-directional**: if the naive builder wins, a human can. A greedy
failure is *not* proof a level is impossible — redesign or store a hand-authored
reference build, never conclude "unwinnable".
### Baselines
```
node tools/verifyGooTower.js # 179 passed, 0 failed (~5s, includes the winnability gate)
node tools/genGooTower.js # rewrites the 6-level bank
```
## Wave 2 — the editor — ✅ DONE 2026-07-26 (pending playtest)
Reached at `index.html?gootower-editor=1`. Tools: **terrain** (click vertices, Enter or
double-click to close; solid/spike/fire), **ball** (starting structure, auto-strands, shift
for pinned), **pile** (loose goo), **pipe**, **erase** (or right-click anywhere). Ctrl-Z
undoes. Settings live in a DOM panel on the right; **Settle**, **Test Winnable**, **Test
Play**, and Blob-download **⬇ Level** / **⬇ Manifest** sit along the bottom.
- [x] Terrain polygon drawing, all three kinds
- [x] Structure goo with a live attachment preview — it calls the game's own
`chooseAttachments`, so the MIN_ANGLE rule is visible while authoring
- [x] Pile, pipe, erase, undo
- [x] Settle before export; validation blocks a broken export
- [x] Test-play round-trip via `registry` + `scene.start('GooTowerGame', { testLevel, returnToEditor })`
- [x] Test Winnable runs the same greedy gate the verifier uses
- [x] Load from the manifest or from a local file
- [ ] **Browser playtest — NOT DONE.**
### Wave 2 findings
1. **Settling for export must be physics-only.** `settle()` runs the *whole* sim, agents
included — so loose goo walks to the structure and climbs it while settling. Capturing
those positions bakes the heap INSIDE the tower, and reloading that level explodes as the
collision solver ejects it (measured: 1801px of drift, strands snapped, goo lost). Hence
`settlePhysics()`. The editor's Settle button would have quietly corrupted every level
with a heap.
2. **`grounded` is only true on a simulated state.** It is set by terrain contact during a
substep, so validation on a never-stepped state sees `false` everywhere and a floating
structure sails straight through. `validate()` settles a throwaway copy first.
3. **The hover preview cannot build a state per mouse event.** `drawHoverPreview` runs on
every `pointermove`; a version-stamped cache (`touch()` / `draftState()`) keeps it to one
rebuild per edit.
4. The editor deliberately asks the *real* rules for everything — attachment, legality,
winnability — so what it shows can never disagree with what the game does.
### Editor contract, pinned by the verifier
`GooTowerEditor.js` imports Phaser and cannot run in Node, so §10 of the harness asserts the
assumptions it rests on: structure ball index === ball id (it wires strands by index), pile
goo comes after the structure in order, a settled level stays settled when reloaded, and an
editor round-trip reproduces a byte-identical level.
## Wave 3 — remaining goo types and hazards — ✅ DONE 2026-07-26 (pending playtest)
- [x] **anchor / pokey** — bond to any solid surface within `STICK_DIST` and pin there.
`requiredStrands()` drops to 0 when stuck, which is what lets a structure start from
a wall instead of only from the ground.
- [x] **bomb** — fire lights a fuse (`FUSE_TIME`), then the blast kills goo inside
`BLAST_KILL_R`, shears every strand inside `BLAST_R`, and removes terrain the author
marked `destructible`. Bombs chain-react; anchors survive.
- [x] **balloon** — buoyant, single strand, ~1.3 ball-weights of lift
- [x] **block / bit / skull** — stiffness, cheapness and spike immunity
- [x] **fans** — axis-aligned force volumes; goo inside weighs less
- [x] **gears** — rotating solid terrain that drags whatever touches its rim
- [x] `hazardAt()` — "legal but lethal", used by both the reference player and the drag preview
- [x] Sandbox tier: levels 712, one per mechanic (**throwaway**, replaced in Wave 4)
- [x] Editor gained fan/gear tools and a destructible flag
- [ ] **Browser playtest — NOT DONE.**
### Wave 3 findings
1. **Gears are cheap if you store them as terrain.** A gear is an ordinary `solid` entry
carrying a `gear` record; `updateMovers()` respins its polygon each substep and the
existing collision path does the rest. The only extra rule is that friction pulls a
contacting ball toward the **rim's surface velocity** instead of toward zero — that one
line is the whole mechanic. Time-varying terrain costs nothing in determinism.
2. **Legal is not survivable.** The reference player happily dropped goo into fire 24 times
in a row, because `canPlace` only asks whether a strand can form. `hazardAt()` closes
that, and the drag preview now warns the player too (orange, not red — it *is* a legal
drop).
3. **Greedy will not climb a wall.** Getting over an obstacle means first building *away*
from the pipe, which a nearest-to-target search never does. Levels can hand the gate
`waypoints` to route it — a hint to the verifier, not gameplay. `autoPlay` also accepts a
stored `reference` build for anything waypoints cannot express.
4. **A sandbox level must not make its mechanic the only route.** L9's first draft capped
the destructible rock with fire, so the climb-over was lethal and the bomb was the sole
solution — which greedy cannot find, making the level unwinnable by the gate *and* by any
player who did not guess. The fire moved beside the rock; the bomb is now a shortcut.
5. **Overlapping goo at level start is violent.** Two balls 6px apart are ejected hard
enough to snap their own strand (found when a balloon flew to y=-31120). Both the bank
lint and the editor now refuse it — and it immediately caught a real overlap in L2.
### Baselines
```
node tools/verifyGooTower.js # 303 passed, 0 failed (~15s, includes 12 winnability runs)
node tools/genGooTower.js # rewrites the 12-level bank
```
## Wave 4 — the campaign — ⬜ NEXT
Five named chapters × ~15 levels, **replacing the throwaway sandbox tier**. Hand-authored
teaching levels in the editor; `tools/genGooTower.js` fills drill slots. Gates adapted from `genPuddingMonsters.js`:
**solvable** (a recorded reference build wins under the deterministic sim), **load-bearing**
(strip the featured element and the level must break or get materially harder), **gentle**
(no instant-fail opening in a teaching level).
The uncertain piece: a physics sandbox has no clean BFS solver, so "solvable" means *a
stored reference build replays to a win*. That is why levels are hand-authored first and
generated second.
---
## Sources for original-game behaviour
Design reconstructed from the shipped game's mechanics: goo balls as point masses joined by
springy strands, rigidity from triangulation, loose goo crawling the structure, the pipe
drinking a required count, and the OCD (Obsessive Completion Distinction) as a separate
award. Level layouts, art, music and the original's physics constants are **not**
reproduced — everything here is authored fresh, as Peggle and Jell-o Monsters were.