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

15 KiB
Raw Blame History

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 chaptersnode tools/verifyGooTower.js1061 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 (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, 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, 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.

  • 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
  • Ballball collision (uniform grid, rebuilt once per substep)
  • Strand breaking on load, with a shock path and a length backstop
  • Structure flood-fill; rooted components; adrift chunks fall as chunks
  • Hazards: spikes, fire, falling out of the world
  • Determinism: identical replay, frame-rate independence, ragged pacing, cloneState
  • 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)

  • 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, D depth 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, 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.

  • 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

  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)

  • 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.
  • 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.
  • 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 712, one per mechanic (throwaway, replaced in Wave 4)
  • 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.