bc891527ed
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bc891527ed |
[lane F] The deploy stops checking a URL nobody visits, and the frame rate gets a switch
Audit item 14. THE DEPLOY VERIFIED THE ONE URL A PLAYER NEVER LOADS. Every check in deploy.sh appends ?v=$BUST — which is exactly what makes it bypass Cloudflare's edge — so all six could pass green while every actual player was still being served the previous build from a POP. The script's answer was a line of prose at the end asking a human to remember to purge. It now fetches js/boot.js the way a browser does, with no query string, and byte-compares it to what was just shipped; a stale edge fails the deploy and prints the purge steps. No credentials needed, and it asks the question the prose was only gesturing at. This is the same mistake as the `assets` bug and the unwinnable saliva tide: verifying the mechanism rather than the path the player actually takes through it. Third time, so it is written into the tool this time rather than into a comment. QUALITY SWITCH. renderer.setPixelRatio was hardcoded to min(dpr, 2), so a retina laptop drew four times the pixels of a 1x one with no way for its owner to decline — and this game is fill-bound, not geometry-bound (fogged tube, additive glows, a full-screen damage layer), so that is the lever that moves. `performance` caps it at 1. On the title beside difficulty, saved, same reload path (the pixel ratio is set at boot, before any card exists). save.js's two one-line setters collapsed into setOpt(key, value) now that there are two options and a third is obvious. Note on verification: the chip persists and boot reads it, confirmed live. The resolution change itself is not observable in the harness — that browser pane runs at devicePixelRatio 1, where min(1,1) and min(1,2) are the same number. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9482b8a2bf |
[lane E+B] The pause card stops fighting the player, and a refused pointer lock stops
faking a crash Audit item 8, plus two bugs the last commit introduced and one it exposed. REQUESTPOINTERLOCK RETURNS A PROMISE. Both new call sites wrapped it in try/catch, which catches nothing — the rejection went to window.onunhandledrejection, which the previous commit had just taught to paint a full-screen FEED LOST. So a browser declining a lock for any of its ordinary reasons (document not focused; Chrome's ~1s lockout after the player leaves a lock with Escape) presented to the player as the game crashing. Caught by clicking: "the root document of this element is not valid for pointer lock" in 15px type over a game running perfectly well underneath. All three sites — title dismissal, resume, and input.js's canvas click — now swallow the rejection. A refused lock costs one click, which is what input.js has always been for. PAUSE ON BLUR HAD NO CARD AND TWO OWNERS. boot.js emitted ui:pause directly, so the loop halted while cards.js's own `paused` stayed false: the game stopped with nothing on screen to say why, and the next P press raised a card that then took a SECOND press to leave. Moved to cards.js, which owns the pause state — setPaused raises the card and emits. It already refuses while the title or debrief is up, so alt-tabbing away from a card correctly does nothing. Verified: blur raises PAUSED, one P resumes, exactly one true and one false on the bus. THE PAUSE CARD WAS NOT MODAL. #ui is pointer-events:none and it inherits, so a click anywhere but the RESUME button fell through to the canvas — whose mousedown handler re-requests pointer lock whenever it is not held. Clicking beside the button captured the pointer, hid the cursor, and left the player paused with nothing to press RESUME with. The dimmer takes pointer-events now (our own handlers are capture-phase on window, so they still see every click; only the canvas stops seeing them). The title keeps no dimmer and stays click-through by design. Pausing also now releases the lock and resuming takes it back. Pausing with P used to keep the pointer captured — a RESUME button that could not be pointed at — while Escape looked fine only because the browser drops the lock for you. Same key, two behaviours, no stated reason. And losing the lock now drops the queued EDGES, not just held keys: the Space you pressed to reach the RESUME button was still in the queue when the card went, so the run restarted by spending a boost you did not ask for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4e4311b568 |
[lane E+F] The game remembers you: a save file, a level select and an ending
Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did not exist. DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and every single call site in the codebase took the default. `easy` and `hard` were a table nobody could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal against 8 on Clinical. THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl= in the address bar. The title now lists the campaign, unlocking each segment as the one before it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix stays a row of question marks until you have earned your way in — naming it in the menu would have given away the one thing in this game you have to discover. THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way out but the address bar, and the card said "any key", the same words it uses when it has nothing to tell you. Now the clear is filed and counted, the card says you are out, and the title carries CANAL CLEARED. New core/save.js is the whole persistence layer: one localStorage key, one flat object, best time / best score / best medal kept per column (they need not come from the same run) plus an attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js --selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned and malformed saves. cards.js records rather than boot: this file computes the grade, so routing it through the bus for boot to re-derive would be two copies of the medal table waiting to disagree. One bug found by clicking rather than reading: the first cut of the chips had no pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so — so the buttons rendered, highlighted nothing, and passed the click through to the canvas, which on the title starts the run. It looked like a menu and behaved like wallpaper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
caa5f95339 |
[lane E] The boss banner: five fights stop being guesswork
boss.js has emitted boss:start / boss:phase / boss:window / boss:telegraph / boss:end since the framework landed, and NOTHING has ever listened. Every boss in the game was therefore illegible: the Pyloric Guardian's whole design is "shoot only while the iris relaxes", and the only tell a player had was a node's flash uniform going white-hot — a cue nobody is told to read. A top-centre banner, fed entirely by events the boss already broadcasts: - the boss's NAME (five of them; there was no way to know what you were fighting) - phase pips, filling as you break each phase - the state: SEALED / opening (blinking, off boss:telegraph) / VULNERABLE in green The Sovereign's Communion reports `phases: 0`, so a symbiotic player gets "stand down" and no pip rail rather than the promise of a battle they have just earned their way out of. Verified in the Guardian's lair: banner on, "PYLORIC GUARDIAN / VULNERABLE", 3 pips, state tracking boss.open exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b9d9d816ad |
[lane B+E] Stop the game lying: the death sting at living players, the stuck hiss, and a crew naming the wrong organ
Four audio/comms cues wired to the wrong condition. All four are written content that has been
in the repo for rounds, firing at the wrong moment or never firing at all.
THE DEATH STING PLAYED AT LIVING PLAYERS. finish() mapped every non-win outcome to `death` — and
`death` is not a stinger, it is a ~12 s noise table (synth.js). So surviving L1's Mast Cell
("degranulated"), outrunning the Tapeworm ("escaped") and clearing the Sovereign's Toll all told
an alive player they had died, over the top of the next twelve seconds of play. Outcomes are
classified now: won/spared/communion triumph, alive-but-it-went-badly gets a downbeat, actually
dead gets the sting, and housekeeping (reset/dispose) is silent because nothing happened to the
player at all. Verified: surviving the Mast Cell no longer emits `death`; dying still does.
THE PHASE TELL WAS A SUSTAINING HISS. boss.js used `overheat` as its phase-change cue, but that
layer sustains until the engine sends `overheat_clear` on a combat:state.overheated falling edge
— which a boss never produces. Every phase change leaked a ~12 s steam hiss and ducked the mix
under it for the rest of the fight. Three sites, now short cues.
THE CREW HAS NEVER MENTIONED HULL DAMAGE. comms.js gates its two hull lines on
`kind === 'hull_hit'`, but `kind` is the damage SOURCE ('dart'|'acid'|'gas'…), so that branch has
never once been true in the game's life and every hit read as a coat graze. damage() already
computes `leak > 0` for its own audio cue — the bus event now carries it as `hull`.
AND IT NAMED THE WRONG ORGAN. There was exactly ONE boss line — "that is the pylorus. it does not
open for you." — fired for all five bosses, so the tutorial's Mast Cell and the campaign's final
boss were both announced as a stomach valve. Per-boss lines now, off boss:start (which is the only
place the id is known, and for the Sovereign whether there is a fight at all). The Mast Cell's is
doing real work: "wait — wait. that is a mast cell. it is not hunting you." / "do NOT shoot it."
The crew is the game's only tutorialisation channel, and that is the lesson L1 exists to teach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
7455c03ae3 |
[lane B+C+E+F] Playtest blockers: the game never taught its controls, deleted bosses on death, and shipped an unwinnable tutorial
A five-way audit of the shipped build found the first thirty seconds are broken in three
independent ways. All three are verified, not inferred.
1. THE CONTROLS WERE NEVER STATED. The title said "press any key", and aim + fire are both gated
on pointer lock (input.js:45 onMouseMove early-returns unlocked; input.js:50 the first
mousedown only REQUESTS the lock) — so a player who dismissed with a key got no mouse look and
a cannon that silently did nothing. Worse: in an ARENA the forward axis belongs solely to
intent.throttle (player.js), so an untaught player coasts to a dead stop in front of L1's Mast
Cell and reasonably concludes the game is broken. Six arenas across L1/L3/L5/LS are the same
trap. The card now says CLICK TO FLY, takes the pointer lock on any dismissal, and prints the
legend that has existed — written and styled — in flight/dev.html since round 1, visible only
to whoever was running Lane B's harness.
2. DYING DELETED THE BOSS. boot's pump advanced `_ei` monotonically and never rewound. That
reasoning is right for `spawn` and `pickup` (re-firing double-spawns survivors and resurrects
banked pickups) but it silently applied to `boss` as well: die to any of the five, respawn,
fly through an empty lair, cross the gate, and collect a graded medal for a fight that never
happened. Dying was the fastest route past every boss in the game. Now a REARM set ({boss,
acid}) re-fires on respawn while everything else is retired into `_spent`. Verified by event
trace: start:mast_cell -> end:wipe -> start:mast_cell. Also despawns the sentinel's body on a
wipe, which the fix exposed as a duplicate (1 -> 2 Mast Cells per death).
3. L1'S TIDE WAS UNWINNABLE, AND MINE. I authored it this morning at speed 5 in a flow-2 segment,
reasoning "5 against 2 catches a dawdler". The player ceiling there is 2.8 u/s sustained
(throttleMax 1.4 x flow 2); boost peaks 5.2 for 0.55s on a 2.4s cooldown. C's own --sim prints
the verdict in plain text — "required 2.50x UNWINNABLE" — and I never ran it. Because
hazards.js grazes on gap<=catchRadius and the gap stays negative once passed, it was a ~74 dps
loop back onto the s=475 checkpoint: a brand-new player, in the default level, hard-stopped
three minutes in. Retuned to 2.4 (required 1.20x — the same shape as L2's teaching burp).
The validator was exempting it: `if (!c.lethal) continue` in levels/index.js meant qa.sh went
green over a death loop. An unwinnable chase is a design failure whatever its lethality — a
non-lethal one you cannot outrun is a continuous-damage trap. That exemption is gone, and all
six levels still validate.
Also: DEFAULT_LEVEL was still 'L2_esophagus' from when L2 was the only finished level, so every
new player skipped the tutorial entirely and started mid-campaign. LANE_C_NOTES §→ Lane F #2
said "change it if you would rather it be L1". It is L1 now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
4ee6ae49a1 |
[lane B+E+F] Arena mode: in a room, you own the forward axis
The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.
The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.
- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.
Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.
Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
4bcb942312 |
[lane E] The biome meter: the reputation you're building, made visible
A teal BIOME STANDING bar joins coat/hull in the HUD (matches the thin-cyan line-work; scale- only per the 60fps law). Hidden until the first `standing` event, so a flora-less level never shows a bar you can't fill. Brightens near full to telegraph an incoming phage ally. combat/index.js: rescaled standing to a clean 0..100 (tend +5, harm -35) — a FULL bar buds an ally and drops to a 50 recharge floor, so the meter reads as "charge it to summon help." First- pass numbers; tune to taste. Verified in-engine: tending flora reveals the bar and fills it proportionally (0.20 -> 0.45), teal, below coat/hull. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
464f5c8a18 |
[lane E] The game finds its voice: procedural synth bank, the WebAudio graph, the damage feed, and the medal/title/pause cards
Round-1 Lane E audio + the two remaining UI surfaces, consolidated from the JING5
clone onto main and made determinism-clean:
- audio/synth.js — procedural voice bank (primary path; audible with an empty manifest)
- audio/engine.js — WebAudio graph, cue router, bed, heartbeat; now the listener for the
11 gameplay bus events that were previously firing into the void
- ui/feedback.js — feed-corruption damage overlay (an ART_BIBLE law previously unmet)
- ui/cards.js — title / medal / pause cards; boot now honours ui:pause
- boot.js — mounts all five modules with per-module failure isolation and a
frame() tick (never step(), so stepped sims stay deterministic)
Determinism gate: threaded engine's seeded rnd (mulberry32 off ?seed=) into createSynth,
replacing every Math.random in synth.js. Audio texture is now reproducible per-seed and
qa.sh is green.
Verified: QA green; runtime smoke on ?seed=7 scheduled 182 osc + 91 buffer voices + 268
envelope ramps during play, zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
83c30bf344 |
[lane E] The instrument exists: a HUD, and a surgical team who talk to you
#ui had been an empty div since round 0 — E never ran. It has an owner now. HUD (charter #1): coat/hull, heat, torpedo pips, speed/flow/throttle, boost, score, combo, biome + segment name, and a progress rail with C's 10 checkpoints ticked. Bus-only — zero imports from B, exactly as the charter asks. player:state and combat:state arrive every frame and ARE the clock, so the HUD cannot tick while the game isn't running. 60fps law: DOM is built once; the hot path is transform + textContent-on-change only. Bars scale (scaleX), they never resize — a width:% write per bar per frame is a layout pass. Torpedo pips rebuild only when ammoMax changes, not when ammo does. COMMS — on nobody's list. Argued for, then built. The GDD says "Star Fox arcade flow" and there was nobody in the game but the player. Three-hander: voss (attending, terse), park (resident, over-excited), adeyemi (immunology, apologetic — and amber, the colour of the cells attacking you; his fault). Deterministic round-robin per trigger key, no RNG. Priority ladder + per-key cooldowns so the crew shuts up during a firefight. It turned out to be more than flavour: it is the tutorialisation channel the GDD asks for and L1 has no other vehicle for. The hazard lines teach what C's HAZARDS registry note says the hazard IS ("ring gate. it opens on a beat — match the beat, do not ram it"). Keys are the real HAZARDS ids, checked against levels/enemies.js rather than guessed — all three L2 kinds get bespoke lines, zero generic fallbacks. A level's worth of dialogue is now a data edit in LINES. Three defects, all mine, all found by the stepped sim, none visible to the eye: - The progress rail was dead. I read `level.length` — a field that does not exist. The level carries segments, not a total. It now uses the runtime world.length that B already divides into p.progress. - My clock was fiction. Comms counted `+= 0.1` per timer tick and called it seconds; a backgrounded tab throttles setTimeout to ~1s, so lines held ~10x too long. It now reads performance.now(). - Every hazard warning had the LOWEST priority in the game. PRI has a key `warn`, but say() is called with `warn_reflux_surge`, so PRI[key] was undefined and fell through to the default of 1 — the surge screaming "GO. GO." could be talked over by park admiring a specimen. Keys now resolve exact-then-family. The evidence shot IS the fix: same seed, same frame, the warning now sits where the checkpoint line used to be. ### -> Lane B — player:state stops emitting the moment you die player.js:103 early-returns before the emit when !st.alive. So `alive` is unobservable (E can only ever read true — the field is currently decorative), and E cannot use player:state as a clock, because it stops exactly when the death UI needs to run and through boot's whole 2.0s respawn window. Comms works around it with its own wall-clock. The death/respawn feed-drop (charter #4) will not be able to. Ask is in NOTES; not patching your file. ### -> Lane F — two contract items I edited boot.js (~10 lines mirroring your own dynamic-import/try-catch/ console.info fallback) because #ui needed an owner and two modules nothing constructs aren't worth much. Ratify or move it. Second: shot_sink.py cannot photograph Lane E — it POSTs renderer.domElement.toDataURL(), and the HUD is a DOM overlay, so the house evidence tool renders it invisible. This shot is a composite (canvas -> 2D canvas, then #ui via an SVG foreignObject). Offer to lift it into pipeline/ as DBG.shotUI() stands. qa GREEN. Evidence: docs/shots/laneE/round2_hud_aortic.webp (s=1111, seed 7, live hazard warn + x6 chain). Dev server guts-e on 8146. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |