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>
237 lines
14 KiB
Markdown
237 lines
14 KiB
Markdown
# 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.
|