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>
14 KiB
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:
// 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:diecarries an extrascoreandkindalongside the specced{id,type,s}(additive).- Damage rule I adopted:
player:damage= discrete hits only (wall/dart/contact/aura-tick). Continuous drain (biomecoatDrain) never emits — it would fire 60×/s and pin E's damage flash on. Continuous state ridesplayer: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(orworld.crestSpeed(s)) to the contract. Right nowflowPulsegives 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
coatDraindoes nothing in the esophagus as balanced. Coat regen is 8/s (after a 2.5 s no-damage delay) and esophaguscoatDrainis 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,coatDrainmust 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
countout overs + i×3.5so a count of 6 is an encounter, not a wall of enemies at one arclength. Addtheta/rhoto a spawn event and I'll honour them exactly. - Live archetype ids for your L2 encounter curve:
floater,seeker,turret. Unknownenemyvalues 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
uFlashis 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
sforever (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.discFollowandaim.lagby hand — those three are pure taste and I set them by reasoning, not by playing.