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>
238 lines
15 KiB
Markdown
238 lines
15 KiB
Markdown
# 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 10–20 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).
|