Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub. boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors. - spline.js: centreline + arclength LUTs + parallel-transport frames + radius law + peristalsis phase + project() + hash(). No three.js import, so qa can run its 14-assertion selfcheck headless. - curviness is solved from a curvature budget, not authored as an amplitude: the pinch guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs tube radius 17.2). C can no longer author a pinch. - peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest therefore travels at exactly the local flow speed, so "surf the crest" == "ride the current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B. - tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span. - arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2, not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog tuned for 100u corridors renders C's 360u acid sea as a black rectangle. - index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat), plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss silently forever under the assets-optional law. Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame (budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load, pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
LANE A — NOTES (round 1)
World v0 is in and the real game boots it: http://localhost:8140/?dbg=1 runs Lane A's
canal on C's L2_esophagus (3600 units, hash cefc4f83), zero console errors. No boot.js
change was needed — C's levels/index.js landing is what unblocked pickWorld().
What landed
web/js/world/ — nothing outside it was touched.
| file | what |
|---|---|
spline.js |
the math: centreline, arclength LUTs, parallel-transport frames, radius law, wave phase, project(), hash(), stats(). Imports no three.js → node web/js/world/spline.js --selfcheck runs headless (14 assertions). |
biomes.js |
palette/param registry (ART_BIBLE values). C ratified this as the biome-id source of truth. |
wall_material.js |
the synthetic-scanner wall: tint × detail + fresnel rim + fog, peristalsis in the vertex shader. |
tube.js |
chunked extrusion + streaming window. |
arena.js |
displaced-icosphere shells (the stretch goal), v0. |
flycam.js |
?fly=1 noclip. Not wired — snippet for F below. |
index.js |
createWorld() — THE WORLD CONTRACT, drop-in for the stub. |
dev.html |
my harness. Dev-only; delete when boot covers it. |
_fixture.js |
dev fixture level. Delete when C's levels cover every case (arenas: L3 does now). |
Contract is implemented as written — same surface, units and semantics as the stub, so B's
stub-built work should port unchanged. Extras beyond it: flowPulse(s,t), hash(), stats(),
dispose(), spline, and an optional 2nd arg on project(pos, hint) (see → Lane B).
Measured (say how you measured it — so: how)
All from dev.html on C's real L2_esophagus with D's real pack, 1920×1080, via
renderer.info (DBG.pose() / the sweep in DBG.sweep()):
| thing | measured | budget |
|---|---|---|
| draws, worst frame over a full-canal sweep | 8 | ≤150 (charter), ≤300 (TECH) |
| tris, worst frame | 74,420 | ≤500k |
| live chunks | 5–8 (+1 per arena) | — |
| geometries after full sweep + return to s=0 | 6 → 6, no leak | dispose-clean |
geometries after dispose() |
0 (textures left alone — D owns them) | — |
| level load, C's 3600-unit L2 | <120 ms | <2 s |
| pinch ratio (min turn radius ÷ max tube radius) | L2 3.32, L3 1.89, fixture 2.37 | >1.5 |
fps is NOT measured and I'm not claiming it. Screenshot tooling drives the tab hidden,
which pauses rAF; gl.finish() didn't sync either (it reported 0.12 ms/frame = 8219 fps,
which is obviously junk). Draws/tris are reliable and are 20× under budget, so I expect fps to
be fine — but someone with a real window has to confirm. → Lane F, at round end.
Determinism: same seed ⇒ same hash, and node and the browser agree (50a5b0ec for the
fixture in both). Different seed ⇒ different hash. Both asserted in the selfcheck.
Decisions (mine to make; flagging the ones that touch you)
curviness= fraction of the tightest bend this pipe can survive, not an amplitude. Octave amplitudes are solved from a curvature budgetkappa = curviness / (2.2 × baseRadius). Why: authoring amplitudes in units let a wide, curvy segment fold the tube through itself — the selfcheck's pinch guard caught exactly that on its first run (turn radius 9.9 vs tube radius 17.2). Now a wide pipe straightens itself and C never has to think about curvature. C's 0.15–0.95 range works as documented; nothing to change.- Peristalsis: global
OMEGA = 3.08 rad/s, wavenumberk(s) = OMEGA / flow(s), phaseK(s) = ∫k ds(baked per-vertex asaPhase). Consequence worth knowing: a wave crest travels at exactly the local flow speed, so "ride the crest for boost" is physically the same thing as "ride the current". Plaink·s − ω·ttears the wave at every flow change;K(s)doesn't. For a flow-14 esophagus this reproduces the stub's wave exactly (k = 0.22). - Esophagus
wave.amp0.9 → 1.4 (biomes.js). The stub's 0.9 is a barely-legible ripple; GDD §2 makes riding a crest the level's signature, and B can't surf what the player can't see. 1.4 reads as distinct rings and still leaves ~78% of a radius-10 tube flyable. Measured in the harness against 0.9 and 3.2. This moveswallRho→ Lane B. - Chunk seams align to biome joins; an arena owns its whole s-span and the tube is skipped
there (otherwise the corridor renders inside the room and
collide()disagrees with what you can see). Seam is chunk-quantised ±20u — fine until sphincter geometry exists. - Arena fog is sized to the room, not the biome:
min(biome.fog, 1.09/radius). Biome fog is tuned for ~100u corridor sightlines; reused in C's 360u-wide acid sea it renders a black rectangle (that's what my first arena shot literally was). Small lairs keep the murk. - One
uvattribute = (θ/2π, s in world units), no uv2 — tiling is derived in-shader from auTileuniform, so a second UV set would be dead weight. Charter said uv2; this is the simplification, flagged rather than done silently. - Colorspace: matched to the stub — the wall writes its colour with no
<colorspace_fragment>conversion. That's a whole-game decision (it moves every colour at once) so I'm not making it unilaterally → Lane F.
Known limitations (v0, stated plainly)
- Arena pole pinch. Arena UVs are spherical, so the fold pattern converges into a starburst
at the poles — visible top-right in
round1_L3_arena_acid_sea.png. A real texture will pinch there too. Round 2: triplanar projection for arena shells. - Arena shape. A sphere is the wrong shape for a stomach and C says so in their own
L3note. Round 2 should discuss a capsule/lobed form; thearenaAtcontract (centre+radius) survives either way for B's clamp. - No acid plane (GDD's animated
#c8ff3alevel) — round 2, needs C's event to drive height. - No villi (small-intestine InstancedMesh band) — round 2, no level needs it yet.
- No matcap / normal map. D ships
normalmaps already; using them needs a TBN in the wall shader. Round 2. Untested code paths are worse than absent ones. project()in arena mode is step-clamped rather than properly solved; it's accurate in tube mode (recovers s to <0.25, θ to <0.02 rad — asserted). Arena collision doesn't use it (sphere test), so this only matters if B callsprojecton a point way off the centreline.- Segment param blending lags the march by one 0.5u step. Deterministic, sub-step, harmless.
→ Lane F
- Pass
assetsintocreateWorld. The game currently boots my world but D's textures are loaded and then unused — I verified every material comes upUSE_DETAIL-less underboot.js. InpickWorld():
(const mod = await import('./world/index.js'); const level = await loadLevel(flags.lvl); const assets = await (await import('./core/assets.js')).createAssets({ flags, renderer }); return mod.createWorld(level, { rng: createRng(flags.seed ?? level.seed), assets });createWorldalready ignores a missing/failedassets— the fallback law holds either way.) - Add the world selfcheck to the qa gate — it has caught two real defects already:
# 4b. Lane A world math (headless; no three.js needed) node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed" - Screenshot tooling traps, which will bite you at round-end boot-matrix time. The pane
drives tabs hidden: rAF never fires (so the loop isn't running when you shoot) and
innerWidthis 0 (so the canvas is 0×0 andtoDataURL()returns"data:,"). The pane screenshot still looks right because grabbing it fronts the tab.DBG.shot()'s anchor-click download also silently no-ops here. My harness works around all three inDBG.size/pose/poseAt— worth lifting intoboot.js's DBG. I got PNGs out by having the pagefetch(PUT)them to a 30-line local sink (in my scratchpad, happy to promote it totools/if you want it). - Dev port: 8140 was already held by another session's server, and
launch.jsonis yours, so I left it alone and reused the running one. If lanes are meant to run concurrently on one box this needs a call. - Contract freeze: I'd like
project(pos, hint)andflowPulse/hash/stats/disposefolded into TECH.md's world contract, andassets.texture()added to the asset contract (see → Lane D).
→ Lane B
wallRhoshrank: it subtractswave.amp + wave.breathe + 0.6and esophaguswave.ampis now 1.4 (was 0.9). Playable radius in a base-10 tube ≈ 7.85. Re-check your graze distances. It's time-independent on purpose — a collision surface that breathed would have you fighting the wall.project(pos, hint)— pass your previoussashintand it's near-free and exact. With no hint it seeds from-pos.zand still converges (6 fixed-point steps), but the hint is strictly better in your per-frame path. Both are asserted in the selfcheck.- Crest speed ==
biomeAt(s).flow, exactly, by construction.world.flowPulse(s, t)is the same function the vertex shader runs (0 = trough, 1 = crest), so pulse ≈ 1 means the wall is at its narrowest AND the crest is moving at flow speed right there. Boost window. biomeAt(s).flowis blended across segment joins, not stepped — safe to read per-frame.- Arena mode:
modeAt(s)/arenaAt(s)→{center, radius}for your 6DOF clamp.collide()branches automatically (sphere test inside a room, radius test in a tube); you don't have to. sample()allocates fresh Vector3s per call, same as the stub. If that shows up in your profile, ask and I'll addsampleInto(s, out).
→ Lane C
- Your schema v1 reads clean here — v1 is additive and my reader consumes L1/L2/L3 unchanged.
Your ratified arena semantics (sphere spanning
[at-radius, at+radius]) are exactly what's implemented, andmodeAt/arenaAtagree with your notes on all three L3 arenas. - Biome ids are the registry in
world/biomes.js:oral · esophagus · stomach · small_intestine · large_intestine · appendix. Unknown id ⇒ neutral fallback + one warning, never a crash. Thanks for writing the "biome = tissue family, not anatomy" rule into TECH — that's exactly right and it's whynamecarries the anatomy. curvinessbehaves as you documented (0 straight … 1 writhing). Under the hood 1.0 now means "as curvy as this radius can safely be", so you can't author a pinch — L2 measures 3.32× clear, L3 1.89×. Author freely.- Free lever if you want it:
flowis per-segment and it now sets the peristalsis wavelength (k = 3.08/flow). Low flow ⇒ tighter, slower rings. A "sluggish" segment reads sluggish. - Your L3 note that a sphere is the wrong shape for the stomach body: agreed, see limitations.
→ Lane D
- Naming mismatch, silent by construction. Your keys are
wall_smallint_a/ (future) colon; my biome ids aresmall_intestine/large_intestine.wall_${biomeId}_atherefore misses, and because assets are optional it falls back to procedural forever with no error. I've added an explicit slug map inindex.jsso it works today. Proposal for round 2: standardise on the biome ids (wall_small_intestine_a) and delete the map — one name, one place. Your call, just let's not leave it implicit. - Your
tilesemantics and mine converged independently —[repeats around theta, units of s per repeat], both read off the stub's uv. Ratified from my side;[4,16]looks right in engine (round1_L2_textured.png). texture()is the useful entry point, notget(). TECH namesget(category,name)as THE contract, but it returns raw JSON a shader can't eat. I preferassets.texture(name)and keepgetduck-typed as the documented floor. Suggest F promotestexture()into TECH.- FYI: tiling is applied in-shader from
tile. A raw ShaderMaterial has nouvTransform, so therepeatyou pre-apply on the texture is inert here. Not a problem — just don't count on it. wall_esophagus_alooks genuinely good in engine. The_bvariants and matcap aren't wired yet (round 2, with the TBN work).
→ Lane E
world.flowPulse(s, t)(0..1, crest = 1) is yours to pulse audio/HUD with; it's exact, cheap, and matches what the wall is visibly doing.world.biomeAt(s)→{id, palette, fog, flow, coatDrain}.palette.tint/rim/voidare the ART_BIBLE hexes — userimif you want HUD accents that match the biome you're in.
Round 2 (mine)
Triplanar arena shells + acid plane · villi band (InstancedMesh, small intestine) · normal-map
TBN + matcap · sphincter joint geometry at biome seams · sampleInto if B needs it · delete
_fixture.js/dev.html once boot + C's levels cover them.