// boot.js (Lane F) — the shell. Creates renderer/scene/loop, picks the world (real or
// stub), constructs assets + player + combat, pumps level events, hosts DBG.
// Lanes: request wiring via NOTES, don't edit here. Round 2: A/B/C/D snippets integrated.
import * as THREE from 'three';
import { parseFlags } from './core/flags.js';
import { createRng } from './core/rng.js';
import { createBus } from './core/bus.js';
import { createAssets } from './core/assets.js';
import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js';
import { createPlayer } from './flight/player.js';
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
// `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;
}
}
async function pickWorld() {
const level = (await loadLevel(flags.lvl)) ?? STUB_LEVEL;
const rng = createRng(flags.seed ?? level.seed);
if (!flags.stub) {
try {
const mod = await import('./world/index.js'); // Lane A's world
return mod.createWorld(level, { rng, assets });
} catch (e) {
console.info('[boot] Lane A world not available, using stub —', e.message);
}
}
return createStubWorld(level, { rng, assets }); // ?stub=1&lvl= composes (beat 1 only — stub is single-segment)
}
const world = await pickWorld();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
scene.add(world.group);
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
// --- player + combat (Lane B). ?fly=1 keeps the noclip/drift camera instead. ---
const rng = createRng(flags.seed ?? world.level?.seed ?? 0);
const player = flags.fly ? null
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
// `assets` is not optional here, and leaving it out was a silent, months-old bug: combat
// defaults it to null, so `assets.get('models', type)` in enemies.js always returned null and
// EVERY enemy quietly drew its procedural primitive. Lane D's GLBs loaded fine, were verified
// fine, and were never once rendered — the fallback path is indistinguishable from success
// unless you check the pool's geometry, which nothing did.
const combat = player ? createCombat({ scene, world, bus, rng, flags, assets, player }) : null;
// --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never
// sees player/combat, just the events they emit. After combat so every subscription exists
// before the first state emit; skipped under ?fly=1, which has no player to instrument.
let ui = null;
if (player) {
try {
const [hudMod, commsMod, fbMod, cardsMod, audioMod] = await Promise.all([
import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'),
import('./ui/cards.js'), import('./audio/engine.js'),
]);
const args = { bus, flags, assets, level: world.level, world };
// Each module is constructed independently: one that throws must not cost us the rest.
// A missing score readout is a blemish; a UI layer that fails to build in a game whose
// only other feedback is the wall rushing past is an unplayable build.
const parts = {};
for (const [key, make] of [
['hud', () => hudMod.createHUD({ bus, flags, level: world.level })],
['comms', () => commsMod.createComms({ bus, flags })],
['feedback', () => fbMod.createFeedback(args)],
['cards', () => cardsMod.createCards(args)],
['audio', () => audioMod.createAudio(args)],
]) {
try { parts[key] = make(); } catch (e) { console.info(`[boot] Lane E ${key} failed —`, e.message); }
}
const ticking = Object.values(parts).filter((p) => typeof p?.update === 'function');
ui = {
...parts,
// The HUD is bus-driven and needs no tick; the audio heartbeat, the corruption decay
// and the card animations do. Called from frame(), never from step(): step() is the
// deterministic sim that stepped-sim harnesses drive thousands of times a second, and
// it must not do DOM or WebAudio work.
update(dt) { for (const p of ticking) p.update(dt); },
dispose() { for (const p of Object.values(parts)) { try { p?.dispose?.(); } catch { /* keep going */ } } },
};
} catch (e) {
console.info('[boot] Lane E UI not available —', e.message);
}
}
// Pause. cards.js raises ui:pause; somebody has to honour it or the button is a lie (its
// reviewer flagged exactly that, and it is boot's to fix — E cannot reach the loop).
// Gated in frame(), NOT in step(), so a paused game still renders and animates its overlay
// while the simulation holds still — and so stepped sims, which call step() directly, are
// unaffected by a UI state they have no business knowing about.
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
// deterministic for stepped sims. No sea on this level => the event is inert.
let acidTarget = null;
bus.on('level:event', (ev) => { if (ev?.type === 'acid') acidTarget = ev.height ?? 0; });
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
// player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
// (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 _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
// "respawn at the last checkpoint crossed" is safe by construction. Stats accumulate across
// deaths (a run's story includes its deaths); E renders the medal card off level:complete.
const run = { t: 0, deaths: 0, checkpointS: player?.state.s ?? 0, respawnT: 0, done: false };
bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint') {
run.checkpointS = ev.s;
bus.emit('audio:cue', { name: 'checkpoint' });
} else if (ev.type === 'gate' && !run.done) {
run.done = true;
// A gate may carry a SECRET branch (`secretTo` + `secretNeed`): C's ileocecal valve opens
// onto the appendix instead of the colon when BIOME STANDING has earned it. Resolved here
// because boot owns where the run goes next, and read at the moment of crossing — the
// reputation you hold when you arrive is the one that counts.
const standing = combat?.karma ?? 0; // conduct, not the spendable meter
const secret = ev.secretTo && standing >= (ev.secretNeed ?? 70);
const to = secret ? ev.secretTo : (ev.to ?? null);
if (secret) bus.emit('secret:open', { gate: ev.id ?? null, to, standing });
bus.emit('level:complete', {
id: world.level?.id ?? null, to, gate: ev.id ?? null, secret: !!secret,
stats: {
time: run.t, deaths: run.deaths,
score: combat?.score.value ?? 0, kills: combat?.score.kills ?? 0,
samples: combat?.score.samples ?? 0,
},
par: world.level?.par ?? null,
});
}
});
// CONTINUE on the medal card -> load the next segment. cards.js raises ui:continue with C's
// `next` id; consuming it here is what makes the campaign a campaign rather than three levels
// in a folder. A full page load is deliberate (ponytail: the alternative is rebuilding world +
// player + combat + UI in place and re-wiring every bus listener, to save ~1s on a transition
// that happens 4 times a run) — it also guarantees no leaked pools or stale event pumps.
// Flags ride along, so ?dbg=1 / ?seed= survive the hop. Unknown/absent `to` = campaign over:
// stay on the card rather than dumping the player into the stub tube.
bus.on('ui:continue', async (e) => {
if (!e?.to) return;
const mod = await import('./levels/index.js').catch(() => null);
if (!mod?.CAMPAIGN?.includes(e.to)) { console.info(`[boot] no level "${e.to}" yet — campaign ends here`); return; }
const q = new URLSearchParams(location.search);
q.set('lvl', e.to);
location.search = q.toString();
});
bus.on('player:death', () => { run.deaths++; run.respawnT = 2.0; }); // 2s of feed-drop, then back
function updateRun(dt) {
if (!run.done) run.t += dt;
if (run.respawnT > 0 && player) {
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);
}
}
}
// --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) ---
let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6;
const keys = new Set();
addEventListener('keydown', (e) => keys.add(e.code));
addEventListener('keyup', (e) => keys.delete(e.code));
addEventListener('mousedown', () => (dragging = true));
addEventListener('mouseup', () => (dragging = false));
addEventListener('mousemove', (e) => {
if (!dragging || player) return;
look.yaw -= e.movementX * 0.003;
look.pitch = THREE.MathUtils.clamp(look.pitch - e.movementY * 0.003, -1.2, 1.2);
});
function updateCamera(dt) {
if (flags.fly) {
if (keys.has('KeyW')) sCam += speed * 2 * dt;
if (keys.has('KeyS')) sCam -= speed * 2 * dt;
} else {
sCam += speed * dt; // title-screen auto-drift
}
sCam = ((sCam % world.length) + world.length) % world.length;
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
const target = ahead.pos.clone()
.addScaledVector(f.bin, Math.sin(look.yaw) * 8)
.addScaledVector(f.nor, Math.sin(look.pitch) * 8);
camera.up.copy(f.nor);
camera.lookAt(target);
}
// --- DBG (?dbg=1) — cite these numbers in NOTES; DBG.shot('name') saves a frame ---
const dbgEl = document.getElementById('dbg');
let frames = 0, fpsT = 0, fps = 0;
renderer.info.autoReset = false; // three resets info at render end — cache per-frame instead
const stats = { draws: 0, tris: 0 };
window.DBG = {
get draws() { return stats.draws; },
get tris() { return stats.tris; },
get fps() { return fps; },
world, player, combat, assets, ui,
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
a.href = renderer.domElement.toDataURL('image/png');
a.click();
},
};
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// One deterministic simulation step — the whole game minus rendering. Exported so the
// stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises
// the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts.
function step(dt) {
if (player) {
player.update(dt); // must run first: combat hit-tests this frame's ship position
pumpTo(player.state.s);
combat.update(dt);
updateRun(dt);
if (acidTarget !== null && world.acid) world.acid.set(acidTarget, dt); // the tide eases
world.update(dt, player.state.s);
} else {
updateCamera(dt); // ?fly=1 noclip / title drift
world.update(dt, sCam);
}
}
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 (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;
stats.tris = renderer.info.render.triangles;
renderer.info.reset();
frames++; fpsT += dt;
if (fpsT >= 0.5) {
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
if (flags.dbg && !flags.shots) {
const s = player?.state.s ?? sCam;
dbgEl.textContent = `${fps} fps · ${DBG.draws} draws · ${(DBG.tris / 1000) | 0}k tris · s=${s.toFixed(0)} · ${world.level?.id ?? '?'} ${world.hash?.() ?? ''}`;
}
}
}
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
requestAnimationFrame(frame);
export { scene, camera, renderer, bus, flags, world, player, combat, assets, ui, step };