diff --git a/web/js/audio/engine.js b/web/js/audio/engine.js
index c523c21..67e4c3e 100644
--- a/web/js/audio/engine.js
+++ b/web/js/audio/engine.js
@@ -1385,6 +1385,21 @@ export function createAudio({ bus, flags = {}, assets = null, level = null, worl
// ═══ 11. BUS SUBSCRIPTIONS ══════════════════════════════════════════════════════════════════
// Verified against the emitters: every field below was read out of the file that emits it.
+ // PAUSE. The engine handled eleven bus events and not this one, so the bed, the drone, the
+ // grain scheduler and the heartbeat all ran at full level behind the pause card — and the
+ // heart slewed down to its resting 50 bpm while you read, then snapped back on resume.
+ // Ducking the master (rather than ctx.suspend) keeps ctx.currentTime advancing, which the
+ // heartbeat's setTimeout pump schedules against and would otherwise wake up in the past.
+ let pauseDuck = false;
+ offs.push(bus.on('ui:pause', (e) => {
+ const p = !!e?.paused;
+ if (p === pauseDuck) return;
+ pauseDuck = p;
+ const t = t0();
+ master.gain.cancelScheduledValues(t);
+ master.gain.setValueAtTime(master.gain.value, t);
+ master.gain.linearRampToValueAtTime(p ? 0.0001 : baseMaster(), t + 0.18);
+ }));
offs.push(bus.on('audio:cue', (e) => { if (e && e.name) cue(e.name); }));
// player:state — 60 Hz. R4: scalars only, no allocation, no node work. Also the biome edge.
diff --git a/web/js/boot.js b/web/js/boot.js
index 1e7db89..9de6f7b 100644
--- a/web/js/boot.js
+++ b/web/js/boot.js
@@ -14,17 +14,55 @@ import { createCombat } from './combat/index.js';
const flags = parseFlags();
const bus = createBus();
+/**
+ * Say something went wrong, on the screen, in the fiction.
+ *
+ * Every failure in this game used to present as the same black rectangle — a bad ?lvl=, a throw
+ * inside a bus listener, a lost WebGL context — so a player could not tell "loading" from
+ * "crashed" and had nothing to report. This is the difference between a bug report and a shrug.
+ */
+let _failed = false;
+function fail(what, detail) {
+ if (_failed) return; // first cause only; the rest are noise
+ _failed = true;
+ console.error('[guts]', what, detail ?? '');
+ const el = document.createElement('div');
+ el.setAttribute('style', [
+ 'position:fixed', 'inset:0', 'display:grid', 'place-content:center', 'gap:10px',
+ 'background:#04070a', 'color:#39e6ff', 'z-index:9999', 'text-align:center',
+ 'font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace', 'letter-spacing:.16em',
+ 'text-transform:uppercase', 'padding:24px',
+ ].join(';'));
+ el.innerHTML =
+ '
feed lost
' +
+ `${String(what).replace(/[<&]/g, '')}
` +
+ (detail ? `${String(detail).slice(0, 200).replace(/[<&]/g, '')}
` : '') +
+ 'reload to retry · ?lvl=L1_mouth for the start
';
+ document.body.appendChild(el);
+}
+addEventListener('error', (e) => fail('script error', e?.message));
+addEventListener('unhandledrejection', (e) => fail('failed to load', e?.reason?.message ?? e?.reason));
+
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.getElementById('game').appendChild(renderer.domElement);
+// A laptop sleeping or a display being hotplugged takes the GL context away; without this the
+// canvas simply stops updating and the HUD floats over a white page forever.
+renderer.domElement.addEventListener('webglcontextlost', (e) => {
+ e.preventDefault();
+ fail('graphics context lost', 'the browser released the GPU — reloading restores it');
+});
const assets = await createAssets({ flags, renderer });
async function loadLevel(id) {
try {
const mod = await import('./levels/index.js'); // Lane C's registry
- return mod.getLevel(id); // default level lives there
+ // `await`, not a bare return: without it the promise is handed to the caller and a rejection
+ // (a mistyped ?lvl=, a 404 on the JSON) escapes this catch entirely, kills top-level module
+ // evaluation, and the player gets a black page with no DBG, no HUD and no message.
+ return await mod.getLevel(id); // default level lives there
} catch (e) {
console.info('[boot] Lane C registry not available —', e.message);
return null;
@@ -109,6 +147,22 @@ if (player) {
let paused = false;
bus.on('ui:pause', (e) => { paused = !!e?.paused; });
+// THE RUN HAS A START. cards.js has emitted `ui:start` on title dismissal since round 1 and its
+// own comment says nothing consumes it — so the sim ran behind the title card. Three costs, all
+// real: the debrief's DURATION-vs-PAR included however long you spent reading the title, L1's
+// first checkpoint and the opening comms line both fired while it was still up, and a hazard
+// could hurt you before you had touched a key (which is why cards.js already carries a
+// player:damage auto-dismiss as a safety valve). ?fly=1 has no title, so it starts immediately.
+let started = !player;
+bus.on('ui:start', () => { started = true; });
+
+// PAUSE ON BLUR. The consumer above already works; nothing ever produced for it except the
+// pause key. Alt-tab during a fight and you come back to a dead ship, which is nobody's idea of
+// a fair death.
+const autoPause = () => { if (started && !paused) bus.emit('ui:pause', { paused: true, auto: true }); };
+addEventListener('blur', autoPause);
+document.addEventListener('visibilitychange', () => { if (document.hidden) autoPause(); });
+
// The acid sea's level. world/acid.js exposes set(y, dt) and says "C's event pump drives this",
// so an `acid` level event names a target height and the sim eases toward it every step — a
// rising tide is a beat C can author, and because it is eased in step() (not frame()) it stays
@@ -279,9 +333,21 @@ function step(dt) {
let last = performance.now();
function frame(now) {
+ // The rAF chain re-arms at the BOTTOM of this function, so a single throw anywhere inside it
+ // stops the loop for good — the canvas keeps displaying its last frame and the game looks
+ // hung rather than crashed. Guard the body, re-arm regardless, and say so on screen once.
+ try {
+ frameBody(now);
+ } catch (e) {
+ fail('simulation stopped', e?.message);
+ }
+ requestAnimationFrame(frame);
+}
+
+function frameBody(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
- if (!paused) step(dt);
+ if (started && !paused) step(dt); // the run does not tick behind the title card
ui?.update?.(dt); // overlay keeps animating while paused — the pause card moves
renderer.render(scene, camera);
stats.draws = renderer.info.render.calls;
@@ -295,7 +361,6 @@ function frame(now) {
dbgEl.textContent = `${fps} fps · ${DBG.draws} draws · ${(DBG.tris / 1000) | 0}k tris · s=${s.toFixed(0)} · ${world.level?.id ?? '?'} ${world.hash?.() ?? ''}`;
}
}
- requestAnimationFrame(frame);
}
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
requestAnimationFrame(frame);
diff --git a/web/js/core/bus.js b/web/js/core/bus.js
index 89f51c5..75da3dc 100644
--- a/web/js/core/bus.js
+++ b/web/js/core/bus.js
@@ -11,7 +11,15 @@ export function createBus() {
off(name, fn) { map.get(name)?.delete(fn); },
emit(name, payload) {
const set = map.get(name);
- if (set) for (const fn of [...set]) fn(payload);
+ if (!set) return;
+ // ISOLATED. Listeners run bare here until now, so one throw in (say) the HUD aborted the
+ // rest of the emit — and since player:state is emitted every frame from inside step(),
+ // a single bad frame in one subscriber took down combat, audio and the run loop with it.
+ // A UI module failing should cost its own readout and nothing else, which is the same
+ // principle boot already applies when CONSTRUCTING them.
+ for (const fn of [...set]) {
+ try { fn(payload); } catch (e) { console.error(`[bus] listener for "${name}" threw —`, e); }
+ }
},
};
}