guts/docs/LANES/LANE_B_NOTES.md
jing 0a038582a7 [lane B] Round 1: flight controller, cannon, enemy framework on the stub
Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked
forward motion, throttle as a spring-back lean, boost + i-frames, banking chase
cam on the parallel-transport frame, arcade wall response (shove + graze damage,
never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc,
mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported.

Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with
hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework
with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's
level:event. Coat/hull model and the full bus surface E builds against.

Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0);
boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s
with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80);
0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF
does not run in the automated pane. Evidence: docs/shots/laneB/.

Three findings escalated in LANE_B_NOTES:
- qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax
  gate has never checked anything. I shipped a real SyntaxError past a GREEN qa.
  Fix + verification handed to F.
- Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than
  the player (19.6 u/s), so level 2's signature mechanic loses to mashing
  throttle. Not tunable from this lane; escalated to A/F with options.
- Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's
  hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls
  inherit it.

js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it
retires once F pastes the snippet in LANE_B_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:56:32 +10:00

237 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.

# LANE B — Flight & Combat · NOTES
Round 1. Owner: Lane B. Territory: `web/js/flight/**`, `web/js/combat/**`.
Everything below was measured on the **stub** (`js/stub/world_stub.js`), per ROUND1 §Lane B.
---
## What landed
| file | what |
|---|---|
| `flight/tuning.js` | **every** flight feel number, with rationale. The deliverable. |
| `flight/input.js` | devices → one `intent`. Twin-stick: WASD/stick = disc, mouse/stick = aim. |
| `flight/player.js` | tube controller (s,x,y), chase cam, coat/hull, wall response, bus emissions |
| `flight/ship.js` | ENDO-1 procedural hull + reticle (`assets.get('models','endo1')` upgrade path) |
| `flight/emissive.js` | the one material behind every glowing thing B draws |
| `combat/balance.js` | every combat number |
| `combat/weapons.js` | lysozyme cannon + antacid torpedo, pooled/instanced, heat |
| `combat/enemies.js` | registry + floater/seeker/turret + homing darts |
| `combat/index.js` | `createCombat` — composes the above, consumes `level:event` |
| `flight/dev.html/.js` | **dev harness** (see → Lane F #1; retire once boot wires the player) |
Round-1 asks 14 are done. Arena/6DOF is round 2 as instructed.
---
## Feel numbers (the deliverable)
The full set with rationale lives in `flight/tuning.js` + `combat/balance.js`. The load-bearing ones:
- **Forward is never yours.** `ds/dt = flow × (throttle + surf + boost)`. Throttle ∈ [0.6, 1.4] and
springs back to 1.0 — it's a lean, not a button you hold. No stop, no reverse, ever.
- **Throttle is NOT WASD.** WASD/left-stick moves the ship in the cross-section disc; mouse/right-stick
aims; throttle is Shift/Ctrl (triggers). This is what makes "mouse-aim + WASD" and "gamepad
twin-stick" the same game instead of two.
- **Disc:** accel 78 u/s², max 13 u/s, damping 6.5/s → crosses the ~16 u tube in ~1.2 s. Dodgeable, not twitchy.
- **Boost:** +120% flow for 0.55 s, 2.4 s cooldown, **i-frames 0.35 s** — deliberately shorter than the
burst, so boosting *through* something is a read, not a get-out-of-jail.
- **Wall = shove + graze, never a hard stop** (arcade law). Damage `(impact 3.0) × 1.6`, capped 28,
0.25 s cooldown. Below 3.0 u/s is free, so you can lean on a fold. Forward speed is untouched.
- **Cannon heat is the gun's only cost.** 11 shots/s × 5.5 heat 30/s cooling = **+25 heat/s net** ⇒
~4 s of held fire, then a 2.0 s lockout (100 → resume at 40). Burst and you never see the meter.
### Measured (DBG.step, deterministic fixed timestep — see → Lane F #2)
| claim | measured |
|---|---|
| flow-lock exact | 3.0 s at flow 14 from s=2 → **s=44.0** (predicted 44.0) |
| boost peak | **30.8 u/s** = 14 × (1.0 + 1.2) ✓ · i-frames end at 0.35 s ✓ · cd 2.4 s ✓ |
| heat cycle | overheat at 4.0 s held fire → **2.0 s lockout** (60 heat ÷ 30/s) → resume ✓ |
| wall slam | impact 11.3 u/s → **13.3 coat** = (11.33.0)×1.6 ✓ · forward speed **14.0 → 14.0** ✓ · no tunnelling (rho 7.58 < wallRho 8.49) |
| scraping | one damage tick only; subsequent per-frame bounces fall under grazeFloor |
| turret mount | rho **6.13** = wallRho(7.73) radius(1.6) · darts 14 dmg each |
| cannon | 2 hits kill a 24 hp floater · `enemy:die {type:'floater', kind:'cannon', score:100}` |
| level pump | stub events fired: 4 floaters @ s=60, 3 seekers @ s=140 |
| **draws** | **10 combat draws** with **55 live enemies** + full cannon stream (budget 80) |
| tris | 107.8k worst case **102k of that is the stub tube**; all of combat 5.7k |
| cpu | **0.10 ms/frame** (update+render, 55 enemies, firing) |
| **leak test** | **0** geometries / textures / scene-children leaked over 6 and 20 createdispose cycles |
**fps is NOT claimed.** `requestAnimationFrame` does not run in the automated browser pane
(`document.visibilityState === 'hidden'`), so wall-clock fps could not be honestly measured this
round. The 0.10 ms CPU figure is real; the GPU side needs a human session once boot wires the player.
Evidence: `docs/shots/laneB/` `round1_combat` (roster + pellet, banked), `round1_wallgraze`
(pinned to the fold, the 13.3 hit above), `round1_boost` (FOV kick at 30.8 u/s), `round1_enemies`
(wall-mounted turrets + floater). WebP not PNG: a 1280×720 PNG was 1.9 MB of base64 vs 25 KB WebP,
and the repo already standardises on WebP (ART_BIBLE). Say the word and I'll switch.
---
## ⚠ surf: the esophagus mechanic does not work, and I can't fix it in my lane
This is the round's most important finding. **`world.flowPulse` surf is implemented and fires
correctly but riding a crest is strictly slower than ignoring the wave.**
```
crest phase speed = WAVE_W / WAVE_K = 3.0 / 0.22 = 13.64 u/s (stub, and A's shader will mirror it)
player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
```
A wave travelling 13.64 u/s **cannot carry anything faster than 13.64 u/s**. Measured: riding a crest
at phase-lock = 19.2 u/s vs just mashing throttle = 19.6 u/s. Surfing **loses**. The correct play is
to ignore the signature mechanic of level 2 and hold the throttle key.
It is not a tuning problem. The crest window is fixed in **space**, so a bigger `surf.gain` just
ejects you out the front sooner measured distance gained per crest is **~3.0 units at gain 0.4,
0.75 AND 1.2** (2.96 / 3.04 / 3.08). The bonus cancels itself. I verified the ride directly: starting
*on* a crest at neutral throttle, the ship falls off after **0.6 s** because the surf bonus (24.5 u/s)
outruns the very wave granting it.
Shipped as an honest **peristaltic shove** (~0.30.6 s at 24.5 u/s as each crest sweeps over you).
It feels good and is truthful to what the wave physically is. It is not the GDD's "ride peristalsis
rings for boost" speed line. `WAVE_W`/`WAVE_K` live in F's stub and will live in A's wall shader
(gameplay wave must equal visual wave), so **B cannot fix this alone** see Lane A/F #1.
---
## ### → Lane F
**1. Wire the player into `boot.js`** (I own no shell). Paste-ready; `?fly=1` keeps A's noclip:
```js
// imports, top of boot.js
import { createPlayer } from './flight/player.js';
import { createCombat } from './combat/index.js';
// after: scene.add(world.group) / scene.background = ...
const rng = createRng(flags.seed ?? world.level?.seed ?? 0);
const player = flags.fly ? null
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
// level-event pump — nobody owns this yet (see #3). Emits C's events as the player crosses them.
let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0;
const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); };
// in frame(), REPLACING `world.update(dt, sCam); updateCamera(dt);`
if (player) {
player.update(dt); // must run first: combat hit-tests this frame's ship position
pumpTo(player.state.s);
combat.update(dt);
world.update(dt, player.state.s);
} else {
updateCamera(dt); // ?fly=1 noclip / title drift
world.update(dt, sCam);
}
```
The player drives `camera` (position, `up`, `lookAt`, FOV) `updateCamera()` must not also run.
`DBG` line's `s=${sCam}` should read `player?.state.s ?? sCam`.
**2. `tools/qa.sh` gate #1 is a no-op — it passes every broken file in the repo.**
`node --check` **silently exits 0 on any file containing `import`/`export`** (ESM detection). Every
module in `web/js` is ESM, so the syntax gate has never checked anything. I found this the hard way:
I shipped a genuine `SyntaxError` (backticks inside a GLSL comment inside a template literal, closing
the literal early), qa.sh went GREEN, and the browser refused the module. Verified on node v26.5.0:
```bash
node --check broken_esm.js ; echo $? # -> 0 (!!)
node --check --input-type=module < broken.js ; echo $? # -> 1 (correct)
```
Fix swap the loop body in gate #1:
```bash
node --check --input-type=module < "$f" 2>/dev/null || { node --check --input-type=module < "$f"; JSBAD=1; }
```
Confirmed it still passes all good modules (`rng.js`, `world_stub.js`) and fails real breakage. Worth
landing before round 2 A/C/D/E are all writing ESM against a gate that cannot fail.
**3. Nobody owns the `level:event` emitter.** TECH specs the event and says "raw C events as player
crosses their `s`", but no lane owns the pump. B consumes it. I put a dev-only copy in my harness and
the snippet in #1 please assign it (boot or C's registry) and delete whichever copy loses.
**4. Bus additions** (TECH §Bus says F referees names). All emitted faithfully already:
- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` **E cannot build a HUD without this.** Damage events
alone can't express regen, ambient drain, throttle or progress. Emitted every frame; treat as
read-only, don't retain.
- `combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies}` same reason.
- `player:surf {active, s}` edge-triggered.
- `enemy:die` carries an extra `score` and `kind` alongside the specced `{id,type,s}` (additive).
- **Damage rule I adopted:** `player:damage` = *discrete* hits only (wall/dart/contact/aura-tick).
Continuous drain (biome `coatDrain`) never emits it would fire 60×/s and pin E's damage flash on.
Continuous state rides `player:state`. Floater auras tick at 0.2 s so they read as pulses.
**5. `?shots=1` respected** in my harness. My `DBG.step/leakTest/spawn/tp` helpers are harness-only.
---
## ### → Lane A (and F, who owns the stub today)
**1. The wave must outrun the player, or level 2's mechanic is dead.** See §surf above. For "ride the
crest" to be the fast line, **crest phase speed must exceed `flow × throttleMax` (19.6 u/s)**; today
it's 13.64. Options, your call (F settles design):
- **(a) Raise the wave** `WAVE_W/WAVE_K > 19.6`, e.g. `WAVE_W 4.8 @ K 0.22` 21.8 u/s. Then
riding pays ~+11% over mashing and the crest genuinely carries you. Cheapest fix; the visual
wave just travels faster (arguably better for a speed-run level).
- **(b) Expose the phase speed** so B can *speed-lock* the player to the crest while riding the
truest "flow-locked" reading, and then the wave carries you at exactly its own speed.
Either way **B needs (b)'s data**: please add `biome.waveSpeed` (or `world.crestSpeed(s)`) to the
contract. Right now `flowPulse` gives amplitude only, so B can see *that* it's on a crest but not
*how fast the crest is going* I had to hardcode 3.0/0.22 from the stub to analyse this.
**2. `world.flowPulse(s, t)` is missing from the TECH world contract** but is specced in both LANE_A
and LANE_B charters and in ROUND1 §A.4. I call it guarded (`world.flowPulse?.(s) ?? 0`). Please add it
to TECH §THE WORLD CONTRACT before F freezes it.
**3. `world.sample(s)` allocates** ~6 `Vector3`s per call. It's the hot path: I call it for the ship,
the aim frame, the camera (×2) and once per live enemy per frame ~60 calls/frame at 55 enemies,
i.e. ~350 allocations/frame of pure GC churn. Not a problem yet (0.10 ms/frame), but it will be with
villi + a boss. Request: an out-param variant, `world.sample(s, out)` reusing a caller-owned frame
object, keeping the allocating form as sugar. I'll adopt it the round it exists.
**4. Frame convention I built against** (please keep): `worldPos = frame.pos + frame.nor·y + frame.bin·x`,
`theta = atan2(x, y)` matches the stub's `project()` (`atan2(rel·bin, rel·nor)`). Camera `up` is
`frame.nor`, never world-up. Parallel transport must stay roll-stable or the chase cam rolls with it.
**5. Custom `ShaderMaterial` needs `#include <colorspace_fragment>` or ART_BIBLE colours are wrong.**
`ColorManagement` is on and `outputColorSpace` is `srgb`, so `THREE.Color(0xff5a2a)` is converted to
**linear** on the way in but a hand-written fragment shader gets no conversion on the way out and
writes linear straight into the sRGB target. Measured: hostile amber `#ff5a2a` reached the display as
**rgb(255, 26, 6)** blood red, not amber silently breaking the emissive readability law. One line
at the end of `main()` fixes it. **The stub's wall shader has the same issue** (F: its teal is being
written linear too), and A's wall/villi shaders will inherit it. Worth an ART_BIBLE or TECH note so
every lane writing GLSL gets it right once.
---
## ### → Lane C
- **Ambient `coatDrain` does nothing in the esophagus as balanced.** Coat regen is 8/s (after a 2.5 s
no-damage delay) and esophagus `coatDrain` is 0.5/s, so out of combat you are always topped up
ambient drain only bites while regen is suppressed, i.e. for 2.5 s after each hit. That's a
defensible design (it makes *taking* a hit hurt twice), but if you want standing ambient pressure in
a biome, `coatDrain` must exceed **8/s** which is presumably exactly what the stomach should do.
Flagging so your biome numbers are chosen knowingly, not by accident.
- Spawn events: I fan `count` out over `s + i×3.5` so a count of 6 is an encounter, not a wall of
enemies at one arclength. Add `theta`/`rho` to a spawn event and I'll honour them exactly.
- Live archetype ids for your L2 encounter curve: `floater`, `seeker`, `turret`. Unknown `enemy`
values warn and no-op, so you can author ahead of me safely.
- Pool caps are the population caps: floater 24, seeker 32, turret 12 concurrent.
---
## Open questions / next round
- **Arena/6DOF controller** (round 2, as instructed). `modeAt()`/`arenaAt()` are guarded but unused.
- **Per-instance hit flash.** Today `uFlash` is one uniform per *type*, so hitting one floater blinks
the whole cohort. Needs an instanced colour attribute round 2.
- **Enemies behind the player are never reaped.** They idle at their spawn `s` forever (pool-capped, so
bounded, but wasteful). Wants a despawn rule once C's checkpoints exist.
- **Dart readability:** a dart passing the camera fills a chunk of the screen with white-hot additive.
Correct per ART_BIBLE, possibly too dominant in motion. Wants a human eye once boot wires the player.
- **Torpedo** is built and emits `level:neutralize {s,radius,duration}` faithfully **nothing consumes
it yet** (A/C). Ammo is fixed at 6 until pickups exist (round 2).
- **Real fps + real feel.** Everything above is deterministic-stepper truth. Numbers can't tell you
whether the bank rate feels good. First human session should re-tune `bank.gain`, `cam.discFollow`
and `aim.lag` by hand those three are pure taste and I set them by reasoning, not by playing.