# 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 0–3 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 1–3 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** (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, **303 checks** | Waves 0–3 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, 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.