guts/docs/TECH.md
jing 1b5ec767cd [lane C] Round 1: schema v1, levels registry + selfcheck + pacing sim, L2 vertical slice
Data spine:
- Schema v1 ratified (TECH.md, C-owned). Additive over v0: schema/par/next, segment
  name, cross-section placement (theta/rho as a FRACTION of safe radius), spread, span,
  pickup+gate event types, hazard params. STUB_LEVEL synced (that const only, per
  ROUND1 §Lane C.1) — world.hash() unchanged (1bdf001), geometry provably untouched.
- levels/index.js: registry + loader (node+browser, no THREE), real schema selfcheck
  (qa's --selfcheck hook is now live) and a pacing simulator (--sim). Consumes the other
  lanes' contracts live rather than restating them: A's biomes.js for ids and
  wave.amp+breathe, A's SKIN, B's tuning.js hull radius, B's balance.js for scores.
- levels/enemies.js (fiction id -> archetype join table), levels/difficulty.js.

L2_esophagus — the vertical slice, complete: 3600 units, 7 beats, 47 events, 65 enemies,
10 checkpoints, 3 samples, reflux-surge finale. L1/L3 skeletons (shape only).
Anatomy is the level designer: the three real esophageal constrictions are the three
setpieces, and all three enemies are real esophageal pathology (bolus chunk, eosinophilic
esophagitis, candida esophagitis).

Measured (node web/js/levels/index.js --selfcheck|--sim; qa GREEN):
- selfcheck 3/3 valid, exit 0.
- pressure curve 0.27 -> 2.14 -> 4.35 -> 0.69 -> 4.53 -> 6.35 -> 4.40 (breather/pressure/
  spike/breather/pressure/peak/finale).
- par 3:30 vs measured 2:47 speedrun / 3:19 par-pace / 4:22 timid; par.score 9000 of a
  9400 kill budget (95%), computed from B's scores.
- all 10 checkpoint gaps within the <=30s death-cost law (max 28.8s); min clearance 2.73.

Two contract collisions found and resolved (LANE_C_NOTES):
- Biome ids: authored mouth/pharynx/cardia/pylorus; A's registry has six canonical ids and
  rejected all four. A is right — biome = look, segments[].name = anatomy. Conformed.
- Balance: my first enemies.js restated hp/size/speed/score that B owns in balance.js.
  Cut to a join table; index.js reads B's scores via scoreFor().

Caught live: A raised esophagus wave.amp 0.9 -> 1.4 mid-session, making the diaphragmatic
hiatus unflyable (2.33 vs the 2.5 floor). Widened 6.5 -> 6.8. The invariant also failed my
own aortic constriction's first draft (radius 7, symmetric squeeze) -> radius 9, directional.

No in-engine flythrough: ?stub=1&lvl= does not compose (snippet offered to F) and the stub
is single-segment, so it could only render beat 1. The pacing evidence is a kinematic model,
not a playtest — it proves L2 is flyable, paced and survivable, not that it feels good.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:53:34 +10:00

11 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 (A implements; stub implements it today; frozen after round-1 sign-off)

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. +Y up, right-handed.

const world = await createWorld(levelData, { rng, quality });
world.length                    // total arclength
world.group                     // THREE.Group (boot adds to scene)
world.sample(s)                 // → { pos, tan, nor, bin, radius }  (parallel-transport frame)
world.project(pos)              // → { s, theta, rho }  (world → spline space, player-local fast path)
world.wallRho(s, theta)         // → max safe ρ (radius minus conservative displacement amplitude)
world.collide(pos, r)           // → null | { push:Vector3, kind:'wall'|'hazard', biome }
world.biomeAt(s)                // → { id, palette, fog, flow, coatDrain }   (see biomes.js registry)
world.modeAt(s)                 // → 'tube' | 'arena'
world.arenaAt(s)                // → null | { center:Vector3, radius }      (bounds for 6DOF clamp)
world.update(dt, playerS)       // stream chunks, advance shader time

Determinism law: same levelData.seed ⇒ byte-identical geometry. A maintains a golden hash (world.hash() over sampled frames) checked by qa.

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} · player:death · player:boost · enemy:spawn {id,type} · enemy:die {id,type,s} · pickup {kind} · level:event {…} (raw C events as player crosses their s) · level:checkpoint {s} · level:complete {stats} · boss:start {id} / boss:phase / boss:end · combo {n} · audio:cue {name}.

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.

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).