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

14 KiB
Raw Blame History

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

// 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:

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:

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 waveWAVE_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 Vector3s 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.