15 KiB
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.
- Verlet integrator with global drag and a per-substep travel cap
- XPBD distance constraints (compliance + axial damping)
- Terrain: point-in-poly, closest-point pushout, friction/restitution, slopes
- Ball–ball collision (uniform grid, rebuilt once per substep)
- Strand breaking on load, with a shock path and a length backstop
- Structure flood-fill;
rootedcomponents; adrift chunks fall as chunks - Hazards: spikes, fire, falling out of the world
- Determinism: identical replay, frame-rate independence, ragged pacing,
cloneState hashStatefingerprint for the generator and verifier
Five findings worth not rediscovering
- Plain PBD cannot break strands. A near-rigid solver yanks every strand back to its
rest length each substep, so
length/restcan 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). - Break on force, not on stretch. At the stiffness a buildable truss needs
(k ≈ 8333), a
BREAK_RATIOthreshold would demand ~350 ball-weights and nothing would ever collapse.BREAK_FORCEis expressed in ball-weights (25) and is therefore independent of any later stiffness retune. The length ratio survives only as a backstop. - 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. 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.- 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_DEGin 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_TYPEScarries all nine varieties already, but only mass/buoyancy/stiffness/strength andspikeProofare wired. Detachability, terrain-sticking and explosions are Wave 3.refreshStructureruns 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)
chooseAttachments()— reach, line of sight, nearest-first, MIN_ANGLE rejection- Drag a ball out of the pile; place or spring back; detachable types can be lifted off
- Crawling goo:
{onStrand, t, dir}, deterministic node choice, seeded wander - Pipe: opens on contact; BFS over the strand graph; crawlers path in and are drunk
- Win at
collected >= required; OCD tracked as a separate award GooTowerGame.js— accumulator + events array, procedural rendering,Ddepth map- Wiring (all four files) +
tutorial.md - Progress via
/puzzles/gootower/{progress,complete,reset}+/history/single-player - Six hand-built levels in
assets/gamedata/gootower/ - Greedy reference player; all six levels beaten headlessly
- First browser playtest — NOT DONE.
MIN_ANGLE_DEG,ATTACH_R,GRAVITYandWANDER_SPEEDget tuned by feel here. No amount of Node testing substitutes.
Wave 1 findings
- 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 toWANDER_HZbecause it is O(loose x attached). - 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. - 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.
- 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.
- Terrain polygon drawing, all three kinds
- Structure goo with a live attachment preview — it calls the game's own
chooseAttachments, so the MIN_ANGLE rule is visible while authoring - Pile, pipe, erase, undo
- Settle before export; validation blocks a broken export
- Test-play round-trip via
registry+scene.start('GooTowerGame', { testLevel, returnToEditor }) - Test Winnable runs the same greedy gate the verifier uses
- Load from the manifest or from a local file
- Browser playtest — NOT DONE.
Wave 2 findings
- 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). HencesettlePhysics(). The editor's Settle button would have quietly corrupted every level with a heap. groundedis only true on a simulated state. It is set by terrain contact during a substep, so validation on a never-stepped state seesfalseeverywhere and a floating structure sails straight through.validate()settles a throwaway copy first.- The hover preview cannot build a state per mouse event.
drawHoverPreviewruns on everypointermove; a version-stamped cache (touch()/draftState()) keeps it to one rebuild per edit. - 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)
- anchor / pokey — bond to any solid surface within
STICK_DISTand pin there.requiredStrands()drops to 0 when stuck, which is what lets a structure start from a wall instead of only from the ground. - bomb — fire lights a fuse (
FUSE_TIME), then the blast kills goo insideBLAST_KILL_R, shears every strand insideBLAST_R, and removes terrain the author markeddestructible. Bombs chain-react; anchors survive. - balloon — buoyant, single strand, ~1.3 ball-weights of lift
- block / bit / skull — stiffness, cheapness and spike immunity
- fans — axis-aligned force volumes; goo inside weighs less
- gears — rotating solid terrain that drags whatever touches its rim
hazardAt()— "legal but lethal", used by both the reference player and the drag preview- Sandbox tier: levels 7–12, one per mechanic (throwaway, replaced in Wave 4)
- Editor gained fan/gear tools and a destructible flag
- Browser playtest — NOT DONE.
Wave 3 findings
- Gears are cheap if you store them as terrain. A gear is an ordinary
solidentry carrying agearrecord;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. - Legal is not survivable. The reference player happily dropped goo into fire 24 times
in a row, because
canPlaceonly 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). - 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
waypointsto route it — a hint to the verifier, not gameplay.autoPlayalso accepts a storedreferencebuild for anything waypoints cannot express. - 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.
- 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.