guts/docs/TECH.md
type-two 4ee6ae49a1 [lane B+E+F] Arena mode: in a room, you own the forward axis
The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.

The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.

- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
  12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
  wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
  respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.

Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.

Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:06 +10:00

15 KiB
Raw Blame History

TECH — architecture, contracts, ownership

Stack: vanilla three.js r175 (vendored, web/vendor/), ES modules + import map, zero build step, python3 -m http.server to run. No physics engine, no framework. This is house style (proven in PROCITY) — keep it.

Module layout & ownership

web/
  index.html            F   shell + import map + flags table wiring
  js/boot.js            F   creates renderer/scene/loop, wires lane factories, window.DBG
  js/core/rng.js        F   mulberry32 seeded RNG (THE only randomness source)
  js/core/bus.js        F   event bus (on/off/emit)
  js/core/flags.js      F   URL flag parsing
  js/core/assets.js     D   manifest loader + `depot:`/local resolution + fallback law
  js/stub/world_stub.js F   straight-tube world implementing the world contract
  js/world/**           A   createWorld() — spline, tube chunks, arenas, biome shaders
  js/flight/**          B   createPlayer() — controller, camera rig, collision response
  js/combat/**          B   weapons, damage, enemy framework + behaviors
  js/levels/*.json      C   level data (schema below)
  js/levels/index.js    C   level registry + loader + schema selfcheck
  js/ui/**              E   HUD, menus, gut-map, screens
  js/audio/**           E   WebAudio engine (plays D's pack; procedural fallback)
  assets/**             D   manifest.json + generated webp/glb/ogg/m4a + thumbs
pipeline/**             D   generation scripts (run on the m3ultra box)
tools/qa.sh             F   the gate
docs/**                 all (own lane's files only)

Never edit outside your column. Shell wiring requests → your NOTES (### → Lane F), with the exact snippet to paste (PROCITY house style).

Lane factory convention

Every lane exports pure factories, constructed by boot, no side effects at import time:

export function createX({ scene, world, bus, rng, flags, assets }) {
  return { update(dt, ctx){}, dispose(){}, /* lane-specific API */ };
}

Default-off behind flags until F wires them on. Dispose must actually free GPU resources (geometries, materials, textures) — enter/exit cycles are leak-tested.

THE WORLD CONTRACT — FROZEN v1.1 (round-1 sign-off, 2026-07-16)

Changes now go through NOTES + F referee only. A implements (world/index.js); the stub is the executable reference. Spline-space: s = arclength along the canal centreline (units), θ = angle in the cross-section (rad), ρ = radial distance from centreline. Units are metres in three.js terms; fiction scale ≈ 1 unit : 1 mm (lengths story-dilated ~14×, see LANE_C_NOTES). +Y up, right-handed. Frame convention (B builds against it): worldPos = pos + nor·y + bin·x, theta = atan2(x, y); camera up = nor, never world-up.

const world = await createWorld(levelData, { rng, assets, quality });   // assets optional
world.length                    // total arclength
world.level                     // the levelData it was built from
world.group                     // THREE.Group (boot adds to scene)
world.sample(s)                 // → { pos, tan, nor, bin, radius }  (parallel-transport frame)
world.project(pos, hint?)       // → { s, theta, rho }; pass previous s as hint (near-free path)
world.wallRho(s, theta)         // → max safe ρ (radius  conservative displacement); time-independent
world.collide(pos, r)           // → null | { push:Vector3, kind:'wall'|'hazard', biome }
world.biomeAt(s)                // → { id, palette, fog, flow, coatDrain }; flow BLENDS across joins
world.modeAt(s)                 // → 'tube' | 'arena'
world.arenaAt(s)                // → null | { center:Vector3, radius }      (bounds for 6DOF clamp)
world.flowPulse(s, t?)          // → 0..1 peristalsis phase (1 = crest); same fn the shader runs
world.crestSpeed(s)             // → u/s the crest travels; == CREST_FACTOR × flow(s), see law below
world.update(dt, playerS)       // stream chunks, advance shader time
world.hash()                    // golden determinism hash (qa gate)
world.dispose()                 // frees geometries/materials it created (not D's textures)

Planned v1.2 (A, round 2; B adopts when it exists): sample(s, out) out-param variant — sample currently allocates ~6 Vector3s/call and it's B's hot path.

Crest-speed law (round-2 ruling, from B's round-1 finding): the wave must outrun the player or surfing loses to throttle-mashing. crestSpeed = CREST_FACTOR × flow with CREST_FACTOR = 1.6 (> throttleMax 1.4), i.e. A's k(s) = OMEGA / (1.6 · flow(s)). Gameplay wave == visual wave, always. B speed-locks to crestSpeed(s) while riding.

Determinism law: same levelData.seed ⇒ byte-identical geometry, node == browser.

Shader law (round-1 finding, B): every custom shader that writes to the default target must end main() with #include <colorspace_fragment> — three converts THREE.Color inputs to linear but does NOT convert a raw shader's output back to sRGB, so un-converted colours ship visibly wrong (measured: ART_BIBLE amber #ff5a2a displayed as pure red). Stub complies; A's wall + B's emissive materials comply from round 2.

Level data schema v1 (C owns — ratified round 1; F drafted v0)

Changes from v0 are additive: every v0 level still parses. New in v1 — schema, par, next, segment name, cross-section placement (theta/rho), group spread, zone span, event types pickup/gate, and hazard params. Amend via LANE_C_NOTES + F referee, never silently. Validated by node web/js/levels/index.js --selfcheck (qa gate).

{
  "schema": 1,                           // REQUIRED. levels/index.js refuses other versions.
  "id": "L2_esophagus",                  // must equal the filename
  "name": "Esophagus", "tagline": "Ride the swallow",   // tagline: optional, for E's menus
  "seed": 20260716,                      // int. Determinism law: same seed => same geometry.
  "par": { "time": 210, "score": 9000, "samples": 3 },  // medal thresholds (GDD §Scoring)
  "next": "L3_stomach",                  // campaign order; null on the last level
  "design": { … },                       // free-form. C's rationale; no consumer reads it.
  "segments": [                          // concatenated along s, in order
    { "biome": "esophagus",              // MUST be an id from A's world/biomes.js registry.
                                         // biome = the LOOK/tissue family, not the anatomy —
                                         // that is what `name` is for. This is why there is
                                         // no 'cardia' or 'pharynx' biome and never will be.
      "name": "Aortic Constriction",     // optional; the anatomy, for HUD/gut-map/docs
      "length": 400,
      "radius": { "base": 9, "wobble": 0.15 },   // wobble: seeded radius variation, 0..1
      "curviness": 0.5,                          // 0 straight .. 1 writhing
      "flow": 16 }                               // base current u/s; OVERRIDES the biome default
  ],
  "arenas": [                            // 6DOF bubbles. A sphere of `radius` centred on the
    { "at": 2350, "radius": 70,          // centreline at s=`at`, spanning [at-radius, at+radius].
      "biome": "stomach" }               // modeAt(s) => 'arena' inside it. (Ratified round 1.)
  ],
  "events": [                            // MUST be sorted by s. Consumed by B (spawns/hazards)
                                         // and E (telegraphs) via `level:event` on the bus.
    { "s": 520, "type": "spawn", "enemy": "bolus_chunk", "count": 4, "spread": 120 },
    { "s": 980, "type": "spawn", "enemy": "candida_bloom", "count": 2, "theta": [1.05, 4.19] },
    { "s": 700, "type": "pickup", "kind": "biopsy_sample", "theta": 3.1416, "rho": 0.92 },
    { "s": 1100, "type": "hazard", "kind": "aortic_squeeze",
      "span": 400, "period": 2.4, "amplitude": 0.45, "theta": 0, "warn": 2.5 },
    { "s": 3000, "type": "hazard", "kind": "reflux_surge",
      "speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true },
    { "s": 2980, "type": "checkpoint", "name": "Cardia Approach" },
    { "s": 2350, "type": "boss", "id": "pyloric_guardian" },
    { "s": 3580, "type": "gate", "id": "cardiac_sphincter", "to": "L3_stomach" }
  ]
}

Event fields. s (required) · type: spawn|pickup|hazard|checkpoint|boss|gate.

field applies to meaning
enemy spawn fiction id from levels/enemies.js. Resolve the archetype via getEnemy(id).archetype — B's pools are keyed by archetype; bare archetype names also resolve.
kind pickup, hazard id from PICKUPS / HAZARDS in levels/enemies.js
count spawn, pickup how many (default 1)
spread spawn, pickup units along s the group occupies (default: B's spacing)
theta any angle in the cross-section, radians, [0, 2π). Number, or an array of length count (one per instance). Required for wall-mounted enemies (wall: true).
rho any fraction (0..1) of the safe radius, never an absolute distance — the tube's radius varies with s, so a fraction is the only placement that cannot end up inside a wall.
span hazard length of a zone hazard along s (omit ⇒ point hazard)
warn hazard seconds of telegraph. Law: ≥ 2 s for anything lethal (readable at 1020 u/s).
name checkpoint, segment display/anatomy label
to gate level id to load next

Placement law: theta/rho are the cross-section; s is along the canal. Content is authored in spline space and never in world space — the spline is the only thing both C and A agree on. Wall-mounted content must have an explicit theta: which arc a turret owns is a design decision, never a random one.

Playability invariant (C's law, enforced by the selfcheck): at its narrowest, a segment must leave a free disc of radius ≥ 2.5 units after subtracting A's wave.amp + wave.breathe, A's SKIN, and B's hull radius. The selfcheck reads those live from world/biomes.js, world/index.js and flight/tuning.js — if A or B retune, C's levels fail qa and get re-tuned. Below 2.5 a constriction is not a challenge, it is a toll.

Bus events (append here when adding; F referees name collisions)

player:spawn {s} · player:damage {amount, kind} (discrete hits only — continuous drain rides player:state, never events, or E's damage flash pins on) · player:death · player:boost · enemy:spawn {id,type} · enemy:die {id,type,s,score,kind} · pickup {kind} · level:event {…} (raw C events as the player crosses their s; the pump is owned by boot.js/F, wired round 2) · level:checkpoint {s} · level:complete {stats} · level:neutralize {s,radius,duration} (B's antacid torpedo → A/C acid zones) · boss:start {id} / boss:phase / boss:end · combo {n} · audio:cue {name}.

Ratified round 2 (round-1 requests):

  • player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,mode,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive} — every frame, read-only, don't retain (B → E). mode is 'tube'|'arena' (mirrors world.modeAt(s)). In 'arena': flow reads 0 (there is no current in a room), speed is signed (negative = reversing), and throttle is inert — the stick is thrust, not a trim on the flow.
  • combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies} — every frame (B → E).
  • player:surf {active, s} — edge-triggered (B → E).
  • hazard:warn {kind, s, eta} — fires warn seconds before a hazard (C events carry warn).
  • hazard:proximity {kind, distance} — rear-chaser telemetry (reflux surge); drives E's rear indicator, C's most important UI dependency.
  • flora:tended {s} / flora:harmed {s} — a friendly microbe's aura gave the player coat / a friendly microbe was killed by the player (round-2 microbe pass; feeds BIOME STANDING).
  • standing {value} — BIOME STANDING 0..100 (combat/index.js owns it). Tending flora raises it; harming flora drops it; crossing the threshold buds phage allies. E can meter it. ("Don't shoot the biome, it helps you" — V2_IDEAS, now partly real.)
  • biofilm {opened, s} — a biofilm level event was crossed (the reputation cash-out). opened = standing was high enough, so the biome parted (allies budded + coat refilled, standing spent); false = it stayed sealed. E can voice both. (New event type biofilm, fields need/allies/coat.)

Asset manifest contract (D owns assets.js + manifest.json)

{ "textures": { "wall_esophagus_a": { "url": "gen/wall_esophagus_a.webp",
                 "normal": "gen/wall_esophagus_a_n.webp", "tile": [3, 8] } },
  "matcaps":  { "tissue_wet": { "url": "gen/matcap_tissue.webp" } },
  "models":   { "macrophage": { "url": "models/macrophage.glb", "tris": 4200 } },
  "audio":    { "beds": { "stomach": {"ogg": "...", "m4a": "..."} }, "sfx": { … } } }

Law: every entry is optional at runtime. assets.get('textures','x') returns null on miss and the consumer must have a procedural fallback (flat palette color, primitive mesh, synthesized beep). The game must be fully playable with an empty assets/gen/. House GLB law (from PROCITY): metres, +Y up, sensible origin, ≤5k tris, WebP textures ≤1024, no Draco. ?localassets=0 simulates empty-manifest boot for fallback testing.

Ratified round 2: get(category, name) is the raw floor; the consumer entry points are assets.texture(name){map, normalMap, tile, repeat} (repeat pre-computed [tile[0], 1/tile[1]]; multiply into UVs yourself — texture.repeat is inert in a raw ShaderMaterial), assets.audioUrl(group, key) (browser-correct ogg/m4a pick) and assets.audio(group, key){ogg, m4a, loop, gain, seconds} (respect gain). Misses are ledgered: first miss logs once, assets.misses() lists all (drifted names announce themselves instead of falling back silently forever). Texture naming law: keys use A's biome ids (wall_small_intestine_a), no abbreviations — D renames + A deletes its slug map in round 2, in that order.

Flags (F owns this table; lanes request rows via NOTES)

flag owner effect
?stub=1 F world stub instead of Lane A world
?lvl=<id> C boot straight into a level
?seed=<n> F override level seed
?fly=1 A noclip fly camera (no player)
?dbg=1 F perf HUD + window.DBG helpers
?mute=1 E no audio
?shots=1 F hide HUD/debug for clean screenshots
?localassets=0 D boot with empty manifest (fallback test)

Perf & QA budgets

≤300 draw calls worst view · ≤500k tris in frustum · 60 fps M-series & mid laptops · level load <2 s · zero console errors · dispose-clean on level exit (renderer.info deltas). window.DBG (F provides): {draws, tris, fps, shot(name)} — cite it when claiming numbers.

tools/qa.sh gates: node --check every js file · no Math.random outside core/rng.js · level JSONs parse + required keys · vendor present · (grows per round).