Wired B's player/combat + event pump, D's assets, C's level pick into boot. Fixed qa ESM gate (node --check no-op, found by B) + added spline selfcheck. TECH: world contract FROZEN v1.1, bus events ratified, colorspace + crest-speed laws (CREST_FACTOR 1.6 — B proved surfing lost to throttle at 1.0). Stub complies. Verified integrated build via 60s stepped sim: 9 draws, 0 errors, 0 asset misses; one known gap (fiction-id spawn resolve) confirmed and assigned as B's top item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
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:
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.
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).
{
"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}— fireswarnseconds before a hazard (C events carrywarn).hazard:proximity {kind, distance}— rear-chaser telemetry (reflux surge); drives E's rear indicator, C's most important UI dependency.
Asset manifest contract (D owns assets.js + manifest.json)
{ "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).