guts/docs/TECH.md
type-two 0ac11ae040 [lane B+C+D] Microbes, friend and foe: the GLB loader, a friendly commensal, and a distinct-mesh yeast
The round-2 microbe pass, vertical slice. Cross-lane (integrator call): the point was one
thread proven end to end — art pipeline in, friend and foe out.

- core/assets.js (D): the GLB loader, finally wired. GLTFLoader was vendored but never
  imported and the manifest had 0 models, so enemies.js's `glb?.geometry` hook was dead code.
  Now every manifest model preloads (AWAITED — pools set geometry at construction, a late load
  can't retrofit an InstancedMesh) into ONE BufferGeometry normalized to a unit bounding
  sphere; get('models',n).geometry lights up the hook. Optional-asset law intact: any failure
  -> no geometry -> procedural fallback.

- combat/enemies.js + balance.js (B): two new archetypes.
    flora     — the FIRST FRIEND. Cyan (ART_BIBLE: flora reads friendly). Drifts like a
                floater but its aura HEALS (sheds coat), never harms. Shootable — and shooting
                it is the reputation trap: kill() gives no score, no combo, emits flora:harmed.
                Verified: the aura fires player.refill({coat}); a shot flora scores 0.
    spore_pod — a foe yeast cluster: the floater's drift+aura, but its OWN pool so its blobby
                GLB will not reskin the food-debris floater.
  Pool geometry now scales a GLB by the archetype radius (GLBs ship unit-normalized).

- levels/enemies.js (C): ARCHETYPES += flora, spore_pod; catalogue entries lacto_drifter
  (Lactobacillus) + yeast_pod (Candida). L2 gets a light teach beat in the calm opening — a
  friendly reef at s180, one yeast at s300, both off the racing line.

Ships playable as glowing primitives (a capsule + an icosahedron), exactly like the rest of
the round-1 roster; the Trellis/Hunyuan meshes drop in via the manifest with no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:13:30 +10:00

233 lines
14 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; seeds a future BIOME
STANDING reputation meter — "don't shoot the biome, it helps you in level 5", V2_IDEAS).
## 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).