[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>
This commit is contained in:
type-two 2026-07-26 19:04:25 +10:00
parent 5a891107e2
commit 7455c03ae3
5 changed files with 73 additions and 8 deletions

View File

@ -121,7 +121,31 @@ bus.on('level:event', (ev) => { if (ev?.type === 'acid') acidTarget = ev.height
// (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(),
// which is the one replay a death actually needs (the thing that killed you works again).
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++]); };
const _spent = new Set(); // indices consumed for good — never re-fire these
// Which event types must COME BACK after a death. The original pump was strictly monotonic and
// the reasoning was right for most types (re-firing `spawn` double-spawns enemies that survived
// your death; re-firing `pickup` resurrects ones you already banked) — but it silently applied
// to `boss` too, so dying to any of the five bosses DELETED it: you respawned, flew through an
// empty lair, crossed the gate and were handed a graded medal card for a fight that never
// happened. boss.js's own header promises "a wipe costs seconds", which only holds if the
// encounter re-arms. `acid` re-fires so L3's tide sits where the checkpoint left it.
const REARM = new Set(['boss', 'acid']);
const pumpTo = (s) => {
while (_ei < _evts.length && s >= _evts[_ei].s) {
const i = _ei++;
if (!_spent.has(i)) bus.emit('level:event', _evts[i]);
}
};
/** Respawn: rewind the pump to `s`, retiring everything that must not happen twice. */
const rewindTo = (s) => {
let i = 0;
while (i < _evts.length && _evts[i].s < s) i++;
for (let k = i; k < _ei; k++) if (!REARM.has(_evts[k].type)) _spent.add(k);
_ei = i;
};
// --- run state: checkpoint -> death -> respawn, gate -> level:complete ------------------
// C's authoring laws carry this: checkpoints sit outside hazard zones and <=30s apart, so
@ -176,6 +200,7 @@ function updateRun(dt) {
run.respawnT -= dt;
if (run.respawnT <= 0) {
combat?.reset(run.checkpointS); // re-arm hazards at/ahead of the checkpoint
rewindTo(run.checkpointS); // ...and the boss you just died to
player.respawn(run.checkpointS);
}
}

View File

@ -260,6 +260,13 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n
if (f.done) return;
f.done = true;
for (const e of [...f.nodes, ...f.vents]) if (e.alive) e.alive = false;
// The sentinel's cell survives a RESOLVED encounter on purpose — you spared it, so it lives,
// and it should still be drifting there as you leave. But a wipe/reset is not a resolution:
// the encounter re-arms on respawn (boot's rewindTo), and leaving the old body behind stacks
// a second Mast Cell in the cavity every time you die. Measured: 1 -> 2 cells after one death.
if (f.cell?.alive && (outcome === 'wipe' || outcome === 'reset' || outcome === 'dispose')) {
f.cell.alive = false;
}
active = null;
bus.emit('boss:end', { id: f.id, outcome });
// `spared` and `communion` are WINS that involve no killing, and the instrument has to agree

View File

@ -73,7 +73,7 @@
{ "s": 450, "type": "spawn", "enemy": "amylase_droplet", "count": 3, "spread": 65, "note": "TEST the droplet: three at once, in the open. Everything taught so far, with room to fail safely." },
{ "s": 475, "type": "checkpoint", "name": "Faucial Arch" },
{ "s": 500, "type": "hazard", "kind": "reflux_surge", "speed": 5, "from": -30, "span": 140, "warn": 3.0, "lethal": false, "note": "THE SALIVA TIDE (C's round2_plan). A slow harmless wash sweeping forward at 5 u/s against a 2 u/s current - it will catch a dawdling player and it cannot kill them. This is the surge VERB in its safest possible form, 1200 units before L2's teaching burp and 2500 before L2's lethal finale. By the time an acid wall is genuinely chasing them, the player has met the shape of it twice." },
{ "s": 500, "type": "hazard", "kind": "reflux_surge", "speed": 2.4, "from": -30, "span": 140, "warn": 3.0, "lethal": false, "note": "THE SALIVA TIDE (C's round2_plan). A slow harmless wash sweeping forward at 5 u/s against a 2 u/s current - it will catch a dawdling player and it cannot kill them. This is the surge VERB in its safest possible form, 1200 units before L2's teaching burp and 2500 before L2's lethal finale. By the time an acid wall is genuinely chasing them, the player has met the shape of it twice." },
{ "s": 540, "type": "checkpoint", "name": "Oropharynx" },
{ "s": 560, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 2.36, "rho": 0.9, "note": "SAMPLE 3/3 - a tonsillar crypt, where C put it. In the funnel, where the current has started to build: the last sample is the first one that must be taken at speed, which is the shape of every sample from L2 onward." },
{ "s": 600, "type": "spawn", "enemy": "amylase_droplet", "count": 2, "spread": 40, "note": "Traffic in the funnel - the first time the player meets a known enemy while the flow is doing something to them." },

View File

@ -62,8 +62,17 @@ export const SUPPORTED_SCHEMAS = new Set([1, 2]);
// boot.js). It lives in the registry so it can be loaded, chained out of, and selfchecked.
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach', 'L4_small_intestine', 'L5_large_intestine', 'LS_appendix'];
/** Booting with no ?lvl= gives you the vertical slice. Lane F: see LANE_C_NOTES §→ Lane F. */
export const DEFAULT_LEVEL = 'L2_esophagus';
/**
* Booting with no ?lvl= starts the CAMPAIGN, at level 1.
*
* This was 'L2_esophagus' for three rounds and that was correct at the time L2 was the vertical
* slice and L1 was a 2-event skeleton, so the default pointed at the only finished level.
* LANE_C_NOTES § Lane F #2 says in as many words: "Change it if you would rather it be L1."
* L1 is authored now (24 events, the Mast Cell, the whole teaching order), so leaving the default
* on L2 meant every new player skipped the tutorial and started in the middle of the campaign,
* which is the worst first impression the game is capable of making.
*/
export const DEFAULT_LEVEL = 'L1_mouth';
/**
* Playability constants Lane C's model of "is there room to fly here".
@ -569,7 +578,12 @@ export async function validate(level, { expectId = null } = {}) {
// chase hazards must be escapable, and the escape must cost something (ruling #1)
for (const c of chaseAnalysis(level)) {
const where = `reflux_surge@${c.s} (surge ${c.surge} vs flow ${c.flow})`;
if (!c.lethal) continue;
// NOT `if (!c.lethal) continue`. An unwinnable chase is a design failure whatever its
// lethality: a non-lethal one you cannot outrun is a continuous-damage trap that grazes you
// until its span ends, which is how L1's tutorial tide shipped as a death loop with this gate
// green. A level that genuinely wants an inescapable wash should author a ZONE hazard, not a
// chase. Lethality only changes the wording.
if (false) continue;
if (c.required > CREST)
E(`${where}: requires throttle ${c.required.toFixed(2)}× — above even a crest ride (${CREST}×). Unwinnable.`);
else if (c.required > 1.4)

View File

@ -166,7 +166,8 @@ const CSS = `
.crd .title .tag { font-size:10px; letter-spacing:.42em; opacity:.6; margin-top:14px; }
.crd .title .press { font-size:10px; letter-spacing:.34em; margin-top:34px; opacity:.75;
animation:crdPulse 1.9s ease-in-out infinite; }
.crd .title .keys { font-size:8.5px; letter-spacing:.22em; opacity:.28; margin-top:11px; }
.crd .title .keys { font-size:9px; letter-spacing:.16em; opacity:.5; margin-top:14px;
line-height:1.9; }
/* the title sits over the live view; a hairline top/bottom band frames it without hiding it */
.crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3;
margin:0 auto; }
@ -314,9 +315,20 @@ export function createCards({
? `${lvl.name}${lvl.tagline ? `${lvl.tagline}` : ''}`
: 'a flight through the alimentary canal';
const press = el('div', 'press', t);
press.textContent = 'press any key';
// CLICK, not "press any key". Aim and fire are both gated on pointer lock (input.js
// onMouseMove early-returns while unlocked, and the first mousedown only REQUESTS the lock),
// so a player who dismissed this with a key got no mouse look and a cannon that silently did
// nothing. Asking for the click is what makes the game work, not a stylistic choice.
press.textContent = 'click to fly';
// The controls have never been stated anywhere the player can see — the legend below has
// existed, written and styled, in flight/dev.html since round 1, visible only to whoever was
// running Lane B's harness. Shift/Ctrl especially: in an ARENA it is the only forward input
// there is (player.js gives the axis to intent.throttle), so an untaught player coasts to a
// dead stop in front of L1's Mast Cell and concludes the game is broken.
const keys = el('div', 'keys', t);
keys.textContent = 'esc / p — pause';
keys.innerHTML =
'WASD move &middot; mouse aim &middot; LMB cannon &middot; RMB torpedo<br>' +
'SHIFT / CTRL throttle &middot; SPACE boost &middot; ESC or P pause';
}
// ============================ PAUSE =====================================================
@ -622,6 +634,13 @@ export function createCards({
if (!titleUp) return false;
titleUp = false;
titleS.classList.remove('on');
// Take the pointer lock on the way out. input.js only ever requests it from its own
// mousedown, which meant a keyboard dismissal left the player with no aim at all; doing it
// here makes "click to fly" literally true however the card was dismissed. Must be inside a
// user gesture, which every dismissal path is. Wrapped: browsers reject the request if the
// document is not focused, and a rejected lock is not worth a thrown card.
try { document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.(); }
catch { /* no lock, keyboard still flies */ }
bus.emit('ui:start', {});
return true;
};