guts/docs/TECH.md
type-two 3c059f20db [lane B+C+F] The biofilm gate: the reputation cash-out, "it helps you in level 5" made real
The deeper payoff of the flora loop. A new `biofilm` level event: cross one and combat checks
BIOME STANDING — high enough (>= need), the native flora PART the biofilm, budding a wave of phage
allies + refilling coat, then spending the goodwill; too low, it stays sealed and you run the
gauntlet raw. Emits `biofilm {opened}` so E can voice both.

- combat/index.js: biofilmGate(ev) on the level:event pump (fields need/allies/coat).
- levels/index.js: the selfcheck knows the `biofilm` type now (validates need 0..100).
- L2: one demoed at s2500 — the opening flora/akkermansia/beacon let you BANK standing, and the
  gate cashes it into a phage escort right before the hiatus + reflux finale.

The mechanism is complete; L5 also gets the fast-route/Appendix branch when that level + world
support land. Verified in-engine: high standing -> opened, 3 phages, coat 50->90; low -> sealed.

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

238 lines
15 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 — **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.
```js
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).
```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}` (**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,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` — every frame, read-only, don't retain (B → E).
- `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`)
```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.
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).