guts/docs/LANES/LANE_A_NOTES.md
jing 689c478a7f [lane A] Round 1: the canal v0 — spline, streaming tube, scanner wall, arenas
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>
2026-07-16 08:59:19 +10:00

13 KiB
Raw Blame History

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.jsnode 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 58 (+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)

  1. curviness = fraction of the tightest bend this pipe can survive, not an amplitude. Octave amplitudes are solved from a curvature budget kappa = 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.150.95 range works as documented; nothing to change.
  2. Peristalsis: global OMEGA = 3.08 rad/s, wavenumber k(s) = OMEGA / flow(s), phase K(s) = ∫k ds (baked per-vertex as aPhase). 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". Plain k·s ω·t tears 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).
  3. Esophagus wave.amp 0.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 moves wallRho → Lane B.
  4. 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.
  5. 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.
  6. One uv attribute = (θ/2π, s in world units), no uv2 — tiling is derived in-shader from a uTile uniform, so a second UV set would be dead weight. Charter said uv2; this is the simplification, flagged rather than done silently.
  7. 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 L3 note. Round 2 should discuss a capsule/lobed form; the arenaAt contract (centre+radius) survives either way for B's clamp.
  • No acid plane (GDD's animated #c8ff3a level) — 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 normal maps 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 calls project on a point way off the centreline.
  • Segment param blending lags the march by one 0.5u step. Deterministic, sub-step, harmless.

→ Lane F

  1. Pass assets into createWorld. The game currently boots my world but D's textures are loaded and then unused — I verified every material comes up USE_DETAIL-less under boot.js. In pickWorld():
    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 });
    
    (createWorld already ignores a missing/failed assets — the fallback law holds either way.)
  2. 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"
    
  3. 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 innerWidth is 0 (so the canvas is 0×0 and toDataURL() 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 in DBG.size/pose/poseAt — worth lifting into boot.js's DBG. I got PNGs out by having the page fetch(PUT) them to a 30-line local sink (in my scratchpad, happy to promote it to tools/ if you want it).
  4. Dev port: 8140 was already held by another session's server, and launch.json is 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.
  5. Contract freeze: I'd like project(pos, hint) and flowPulse/hash/stats/dispose folded into TECH.md's world contract, and assets.texture() added to the asset contract (see → Lane D).

→ Lane B

  • wallRho shrank: it subtracts wave.amp + wave.breathe + 0.6 and esophagus wave.amp is 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 previous s as hint and it's near-free and exact. With no hint it seeds from -pos.z and 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).flow is 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 add sampleInto(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, and modeAt/arenaAt agree 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 why name carries the anatomy.
  • curviness behaves 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: flow is 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 are small_intestine / large_intestine. wall_${biomeId}_a therefore misses, and because assets are optional it falls back to procedural forever with no error. I've added an explicit slug map in index.js so 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 tile semantics 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, not get(). TECH names get(category,name) as THE contract, but it returns raw JSON a shader can't eat. I prefer assets.texture(name) and keep get duck-typed as the documented floor. Suggest F promotes texture() into TECH.
  • FYI: tiling is applied in-shader from tile. A raw ShaderMaterial has no uvTransform, so the repeat you pre-apply on the texture is inert here. Not a problem — just don't count on it.
  • wall_esophagus_a looks genuinely good in engine. The _b variants 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/void are the ART_BIBLE hexes — use rim if 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.