# 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 1–4 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.3−3.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 create→dispose 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.3–0.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.
