The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.
The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.
- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.
Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.
Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
15 KiB
Markdown
241 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,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.)
|
||
- `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).
|