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

187 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```js
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.
```js
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).
```jsonc
{
"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`)
```jsonc
{ "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).