268 lines
15 KiB
Markdown
268 lines
15 KiB
Markdown
# 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:** **COMPLETE — all five waves done (2026-07-26).** Nine goo types, every hazard,
|
||
a level editor, and a **75-level campaign in 5 chapters** —
|
||
`node tools/verifyGooTower.js` → **1061 checks green**, including a reference player that
|
||
beats every one of the 75 levels headlessly. **Never opened in a browser** — Brian
|
||
playtests, and nothing here is 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** (0–89 all taken) |
|
||
|
||
## Files
|
||
|
||
| File | Role | State |
|
||
|---|---|---|
|
||
| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 0–1 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, **1061 checks** | all waves |
|
||
| `tools/genGooTower.js` | Curriculum generator (5 chapters x 15) | Wave 4 done |
|
||
| `assets/gamedata/gootower/levels.json` | Manifest + per-level `level-NNN.json` | **75 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,
|
||
ball–ball 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] Ball–ball 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 7–12, 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.
|