# 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 ` 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.