guts/docs/TECH.md
type-two a318cf09f7 [lane B+C+F] The Appendix Sovereign: the secret boss, and a reputation you cannot fake
Two boss specs written to C's format (LANE_C_NOTES §Boss specs, round 3 additions) and the
first of them built. Both extend the roster along C's own law — one boss, one pressure — onto
the two axes the game had systems for and no boss pointed at: reputation (§4 Sovereign) and
restraint (§5 Mast Cell, specced only).

THE SOVEREIGN. The appendix is not vestigial: it is a biofilm reservoir where the gut's flora
shelter and re-seed the colon after a purge, and the whole fight is that fact. It is the only
boss whose existence is decided before you arrive — read once on entry:
  karma >= 70  THE COMMUNION — no fight at all. Full coat, a permanent phage escort. The
               reward for five levels of restraint is being spared a boss.
  karma >= 30  THE TOLL — P1 only.
  below        THE SOVEREIGN — three phases. P1's matrix is immune to the cannon and only the
               antacid torpedo disperses it (a mature biofilm's whole defining trait); P2 is a
               quorum bloom that re-armours when the room gets crowded; P3 is a 20s reseed run.
And the mechanic that makes it this boss: the lair is seeded with YOUR OWN FLORA, and it feeds
on dysbiosis — every commensal your stray fire kills re-armours a plate, live, mid-fight.

KARMA vs STANDING — a real bug found in testing, and worth the extra number. `standing` is a
SPENDABLE meter: a full bar is consumed to bud an ally, dropping to a 50 floor. That meant a
perfect player could never HOLD >= 70, so the Communion was almost unreachable — verified: a
symbiotic pilot showed standing 50 and was demoted to "toll". `karma` is the same inputs with
nothing spending it. The Sovereign's tier and gates' `secretTo` branches read karma; E's bar
still reads standing.

Also: boss.js generalised to a registry, which was the claim its header made — the Sovereign
needed zero changes to enemies.js. LS_appendix (3 segments, 800 units, 16 events), reached from
L4's ileocecal valve via `secretTo`/`secretNeed` — anatomically exact, the appendix hangs off
the cecum right past that valve. The secret is hidden behind conduct, not behind a wall.

C's selfcheck caught par.time as a free medal and the fix improved the design: par 160 sits
under the timid traversal, so only a Communion run (which has no fight) can beat it. The time
medal rewards the same conduct the level is about.

Verified: all three tiers branch correctly (communion = 0 nodes + 4 phages; toll = 1 phase;
full = 3 phases, 4 armoured); killing a commensal mid-fight re-armours a plate (reason
"dysbiosis"); the valve routes karma 100 -> LS_appendix and karma 0 -> L5. Levels 5/5 valid.

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

248 lines
16 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,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.)
- `karma {value}` — 0..100, the UNSPENDABLE conduct ledger. Same inputs as `standing`, but
nothing consumes it. `standing` is a resource (a full bar is spent budding an ally, dropping
to 50); `karma` is the record. Anything asking "how did this player treat the biome across the
campaign" must read karma — the Sovereign's tier and a gate's `secretTo` branch both do.
- `secret:open {gate, to, standing}` — a gate's `secretTo` branch was taken (karma ≥ `secretNeed`).
- `boss:telegraph {id,phase,eta}` · `boss:window {id,open,phase}` · `boss:rearm {id,reason}`
boss schedule events (`reason`: `quorum` | `dysbiosis`).
- `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).