diff --git a/README.md b/README.md
index cd75c0c..9a3afb6 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ links to the package's own README.
| [hello-world](challenges/hello-world/README.md) | Meet your AI guide and make your first file. |
| [vibe-coding-mad-libs](challenges/vibe-coding-mad-libs/README.md) | Fill four blanks, and Pi builds the web app you described. |
| [birdsbirdbirds](challenges/birdsbirdbirds/README.md) | Draw a bird and watch it fly across the sky. |
+| [glitch-invaders](challenges/glitch-invaders/README.md) | Shoot the bugs, then debug the three that fight back using Sentry evidence. |
Challenges follow the contract in boothstrap.sh's challenge spec and
authoring guide; agents working here start from [AGENTS.md](AGENTS.md).
diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md
new file mode 100644
index 0000000..a7ba279
--- /dev/null
+++ b/challenges/glitch-invaders/README.md
@@ -0,0 +1,72 @@
+# Glitch Invaders: On-Call
+
+Fly a patch drone in Chrome, trigger three intentional bugs while defending the
+cluster, and debug each one with Sentry evidence and Pi. Each failure opens an
+Incident Console with a stack trace, breadcrumbs, or a performance trace, asks
+for the root cause, and applies your chosen patch so you can watch the fix work
+in play.
+
+The game always runs in demo mode: the Incident Console shows simulated Sentry
+evidence and no telemetry leaves the machine. Live Sentry mode returns once
+boothstrap provides sponsor settings; see
+[boothstrap.sh issue 519](https://github.com/MLH/boothstrap.sh/issues/519).
+
+## Files
+
+| File | Purpose |
+| --- | --- |
+| `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata |
+| `intro.md` | Intro screen text |
+| `skills/start-challenge/SKILL.md` | The guide Pi follows |
+| `monitor/SKILL.md` | Headless browser monitor skill |
+| `setup`, `cleanup` | Start and stop the server (`server/setup.mjs`, `server/cleanup.mjs`) |
+| `server/` | Local page server on `127.0.0.1:4373` |
+| `shared/` | Progress validators |
+| `game/` | Browser arcade game and Incident Console |
+
+There is no `install` hook: the package has no dependencies beyond Node and
+browser ES modules.
+
+## How steps advance
+
+The game shows a `glitch-state v1` line, and the headless monitor reports
+`launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` from it, as
+[monitor/SKILL.md](monitor/SKILL.md) describes. The game also saves
+`glitch-progress.json` in the workspace so Pi can see which bug and stage
+(`unseen`, `encountered`, `investigated`, `repaired`) the participant is on and
+emit non-PII milestone events. Once all three bugs are `repaired`, Pi asks
+which clue helped most and reports `debrief`.
+
+The server listens on port 4373. Set `GLITCH_PORT` to use another port, though
+the challenge manifest expects 4373.
+
+## Run without boothstrap
+
+Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in
+boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the
+package as an Agent Plugin, then:
+
+```sh
+challenge=/absolute/path/to/glitch-invaders
+mkdir /tmp/my-glitch-run && cd /tmp/my-glitch-run
+"$challenge"/setup
+```
+
+Open `http://127.0.0.1:4373/`, then start Pi in the same folder:
+
+```sh
+pi --model google/gemini-3.8-flash \
+ --skill "$challenge"/skills/start-challenge/SKILL.md \
+ '/skill:start-challenge'
+```
+
+When done:
+
+```sh
+"$challenge"/cleanup
+```
+
+`cleanup` stops the server and removes `.glitch/` and `glitch-progress.json`.
+Without boothstrap there is no `report_step`, `session_info`, or `emit_event`,
+so no monitor runs: the skill reads `glitch-progress.json` and acknowledges
+finished steps in plain words.
diff --git a/challenges/glitch-invaders/challenge.json b/challenges/glitch-invaders/challenge.json
new file mode 100644
index 0000000..9aa2b42
--- /dev/null
+++ b/challenges/glitch-invaders/challenge.json
@@ -0,0 +1,59 @@
+{
+ "id": "glitch-invaders",
+ "title": "Glitch Invaders: On-Call",
+ "tagline": "Shoot the bugs, then debug the three that fight back using Sentry evidence.",
+ "icon": "bug",
+ "type": "browser",
+ "agent": "pi",
+ "model": "google/gemini-3.8-flash",
+ "vault_services": [
+ "gemini"
+ ],
+ "points": 150,
+ "estimated_minutes": 9,
+ "learning_objectives": [
+ "reading stack traces and error context",
+ "tracing a logic bug with breadcrumbs",
+ "spotting bottlenecks in a performance trace"
+ ],
+ "featured_tech": [
+ "Sentry"
+ ],
+ "intro": "intro.md",
+ "steps": [
+ {
+ "id": "launch-game",
+ "title": "Launch your patch drone",
+ "hint": "Press Start in the game, then shoot the first glitches.",
+ "done_when": "The page shows the line \"glitch-state v1 …\" containing \"launched=yes\"."
+ },
+ {
+ "id": "fix-weapon",
+ "title": "Fix the jammed weapon",
+ "hint": "Grab the gold upgrade and fire. Then trace the error in the Incident Console.",
+ "done_when": "The page shows the line \"glitch-state v1 …\" containing \"weapon=repaired\"."
+ },
+ {
+ "id": "fix-shield",
+ "title": "Fix the shield that hurts",
+ "hint": "Grab the blue shield and take a hit. Read the breadcrumbs, then patch it.",
+ "done_when": "The page shows the line \"glitch-state v1 …\" containing \"shield=repaired\"."
+ },
+ {
+ "id": "fix-boss",
+ "title": "Fix the slow boss spawn",
+ "hint": "Clear the wave so the boss spawns. Find the bottleneck in the trace.",
+ "done_when": "The page shows the line \"glitch-state v1 …\" containing \"boss=repaired\"."
+ },
+ {
+ "id": "debrief",
+ "title": "Tell your guide what you found",
+ "hint": "Say which clue helped most: the stack trace, the breadcrumbs, or the trace.",
+ "done_when": "All three fixes are repaired and the participant has told the guide, in their own words, which evidence helped them find a cause."
+ }
+ ],
+ "browser": {
+ "start_url": "http://127.0.0.1:4373/",
+ "monitor_skill": "monitor/SKILL.md"
+ }
+}
diff --git a/challenges/glitch-invaders/cleanup b/challenges/glitch-invaders/cleanup
new file mode 100755
index 0000000..4fa0374
--- /dev/null
+++ b/challenges/glitch-invaders/cleanup
@@ -0,0 +1,4 @@
+#!/bin/sh
+# Cleanup hook: stop the page server and remove this attempt's files. Safe to run twice.
+set -eu
+exec node "${BOOTHSTRAP_PACKAGE_DIR:-$(dirname "$0")}/server/cleanup.mjs"
diff --git a/challenges/glitch-invaders/game/index.html b/challenges/glitch-invaders/game/index.html
new file mode 100644
index 0000000..5851ab9
--- /dev/null
+++ b/challenges/glitch-invaders/game/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ Glitch Invaders: On-Call
+
+
+
+
+
+
+
+ ◆
+ Glitch Invaders On-Call
+
+
+ HULL 3
+ SHIELD off
+ SQUASHED 0
+ SCORE 0
+
+ DEMO · simulated evidence
+ Reset ↺
+
+
+
+
+
+
+
+
+
+
+
+ glitch-state v1 launched=no weapon=unseen shield=unseen boss=unseen completed=no mode=demo
+ Progress can't be saved; reload the page.
+
+ Glitch Invaders needs JavaScript. Open this page in Chrome.
+
+
+
diff --git a/challenges/glitch-invaders/game/src/app/app.mjs b/challenges/glitch-invaders/game/src/app/app.mjs
new file mode 100644
index 0000000..a519cc6
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/app/app.mjs
@@ -0,0 +1,437 @@
+import { createProgress } from '../../../shared/progress.mjs';
+
+import { loadBossResources } from '../boss/boss-loader.mjs';
+import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs';
+import { tryRepair } from '../bugs/code-state.mjs';
+import { IncidentTracker } from '../bugs/incident-tracker.mjs';
+import { SimulatedEvidenceProvider } from '../diagnostics/simulated-evidence.mjs';
+import { GameEngine } from '../engine/game-engine.mjs';
+import { createRandom } from '../engine/random.mjs';
+import { CanvasRenderer } from '../render/canvas-renderer.mjs';
+import { publishState } from '../session/monitor-state.mjs';
+
+import { BossLoadingView } from '../ui/boss-loading.mjs';
+import { onClick, setText } from '../ui/dom.mjs';
+import { Hud } from '../ui/hud.mjs';
+import { IncidentConsole } from '../ui/incident-console.mjs';
+import { InputController } from '../ui/input.mjs';
+import { completionCard, introCard, OverlayHost, pauseCard } from '../ui/overlays.mjs';
+import { GameLoop } from './game-loop.mjs';
+import { canonicalEncounter, codeForProgress, firstOpenBug, waveForBug } from './resume.mjs';
+
+/** How long the banner after a success or patch stays up. */
+const BANNER_MS = 6_000;
+
+/** The pause after the last repair, so the participant sees the result before the recap. */
+const COMPLETION_DELAY_MS = 1_800;
+
+/** The seed that makes enemy fire reproducible unless the page asks for another. */
+export const DEFAULT_SEED = 20_260_514;
+
+/** Find a required element by id. */
+function requireElement(id) {
+ const element = document.getElementById(id);
+ if (element === null) {
+ throw new Error(`the page has no #${id} element`);
+ }
+
+ return element;
+}
+
+/** The game page. */
+export class GameApp {
+ options;
+ now;
+ stage = requireElement('stage');
+ banner = requireElement('banner');
+ announcer = requireElement('announcer');
+ hud = new Hud();
+ overlays = new OverlayHost(requireElement('overlay-root'));
+ bossView = new BossLoadingView(requireElement('boss-loading'));
+ engine;
+ evidence = new SimulatedEvidenceProvider();
+ incidentConsole;
+ input;
+ loop;
+ evidenceByBug = new Map();
+ tracker;
+ progress;
+ code;
+ lastShieldHit = null;
+ bannerTimer = null;
+ pauseOpen = false;
+ pendingStart = null;
+
+ constructor(options, saved) {
+ this.options = options;
+ this.now = options.now ?? (() => performance.now());
+ this.progress = saved ?? createProgress(options.config.attemptId);
+ this.tracker = new IncidentTracker(this.progress);
+ this.code = codeForProgress(this.progress);
+
+ this.engine = new GameEngine({
+ random: createRandom(options.seed ?? DEFAULT_SEED),
+ code: this.code,
+ spawnBossResources: (strategy, onTask) => this.spawnBoss(strategy, onTask),
+ now: this.now,
+ particleScale: options.reducedMotion ? 0.25 : 1,
+ });
+ this.incidentConsole = new IncidentConsole({
+ progressOf: (bug) => this.progress.bugs[bug],
+ markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug),
+ useHint: (bug) => this.tracker.useHint(bug),
+ diagnose: (bug, optionId) => this.tracker.answerDiagnosis(bug, optionId),
+ applyPatch: (bug, patchId) => this.applyPatch(bug, patchId),
+ });
+
+ const canvas = requireElement('arena');
+ const renderer = new CanvasRenderer(canvas, { reducedMotion: options.reducedMotion });
+ this.input = new InputController({
+ onPauseKey: () => this.togglePause(),
+ isGameActive: () => !this.overlays.isOpen && this.engine.world.phase !== 'ready',
+ });
+ this.loop = new GameLoop((elapsedMs, nowMs) => {
+ this.engine.update(elapsedMs, this.input.current());
+ renderer.draw(this.engine.world, nowMs);
+ this.bossView.update(this.engine.world, this.now());
+ this.hud.update(this.progress, this.engine.world);
+ });
+ }
+
+ /** Wire the page, show the briefing or the saved run, and start drawing. */
+ begin() {
+ this.tracker.subscribe((progress) => this.onProgress(progress));
+ this.engine.on((event) => this.onEvent(event));
+ publishState(document, this.progress);
+
+ this.input.attach(document, {
+ left: requireElement('pad-left'),
+ right: requireElement('pad-right'),
+ fire: requireElement('pad-fire'),
+ });
+ document.addEventListener('keydown', (event) => this.onPauseCardKey(event));
+ onClick(requireElement('skip-ahead'), () => {
+ this.engine.fastForward();
+ this.stage.focus();
+ });
+ for (const id of ['reset-run', 'reset-controls']) {
+ const button = document.getElementById(id);
+ if (button !== null) {
+ onClick(button, () => this.resetRun());
+ }
+ }
+ this.showOpening();
+ this.loop.start();
+ }
+
+ /** Load the boss's resources under the strategy the current code uses. */
+ spawnBoss(strategy, onTask) {
+ return loadBossResources({
+ strategy,
+ sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
+ now: this.now,
+ ...(onTask === undefined ? {} : { onTask }),
+ });
+ }
+
+ /** Show the briefing for a new run, or the welcome-back card for a saved one. */
+ showOpening() {
+ if (!this.progress.launched) {
+ this.overlays.show(
+ introCard({ returning: false, onStart: () => this.startRun() }),
+ );
+
+ return;
+ }
+
+ if (this.progress.completed) {
+ this.engine.start(1);
+ this.engine.finish();
+ this.overlays.show(
+ completionCard(
+ this.progress,
+ () => this.continuePlaying(),
+ () => this.resetRun(),
+ ),
+ );
+
+ return;
+ }
+
+ const bug = firstOpenBug(this.progress);
+ if (bug === null) {
+ return;
+ }
+ const stage = this.progress.bugs[bug].stage;
+ this.pendingStart = () => {
+ if (stage === 'unseen') {
+ this.engine.start(waveForBug(bug));
+
+ return;
+ }
+ this.engine.restoreIncident(bug);
+ this.openRestoredIncident(bug);
+ };
+ this.overlays.show(
+ introCard({ returning: true, onStart: () => this.resumeRun() }),
+ );
+ }
+
+ /** The participant pressed Start. */
+ startRun() {
+ this.tracker.launch();
+ this.closeCard();
+ this.engine.start(1);
+ this.announce('Run started. Move with the arrow keys, fire with Space.');
+ }
+
+ /** The participant pressed Resume on the welcome-back card. */
+ resumeRun() {
+ this.closeCard();
+ this.pendingStart?.();
+ this.pendingStart = null;
+ this.announce('Welcome back. Your run was restored.');
+ }
+
+ /** Close the card on screen and put focus back on the arena. */
+ closeCard() {
+ this.pauseOpen = false;
+ this.overlays.hide(this.stage);
+ }
+
+ /** Pause or resume from the keyboard. */
+ togglePause() {
+ if (this.overlays.isOpen || this.engine.world.phase !== 'playing') {
+ return;
+ }
+ this.engine.pause();
+ this.input.releaseAll();
+ this.pauseOpen = true;
+ this.overlays.show(pauseCard(() => this.resumeFromPause()));
+ }
+
+ /** Close the pause card and continue. */
+ resumeFromPause() {
+ this.engine.resume();
+ this.closeCard();
+ }
+
+ /** Let P or Escape close the pause card. */
+ onPauseCardKey(event) {
+ if (
+ !event.defaultPrevented &&
+ this.pauseOpen &&
+ (event.code === 'KeyP' || event.code === 'Escape')
+ ) {
+ event.preventDefault();
+ this.resumeFromPause();
+ }
+ }
+
+ /** React to what the engine reports. */
+ onEvent(event) {
+ switch (event.type) {
+ case 'weapon-jammed':
+ this.openIncident({
+ bug: 'weapon',
+ jam: {
+ weaponId: event.error.weaponId,
+ availableWeapons: event.availableWeapons,
+ error: event.error,
+ },
+ });
+ break;
+ case 'shield-hit':
+ this.lastShieldHit = event;
+ break;
+ case 'ship-down':
+ if (event.cause === 'shield-failure' && this.lastShieldHit !== null) {
+ this.openIncident({
+ bug: 'shield',
+ failure: {
+ incomingDamage: this.lastShieldHit.incomingDamage,
+ actualDamage: this.lastShieldHit.outcome.actualDamage,
+ hullBefore: this.lastShieldHit.hullBefore,
+ hullAfter: this.lastShieldHit.hullAfter,
+ },
+ });
+ }
+ break;
+ case 'boss-online':
+ if (event.stalled) {
+ this.openIncident({
+ bug: 'boss',
+ stall: {
+ totalMs: event.result.totalMs,
+ spans: event.result.spans.map((span) => ({
+ name: span.name,
+ operation: span.op,
+ startMs: span.startMs,
+ durationMs: span.durationMs,
+ })),
+ },
+ });
+ }
+ break;
+ case 'verification-passed':
+ this.onVerified(event.bug);
+ break;
+ case 'pickup-collected':
+ this.announce(event.kind === 'fanout' ? 'Fan-out upgrade equipped.' : 'Shield equipped.');
+ break;
+ case 'wave-started':
+ case 'enemy-destroyed':
+ case 'pickup-spawned':
+ case 'volley-fired':
+ case 'ship-restored':
+ case 'boss-loading':
+ break;
+ case 'run-finished':
+ if (this.freeplayActive) {
+ this.freeplayActive = false;
+ const bossDefeated = this.engine.world.boss === null;
+ this.showBanner(
+ bossDefeated
+ ? '★ Monolith destroyed! All threats eliminated.'
+ : 'Hull breached by The Monolith. Final score recorded.',
+ bossDefeated ? 'success' : 'info',
+ );
+ setTimeout(() => this.complete(), 1_400);
+ }
+ break;
+ }
+ }
+
+ /** A failure just happened in play: record it and open the console on its evidence. */
+ openIncident(encounter) {
+ const bug = encounter.bug;
+ this.tracker.encounter(bug);
+ this.input.releaseAll();
+ this.incidentConsole.open(bug);
+ this.overlays.show(this.incidentConsole.element);
+ const definition = BUG_DEFINITIONS[bug];
+ this.announce(
+ `${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`,
+ );
+
+ void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence));
+ }
+
+ /** A failure was restored from a saved run: reopen the console on its evidence. */
+ openRestoredIncident(bug) {
+ this.input.releaseAll();
+ this.incidentConsole.open(bug);
+ this.overlays.show(this.incidentConsole.element);
+ const cached = this.evidenceByBug.get(bug);
+ if (cached !== undefined) {
+ this.incidentConsole.showEvidence(cached);
+
+ return;
+ }
+ void this.evidence
+ .evidenceFor(canonicalEncounter(bug))
+ .then((evidence) => this.deliverEvidence(evidence));
+ }
+
+ /** Remember the evidence and show it if its console is still open. */
+ deliverEvidence(evidence) {
+ this.evidenceByBug.set(evidence.bug, evidence);
+ this.incidentConsole.showEvidence(evidence);
+ }
+
+ /** Try a patch from the console. A good one is kept, closes the console, and starts the replay. */
+ applyPatch(bug, patchId) {
+ const trial = tryRepair(bug, patchId, this.code);
+ this.tracker.recordRepairAttempt(bug, trial.check.ok);
+ if (!trial.check.ok) {
+ this.announce(`Patch reverted. ${trial.check.observation}`);
+
+ return trial.check;
+ }
+
+ this.code = trial.state;
+ this.engine.setCode(this.code);
+ this.closeCard();
+ this.engine.beginVerification(bug);
+ this.showBanner(`Patch applied. ${BUG_DEFINITIONS[bug].verifyPrompt}`, 'info');
+ this.announce(`Patch applied. ${BUG_DEFINITIONS[bug].verifyPrompt}`);
+
+ return trial.check;
+ }
+
+ /** The repaired behavior was seen in the arena. */
+ onVerified(bug) {
+ this.tracker.markRepaired(bug);
+ const definition = BUG_DEFINITIONS[bug];
+ this.showBanner(`✓ ${definition.incidentLabel} fixed. ${definition.fixExplanation}`, 'success');
+ this.announce(`${definition.title} repaired.`);
+
+ if (this.tracker.allRepaired()) {
+ setTimeout(() => this.complete(), COMPLETION_DELAY_MS);
+ }
+ }
+
+ /** All three incidents are closed: freeze the arena and show the recap. */
+ complete() {
+ this.freeplayActive = false;
+ this.engine.finish();
+ this.input.releaseAll();
+ this.hideBanner();
+ this.overlays.show(
+ completionCard(
+ this.progress,
+ () => this.continuePlaying(),
+ () => this.resetRun(),
+ ),
+ );
+ this.announce('All incidents closed.');
+ }
+
+ freeplayActive = false;
+
+ /** Dismiss the completion recap and keep playing in the arena. */
+ continuePlaying() {
+ this.freeplayActive = true;
+ this.closeCard();
+ this.engine.continuePlaying();
+ this.showBanner('Boss Showdown: Destroy The Monolith before your hull fails!', 'info');
+ this.announce('Continuing play in the arena.');
+ }
+
+ /** Clear saved progress and reload the page for a fresh run. */
+ resetRun() {
+ void this.options.store.clear().then(() => this.options.reload());
+ }
+
+ /** Save and publish every progress change. */
+ onProgress(progress) {
+ this.progress = progress;
+ void this.options.store.save(progress);
+ publishState(document, progress);
+ }
+
+ /** Show a banner over the arena, replacing any earlier one. */
+ showBanner(text, kind) {
+ this.banner.dataset.kind = kind;
+ setText(this.banner, text);
+ this.banner.hidden = false;
+ if (this.bannerTimer !== null) {
+ clearTimeout(this.bannerTimer);
+ }
+ this.bannerTimer = setTimeout(() => this.hideBanner(), BANNER_MS);
+ }
+
+ /** Hide the banner. */
+ hideBanner() {
+ this.banner.hidden = true;
+ if (this.bannerTimer !== null) {
+ clearTimeout(this.bannerTimer);
+ this.bannerTimer = null;
+ }
+ }
+
+ /** Say something to screen readers. */
+ announce(message) {
+ setText(this.announcer, message);
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/app/game-loop.mjs b/challenges/glitch-invaders/game/src/app/game-loop.mjs
new file mode 100644
index 0000000..fa1eb0c
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/app/game-loop.mjs
@@ -0,0 +1,38 @@
+/** The animation frame loop: one `step` per frame, with the elapsed time since the last one. */
+
+/** Runs a {@link FrameStep} on every animation frame until stopped. */
+export class GameLoop {
+ step;
+ handle = null;
+ previousMs = 0;
+
+ constructor(step) {
+ this.step = step;
+ }
+
+ /** Begin looping. Starting twice does nothing. */
+ start() {
+ if (this.handle !== null) {
+ return;
+ }
+
+ this.previousMs = performance.now();
+ this.handle = requestAnimationFrame((nowMs) => this.frame(nowMs));
+ }
+
+ /** Stop looping. */
+ stop() {
+ if (this.handle !== null) {
+ cancelAnimationFrame(this.handle);
+ this.handle = null;
+ }
+ }
+
+ /** Run one frame and ask for the next. */
+ frame(nowMs) {
+ const elapsedMs = Math.max(0, nowMs - this.previousMs);
+ this.previousMs = nowMs;
+ this.step(elapsedMs, nowMs);
+ this.handle = requestAnimationFrame((next) => this.frame(next));
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/app/resume.mjs b/challenges/glitch-invaders/game/src/app/resume.mjs
new file mode 100644
index 0000000..ab4c82a
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/app/resume.mjs
@@ -0,0 +1,56 @@
+/**
+ * Rebuilding a saved run after a refresh: the code the repaired bugs left behind, and the evidence
+ * for a failure that was still open.
+ */
+import { BUG_IDS } from '../../../shared/progress.mjs';
+import { CORRECT_PATCH_IDS, createBrokenCodeState, tryRepair } from '../bugs/code-state.mjs';
+
+import { SEQUENTIAL_BOSS_SPANS } from '../diagnostics/evidence-builders.mjs';
+import { WeaponConfigError } from '../weapons/weapons.mjs';
+
+/** The code with every already repaired bug fixed. */
+export function codeForProgress(progress) {
+ let code = createBrokenCodeState();
+ for (const bug of BUG_IDS) {
+ if (progress.bugs[bug].stage === 'repaired') {
+ code = tryRepair(bug, CORRECT_PATCH_IDS[bug], code).state;
+ }
+ }
+
+ return code;
+}
+
+/** The first failure that is not repaired, or `null` when all are. */
+export function firstOpenBug(progress) {
+ return BUG_IDS.find((bug) => progress.bugs[bug].stage !== 'repaired') ?? null;
+}
+
+/** The wave a run starts at to reach `bug`: the weapon in wave 1, the shield in 2, the boss in 3. */
+export function waveForBug(bug) {
+ return bug === 'weapon' ? 1 : bug === 'shield' ? 2 : 3;
+}
+
+/** What `bug` looked like when it happened, for evidence rebuilt after a refresh. */
+export function canonicalEncounter(bug) {
+ switch (bug) {
+ case 'weapon':
+ return {
+ bug,
+ jam: {
+ weaponId: 'fanout',
+ availableWeapons: ['single'],
+ error: new WeaponConfigError('fanout'),
+ },
+ };
+ case 'shield':
+ return {
+ bug,
+ failure: { incomingDamage: 1, actualDamage: 3, hullBefore: 3, hullAfter: 0 },
+ };
+ case 'boss':
+ return {
+ bug,
+ stall: { totalMs: 4_500, spans: [...SEQUENTIAL_BOSS_SPANS] },
+ };
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/boss/boss-loader.mjs b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs
new file mode 100644
index 0000000..98c4ff1
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/boss/boss-loader.mjs
@@ -0,0 +1,77 @@
+/**
+ * Boss resource loading, with the inefficiencies that are the third intentional failure: the work
+ * runs one task at a time, and one task repeats work already done at game start.
+ */
+import {
+ INIT_AI_MS,
+ LOAD_ANIMATIONS_MS,
+ LOAD_SOUNDS_MS,
+ LOAD_TEXTURES_MS,
+} from '../engine/constants.mjs';
+
+/** The span operation name every boss load reports under. */
+export const LOAD_SPAN_OP = 'game.resource.load';
+
+/** Every load a spawn can run, in the order the shipped code runs them. */
+export const ALL_LOAD_TASKS = [
+ { name: 'loadTextures', durationMs: LOAD_TEXTURES_MS },
+ { name: 'loadSounds', durationMs: LOAD_SOUNDS_MS },
+ { name: 'loadAnimations', durationMs: LOAD_ANIMATIONS_MS },
+ { name: 'initializeAI', durationMs: INIT_AI_MS },
+];
+
+/** The loads `strategy` performs. Only `parallel-deduplicated` drops the redundant animation load. */
+export function tasksFor(strategy) {
+ if (strategy === 'parallel-deduplicated') {
+ return ALL_LOAD_TASKS.filter((task) => task.name !== 'loadAnimations');
+ }
+
+ return ALL_LOAD_TASKS;
+}
+
+/** Whether `strategy` starts its loads together. */
+export function runsInParallel(strategy) {
+ return strategy === 'parallel-all' || strategy === 'parallel-deduplicated';
+}
+
+/**
+ * How long a first spawn takes under `strategy`. A cache cannot help on a first spawn, so
+ * `cached-sequential` costs the same as `sequential`.
+ */
+export function plannedLoadMs(strategy) {
+ const durations = tasksFor(strategy).map((task) => task.durationMs);
+ if (runsInParallel(strategy)) {
+ return Math.max(...durations);
+ }
+
+ return durations.reduce((total, duration) => total + duration, 0);
+}
+
+/** Run one load, timing it against `startedAt`. */
+async function runTask(task, options, startedAt, spans) {
+ const startMs = options.now() - startedAt;
+ options.onTask?.({ name: task.name, phase: 'start', atMs: startMs });
+
+ await options.sleep(task.durationMs);
+
+ const endMs = options.now() - startedAt;
+ spans.push({ name: task.name, op: LOAD_SPAN_OP, startMs, durationMs: endMs - startMs });
+ options.onTask?.({ name: task.name, phase: 'end', atMs: endMs });
+}
+
+/** Load the boss's resources as `options.strategy` dictates and report what actually ran. */
+export async function loadBossResources(options) {
+ const startedAt = options.now();
+ const spans = [];
+ const tasks = tasksFor(options.strategy);
+
+ if (runsInParallel(options.strategy)) {
+ await Promise.all(tasks.map((task) => runTask(task, options, startedAt, spans)));
+ } else {
+ for (const task of tasks) {
+ await runTask(task, options, startedAt, spans);
+ }
+ }
+
+ return { strategy: options.strategy, totalMs: options.now() - startedAt, spans };
+}
diff --git a/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs b/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs
new file mode 100644
index 0000000..62f05f8
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/bugs/bug-definitions.mjs
@@ -0,0 +1,120 @@
+/** The three failures, in play order. */
+export const BUG_DEFINITIONS = {
+ weapon: {
+ id: 'weapon',
+ incidentLabel: 'INC-1',
+ title: 'Jammed weapon',
+ lens: 'Stack traces',
+ symptom: 'You grabbed the fan-out upgrade and your cannon stopped firing.',
+ requiredSections: ['exception', 'stacktrace', 'context'],
+ question: 'Why did the cannon jam?',
+ options: [
+ {
+ id: 'no-ammo',
+ text: 'The cannon ran out of ammo.',
+ correct: false,
+ feedback: 'Nothing in the evidence mentions ammo. Re-read the exception message.',
+ },
+ {
+ id: 'missing-config',
+ text: 'fire() looked up the fanout weapon and found no configuration for it.',
+ correct: true,
+ feedback: '',
+ },
+ {
+ id: 'slow-cooldown',
+ text: 'The single weapon has a cooldown that is far too long.',
+ correct: false,
+ feedback:
+ 'The single weapon fired fine before the upgrade. Compare what was requested with what exists.',
+ },
+ ],
+ hints: [
+ 'Read the exception message first. It names exactly one thing.',
+ 'Compare attemptedWeapon with availableWeapons in the weapon_state context.',
+ 'fanout was requested, but only single exists. The configuration is missing.',
+ ],
+ fixExplanation:
+ 'Registering fanout gives fire() the entry it looks up: three projectiles, a 24° fan, and a 300 ms cooldown.',
+ verifyPrompt: 'Collect the upgrade again and fire. Three bolts should leave the ship.',
+ },
+ shield: {
+ id: 'shield',
+ incidentLabel: 'INC-2',
+ title: 'Friendly fire',
+ lens: 'Breadcrumbs',
+ symptom: 'The shield was up, one hit landed, and your ship was destroyed.',
+ requiredSections: ['breadcrumbs', 'context'],
+ question: 'What was the shield doing when the hit landed?',
+ options: [
+ {
+ id: 'expired',
+ text: 'It had already expired, so the hit did full damage.',
+ correct: false,
+ feedback: 'Check the breadcrumb that reports whether the shield was active.',
+ },
+ {
+ id: 'amplified',
+ text: 'It was active, but it multiplied the damage instead of absorbing it.',
+ correct: true,
+ feedback: '',
+ },
+ {
+ id: 'never-equipped',
+ text: 'The shield was never equipped.',
+ correct: false,
+ feedback: 'The first breadcrumbs show the pickup and the shield switching on.',
+ },
+ ],
+ hints: [
+ 'Follow the breadcrumbs from the pickup to the destroyed ship.',
+ 'Compare "Actual damage applied" with the expected value in shield_state.',
+ 'The shield was active, yet damage was 3 where 0 was expected.',
+ ],
+ fixExplanation:
+ 'An active shield now sets the damage to 0 and is consumed, so a hit costs the ship nothing.',
+ verifyPrompt: 'Collect the shield and let a shot hit you. The ship should survive untouched.',
+ },
+ boss: {
+ id: 'boss',
+ incidentLabel: 'INC-3',
+ title: 'Boss buffering',
+ lens: 'Performance tracing',
+ symptom: 'The Monolith took far too long to appear. The game just sat there loading.',
+ requiredSections: ['waterfall', 'context'],
+ question: 'In the boss-spawn trace waterfall, which operation takes the longest?',
+ options: [
+ {
+ id: 'load-textures',
+ text: 'loadTextures',
+ correct: false,
+ feedback:
+ 'loadTextures takes 1.20 s. Look at the waterfall for an even longer bar (1.80 s).',
+ },
+ {
+ id: 'load-animations',
+ text: 'loadAnimations',
+ correct: true,
+ feedback: '',
+ },
+ {
+ id: 'initialize-ai',
+ text: 'initializeAI',
+ correct: false,
+ feedback:
+ 'initializeAI is the shortest bar at 500 ms. Look for the longest bar in the waterfall.',
+ },
+ ],
+ hints: [
+ 'In the waterfall, the four loads run one after another. Which bar is longest?',
+ 'Compare the durations in the waterfall: loadTextures (1.20 s), loadSounds (1.00 s), loadAnimations (1.80 s), and initializeAI (500 ms).',
+ 'loadAnimations takes 1.80 s — the longest span in the trace.',
+ ],
+ fixExplanation:
+ 'Running core loads in parallel with Promise.all and streaming loadAnimations non-blocking cuts the spawn from 4.5 s to 1.2 s.',
+ verifyPrompt: 'Watch the Monolith spawn again. It should arrive in about a second.',
+ },
+};
+
+/** The failures in play order. */
+export const BUG_ORDER = ['weapon', 'shield', 'boss'];
diff --git a/challenges/glitch-invaders/game/src/bugs/code-state.mjs b/challenges/glitch-invaders/game/src/bugs/code-state.mjs
new file mode 100644
index 0000000..0a8e8d4
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/bugs/code-state.mjs
@@ -0,0 +1,225 @@
+/**
+ * The mutable "code" of the game: the three things a patch can change. The shipped state is
+ * deliberately broken, and the Incident Console's patches transform it.
+ */
+
+import { plannedLoadMs, tasksFor } from '../boss/boss-loader.mjs';
+import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs';
+import { resolveShieldHit } from '../shield/shield.mjs';
+import {
+ createBrokenWeaponTable,
+ FANOUT_VOLLEY,
+ fireWeapon,
+ SINGLE_SHOT,
+ WeaponConfigError,
+} from '../weapons/weapons.mjs';
+
+/** The state as shipped, with all three intentional bugs present. */
+export function createBrokenCodeState() {
+ return {
+ weapons: createBrokenWeaponTable(),
+ shield: 'amplify',
+ bossLoad: 'sequential',
+ };
+}
+
+/** The shortest cooldown the weapon loop can tolerate without flooding. */
+const MIN_SAFE_COOLDOWN_MS = 100;
+
+/** Install `config` as the `fanout` weapon. */
+function withFanout(state, config) {
+ return { ...state, weapons: { ...state.weapons, fanout: config } };
+}
+
+/**
+ * The patches for each failure, in the order the console lists them. Exactly one per failure is
+ * correct and it is never first, so position gives nothing away; the rest fail their check.
+ */
+export const REPAIR_PATCHES = {
+ weapon: [
+ {
+ id: 'add-fanout-zero-cooldown',
+ title: 'Add fanout with no cooldown',
+ description: 'Three projectiles and no delay between volleys. Maximum firepower.',
+ snippet:
+ 'weapons.fanout = {\n projectileCount: 3,\n spreadDegrees: 24,\n cooldownMs: 0,\n damage: 1,\n};',
+ apply: (state) => withFanout(state, { ...FANOUT_VOLLEY, cooldownMs: 0 }),
+ },
+ {
+ id: 'add-fanout-config',
+ title: 'Add the missing fanout config',
+ description:
+ 'Register the complete fan-out volley: 3 projectiles, a 24° fan, 300 ms cooldown.',
+ snippet:
+ 'weapons.fanout = {\n projectileCount: 3,\n spreadDegrees: 24,\n cooldownMs: 300,\n damage: 1,\n};',
+ apply: (state) => withFanout(state, FANOUT_VOLLEY),
+ },
+ {
+ id: 'add-single-projectile-fanout',
+ title: 'Register fanout as a basic shot',
+ description: 'Add a fanout entry that copies the single weapon. Simple, so safe?',
+ snippet: 'weapons.fanout = { ...weapons.single };',
+ apply: (state) => withFanout(state, SINGLE_SHOT),
+ },
+ ],
+ shield: [
+ {
+ id: 'remove-multiplier',
+ title: 'Remove the ×3 multiplier',
+ description: 'Let damage pass through normally and keep the shield up.',
+ snippet: 'if (shieldActive) {\n actualDamage = incomingDamage;\n}',
+ apply: (state) => ({ ...state, shield: 'pass-through' }),
+ },
+ {
+ id: 'drop-shield-on-hit',
+ title: 'Switch the shield off on impact',
+ description: 'Turn the shield off when it is hit, but still apply the damage.',
+ snippet: 'if (shieldActive) {\n shieldActive = false;\n actualDamage = incomingDamage;\n}',
+ apply: (state) => ({ ...state, shield: 'drop-then-pass' }),
+ },
+ {
+ id: 'shield-absorbs-hit',
+ title: 'Absorb the hit completely',
+ description: 'While the shield is active, apply 0 damage and consume the shield.',
+ snippet: 'if (shieldActive) {\n actualDamage = 0;\n shieldConsumed = true;\n}',
+ apply: (state) => ({ ...state, shield: 'absorb' }),
+ },
+ ],
+ boss: [
+ {
+ id: 'parallelize-everything',
+ title: 'Await all four loads in parallel',
+ description:
+ 'Start every load at once with Promise.all, but still wait for the heavy 1.8 s loadAnimations() call before spawning.',
+ snippet:
+ 'await Promise.all([\n loadTextures(),\n loadSounds(),\n loadAnimations(), // still blocks for 1.8 s\n initializeAI(),\n]);',
+ apply: (state) => ({ ...state, bossLoad: 'parallel-all' }),
+ },
+ {
+ id: 'parallel-without-redundant-load',
+ title: 'Parallelize core loads, stream animations',
+ description:
+ 'Run loadTextures, loadSounds, and initializeAI in parallel, and stream loadAnimations() without blocking spawn.',
+ snippet:
+ 'await Promise.all([\n loadTextures(),\n loadSounds(),\n initializeAI(),\n]);\nvoid loadAnimations(); // non-blocking',
+ apply: (state) => ({ ...state, bossLoad: 'parallel-deduplicated' }),
+ },
+ {
+ id: 'add-cache-checks',
+ title: 'Check a cache before each load',
+ description:
+ 'Skip a load if its result is cached. On first spawn, all four loads still run one after another.',
+ snippet:
+ "if (!cache.has('textures')) await loadTextures();\nif (!cache.has('sounds')) await loadSounds();\nif (!cache.has('anims')) await loadAnimations();\nif (!cache.has('ai')) await initializeAI();",
+ apply: (state) => ({ ...state, bossLoad: 'cached-sequential' }),
+ },
+ ],
+};
+
+/** Format milliseconds as seconds with one decimal, such as `1.8 s`. */
+function seconds(milliseconds) {
+ return `${(milliseconds / 1_000).toFixed(1)} s`;
+}
+
+/** Try firing the upgrade and report whether the result is the intended three-way fan. */
+function checkWeapon(state) {
+ try {
+ const { shots, config } = fireWeapon(state.weapons, 'fanout');
+ if (shots.length !== 3) {
+ return {
+ ok: false,
+ observation: `The upgrade fired ${shots.length} projectile${shots.length === 1 ? '' : 's'}. A fan-out should fire 3.`,
+ };
+ }
+ if (config.cooldownMs < MIN_SAFE_COOLDOWN_MS) {
+ return {
+ ok: false,
+ observation: `A ${config.cooldownMs} ms cooldown lets the weapon fire every frame and floods the game loop.`,
+ };
+ }
+
+ return { ok: true, observation: 'The upgrade fired a 3-way fan with a 300 ms cooldown.' };
+ } catch (error) {
+ if (error instanceof WeaponConfigError) {
+ return { ok: false, observation: error.message };
+ }
+ throw error;
+ }
+}
+
+/** Hit an active shield and report whether it protected the ship. */
+function checkShield(state) {
+ const outcome = resolveShieldHit(state.shield, true, 1);
+ if (outcome.actualDamage > 0) {
+ return {
+ ok: false,
+ observation: outcome.shieldConsumed
+ ? `The shield switched off, but the hit still landed for ${outcome.actualDamage}. A shield must absorb it.`
+ : `The shield let ${outcome.actualDamage} damage through and stayed up. A shield must absorb the hit.`,
+ };
+ }
+
+ return { ok: true, observation: 'The shield absorbed the hit: 0 damage, shield consumed.' };
+}
+
+/** Spawn the boss on a cold start and report whether it stays within the budget. */
+function checkBoss(state) {
+ const totalMs = plannedLoadMs(state.bossLoad);
+ const blocksOnAnimations = tasksFor(state.bossLoad).some(
+ (task) => task.name === 'loadAnimations',
+ );
+ if (totalMs > BOSS_SPAWN_BUDGET_MS) {
+ return {
+ ok: false,
+ observation: `The boss still took ${seconds(totalMs)} to spawn on a cold start. The budget is ${seconds(BOSS_SPAWN_BUDGET_MS)}.`,
+ };
+ }
+ if (blocksOnAnimations) {
+ return {
+ ok: false,
+ observation: `The boss spawned in ${seconds(totalMs)}, which still blocks on the 1.8 s loadAnimations() call.`,
+ };
+ }
+
+ return {
+ ok: true,
+ observation: `The boss spawned in ${seconds(totalMs)} by running core loads in parallel without blocking on loadAnimations.`,
+ };
+}
+
+/** Run the behavior check for `bug` against `state`. */
+export function checkRepair(bug, state) {
+ switch (bug) {
+ case 'weapon':
+ return checkWeapon(state);
+ case 'shield':
+ return checkShield(state);
+ case 'boss':
+ return checkBoss(state);
+ }
+}
+
+/**
+ * Try patch `patchId` for `bug` on `state`. A patch that fails its check is reverted, so a wrong
+ * choice never leaves the game in a half-fixed state.
+ *
+ * @throws {Error} when `patchId` is not a patch for `bug`.
+ */
+export function tryRepair(bug, patchId, state) {
+ const patch = REPAIR_PATCHES[bug].find((candidate) => candidate.id === patchId);
+ if (patch === undefined) {
+ throw new Error(`unknown patch for ${bug}: ${patchId}`);
+ }
+
+ const patched = patch.apply(state);
+ const check = checkRepair(bug, patched);
+
+ return { patchId, state: check.ok ? patched : state, check };
+}
+
+/** The one patch per failure that passes its behavior check, for restoring a saved run. */
+export const CORRECT_PATCH_IDS = {
+ weapon: 'add-fanout-config',
+ shield: 'shield-absorbs-hit',
+ boss: 'parallel-without-redundant-load',
+};
diff --git a/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs b/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs
new file mode 100644
index 0000000..29f00a6
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs
@@ -0,0 +1,207 @@
+/**
+ * Progress through the three failures. Each failure moves `unseen → encountered → investigated →
+ * repaired`, one step at a time, and every move is recorded as a milestone the guide can forward.
+ */
+import { BUG_IDS } from '../../../shared/progress.mjs';
+import {
+ SCORE_BUG_FOUND,
+ SCORE_DIAGNOSIS_BASE,
+ SCORE_DIAGNOSIS_FLOOR,
+ SCORE_HINT_COST,
+ SCORE_REPAIR_FIRST_TRY,
+ SCORE_REPAIR_RETRY,
+ SCORE_VERIFIED,
+} from '../engine/constants.mjs';
+import { BUG_DEFINITIONS } from './bug-definitions.mjs';
+
+/** Thrown when an action does not fit the failure's current stage, such as diagnosing before seeing it. */
+export class IllegalBugTransitionError extends Error {
+ constructor(bug, action, stage) {
+ super(`cannot ${action} for ${bug} while it is ${stage}`);
+ this.name = 'IllegalBugTransitionError';
+ }
+}
+
+/** The state machine behind the three failures. */
+export class IncidentTracker {
+ progress;
+ listeners = new Set();
+
+ constructor(initial) {
+ this.progress = structuredClone(initial);
+ }
+
+ /** A copy of the current progress. */
+ snapshot() {
+ return structuredClone(this.progress);
+ }
+
+ /** Listen for changes; returns the function that stops listening. */
+ subscribe(listener) {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ /** The stage of `bug`. */
+ stageOf(bug) {
+ return this.progress.bugs[bug].stage;
+ }
+
+ /** Whether every failure is repaired. */
+ allRepaired() {
+ return BUG_IDS.every((bug) => this.progress.bugs[bug].stage === 'repaired');
+ }
+
+ /** The participant pressed Start. Safe to call again. */
+ launch() {
+ if (this.progress.launched) {
+ return;
+ }
+
+ this.progress.launched = true;
+ this.record('run-started');
+ }
+
+ /** The failure became observable in play. Safe to call again. */
+ encounter(bug) {
+ const entry = this.progress.bugs[bug];
+ if (entry.stage !== 'unseen') {
+ return;
+ }
+
+ entry.stage = 'encountered';
+ this.progress.score += SCORE_BUG_FOUND;
+ this.record('bug-encountered', bug);
+ }
+
+ /** The participant opened the console's evidence for `bug`. */
+ markEvidenceViewed(bug) {
+ const entry = this.progress.bugs[bug];
+ if (entry.stage === 'unseen') {
+ throw new IllegalBugTransitionError(bug, 'view evidence', entry.stage);
+ }
+ if (entry.evidenceViewed) {
+ return;
+ }
+
+ entry.evidenceViewed = true;
+ this.record('evidence-viewed', bug);
+ }
+
+ /**
+ * Reveal the next hint and charge for it.
+ *
+ * @returns the index of the newly revealed hint, or `null` when all three are already shown.
+ */
+ useHint(bug) {
+ const entry = this.progress.bugs[bug];
+ if (entry.hintsUsed >= BUG_DEFINITIONS[bug].hints.length) {
+ return null;
+ }
+
+ entry.hintsUsed += 1;
+ this.notify(null);
+
+ return entry.hintsUsed - 1;
+ }
+
+ /**
+ * Check a diagnosis. A right answer, given after viewing the evidence, makes the failure
+ * `investigated`.
+ *
+ * @throws {IllegalBugTransitionError} when the evidence has not been viewed yet.
+ */
+ answerDiagnosis(bug, optionId) {
+ const entry = this.progress.bugs[bug];
+ if (entry.stage !== 'encountered' || !entry.evidenceViewed) {
+ throw new IllegalBugTransitionError(bug, 'diagnose', entry.stage);
+ }
+
+ const option = BUG_DEFINITIONS[bug].options.find((candidate) => candidate.id === optionId);
+ if (option === undefined) {
+ throw new Error(`unknown diagnosis option for ${bug}: ${optionId}`);
+ }
+
+ entry.diagnosisAttempts += 1;
+ if (!option.correct) {
+ this.notify(null);
+
+ return { correct: false, feedback: option.feedback };
+ }
+
+ entry.stage = 'investigated';
+ this.progress.score += Math.max(
+ SCORE_DIAGNOSIS_FLOOR,
+ SCORE_DIAGNOSIS_BASE - SCORE_HINT_COST * entry.hintsUsed,
+ );
+ this.notify(null);
+
+ return { correct: true, feedback: '' };
+ }
+
+ /**
+ * Record that the participant applied a patch.
+ *
+ * @throws {IllegalBugTransitionError} when the failure has not been diagnosed.
+ */
+ recordRepairAttempt(bug, correct) {
+ const entry = this.progress.bugs[bug];
+ if (entry.stage !== 'investigated') {
+ throw new IllegalBugTransitionError(bug, 'attempt a repair', entry.stage);
+ }
+
+ entry.repairAttempts += 1;
+ if (correct) {
+ this.progress.score +=
+ entry.repairAttempts === 1 ? SCORE_REPAIR_FIRST_TRY : SCORE_REPAIR_RETRY;
+ }
+ this.record('repair-attempted', bug, correct);
+ }
+
+ /**
+ * The repaired behavior was seen working in the game. Completes the run once all three are done.
+ *
+ * @throws {IllegalBugTransitionError} when the failure has not been diagnosed.
+ */
+ markRepaired(bug) {
+ const entry = this.progress.bugs[bug];
+ if (entry.stage === 'repaired') {
+ return;
+ }
+ if (entry.stage !== 'investigated') {
+ throw new IllegalBugTransitionError(bug, 'mark repaired', entry.stage);
+ }
+
+ entry.stage = 'repaired';
+ this.progress.score += SCORE_VERIFIED;
+ this.record('repair-succeeded', bug);
+
+ if (this.allRepaired() && !this.progress.completed) {
+ this.progress.completed = true;
+ this.record('challenge-completed');
+ }
+ }
+
+ /** Append a milestone and tell listeners. */
+ record(name, bug, correct) {
+ const seq = this.progress.milestones.length + 1;
+ this.progress.milestones.push({
+ seq,
+ name,
+ ...(bug === undefined ? {} : { bug }),
+ ...(correct === undefined ? {} : { correct }),
+ });
+ this.notify(name);
+ }
+
+ /** Tell every listener about the current progress. */
+ notify(change) {
+ const snapshot = this.snapshot();
+ for (const listener of this.listeners) {
+ listener(snapshot, change);
+ }
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs
new file mode 100644
index 0000000..b60612d
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs
@@ -0,0 +1,182 @@
+import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs';
+
+/** Format milliseconds as seconds, such as `1.8 s`. */
+function seconds(milliseconds) {
+ return `${(milliseconds / 1_000).toFixed(1)} s`;
+}
+
+/**
+ * Where the weapon failure's stack frames point. A unit test checks each line still holds the call
+ * it names, so these cannot drift from the source unnoticed.
+ */
+export const WEAPON_STACK_FRAMES = [
+ { functionName: 'fireWeapon', file: 'game/src/weapons/weapons.mjs', line: 46, inApp: true },
+ {
+ functionName: 'GameEngine.tryFire',
+ file: 'game/src/engine/game-engine.mjs',
+ line: 320,
+ inApp: true,
+ },
+ {
+ functionName: 'GameEngine.updatePlayer',
+ file: 'game/src/engine/game-engine.mjs',
+ line: 294,
+ inApp: true,
+ },
+ {
+ functionName: 'GameEngine.update',
+ file: 'game/src/engine/game-engine.mjs',
+ line: 160,
+ inApp: true,
+ },
+ { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true },
+ { functionName: 'requestAnimationFrame', file: '', line: 0, inApp: false },
+];
+
+/** The canonical boss waterfall: the four loads, back to back. */
+export const SEQUENTIAL_BOSS_SPANS = [
+ { name: 'loadTextures', operation: 'game.resource.load', startMs: 0, durationMs: 1_200 },
+ { name: 'loadSounds', operation: 'game.resource.load', startMs: 1_200, durationMs: 1_000 },
+ { name: 'loadAnimations', operation: 'game.resource.load', startMs: 2_200, durationMs: 1_800 },
+ { name: 'initializeAI', operation: 'game.resource.load', startMs: 4_000, durationMs: 500 },
+];
+
+/** The canonical run-up to the shield failure, used by the simulated provider's breadcrumbs. */
+export const SIMULATED_SHIELD_BREADCRUMBS = [
+ {
+ offsetLabel: '-9.4 s',
+ category: 'game.wave',
+ message: 'Wave 2 started',
+ level: 'info',
+ data: null,
+ },
+ {
+ offsetLabel: '-6.8 s',
+ category: 'game.shield',
+ message: 'Shield collected',
+ level: 'info',
+ data: { type: 'aegis' },
+ },
+ {
+ offsetLabel: '-6.8 s',
+ category: 'game.shield',
+ message: 'Shield enabled',
+ level: 'info',
+ data: { shieldActive: 'true' },
+ },
+ {
+ offsetLabel: '-1.2 s',
+ category: 'game.combat',
+ message: 'Incoming damage: 1',
+ level: 'info',
+ data: { damage: '1' },
+ },
+ {
+ offsetLabel: '-1.2 s',
+ category: 'game.shield',
+ message: 'Shield active: true',
+ level: 'info',
+ data: { shieldActive: 'true' },
+ },
+ {
+ offsetLabel: '-1.2 s',
+ category: 'game.combat',
+ message: 'Actual damage applied: 3',
+ level: 'warning',
+ data: { actualDamage: '3', expected: '0' },
+ },
+ {
+ offsetLabel: '0.0 s',
+ category: 'game.combat',
+ message: 'Ship destroyed: shield failed to protect',
+ level: 'error',
+ data: null,
+ },
+];
+
+/** The title Sentry would give the issue or trace for `encounter`. */
+export function evidenceTitle(encounter) {
+ switch (encounter.bug) {
+ case 'weapon':
+ return `WeaponConfigError: Configuration missing for weapon: ${encounter.jam.weaponId}`;
+ case 'shield':
+ return `GameLogicError: Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0)`;
+ case 'boss':
+ return `boss-spawn took ${seconds(encounter.stall.totalMs)} (budget ${seconds(BOSS_SPAWN_BUDGET_MS)})`;
+ }
+}
+
+/**
+ * The sections for `encounter`. `breadcrumbs` is the trail to show for the shield failure, and
+ * `spans` is the waterfall to show for the boss stall.
+ */
+export function evidenceSections(encounter, breadcrumbs, spans) {
+ switch (encounter.bug) {
+ case 'weapon':
+ return [
+ {
+ kind: 'exception',
+ title: 'Exception',
+ errorType: 'WeaponConfigError',
+ message: `Configuration missing for weapon: ${encounter.jam.weaponId}`,
+ },
+ { kind: 'stacktrace', title: 'Stack trace', frames: [...WEAPON_STACK_FRAMES] },
+ {
+ kind: 'context',
+ title: 'weapon_state',
+ contextName: 'weapon_state',
+ entries: {
+ attemptedWeapon: encounter.jam.weaponId,
+ availableWeapons: encounter.jam.availableWeapons.join(', '),
+ },
+ },
+ ];
+ case 'shield':
+ return [
+ {
+ kind: 'exception',
+ title: 'Exception',
+ errorType: 'GameLogicError',
+ message: `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`,
+ },
+ { kind: 'breadcrumbs', title: 'Breadcrumbs', entries: [...breadcrumbs] },
+ {
+ kind: 'context',
+ title: 'shield_state',
+ contextName: 'shield_state',
+ entries: {
+ shieldActive: 'true',
+ incomingDamage: String(encounter.failure.incomingDamage),
+ actualDamage: String(encounter.failure.actualDamage),
+ expectedDamage: '0',
+ hullBefore: String(encounter.failure.hullBefore),
+ hullAfter: String(encounter.failure.hullAfter),
+ },
+ },
+ ];
+ case 'boss':
+ return [
+ {
+ kind: 'waterfall',
+ title: 'Trace: boss-spawn',
+ totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0),
+ spans: [...spans],
+ },
+ {
+ kind: 'context',
+ title: 'boot_state',
+ contextName: 'boot_state',
+ entries: {
+ animationsLoaded: 'at game start',
+ spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS),
+ strategy: 'sequential',
+ },
+ },
+ ];
+ }
+}
+
+/** Format an issue reference for the simulated provider, such as `SIM-WEAPON-1`. */
+export function simulatedReference(bug) {
+ return `SIM-${bug.toUpperCase()}-1`;
+}
diff --git a/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs
new file mode 100644
index 0000000..57b152e
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs
@@ -0,0 +1,26 @@
+/**
+ * Demo-mode evidence: realistic and deterministic, built without any Sentry credentials. It is
+ * clearly labelled simulated.
+ */
+
+import {
+ evidenceSections,
+ evidenceTitle,
+ SEQUENTIAL_BOSS_SPANS,
+ SIMULATED_SHIELD_BREADCRUMBS,
+ simulatedReference,
+} from './evidence-builders.mjs';
+
+/** Produces fixed evidence, whatever the run measured. */
+export class SimulatedEvidenceProvider {
+ /** Evidence for `encounter`, labelled simulated. */
+ async evidenceFor(encounter) {
+ return {
+ bug: encounter.bug,
+ source: 'simulated',
+ title: evidenceTitle(encounter),
+ referenceId: simulatedReference(encounter.bug),
+ sections: evidenceSections(encounter, SIMULATED_SHIELD_BREADCRUMBS, SEQUENTIAL_BOSS_SPANS),
+ };
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/engine/constants.mjs b/challenges/glitch-invaders/game/src/engine/constants.mjs
new file mode 100644
index 0000000..222ccf9
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/engine/constants.mjs
@@ -0,0 +1,97 @@
+/** Fixed numbers for the arena, ship, enemies, and incidents. Distances are world pixels. */
+
+/** Width of the arena in world pixels; the canvas scales it to fit the screen. */
+export const WORLD_WIDTH = 800;
+
+/** Height of the arena in world pixels. */
+export const WORLD_HEIGHT = 500;
+
+/** Ship footprint. */
+export const PLAYER_WIDTH = 40;
+export const PLAYER_HEIGHT = 26;
+
+/** Vertical position of the ship's top edge. */
+export const PLAYER_Y = 438;
+
+/** Ship speed in pixels per second. */
+export const PLAYER_SPEED = 340;
+
+/** Hull points the ship starts with. */
+export const PLAYER_MAX_HULL = 3;
+
+/** How long the ship ignores damage after a hit or a restore. */
+export const PLAYER_INVULNERABLE_MS = 1_400;
+
+/** How long the ship stays down after losing its hull before it is restored. */
+export const PLAYER_RESTORE_MS = 1_600;
+
+/** Enemy grid shape and footprint. */
+export const ENEMY_ROWS = 3;
+export const ENEMY_COLUMNS = 5;
+export const ENEMY_WIDTH = 38;
+export const ENEMY_HEIGHT = 26;
+export const ENEMY_GAP_X = 26;
+export const ENEMY_GAP_Y = 18;
+export const ENEMY_TOP = 54;
+
+/** Horizontal speed of the enemy formation, in pixels per second. */
+export const ENEMY_SPEED = 46;
+
+/** How far the formation drops when it reaches a wall. */
+export const ENEMY_DROP = 16;
+
+/** The formation stops dropping once its lowest row reaches this y. */
+export const ENEMY_FLOOR = 300;
+
+/** Time between enemy shots, in milliseconds. */
+export const ENEMY_FIRE_INTERVAL_MS = 1_700;
+
+/** Speed of enemy bullets, in pixels per second. */
+export const ENEMY_BULLET_SPEED = 170;
+
+/** Speed of the player's bullets, in pixels per second. */
+export const PLAYER_BULLET_SPEED = 560;
+
+/** Bullet footprint. */
+export const BULLET_WIDTH = 5;
+export const BULLET_HEIGHT = 14;
+
+/** Pickup footprint and fall speed. */
+export const PICKUP_SIZE = 26;
+export const PICKUP_SPEED = 96;
+
+/** Time between aimed shots while the shield test is armed. */
+export const PROBE_INTERVAL_MS = 1_100;
+
+/** Speed of the aimed shots fired at a shielded ship. */
+export const PROBE_BULLET_SPEED = 230;
+
+/** Damage the broken shield applies for each point of incoming damage. */
+export const SHIELD_BUG_MULTIPLIER = 3;
+
+/** Boss footprint and position. */
+export const BOSS_WIDTH = 120;
+export const BOSS_HEIGHT = 64;
+export const BOSS_Y = 46;
+
+/** A boss spawn slower than this counts as a stalled spawn. */
+export const BOSS_SPAWN_BUDGET_MS = 2_000;
+
+/** Simulated durations of each boss resource load, in milliseconds. */
+export const LOAD_TEXTURES_MS = 1_200;
+export const LOAD_SOUNDS_MS = 1_000;
+export const LOAD_ANIMATIONS_MS = 1_800;
+export const INIT_AI_MS = 500;
+
+/** Longest frame the engine simulates in one step, so a stalled tab cannot teleport the ship. */
+export const MAX_STEP_MS = 50;
+
+/** Points shown in the HUD. They are fun feedback; Boothstrap awards the challenge's points. */
+export const SCORE_ENEMY = 10;
+export const SCORE_BUG_FOUND = 100;
+export const SCORE_DIAGNOSIS_BASE = 300;
+export const SCORE_HINT_COST = 100;
+export const SCORE_DIAGNOSIS_FLOOR = 50;
+export const SCORE_REPAIR_FIRST_TRY = 200;
+export const SCORE_REPAIR_RETRY = 100;
+export const SCORE_VERIFIED = 150;
diff --git a/challenges/glitch-invaders/game/src/engine/game-engine.mjs b/challenges/glitch-invaders/game/src/engine/game-engine.mjs
new file mode 100644
index 0000000..79f146c
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/engine/game-engine.mjs
@@ -0,0 +1,886 @@
+import { tasksFor } from '../boss/boss-loader.mjs';
+
+import { resolveShieldHit } from '../shield/shield.mjs';
+import { fireWeapon, WeaponConfigError } from '../weapons/weapons.mjs';
+import {
+ BOSS_HEIGHT,
+ BOSS_SPAWN_BUDGET_MS,
+ BOSS_WIDTH,
+ BOSS_Y,
+ BULLET_HEIGHT,
+ BULLET_WIDTH,
+ ENEMY_BULLET_SPEED,
+ ENEMY_COLUMNS,
+ ENEMY_DROP,
+ ENEMY_FIRE_INTERVAL_MS,
+ ENEMY_FLOOR,
+ ENEMY_GAP_X,
+ ENEMY_GAP_Y,
+ ENEMY_HEIGHT,
+ ENEMY_ROWS,
+ ENEMY_SPEED,
+ ENEMY_TOP,
+ ENEMY_WIDTH,
+ MAX_STEP_MS,
+ PICKUP_SIZE,
+ PICKUP_SPEED,
+ PLAYER_BULLET_SPEED,
+ PLAYER_HEIGHT,
+ PLAYER_INVULNERABLE_MS,
+ PLAYER_MAX_HULL,
+ PLAYER_RESTORE_MS,
+ PLAYER_SPEED,
+ PLAYER_WIDTH,
+ PLAYER_Y,
+ PROBE_BULLET_SPEED,
+ PROBE_INTERVAL_MS,
+ WORLD_HEIGHT,
+ WORLD_WIDTH,
+} from './constants.mjs';
+import { clamp, overlaps } from './geometry.mjs';
+
+/** Boss health. */
+const BOSS_HEALTH = 12;
+
+/** The simulation. */
+export class GameEngine {
+ world;
+ code;
+ options;
+ listeners = new Set();
+ enemyFireAt = ENEMY_FIRE_INTERVAL_MS;
+ probeFireAt = 0;
+ jamSeen = false;
+ shieldFailureSeen = false;
+
+ constructor(options) {
+ this.options = options;
+ this.code = options.code;
+ this.world = {
+ time: 0,
+ phase: 'ready',
+ paused: false,
+ wave: 1,
+ incident: null,
+ verifying: null,
+ player: createPlayer(),
+ enemies: [],
+ bullets: [],
+ pickups: [],
+ boss: null,
+ bossLoad: null,
+ particles: [],
+ kills: 0,
+ formationDirection: 1,
+ };
+ }
+
+ /** Listen for game events; returns the function that stops listening. */
+ on(listener) {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ /**
+ * Begin the run at `wave`. Starting past an incident means that incident was already fixed, so its
+ * trigger is marked as seen and the wave is not held back for it.
+ */
+ start(wave = 1) {
+ if (this.world.phase !== 'ready') {
+ return;
+ }
+
+ this.world.phase = 'playing';
+ this.jamSeen = wave > 1;
+ this.shieldFailureSeen = wave > 2;
+ if (wave === 3) {
+ this.world.wave = 3;
+ this.startBossSpawn();
+
+ return;
+ }
+
+ this.startWave(wave);
+ }
+
+ /**
+ * Put the arena back into a frozen incident, as after a page refresh: the failure is already
+ * recorded, so the Incident Console reopens on it.
+ */
+ restoreIncident(bug) {
+ this.start(bug === 'weapon' ? 1 : 2);
+ this.jamSeen = true;
+ this.shieldFailureSeen = bug !== 'weapon';
+ this.world.incident = bug;
+ this.world.phase = 'frozen';
+
+ switch (bug) {
+ case 'weapon':
+ this.world.player.weapon = 'fanout';
+ break;
+ case 'shield':
+ this.world.player.hull = 0;
+ break;
+ case 'boss':
+ this.world.wave = 3;
+ this.world.enemies = [];
+ this.world.boss = this.createBoss();
+ break;
+ }
+ }
+
+ /** Replace the game's code, as a correct patch does. */
+ setCode(code) {
+ this.code = code;
+ }
+
+ /** Stop simulating until {@link GameEngine.resume}. */
+ pause() {
+ this.world.paused = true;
+ }
+
+ /** Continue after {@link GameEngine.pause}. */
+ resume() {
+ this.world.paused = false;
+ }
+
+ /** Advance the simulation by `elapsedMs`, capped so a stalled frame cannot teleport anything. */
+ update(elapsedMs, input) {
+ const stepMs = Math.min(elapsedMs, MAX_STEP_MS);
+ this.updateParticles(stepMs);
+ if (this.world.paused || this.world.phase !== 'playing') {
+ return;
+ }
+
+ this.world.time += stepMs;
+ const seconds = stepMs / 1_000;
+ this.updatePlayer(seconds, input);
+ this.updateFormation(seconds);
+ this.updateBoss(seconds);
+ this.fireEnemyShots();
+ this.moveBullets(seconds);
+ this.movePickups(seconds);
+ this.resolveBulletHits();
+ this.collectPickups();
+ this.checkWaveCleared();
+ }
+
+ /**
+ * Skip ahead to the next incident: clear every plain drone and drop the carrier's pickup near the
+ * ship. The player still has to collect it and trigger the failure.
+ */
+ fastForward() {
+ if (this.world.phase !== 'playing') {
+ return;
+ }
+
+ for (const enemy of this.world.enemies) {
+ if (enemy.alive && enemy.kind === 'drone') {
+ this.destroyEnemy(enemy);
+ }
+ }
+ const carrier = this.world.enemies.find((enemy) => enemy.alive && enemy.kind !== 'drone');
+ if (carrier !== undefined) {
+ carrier.alive = false;
+ this.burst(carrier.x + carrier.width / 2, carrier.y + carrier.height / 2, 'yellow');
+ this.dropPickup(carrier.kind === 'weapon-carrier' ? 'fanout' : 'aegis');
+ }
+ }
+
+ /**
+ * Replay an incident with the repaired code so the participant sees it work. Resumes play and
+ * emits `verification-passed` once the repaired behavior is observed.
+ */
+ beginVerification(bug) {
+ this.world.incident = null;
+ this.world.verifying = bug;
+ this.world.phase = 'playing';
+ this.world.bullets = this.world.bullets.filter((bullet) => bullet.owner === 'player');
+
+ switch (bug) {
+ case 'weapon':
+ this.world.player.weapon = 'single';
+ this.dropPickup('fanout');
+ break;
+ case 'shield':
+ this.world.player.hull = PLAYER_MAX_HULL;
+ this.world.player.shield = false;
+ this.world.player.restoreAt = null;
+ this.makeInvulnerable();
+ this.dropPickup('aegis');
+ break;
+ case 'boss':
+ this.world.boss = null;
+ this.startBossSpawn();
+ break;
+ }
+ }
+
+ /** The run is over: freeze the arena for the completion screen. */
+ finish() {
+ this.world.phase = 'finished';
+ this.emit({ type: 'run-finished' });
+ }
+
+ /** Put a new pickup above the ship, where it falls into reach. */
+ dropPickup(kind) {
+ const player = this.world.player;
+ const offset = (this.options.random() - 0.5) * 160;
+ this.world.pickups.push({
+ kind,
+ x: clamp(
+ player.x + player.width / 2 - PICKUP_SIZE / 2 + offset,
+ 10,
+ WORLD_WIDTH - PICKUP_SIZE - 10,
+ ),
+ y: -PICKUP_SIZE,
+ width: PICKUP_SIZE,
+ height: PICKUP_SIZE,
+ });
+ this.emit({ type: 'pickup-spawned', kind });
+ }
+
+ /** Lay out a formation of drones with one marked carrier. */
+ startWave(wave) {
+ const carrierKind = wave === 1 ? 'weapon-carrier' : 'shield-carrier';
+ const carrierColumn = wave === 1 ? 2 : 1;
+ const totalWidth = ENEMY_COLUMNS * ENEMY_WIDTH + (ENEMY_COLUMNS - 1) * ENEMY_GAP_X;
+ const left = (WORLD_WIDTH - totalWidth) / 2;
+ const enemies = [];
+
+ for (let row = 0; row < ENEMY_ROWS; row += 1) {
+ for (let column = 0; column < ENEMY_COLUMNS; column += 1) {
+ const isCarrier = row === 1 && column === carrierColumn;
+ enemies.push({
+ x: left + column * (ENEMY_WIDTH + ENEMY_GAP_X),
+ y: ENEMY_TOP + row * (ENEMY_HEIGHT + ENEMY_GAP_Y),
+ width: ENEMY_WIDTH,
+ height: ENEMY_HEIGHT,
+ kind: isCarrier ? carrierKind : 'drone',
+ row,
+ alive: true,
+ });
+ }
+ }
+
+ this.world.wave = wave;
+ this.world.enemies = enemies;
+ this.world.formationDirection = 1;
+ this.enemyFireAt = this.world.time + ENEMY_FIRE_INTERVAL_MS;
+ this.emit({ type: 'wave-started', wave });
+ }
+
+ /** Move the ship and fire when asked. */
+ updatePlayer(seconds, input) {
+ const player = this.world.player;
+ if (player.restoreAt !== null) {
+ if (this.world.time >= player.restoreAt) {
+ this.restorePlayer();
+ }
+
+ return;
+ }
+
+ const direction = (input.right ? 1 : 0) - (input.left ? 1 : 0);
+ player.x = clamp(
+ player.x + direction * PLAYER_SPEED * seconds,
+ 8,
+ WORLD_WIDTH - PLAYER_WIDTH - 8,
+ );
+ if (input.fire) {
+ this.tryFire();
+ }
+ }
+
+ /** Bring the ship back at full hull after a normal loss. */
+ restorePlayer() {
+ const player = this.world.player;
+ player.hull = PLAYER_MAX_HULL;
+ player.shield = false;
+ player.restoreAt = null;
+ this.makeInvulnerable();
+ this.world.bullets = this.world.bullets.filter((bullet) => bullet.owner === 'player');
+ this.emit({ type: 'ship-restored' });
+ }
+
+ /** Give the ship a short window where hits do nothing. */
+ makeInvulnerable() {
+ this.world.player.invulnerableUntil = this.world.time + PLAYER_INVULNERABLE_MS;
+ }
+
+ /** Fire the carried weapon, freezing the game on the first jam. */
+ tryFire() {
+ const player = this.world.player;
+ let volley;
+
+ try {
+ volley = fireWeapon(this.code.weapons, player.weapon);
+ } catch (error) {
+ if (error instanceof WeaponConfigError) {
+ this.jam(error);
+
+ return;
+ }
+ throw error;
+ }
+
+ if (this.world.time - player.lastShotAt < volley.config.cooldownMs) {
+ return;
+ }
+
+ player.lastShotAt = this.world.time;
+ for (const shot of volley.shots) {
+ this.world.bullets.push({
+ x: player.x + player.width / 2 - BULLET_WIDTH / 2,
+ y: player.y - BULLET_HEIGHT,
+ width: BULLET_WIDTH,
+ height: BULLET_HEIGHT,
+ velocityX: Math.sin(shot.angle) * PLAYER_BULLET_SPEED,
+ velocityY: -Math.cos(shot.angle) * PLAYER_BULLET_SPEED,
+ owner: 'player',
+ damage: shot.damage,
+ homing: false,
+ });
+ }
+ this.emit({ type: 'volley-fired', weapon: player.weapon, projectiles: volley.shots.length });
+
+ if (
+ this.world.verifying === 'weapon' &&
+ player.weapon === 'fanout' &&
+ volley.shots.length === 3
+ ) {
+ this.passVerification('weapon');
+ }
+ }
+
+ /** The weapon had no configuration: stop the world on incident 1. */
+ jam(error) {
+ this.jamSeen = true;
+ this.world.incident = 'weapon';
+ this.world.phase = 'frozen';
+ this.burst(this.world.player.x + PLAYER_WIDTH / 2, this.world.player.y, 'red', 18);
+ this.emit({
+ type: 'weapon-jammed',
+ error,
+ availableWeapons: Object.keys(this.code.weapons),
+ });
+ }
+
+ /** Slide the formation and drop it at the walls. */
+ updateFormation(seconds) {
+ const alive = this.world.enemies.filter((enemy) => enemy.alive);
+ if (alive.length === 0) {
+ return;
+ }
+
+ const step = this.world.formationDirection * ENEMY_SPEED * seconds;
+ let touchesWall = false;
+ for (const enemy of alive) {
+ enemy.x += step;
+ if (enemy.x < 14 || enemy.x + enemy.width > WORLD_WIDTH - 14) {
+ touchesWall = true;
+ }
+ }
+
+ if (!touchesWall) {
+ return;
+ }
+
+ this.world.formationDirection = this.world.formationDirection === 1 ? -1 : 1;
+ const lowest = Math.max(...alive.map((enemy) => enemy.y + enemy.height));
+ const drop = lowest + ENEMY_DROP <= ENEMY_FLOOR ? ENEMY_DROP : 0;
+ for (const enemy of alive) {
+ enemy.x -= step;
+ enemy.y += drop;
+ }
+ }
+
+ /** Move the boss side to side and let it shoot. */
+ updateBoss(seconds) {
+ const boss = this.world.boss;
+ if (boss === null) {
+ return;
+ }
+
+ const enraged = this.freeplay && boss.health <= boss.maxHealth / 2;
+ const speedScale = enraged ? 1.5 : 1;
+ boss.x += boss.velocityX * speedScale * seconds;
+ if (boss.x < 10 || boss.x + boss.width > WORLD_WIDTH - 10) {
+ boss.velocityX *= -1;
+ boss.x = clamp(boss.x, 10, WORLD_WIDTH - boss.width - 10);
+ }
+ if (this.world.time >= boss.nextShotAt) {
+ const centerX = boss.x + boss.width / 2;
+ const muzzleY = boss.y + boss.height;
+ if (this.freeplay) {
+ boss.nextShotAt = this.world.time + (enraged ? 440 : 600);
+ this.spawnBossVolley(centerX, muzzleY, enraged);
+ } else {
+ boss.nextShotAt = this.world.time + 900;
+ this.spawnEnemyBullet(centerX, muzzleY, false);
+ }
+ }
+ }
+
+ /** Let a random front-line enemy fire, and keep the shield test supplied with aimed shots. */
+ fireEnemyShots() {
+ const world = this.world;
+ const alive = world.enemies.filter((enemy) => enemy.alive);
+
+ if (alive.length > 0 && world.time >= this.enemyFireAt) {
+ this.enemyFireAt = world.time + ENEMY_FIRE_INTERVAL_MS;
+ const shooter = alive[Math.floor(this.options.random() * alive.length)];
+ if (shooter !== undefined) {
+ this.spawnEnemyBullet(shooter.x + shooter.width / 2, shooter.y + shooter.height, false);
+ }
+ }
+
+ const shieldTestArmed = world.player.shield && world.player.restoreAt === null;
+ if (shieldTestArmed && world.time >= this.probeFireAt) {
+ this.probeFireAt = world.time + PROBE_INTERVAL_MS;
+ const source = alive[Math.floor(this.options.random() * alive.length)];
+ const originX = source === undefined ? WORLD_WIDTH / 2 : source.x + source.width / 2;
+ const originY = source === undefined ? ENEMY_TOP : source.y + source.height;
+ this.spawnEnemyBullet(originX, originY, true);
+ }
+ }
+
+ /** Add a bullet from `(x, y)`; probes are aimed at the ship, others fall straight down. */
+ spawnEnemyBullet(x, y, aimed) {
+ const player = this.world.player;
+ let velocityX = 0;
+ let velocityY = ENEMY_BULLET_SPEED;
+
+ if (aimed) {
+ const deltaX = player.x + player.width / 2 - x;
+ const deltaY = player.y - y;
+ const length = Math.hypot(deltaX, deltaY) || 1;
+ velocityX = (deltaX / length) * PROBE_BULLET_SPEED;
+ velocityY = (deltaY / length) * PROBE_BULLET_SPEED;
+ }
+
+ this.world.bullets.push({
+ x: x - BULLET_WIDTH / 2,
+ y,
+ width: BULLET_WIDTH,
+ height: BULLET_HEIGHT,
+ velocityX,
+ velocityY,
+ owner: 'enemy',
+ damage: 1,
+ homing: aimed,
+ });
+ }
+
+ /**
+ * Move every bullet, steer homing ones to arrive where the ship is now, and drop those that leave
+ * the arena. A ship that keeps still is always hit by the shield test's shot; one that moves can
+ * still dodge.
+ */
+ moveBullets(seconds) {
+ const player = this.world.player;
+ const targetX = player.x + player.width / 2;
+ for (const bullet of this.world.bullets) {
+ if (bullet.homing && bullet.y < player.y && bullet.velocityY > 0) {
+ const secondsToImpact = Math.max(0.05, (player.y - bullet.y) / bullet.velocityY);
+ const neededVelocity = (targetX - (bullet.x + bullet.width / 2)) / secondsToImpact;
+ bullet.velocityX = clamp(neededVelocity, -PROBE_BULLET_SPEED, PROBE_BULLET_SPEED);
+ }
+ bullet.x += bullet.velocityX * seconds;
+ bullet.y += bullet.velocityY * seconds;
+ }
+
+ this.world.bullets = this.world.bullets.filter(
+ (bullet) =>
+ bullet.y > -BULLET_HEIGHT * 2 &&
+ bullet.y < WORLD_HEIGHT + BULLET_HEIGHT &&
+ bullet.x > -20 &&
+ bullet.x < WORLD_WIDTH + 20,
+ );
+ }
+
+ /** Let pickups fall, and send any that were missed back to the top so the incident can proceed. */
+ movePickups(seconds) {
+ for (const pickup of this.world.pickups) {
+ pickup.y += PICKUP_SPEED * seconds;
+ if (pickup.y > WORLD_HEIGHT) {
+ pickup.y = -PICKUP_SIZE;
+ pickup.x = 20 + this.options.random() * (WORLD_WIDTH - PICKUP_SIZE - 40);
+ }
+ }
+ }
+
+ /** Match player bullets against enemies and the boss, and enemy bullets against the ship. */
+ resolveBulletHits() {
+ const world = this.world;
+ const spent = new Set();
+
+ for (const bullet of world.bullets) {
+ if (bullet.owner === 'player') {
+ const enemy = world.enemies.find(
+ (candidate) => candidate.alive && overlaps(bullet, candidate),
+ );
+ if (enemy !== undefined) {
+ spent.add(bullet);
+ this.destroyEnemy(enemy);
+ continue;
+ }
+
+ const boss = world.boss;
+ if (boss !== null && overlaps(bullet, boss)) {
+ spent.add(bullet);
+ boss.health -= bullet.damage;
+ this.burst(bullet.x, bullet.y, 'red', 3);
+ if (boss.health <= 0) {
+ world.boss = null;
+ world.kills += 5;
+ this.burst(boss.x + boss.width / 2, boss.y + boss.height / 2, 'yellow', 36);
+ if (this.freeplay) {
+ this.finish();
+ }
+ }
+ }
+ } else if (world.player.restoreAt === null && overlaps(bullet, world.player)) {
+ spent.add(bullet);
+ this.hitPlayer(bullet.damage);
+ }
+ }
+
+ if (spent.size > 0) {
+ world.bullets = world.bullets.filter((bullet) => !spent.has(bullet));
+ }
+ }
+
+ /** Remove an enemy, with a burst, and let a carrier drop its pickup. */
+ destroyEnemy(enemy) {
+ enemy.alive = false;
+ this.world.kills += 1;
+ this.burst(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, 'green');
+ this.emit({ type: 'enemy-destroyed', kind: enemy.kind });
+
+ if (enemy.kind === 'weapon-carrier' || enemy.kind === 'shield-carrier') {
+ const kind = enemy.kind === 'weapon-carrier' ? 'fanout' : 'aegis';
+ this.world.pickups.push({
+ kind,
+ x: enemy.x + (enemy.width - PICKUP_SIZE) / 2,
+ y: enemy.y,
+ width: PICKUP_SIZE,
+ height: PICKUP_SIZE,
+ });
+ this.emit({ type: 'pickup-spawned', kind });
+ }
+ }
+
+ /** Apply an enemy hit to the ship through the shield rules. */
+ hitPlayer(incomingDamage) {
+ const world = this.world;
+ const player = world.player;
+ if (world.time < player.invulnerableUntil) {
+ return;
+ }
+
+ const shieldWasUp = player.shield;
+ const hullBefore = player.hull;
+ const outcome = resolveShieldHit(this.code.shield, player.shield, incomingDamage);
+ if (outcome.shieldConsumed) {
+ player.shield = false;
+ }
+ player.hull = Math.max(0, player.hull - outcome.actualDamage);
+ this.burst(player.x + player.width / 2, player.y, shieldWasUp ? 'blue' : 'red', 10);
+
+ if (shieldWasUp) {
+ this.emit({
+ type: 'shield-hit',
+ incomingDamage,
+ outcome,
+ hullBefore,
+ hullAfter: player.hull,
+ });
+ }
+
+ if (player.hull <= 0) {
+ this.loseHull(shieldWasUp);
+
+ return;
+ }
+
+ this.makeInvulnerable();
+ if (shieldWasUp && outcome.actualDamage === 0 && world.verifying === 'shield') {
+ this.passVerification('shield');
+ }
+ }
+
+ /** The hull hit zero: freeze on incident 2 if the shield caused it, otherwise restore shortly. */
+ loseHull(shieldWasUp) {
+ const world = this.world;
+ this.burst(world.player.x + PLAYER_WIDTH / 2, world.player.y, 'red', 26);
+
+ if (shieldWasUp && this.code.shield === 'amplify') {
+ this.shieldFailureSeen = true;
+ world.incident = 'shield';
+ world.phase = 'frozen';
+ this.emit({ type: 'ship-down', cause: 'shield-failure' });
+
+ return;
+ }
+
+ if (this.freeplay) {
+ this.emit({ type: 'ship-down', cause: 'damage' });
+ this.finish();
+
+ return;
+ }
+
+ world.player.restoreAt = world.time + PLAYER_RESTORE_MS;
+ this.emit({ type: 'ship-down', cause: 'damage' });
+ }
+
+ /** Pick up anything the ship overlaps. */
+ collectPickups() {
+ const player = this.world.player;
+ if (player.restoreAt !== null) {
+ return;
+ }
+
+ const collected = this.world.pickups.filter((pickup) => overlaps(pickup, player));
+ if (collected.length === 0) {
+ return;
+ }
+
+ this.world.pickups = this.world.pickups.filter((pickup) => !collected.includes(pickup));
+ for (const pickup of collected) {
+ if (pickup.kind === 'fanout') {
+ player.weapon = 'fanout';
+ } else {
+ player.shield = true;
+ this.probeFireAt = this.world.time + 600;
+ }
+ this.burst(pickup.x + PICKUP_SIZE / 2, pickup.y + PICKUP_SIZE / 2, 'yellow', 10);
+ this.emit({ type: 'pickup-collected', kind: pickup.kind });
+ }
+ }
+
+ /**
+ * End a wave once it is cleared and its incident has happened. If the player cleared the field
+ * without triggering the incident, the pickup comes back so it still can. A repair that is still
+ * being proven holds the next wave back, so the proof is never interrupted.
+ */
+ checkWaveCleared() {
+ const world = this.world;
+ const enemiesLeft = world.enemies.some((enemy) => enemy.alive);
+ const waitingOnVerification = world.verifying !== null;
+ if (
+ world.phase !== 'playing' ||
+ enemiesLeft ||
+ world.pickups.length > 0 ||
+ world.boss !== null ||
+ world.wave === 3 ||
+ waitingOnVerification
+ ) {
+ return;
+ }
+
+ if (world.wave === 1) {
+ if (!this.jamSeen) {
+ if (world.player.weapon !== 'fanout') {
+ this.dropPickup('fanout');
+ }
+
+ return;
+ }
+ this.startWave(2);
+
+ return;
+ }
+
+ if (!this.shieldFailureSeen) {
+ if (!world.player.shield) {
+ this.dropPickup('aegis');
+ }
+
+ return;
+ }
+
+ world.wave = 3;
+ this.startBossSpawn();
+ }
+
+ /** Begin loading the boss's resources under the current code. */
+ startBossSpawn() {
+ const world = this.world;
+ const strategy = this.code.bossLoad;
+ world.phase = 'boss-loading';
+ world.bossLoad = {
+ strategy,
+ startedAt: this.options.now(),
+ tasks: tasksFor(strategy).map((task) => ({ name: task.name, startMs: null, endMs: null })),
+ };
+ this.emit({ type: 'boss-loading', strategy });
+
+ void this.options
+ .spawnBossResources(strategy, (event) => this.noteBossTask(event))
+ .then((result) => this.bossArrived(result));
+ }
+
+ /** Record a load starting or finishing for the overlay. */
+ noteBossTask(event) {
+ const task = this.world.bossLoad?.tasks.find((candidate) => candidate.name === event.name);
+ if (task === undefined) {
+ return;
+ }
+
+ if (event.phase === 'start') {
+ task.startMs = event.atMs;
+ } else {
+ task.endMs = event.atMs;
+ }
+ }
+
+ /** The Monolith, centered at the top with full health. */
+ createBoss() {
+ return {
+ x: (WORLD_WIDTH - BOSS_WIDTH) / 2,
+ y: BOSS_Y,
+ width: BOSS_WIDTH,
+ height: BOSS_HEIGHT,
+ health: BOSS_HEALTH,
+ maxHealth: BOSS_HEALTH,
+ velocityX: 70,
+ nextShotAt: this.world.time + 1_200,
+ };
+ }
+
+ /** The boss's resources are ready: show the boss, and freeze if the spawn stalled. */
+ bossArrived(result) {
+ const world = this.world;
+ const stalled = result.totalMs > BOSS_SPAWN_BUDGET_MS;
+ world.boss = this.createBoss();
+ world.bossLoad = null;
+ this.emit({ type: 'boss-online', result, stalled });
+
+ if (stalled) {
+ world.incident = 'boss';
+ world.phase = 'frozen';
+
+ return;
+ }
+
+ world.phase = 'playing';
+ if (world.verifying === 'boss') {
+ this.passVerification('boss');
+ }
+ }
+
+ /** The repaired behavior was seen working. */
+ passVerification(bug) {
+ this.world.verifying = null;
+ this.emit({ type: 'verification-passed', bug });
+ }
+
+ /** Spawn a short burst of particles. */
+ burst(x, y, color, count = 8) {
+ const scaled = Math.round(count * (this.options.particleScale ?? 1));
+ for (let index = 0; index < scaled; index += 1) {
+ const angle = this.options.random() * Math.PI * 2;
+ const speed = 40 + this.options.random() * 140;
+ this.world.particles.push({
+ x,
+ y,
+ velocityX: Math.cos(angle) * speed,
+ velocityY: Math.sin(angle) * speed,
+ ageMs: 0,
+ lifeMs: 300 + this.options.random() * 300,
+ color,
+ });
+ }
+ }
+
+ /** Age particles and drop the expired ones. */
+ updateParticles(stepMs) {
+ const seconds = stepMs / 1_000;
+ for (const particle of this.world.particles) {
+ particle.ageMs += stepMs;
+ particle.x += particle.velocityX * seconds;
+ particle.y += particle.velocityY * seconds;
+ }
+ this.world.particles = this.world.particles.filter(
+ (particle) => particle.ageMs < particle.lifeMs,
+ );
+ }
+
+ /** Tell every listener about `event`. */
+ emit(event) {
+ for (const listener of this.listeners) {
+ listener(event);
+ }
+ }
+
+ freeplay = false;
+
+ /** Fire a spread volley (plus an aimed shot when enraged) from the boss during freeplay. */
+ spawnBossVolley(centerX, muzzleY, enraged) {
+ const speed = ENEMY_BULLET_SPEED * (enraged ? 1.45 : 1.25);
+ const angles = enraged ? [-0.42, -0.2, 0, 0.2, 0.42] : [-0.32, 0, 0.32];
+ for (const angle of angles) {
+ this.world.bullets.push({
+ x: centerX - BULLET_WIDTH / 2,
+ y: muzzleY,
+ width: BULLET_WIDTH,
+ height: BULLET_HEIGHT,
+ velocityX: Math.sin(angle) * speed,
+ velocityY: Math.cos(angle) * speed,
+ owner: 'enemy',
+ damage: 1,
+ homing: false,
+ });
+ }
+ if (enraged) {
+ this.spawnEnemyBullet(centerX, muzzleY, true);
+ }
+ }
+
+ /** Resume play in the arena after the completion screen is dismissed for a boss showdown. */
+ continuePlaying() {
+ this.freeplay = true;
+ this.jamSeen = true;
+ this.shieldFailureSeen = true;
+ this.world.incident = null;
+ this.world.verifying = null;
+ this.world.paused = false;
+ this.world.phase = 'playing';
+ this.world.wave = 3;
+ this.world.enemies = [];
+ this.world.pickups = [];
+ this.world.bullets = [];
+ this.world.player.hull = PLAYER_MAX_HULL;
+ this.world.player.restoreAt = null;
+ this.world.player.weapon = 'fanout';
+ this.world.player.shield = true;
+ this.world.boss = {
+ ...this.createBoss(),
+ health: BOSS_HEALTH * 3,
+ maxHealth: BOSS_HEALTH * 3,
+ velocityX: 140,
+ nextShotAt: this.world.time + 500,
+ };
+ }
+}
+
+/** The ship, centered at the bottom with a full hull. */
+function createPlayer() {
+ return {
+ x: (WORLD_WIDTH - PLAYER_WIDTH) / 2,
+ y: PLAYER_Y,
+ width: PLAYER_WIDTH,
+ height: PLAYER_HEIGHT,
+ hull: PLAYER_MAX_HULL,
+ shield: false,
+ weapon: 'single',
+ invulnerableUntil: 0,
+ restoreAt: null,
+ lastShotAt: -Infinity,
+ };
+}
diff --git a/challenges/glitch-invaders/game/src/engine/geometry.mjs b/challenges/glitch-invaders/game/src/engine/geometry.mjs
new file mode 100644
index 0000000..0c1ed1e
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/engine/geometry.mjs
@@ -0,0 +1,16 @@
+/** Axis-aligned rectangles and the overlap test the engine uses for every hit. */
+
+/** Whether two boxes share any area. Boxes that only touch do not overlap. */
+export function overlaps(first, second) {
+ return (
+ first.x < second.x + second.width &&
+ first.x + first.width > second.x &&
+ first.y < second.y + second.height &&
+ first.y + first.height > second.y
+ );
+}
+
+/** Keep `value` within `[min, max]`. */
+export function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
diff --git a/challenges/glitch-invaders/game/src/engine/random.mjs b/challenges/glitch-invaders/game/src/engine/random.mjs
new file mode 100644
index 0000000..fc06656
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/engine/random.mjs
@@ -0,0 +1,14 @@
+/** A small seeded random source so enemy fire is reproducible in tests and replays. */
+
+/** The mulberry32 generator: tiny, fast, and good enough for gameplay variation. */
+export function createRandom(seed) {
+ let state = seed >>> 0;
+
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let mixed = Math.imul(state ^ (state >>> 15), 1 | state);
+ mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed;
+
+ return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
+ };
+}
diff --git a/challenges/glitch-invaders/game/src/engine/world.mjs b/challenges/glitch-invaders/game/src/engine/world.mjs
new file mode 100644
index 0000000..b083e3c
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/engine/world.mjs
@@ -0,0 +1,4 @@
+/** An input state with nothing pressed. */
+export function idleInput() {
+ return { left: false, right: false, fire: false };
+}
diff --git a/challenges/glitch-invaders/game/src/main.mjs b/challenges/glitch-invaders/game/src/main.mjs
new file mode 100644
index 0000000..32e19eb
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/main.mjs
@@ -0,0 +1,66 @@
+/** The page entry: read this attempt's configuration and saved progress, and start the game. */
+import { parseRuntimeConfig } from '../../shared/runtime-config.mjs';
+import { GameApp } from './app/app.mjs';
+import { HttpProgressStore } from './session/progress-store.mjs';
+
+/** Read `/runtime-config.json`, which the page server writes for every attempt. */
+async function loadConfig() {
+ const response = await fetch('/runtime-config.json', { cache: 'no-store' });
+ if (!response.ok) {
+ throw new Error(`the runtime config failed to load with status ${response.status}`);
+ }
+
+ return parseRuntimeConfig(await response.json());
+}
+
+/** Show a plain message when the game cannot start at all. */
+function showFatal(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ document.body.replaceChildren(
+ Object.assign(document.createElement('p'), {
+ className: 'fatal',
+ textContent: `Glitch Invaders could not start: ${message}. Reload this page.`,
+ }),
+ );
+}
+
+/** Keep the failed save visible: the state line would otherwise keep advancing past a stale file. */
+function showSaveError() {
+ const banner = document.getElementById('save-error');
+ if (banner) {
+ banner.hidden = false;
+ }
+}
+
+/** Start the game. */
+async function main() {
+ const config = await loadConfig();
+ const query = new URLSearchParams(window.location.search);
+ const seed = Number(query.get('seed'));
+ const store = new HttpProgressStore(
+ '/api/progress',
+ (input, init) => fetch(input, init),
+ showSaveError,
+ );
+
+ let saved = null;
+ try {
+ saved = await store.load(config.attemptId);
+ } catch {
+ saved = null;
+ }
+
+ const app = new GameApp(
+ {
+ config,
+ store,
+ reload: () => window.location.reload(),
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ ...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}),
+ },
+ saved,
+ );
+ app.begin();
+}
+
+main().catch(showFatal);
diff --git a/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs b/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs
new file mode 100644
index 0000000..669c571
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/render/canvas-renderer.mjs
@@ -0,0 +1,284 @@
+/** Draws the arena onto a canvas. It only reads the world; it never changes it. */
+import { PLAYER_HEIGHT, PLAYER_Y, WORLD_HEIGHT, WORLD_WIDTH } from '../engine/constants.mjs';
+
+import { PALETTE } from './palette.mjs';
+import { DRONE_FRAMES, MONOLITH, SHIP } from './sprites.mjs';
+
+/** The size of one sprite pixel, in world pixels. */
+const DRONE_SCALE = 3;
+const SHIP_SCALE = 3;
+const MONOLITH_SCALE = 6;
+
+/** Particle colors by name. */
+const PARTICLE_COLORS = {
+ red: PALETTE.danger,
+ blue: PALETTE.accent,
+ yellow: PALETTE.focus,
+ green: PALETTE.success,
+ white: PALETTE.fg,
+};
+
+/** Fixed star positions, so the backdrop never shifts between frames. */
+const STARS = Array.from({ length: 46 }, (_, index) => ({
+ x: (index * 97 + 31) % WORLD_WIDTH,
+ y: (index * 53 + 17) % (PLAYER_Y - 20),
+ phase: index * 0.7,
+}));
+
+/** The canvas renderer. */
+export class CanvasRenderer {
+ context;
+ options;
+
+ constructor(canvas, options) {
+ const context = canvas.getContext('2d');
+ if (context === null) {
+ throw new Error('this browser cannot draw the game: no 2D canvas context');
+ }
+
+ const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1));
+ canvas.width = WORLD_WIDTH * scale;
+ canvas.height = WORLD_HEIGHT * scale;
+ context.scale(scale, scale);
+ context.imageSmoothingEnabled = false;
+ this.context = context;
+ this.options = options;
+ }
+
+ /** Draw one frame. */
+ draw(world, nowMs) {
+ this.drawBackdrop(nowMs);
+ for (const pickup of world.pickups) {
+ this.drawPickup(pickup, nowMs);
+ }
+ for (const enemy of world.enemies) {
+ if (enemy.alive) {
+ this.drawEnemy(enemy, nowMs);
+ }
+ }
+ if (world.boss !== null) {
+ this.drawBoss(world, nowMs);
+ }
+ this.drawBullets(world);
+ this.drawPlayer(world, nowMs);
+ this.drawParticles(world.particles);
+ }
+
+ /** The dark grid, a few stars, and the line the ship defends. */
+ drawBackdrop(nowMs) {
+ const context = this.context;
+ context.fillStyle = PALETTE.bg;
+ context.fillRect(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
+
+ context.strokeStyle = 'rgba(95, 101, 112, 0.22)';
+ context.lineWidth = 1;
+ context.beginPath();
+ for (let x = 0; x <= WORLD_WIDTH; x += 40) {
+ context.moveTo(x + 0.5, 0);
+ context.lineTo(x + 0.5, WORLD_HEIGHT);
+ }
+ for (let y = 0; y <= WORLD_HEIGHT; y += 40) {
+ context.moveTo(0, y + 0.5);
+ context.lineTo(WORLD_WIDTH, y + 0.5);
+ }
+ context.stroke();
+
+ for (const star of STARS) {
+ const twinkle = this.options.reducedMotion
+ ? 0.6
+ : 0.45 + 0.35 * Math.sin(nowMs / 700 + star.phase);
+ context.fillStyle = `rgba(232, 234, 237, ${twinkle})`;
+ context.fillRect(star.x, star.y, 2, 2);
+ }
+
+ const floorY = PLAYER_Y + PLAYER_HEIGHT + 10;
+ context.strokeStyle = PALETTE.border;
+ context.setLineDash([8, 6]);
+ context.beginPath();
+ context.moveTo(0, floorY + 0.5);
+ context.lineTo(WORLD_WIDTH, floorY + 0.5);
+ context.stroke();
+ context.setLineDash([]);
+ context.fillStyle = PALETTE.muted;
+ context.font = '12px ui-monospace, Menlo, monospace';
+ context.fillText('PROD', 10, floorY + 16);
+ }
+
+ /** Draw `sprite` at `(x, y)`, lit pixels in `color` and highlights in `highlight`. */
+ drawSprite(sprite, x, y, scale, color, highlight) {
+ const context = this.context;
+ sprite.forEach((row, rowIndex) => {
+ for (let column = 0; column < row.length; column += 1) {
+ const pixel = row[column];
+ if (pixel === '#' || pixel === 'o') {
+ context.fillStyle = pixel === 'o' ? highlight : color;
+ context.fillRect(x + column * scale, y + rowIndex * scale, scale, scale);
+ }
+ }
+ });
+ }
+
+ /** A drone, with a marker above carriers: a gold diamond for the weapon, a blue shield for the aegis. */
+ drawEnemy(enemy, nowMs) {
+ const frame = DRONE_FRAMES[Math.floor(nowMs / 420) % DRONE_FRAMES.length] ?? DRONE_FRAMES[0];
+ if (frame === undefined) {
+ return;
+ }
+
+ const color = enemy.row === 1 ? PALETTE.warning : PALETTE.danger;
+ const spriteWidth = (frame[0]?.length ?? 0) * DRONE_SCALE;
+ const left = enemy.x + (enemy.width - spriteWidth) / 2;
+ this.drawSprite(frame, left, enemy.y, DRONE_SCALE, color, PALETTE.bg);
+
+ const centerX = enemy.x + enemy.width / 2;
+ const pulse = this.options.reducedMotion ? 0 : Math.sin(nowMs / 250) * 1.5;
+ if (enemy.kind === 'weapon-carrier') {
+ this.drawDiamond(centerX, enemy.y - 9 - pulse, 7, PALETTE.focus);
+ } else if (enemy.kind === 'shield-carrier') {
+ this.drawShieldIcon(centerX, enemy.y - 10 - pulse, 8, PALETTE.accent);
+ }
+ }
+
+ /** A filled diamond centered at `(x, y)`. */
+ drawDiamond(x, y, radius, color) {
+ const context = this.context;
+ context.fillStyle = color;
+ context.beginPath();
+ context.moveTo(x, y - radius);
+ context.lineTo(x + radius, y);
+ context.lineTo(x, y + radius);
+ context.lineTo(x - radius, y);
+ context.closePath();
+ context.fill();
+ }
+
+ /** A shield outline centered at `(x, y)`. */
+ drawShieldIcon(x, y, size, color) {
+ const context = this.context;
+ context.fillStyle = color;
+ context.beginPath();
+ context.moveTo(x - size, y - size);
+ context.lineTo(x + size, y - size);
+ context.lineTo(x + size, y);
+ context.lineTo(x, y + size);
+ context.lineTo(x - size, y);
+ context.closePath();
+ context.fill();
+ context.fillStyle = PALETTE.bg;
+ context.fillRect(x - 2, y - size + 3, 4, 7);
+ }
+
+ /** A falling pickup: a gold chip with a fan of three lines, or a blue shield. */
+ drawPickup(pickup, nowMs) {
+ const context = this.context;
+ const centerX = pickup.x + pickup.width / 2;
+ const centerY = pickup.y + pickup.height / 2;
+ const glow = this.options.reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(nowMs / 200);
+
+ if (pickup.kind === 'fanout') {
+ context.fillStyle = `rgba(248, 185, 42, ${0.18 + glow * 0.18})`;
+ context.fillRect(pickup.x - 5, pickup.y - 5, pickup.width + 10, pickup.height + 10);
+ context.fillStyle = PALETTE.focus;
+ context.fillRect(pickup.x, pickup.y, pickup.width, pickup.height);
+ context.strokeStyle = PALETTE.bg;
+ context.lineWidth = 3;
+ context.beginPath();
+ for (const dx of [-8, 0, 8]) {
+ context.moveTo(centerX, centerY + 7);
+ context.lineTo(centerX + dx, centerY - 7);
+ }
+ context.stroke();
+
+ return;
+ }
+
+ context.fillStyle = `rgba(92, 156, 245, ${0.18 + glow * 0.18})`;
+ context.fillRect(pickup.x - 5, pickup.y - 5, pickup.width + 10, pickup.height + 10);
+ this.drawShieldIcon(centerX, centerY - 2, 13, PALETTE.accent);
+ }
+
+ /** The Monolith, with its health bar. */
+ drawBoss(world, nowMs) {
+ const boss = world.boss;
+ if (boss === null) {
+ return;
+ }
+
+ const rage = this.options.reducedMotion ? 0 : Math.sin(nowMs / 160) * 0.5 + 0.5;
+ this.drawSprite(
+ MONOLITH,
+ boss.x,
+ boss.y,
+ MONOLITH_SCALE,
+ PALETTE.mlhRed,
+ rage > 0.5 ? PALETTE.focus : PALETTE.fg,
+ );
+
+ const context = this.context;
+ const barWidth = boss.width;
+ context.fillStyle = PALETTE.surface;
+ context.fillRect(boss.x, boss.y - 12, barWidth, 6);
+ context.fillStyle = PALETTE.danger;
+ context.fillRect(boss.x, boss.y - 12, barWidth * Math.max(0, boss.health / boss.maxHealth), 6);
+ }
+
+ /** Every bullet: blue for the ship, gold for fan-out volleys, orange for aimed probes, red otherwise. */
+ drawBullets(world) {
+ const context = this.context;
+ for (const bullet of world.bullets) {
+ if (bullet.owner === 'player') {
+ context.fillStyle = world.player.weapon === 'fanout' ? PALETTE.focus : PALETTE.accent;
+ } else {
+ context.fillStyle = bullet.homing ? PALETTE.warning : PALETTE.danger;
+ }
+ context.fillRect(bullet.x, bullet.y, bullet.width, bullet.height);
+ }
+ }
+
+ /** The ship, its shield bubble, or its wreck. */
+ drawPlayer(world, nowMs) {
+ const context = this.context;
+ const player = world.player;
+ const centerX = player.x + player.width / 2;
+ const centerY = player.y + player.height / 2;
+
+ if (player.hull <= 0) {
+ context.strokeStyle = PALETTE.danger;
+ context.lineWidth = 4;
+ context.beginPath();
+ context.moveTo(centerX - 16, centerY - 12);
+ context.lineTo(centerX + 16, centerY + 12);
+ context.moveTo(centerX + 16, centerY - 12);
+ context.lineTo(centerX - 16, centerY + 12);
+ context.stroke();
+
+ return;
+ }
+
+ const flickering = world.time < player.invulnerableUntil && !this.options.reducedMotion;
+ context.globalAlpha = flickering && Math.floor(nowMs / 90) % 2 === 0 ? 0.35 : 1;
+ this.drawSprite(SHIP, player.x, player.y, SHIP_SCALE, PALETTE.accent, PALETTE.fg);
+ context.globalAlpha = 1;
+
+ if (player.shield) {
+ context.strokeStyle = PALETTE.accent;
+ context.fillStyle = 'rgba(92, 156, 245, 0.16)';
+ context.lineWidth = 3;
+ context.beginPath();
+ context.arc(centerX, centerY, 30, 0, Math.PI * 2);
+ context.fill();
+ context.stroke();
+ }
+ }
+
+ /** The decorative bursts. */
+ drawParticles(particles) {
+ const context = this.context;
+ for (const particle of particles) {
+ context.globalAlpha = Math.max(0, 1 - particle.ageMs / particle.lifeMs);
+ context.fillStyle = PARTICLE_COLORS[particle.color];
+ context.fillRect(particle.x, particle.y, 4, 4);
+ }
+ context.globalAlpha = 1;
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/render/palette.mjs b/challenges/glitch-invaders/game/src/render/palette.mjs
new file mode 100644
index 0000000..2ce95f9
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/render/palette.mjs
@@ -0,0 +1,19 @@
+/** The colors shared by the canvas and the page's CSS, taken from Boothstrap's terminal theme. */
+
+/** Boothstrap's on-screen palette. MLH red, blue, and yellow are used sparingly. */
+export const PALETTE = {
+ bg: '#15171c',
+ surface: '#252932',
+ fg: '#e8eaed',
+ border: '#5f6570',
+ muted: '#9ba1ab',
+ disabled: '#6b717c',
+ accent: '#5c9cf5',
+ focus: '#f8b92a',
+ success: '#6cc76c',
+ warning: '#ff9d5c',
+ danger: '#ff6b61',
+ mlhRed: '#e73427',
+ mlhBlue: '#4a86d9',
+ mlhYellow: '#f8b92a',
+};
diff --git a/challenges/glitch-invaders/game/src/render/sprites.mjs b/challenges/glitch-invaders/game/src/render/sprites.mjs
new file mode 100644
index 0000000..4632918
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/render/sprites.mjs
@@ -0,0 +1,51 @@
+/** Pixel-art bitmaps for everything on the canvas. `#` is a lit pixel, `o` the highlight color. */
+
+/** The glitch drone, two frames so it scuttles. */
+export const DRONE_FRAMES = [
+ [
+ '..#.....#..',
+ '...#...#...',
+ '..#######..',
+ '.##o###o##.',
+ '###########',
+ '#.#######.#',
+ '#.#.....#.#',
+ '...##.##...',
+ ],
+ [
+ '..#.....#..',
+ '#..#...#..#',
+ '#.#######.#',
+ '###o###o###',
+ '###########',
+ '..#######..',
+ '..#.....#..',
+ '.#.......#.',
+ ],
+];
+
+/** The patch drone, drawn pointing up. */
+export const SHIP = [
+ '......#......',
+ '.....###.....',
+ '.....#o#.....',
+ '..#########..',
+ '.###########.',
+ '#############',
+ '##.#######.##',
+ '#..#.....#..#',
+];
+
+/** The Monolith. */
+export const MONOLITH = [
+ '...##############...',
+ '..################..',
+ '.##################.',
+ '####oo########oo####',
+ '####oo########oo####',
+ '####################',
+ '.##..##########..##.',
+ '..##..########..##..',
+ '...##..######..##...',
+ '....###......###....',
+];
diff --git a/challenges/glitch-invaders/game/src/session/monitor-state.mjs b/challenges/glitch-invaders/game/src/session/monitor-state.mjs
new file mode 100644
index 0000000..f001a85
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/session/monitor-state.mjs
@@ -0,0 +1,21 @@
+/**
+ * The state surface for Boothstrap's browser monitor. The monitor can only read the page's
+ * accessibility tree, so the state is a fixed-word line of text on the page.
+ */
+import { monitorStateLine } from '../../../shared/progress.mjs';
+
+/** The id of the element holding the state line. */
+export const STATE_ELEMENT_ID = 'boothstrap-state';
+
+/** Write `progress` to the marker element. */
+export function publishState(document, progress) {
+ const element = document.getElementById(STATE_ELEMENT_ID);
+ if (element === null) {
+ throw new Error(`the page has no #${STATE_ELEMENT_ID} element`);
+ }
+
+ const line = monitorStateLine(progress);
+ if (element.textContent !== line) {
+ element.textContent = line;
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/session/progress-store.mjs b/challenges/glitch-invaders/game/src/session/progress-store.mjs
new file mode 100644
index 0000000..7e479f8
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/session/progress-store.mjs
@@ -0,0 +1,64 @@
+/** Where a run's progress is kept so a refresh or an agent restart can pick up where it left off. */
+import { parseProgress } from '../../../shared/progress.mjs';
+
+/** Keeps progress on the page server, which writes it into the participant's workspace. */
+export class HttpProgressStore {
+ endpoint;
+ fetchImpl;
+ onError;
+ pending = Promise.resolve();
+
+ constructor(endpoint, fetchImpl, onError) {
+ this.endpoint = endpoint;
+ this.fetchImpl = fetchImpl;
+ this.onError = onError;
+ }
+
+ async load(attemptId) {
+ const response = await this.fetchImpl(this.endpoint);
+ if (response.status === 204 || response.status === 404) {
+ return null;
+ }
+ if (!response.ok) {
+ throw new Error(`progress load failed with status ${response.status}`);
+ }
+
+ const progress = parseProgress(await response.json());
+
+ return progress.attemptId === attemptId ? progress : null;
+ }
+
+ /** Queue the write behind earlier ones, so the last state always wins. */
+ save(progress) {
+ const body = JSON.stringify(progress);
+ this.pending = this.pending.then(async () => {
+ try {
+ const response = await this.fetchImpl(this.endpoint, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body,
+ });
+ if (!response.ok) {
+ throw new Error(`progress save failed with status ${response.status}`);
+ }
+ } catch (error) {
+ this.onError(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+
+ return this.pending;
+ }
+
+ /** Delete the stored progress once earlier writes have landed. */
+ clear() {
+ this.pending = this.pending.then(async () => {
+ try {
+ await this.fetchImpl(this.endpoint, { method: 'DELETE' });
+ } catch (error) {
+ this.onError(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+
+ return this.pending;
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/shield/shield.mjs b/challenges/glitch-invaders/game/src/shield/shield.mjs
new file mode 100644
index 0000000..690b771
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/shield/shield.mjs
@@ -0,0 +1,25 @@
+/** Shield damage rules, with the amplification bug that is the second intentional failure. */
+import { SHIELD_BUG_MULTIPLIER } from '../engine/constants.mjs';
+
+/**
+ * Apply an enemy hit of `incomingDamage`.
+ *
+ * INTENTIONAL BUG: while `behavior` is `amplify`, an active shield multiplies the damage instead of
+ * absorbing it.
+ */
+export function resolveShieldHit(behavior, shieldActive, incomingDamage) {
+ if (!shieldActive) {
+ return { actualDamage: incomingDamage, shieldConsumed: false };
+ }
+
+ switch (behavior) {
+ case 'amplify':
+ return { actualDamage: incomingDamage * SHIELD_BUG_MULTIPLIER, shieldConsumed: true };
+ case 'absorb':
+ return { actualDamage: 0, shieldConsumed: true };
+ case 'pass-through':
+ return { actualDamage: incomingDamage, shieldConsumed: false };
+ case 'drop-then-pass':
+ return { actualDamage: incomingDamage, shieldConsumed: true };
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css
new file mode 100644
index 0000000..c4572fa
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/styles.css
@@ -0,0 +1,1089 @@
+/* Glitch Invaders: On-Call. Boothstrap's TUI palette on the web: dark surfaces, titles set into
+ borders, a heavy yellow focus ring, and MLH colors used sparingly. */
+
+:root {
+ --bg: #15171c;
+ --surface: #252932;
+ --surface-2: #1d2027;
+ --fg: #e8eaed;
+ --border: #5f6570;
+ --muted: #9ba1ab;
+ --accent: #5c9cf5;
+ --focus: #f8b92a;
+ --success: #6cc76c;
+ --warning: #ff9d5c;
+ --danger: #ff6b61;
+ --mlh-red: #e73427;
+ --mlh-blue: #4a86d9;
+ --mlh-yellow: #f8b92a;
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html,
+body {
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--fg);
+ font: 16px / 1.4 var(--mono);
+}
+
+h1,
+h2,
+h3,
+p,
+ol,
+ul,
+dl {
+ margin: 0;
+}
+
+ol,
+ul {
+ padding: 0;
+ list-style: none;
+}
+
+:focus-visible {
+ outline: 4px solid var(--focus);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.save-error {
+ padding: 6px 16px;
+ border-top: 2px solid var(--danger);
+ background: var(--surface-2);
+ color: var(--danger);
+ font-weight: 700;
+}
+
+.fatal {
+ padding: 32px;
+ font-size: 20px;
+ color: var(--danger);
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+}
+
+.app {
+ position: relative;
+ display: grid;
+ flex: 1 1 auto;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ min-height: 0;
+}
+
+.topbar {
+ position: relative;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 8px 16px;
+ border-bottom: 2px solid var(--border);
+ background: var(--surface-2);
+}
+
+.brand {
+ font-size: 18px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.brand-mark {
+ color: var(--mlh-red);
+}
+
+.brand-sub {
+ color: var(--mlh-yellow);
+}
+
+.stats {
+ display: flex;
+ flex: 1;
+ gap: 18px;
+ color: var(--muted);
+ font-size: 13px;
+ letter-spacing: 0.04em;
+}
+
+.stats b {
+ margin-left: 4px;
+ color: var(--fg);
+ font-size: 17px;
+}
+
+.stats b[data-state="on"] {
+ color: var(--accent);
+}
+
+.badge {
+ padding: 2px 10px;
+ border: 2px solid var(--mlh-yellow);
+ color: var(--mlh-yellow);
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.topbar-reset {
+ padding: 3px 10px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--muted);
+ font: 700 12px var(--mono);
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.topbar-reset:hover {
+ border-color: var(--fg);
+ color: var(--fg);
+}
+
+.stage-wrap {
+ display: grid;
+ place-items: center;
+ min-height: 0;
+ padding: 8px 12px;
+ container-type: size;
+}
+
+.stage {
+ position: relative;
+ width: min(100cqw, 160cqh);
+ aspect-ratio: 8 / 5;
+ border: 2px solid var(--border);
+ background: var(--bg);
+}
+
+.stage:focus-visible {
+ outline-offset: 3px;
+}
+
+.arena {
+ display: block;
+ width: 100%;
+ height: 100%;
+ image-rendering: pixelated;
+}
+
+.banner {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ left: 10px;
+ padding: 10px 14px;
+ border: 2px solid var(--accent);
+ background: var(--surface);
+ font-size: 16px;
+}
+
+.banner[data-kind="success"] {
+ border-color: var(--success);
+}
+
+.boss-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: min(540px, 92%);
+ padding: 16px 18px;
+ transform: translate(-50%, -50%);
+ border: 2px solid var(--warning);
+ background: var(--surface);
+}
+
+.load-head {
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.load-clock {
+ margin-left: 8px;
+ color: var(--mlh-yellow);
+}
+
+.load-note {
+ display: block;
+ margin: 2px 0 10px;
+ color: var(--muted);
+}
+
+.load-note[data-over="true"] {
+ color: var(--warning);
+ font-weight: 700;
+}
+
+.load-task {
+ display: grid;
+ grid-template-columns: 130px 1fr 80px;
+ gap: 10px;
+ align-items: center;
+ margin: 6px 0;
+ font-size: 14px;
+}
+
+.load-bar {
+ height: 14px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.load-fill {
+ display: block;
+ width: 0;
+ height: 100%;
+ background: var(--accent);
+}
+
+.load-status {
+ color: var(--muted);
+}
+
+.dock {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ padding: 0 12px 12px;
+}
+
+.card {
+ position: relative;
+ padding: 20px 14px 10px;
+ border: 2px solid var(--border);
+ background: var(--surface);
+}
+
+.card-title {
+ position: absolute;
+ top: -0.8em;
+ left: 12px;
+ padding: 0 6px;
+ background: var(--bg);
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.card-title-danger {
+ color: var(--danger);
+}
+
+.objective {
+ border-color: var(--focus);
+}
+
+.objective-text {
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.objective-text::before {
+ content: "▶ ";
+ color: var(--focus);
+}
+
+.objective-detail {
+ min-height: 1.4em;
+ color: var(--muted);
+ font-size: 15px;
+}
+
+.incidents {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.chip {
+ padding: 2px 8px;
+ border: 2px solid var(--border);
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.chip-word {
+ font-weight: 700;
+}
+
+.chip[data-stage="encountered"] {
+ border-color: var(--warning);
+ color: var(--warning);
+}
+
+.chip[data-stage="investigated"] {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.chip[data-stage="repaired"] {
+ border-color: var(--success);
+ color: var(--success);
+}
+
+.controls {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ align-items: stretch;
+}
+
+.pads {
+ display: flex;
+ gap: 8px;
+}
+
+.pad {
+ min-width: 56px;
+ min-height: 52px;
+ border: 2px solid var(--border);
+ background: var(--surface-2);
+ color: var(--fg);
+ font: 700 20px var(--mono);
+ cursor: pointer;
+ touch-action: none;
+ user-select: none;
+}
+
+.pad:active {
+ border-color: var(--focus);
+ background: var(--bg);
+}
+
+.pad-fire {
+ min-width: 100px;
+ border-color: var(--mlh-red);
+ background: var(--mlh-red);
+ color: #fff;
+}
+
+.pad-fire:active {
+ background: #b32419;
+}
+
+.control-links {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.link-button {
+ padding: 2px 6px;
+ border: 0;
+ background: none;
+ color: var(--accent);
+ font: 14px var(--mono);
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.link-button-muted {
+ color: var(--muted);
+}
+
+.link-button-muted:hover {
+ color: var(--fg);
+}
+
+.card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.overlay-root {
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ display: grid;
+ place-items: center;
+ padding: 16px;
+ background: rgb(21 23 28 / 0.88);
+}
+
+.overlay-card {
+ width: min(580px, 100%);
+ max-height: 100%;
+ padding: 28px 24px 20px;
+ overflow: visible;
+ border-color: var(--accent);
+}
+
+.overlay-card > * + * {
+ margin-top: 14px;
+}
+
+.hero {
+ font-size: 30px;
+ font-weight: 700;
+}
+
+.hero em {
+ color: var(--mlh-yellow);
+ font-style: normal;
+}
+
+.lede {
+ font-size: 18px;
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.keys {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+ font-size: 16px;
+}
+
+kbd {
+ padding: 1px 8px;
+ border: 2px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ font: 700 15px var(--mono);
+}
+
+.button {
+ min-height: 46px;
+ padding: 8px 16px;
+ border: 2px solid var(--border);
+ background: var(--surface-2);
+ color: var(--fg);
+ font: 700 16px var(--mono);
+ cursor: pointer;
+}
+
+.button:hover:not(:disabled) {
+ border-color: var(--fg);
+}
+
+.button:disabled {
+ border-style: dashed;
+ color: var(--muted);
+ cursor: default;
+}
+
+.button-primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--bg);
+}
+
+.button-large {
+ min-height: 56px;
+ padding: 12px 30px;
+ font-size: 20px;
+}
+
+.recap > li {
+ padding: 8px 0;
+ border-top: 1px solid var(--border);
+}
+
+.recap-text {
+ display: block;
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.lens {
+ padding: 2px 8px;
+ border: 1px solid var(--mlh-yellow);
+ border-radius: 4px;
+ color: var(--mlh-yellow);
+ font: 700 12px var(--sans);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.console-host {
+ display: contents;
+}
+
+.console {
+ container-type: inline-size;
+ display: flex;
+ flex-direction: column;
+ width: min(820px, 100%);
+ max-height: 100%;
+ padding: 16px 20px;
+ border-color: var(--danger);
+ border-radius: 8px;
+ font: 15px / 1.45 var(--sans);
+}
+
+.console-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding-bottom: 12px;
+ border-bottom: 2px solid var(--surface);
+}
+
+.console-heading {
+ flex: 1 1 340px;
+}
+
+.console-badges {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.incident-pill {
+ padding: 2px 8px;
+ border-radius: 4px;
+ background: var(--mlh-red);
+ color: #ffffff;
+ font: 700 12px var(--sans);
+ letter-spacing: 0.03em;
+}
+
+.console-title {
+ margin-top: 6px;
+ font: 700 20px / 1.25 var(--sans);
+ color: #ffffff;
+}
+
+.console-symptom {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+}
+
+.tab {
+ padding: 6px 12px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ font: 600 13px var(--sans);
+ cursor: pointer;
+}
+
+.tab[aria-selected="true"] {
+ background: var(--focus);
+ color: var(--bg);
+ font-weight: 700;
+}
+
+.tab[aria-disabled="true"] {
+ opacity: 0.55;
+ cursor: default;
+}
+
+.pane {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ padding: 12px 2px 2px;
+ overflow: auto;
+}
+
+.pane > * + *,
+.evidence > * + *,
+.diagnose > * + *,
+.patches > * + * {
+ margin-top: 10px;
+}
+
+.status {
+ color: var(--muted);
+}
+
+.spinner {
+ display: inline-block;
+ animation: spin 1.2s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.evidence-head {
+ font: 700 15px / 1.3 var(--mono);
+ color: #ffffff;
+}
+
+.source-badge {
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ font: 700 12px var(--mono);
+}
+
+.source-badge[data-source="simulated"] {
+ border-color: var(--mlh-yellow);
+ color: var(--mlh-yellow);
+}
+
+.sentry-callout {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px;
+ border: 2px solid var(--mlh-blue);
+ border-radius: 8px;
+ background: var(--bg);
+}
+
+@container (min-width: 540px) {
+ .sentry-callout {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+
+.sentry-callout-main {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.sentry-callout-copy {
+ color: var(--muted);
+ font: 13px / 1.35 var(--sans);
+}
+
+.context-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.context-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ font: 12.5px var(--mono);
+}
+
+.context-pill dt {
+ color: var(--muted);
+}
+
+.context-pill dd {
+ margin: 0;
+ color: var(--focus);
+ font-weight: 700;
+}
+
+.panel {
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+ font: 13px / 1.4 var(--mono);
+}
+
+.error-type {
+ color: var(--danger);
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.error-message {
+ margin-top: 4px;
+}
+
+.frame {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 8px 16px;
+ padding: 5px 8px;
+ border-bottom: 1px solid var(--surface);
+}
+
+.frame-fn {
+ font-weight: 700;
+}
+
+.frame-loc {
+ color: var(--muted);
+ text-align: right;
+}
+
+.frame[data-in-app="false"] {
+ color: var(--muted);
+}
+
+.frame[data-culprit="true"] {
+ border-left: 4px solid var(--mlh-red);
+ border-radius: 4px;
+ background: var(--surface-2);
+}
+
+.frame[data-culprit="true"] .frame-loc {
+ color: var(--focus);
+ font-weight: 700;
+}
+
+.crumb {
+ display: grid;
+ grid-template-columns: 62px 18px 105px 1fr;
+ gap: 6px 8px;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--surface);
+}
+
+.crumb-time {
+ white-space: nowrap;
+}
+
+.crumb-time,
+.crumb-category,
+.crumb-data {
+ color: var(--muted);
+}
+
+.crumb-data {
+ grid-column: 3 / -1;
+ font-size: 12px;
+}
+
+.crumb[data-level="warning"] {
+ border-left: 3px solid var(--warning);
+ background: var(--surface-2);
+}
+
+.crumb[data-level="warning"] .crumb-level,
+.crumb[data-level="warning"] .crumb-message {
+ color: var(--warning);
+ font-weight: 700;
+}
+
+.crumb[data-level="error"] {
+ border-left: 3px solid var(--danger);
+ background: var(--surface-2);
+}
+
+.crumb[data-level="error"] .crumb-level,
+.crumb[data-level="error"] .crumb-message {
+ color: var(--danger);
+ font-weight: 700;
+}
+
+.waterfall-axis {
+ display: flex;
+ justify-content: space-between;
+ color: var(--muted);
+ font-size: 12.5px;
+}
+
+.span-row {
+ display: grid;
+ grid-template-columns: 125px 1fr 118px;
+ gap: 8px;
+ align-items: center;
+ margin: 5px 0;
+ padding: 3px 6px;
+ border-radius: 4px;
+}
+
+.span-track {
+ position: relative;
+ height: 16px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--surface-2);
+}
+
+.span-bar {
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ border-radius: 2px;
+ background: var(--accent);
+}
+
+.span-budget {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 2px;
+ background: var(--danger);
+}
+
+.span-time {
+ color: var(--muted);
+ font-size: 12px;
+ text-align: right;
+}
+
+.span-row[data-culprit="true"] {
+ border: 1px solid var(--danger);
+ background: var(--surface-2);
+}
+
+.span-row[data-culprit="true"] .span-name,
+.span-row[data-culprit="true"] .span-time {
+ color: var(--danger);
+ font-weight: 700;
+}
+
+.span-row[data-culprit="true"] .span-bar {
+ background: var(--danger);
+}
+
+.pane-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+ margin-top: auto;
+ padding-top: 6px;
+}
+
+.spacer {
+ flex: 1;
+}
+
+.text-link {
+ color: var(--accent);
+}
+
+.question {
+ font: 700 16px / 1.35 var(--sans);
+ color: #ffffff;
+}
+
+.options {
+ display: grid;
+ gap: 8px;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+ color: var(--fg);
+ font: 14.5px / 1.35 var(--sans);
+ text-align: left;
+ cursor: pointer;
+}
+
+.option:hover:not(:disabled) {
+ border-color: var(--focus);
+}
+
+.option-letter {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ font: 700 12px var(--sans);
+}
+
+.option[data-correct="true"] {
+ border-color: var(--success);
+ color: #ffffff;
+ font-weight: 600;
+}
+
+.option[data-correct="true"] .option-letter {
+ border-color: var(--success);
+ background: var(--success);
+ color: var(--bg);
+}
+
+.feedback {
+ padding: 8px 12px;
+ border: 2px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ font: 600 13.5px / 1.35 var(--sans);
+}
+
+.feedback-danger {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+.feedback-success {
+ border-color: var(--success);
+ color: var(--success);
+}
+
+.hints {
+ padding-left: 0;
+ counter-reset: hint;
+}
+
+.hints li {
+ margin: 4px 0;
+ padding: 6px 10px;
+ border-left: 4px solid var(--mlh-yellow);
+ border-radius: 4px;
+ background: var(--bg);
+ font: 13.5px / 1.35 var(--sans);
+ counter-increment: hint;
+}
+
+.hints li::before {
+ content: "Hint " counter(hint) ": ";
+ color: var(--mlh-yellow);
+ font-weight: 700;
+}
+
+.patch-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+}
+
+@container (min-width: 620px) {
+ .patch-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+.patch {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 12px;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+}
+
+.patch:hover {
+ border-color: var(--focus);
+}
+
+.patch[data-failed="true"] {
+ border-color: var(--danger);
+}
+
+.patch-body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.patch-title {
+ font: 700 14.5px / 1.25 var(--sans);
+ color: #ffffff;
+}
+
+.patch-description {
+ color: var(--muted);
+ font: 12.5px / 1.35 var(--sans);
+}
+
+.patch .button {
+ width: 100%;
+ border-radius: 6px;
+ font: 700 13px var(--sans);
+}
+
+.code {
+ margin: 4px 0 0;
+ padding: 6px 8px;
+ overflow: auto;
+ border: 1px solid var(--surface);
+ border-radius: 4px;
+ background: var(--surface-2);
+ font: 12px / 1.35 var(--mono);
+}
+
+@media (max-width: 720px) {
+ .dock {
+ grid-template-columns: 1fr;
+ }
+
+ .controls {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .stats {
+ gap: 10px;
+ }
+
+ .badge {
+ display: none;
+ }
+}
+
+@media (max-height: 620px) {
+ .objective-detail {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/ui/boss-loading.mjs b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs
new file mode 100644
index 0000000..372bd60
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/boss-loading.mjs
@@ -0,0 +1,101 @@
+/**
+ * The Monolith's loading view: one bar per load, so the participant can watch the waterfall form
+ * while the game waits, and feel the difference once it is repaired.
+ */
+import { ALL_LOAD_TASKS } from '../boss/boss-loader.mjs';
+import { BOSS_SPAWN_BUDGET_MS } from '../engine/constants.mjs';
+
+import { h, setText } from './dom.mjs';
+
+/** Draws the loading panel from the world. */
+export class BossLoadingView {
+ element;
+ rows = new Map();
+ shownFor = null;
+ clock = null;
+ note = null;
+
+ constructor(element) {
+ this.element = element;
+ }
+
+ /** Show or hide the panel and refresh it for the current frame. */
+ update(world, nowMs) {
+ const load = world.bossLoad;
+ if (world.phase !== 'boss-loading' || load === null) {
+ if (!this.element.hidden) {
+ this.element.hidden = true;
+ this.shownFor = null;
+ }
+
+ return;
+ }
+
+ if (this.shownFor !== load.startedAt) {
+ this.build(load.tasks.map((task) => task.name));
+ this.shownFor = load.startedAt;
+ this.element.hidden = false;
+ }
+
+ const elapsed = nowMs - load.startedAt;
+ if (this.clock !== null) {
+ setText(this.clock, `${(elapsed / 1_000).toFixed(1)} s`);
+ }
+ if (this.note !== null) {
+ setText(
+ this.note,
+ elapsed > BOSS_SPAWN_BUDGET_MS
+ ? `Buffering… over the ${(BOSS_SPAWN_BUDGET_MS / 1_000).toFixed(1)} s budget`
+ : 'Loading resources',
+ );
+ this.note.dataset.over = String(elapsed > BOSS_SPAWN_BUDGET_MS);
+ }
+ for (const task of load.tasks) {
+ const row = this.rows.get(task.name);
+ if (row === undefined) {
+ continue;
+ }
+ const fraction =
+ task.endMs !== null
+ ? 1
+ : task.startMs === null
+ ? 0
+ : Math.min(1, (elapsed - task.startMs) / row.durationMs);
+ row.fill.style.width = `${(fraction * 100).toFixed(1)}%`;
+ setText(
+ row.status,
+ task.endMs !== null ? '✓ done' : task.startMs === null ? 'waiting' : 'loading',
+ );
+ }
+ }
+
+ /** Build one row per task. */
+ build(names) {
+ this.rows = new Map();
+ this.clock = h('strong', { class: 'load-clock' }, '0.0 s');
+ this.note = h('span', { class: 'load-note' }, 'Loading resources');
+ const list = h('ul', { class: 'load-tasks', 'aria-label': 'Loading steps' });
+
+ for (const name of names) {
+ const fill = h('span', { class: 'load-fill' });
+ const status = h('span', { class: 'load-status' }, 'waiting');
+ list.append(
+ h(
+ 'li',
+ { class: 'load-task' },
+ h('span', { class: 'load-name' }, name),
+ h('span', { class: 'load-bar' }, fill),
+ status,
+ ),
+ );
+ const durationMs = ALL_LOAD_TASKS.find((task) => task.name === name)?.durationMs ?? 1_000;
+ this.rows.set(name, { fill, status, durationMs });
+ }
+
+ this.element.replaceChildren(
+ h('p', { class: 'load-head' }, 'Spawning the Monolith ', this.clock),
+ this.note,
+ list,
+ );
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/ui/dom.mjs b/challenges/glitch-invaders/game/src/ui/dom.mjs
new file mode 100644
index 0000000..767b2e1
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/dom.mjs
@@ -0,0 +1,72 @@
+/** A tiny element builder, so the UI modules read as structure instead of `createElement` noise. */
+
+/** Create an element with attributes and children. */
+export function h(tag, attributes = {}, ...children) {
+ const element = document.createElement(tag);
+ for (const [name, value] of Object.entries(attributes)) {
+ if (value === undefined || value === false) {
+ continue;
+ }
+ element.setAttribute(name, value === true ? '' : value);
+ }
+ for (const child of children) {
+ if (child !== null && child !== false) {
+ element.append(child);
+ }
+ }
+
+ return element;
+}
+
+/** Remove every child of `element` and add `children`. */
+export function replaceChildren(element, ...children) {
+ element.replaceChildren(...children.filter((child) => Boolean(child)));
+}
+
+/** Set `element`'s text only when it changed, so per-frame updates do not churn the DOM. */
+export function setText(element, text) {
+ if (element.textContent !== text) {
+ element.textContent = text;
+ }
+}
+
+/** Add a click handler and return the element, for chaining inside {@link h} calls. */
+export function onClick(element, handler) {
+ element.addEventListener('click', handler);
+
+ return element;
+}
+
+/** The elements inside `root` that can take keyboard focus, in tab order. */
+export function focusableElements(root) {
+ const selector = 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
+
+ return [...root.querySelectorAll(selector)].filter(
+ (candidate) => !candidate.hasAttribute('hidden'),
+ );
+}
+
+/** Keep Tab and Shift+Tab inside `container`, as a modal dialog must. */
+export function trapFocus(container, event) {
+ if (event.key !== 'Tab') {
+ return;
+ }
+
+ const focusable = focusableElements(container);
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (first === undefined || last === undefined) {
+ event.preventDefault();
+
+ return;
+ }
+
+ const active = document.activeElement;
+ if (event.shiftKey && (active === first || !container.contains(active))) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (active === last || !container.contains(active))) {
+ event.preventDefault();
+ first.focus();
+ }
+}
diff --git a/challenges/glitch-invaders/game/src/ui/hud.mjs b/challenges/glitch-invaders/game/src/ui/hud.mjs
new file mode 100644
index 0000000..0acc831
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/hud.mjs
@@ -0,0 +1,74 @@
+import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs';
+
+import { h, replaceChildren, setText } from './dom.mjs';
+import { currentObjective } from './objective.mjs';
+
+/** The glyph and word that show a failure's stage; never color alone. */
+const STAGE_LABELS = {
+ unseen: { glyph: '○', word: 'Not seen' },
+ encountered: { glyph: '▶', word: 'Open' },
+ investigated: { glyph: '◐', word: 'Diagnosed' },
+ repaired: { glyph: '✓', word: 'Fixed' },
+};
+
+/** Find a required element by id. */
+function requireElement(id) {
+ const element = document.getElementById(id);
+ if (element === null) {
+ throw new Error(`the page has no #${id} element`);
+ }
+
+ return element;
+}
+
+/** Updates the HUD from the world and progress. */
+export class Hud {
+ hull = requireElement('stat-hull');
+ shield = requireElement('stat-shield');
+ kills = requireElement('stat-kills');
+ score = requireElement('stat-score');
+ objectiveText = requireElement('objective-text');
+ objectiveDetail = requireElement('objective-detail');
+ incidents = requireElement('incidents');
+ chipSignature = '';
+
+ /** Refresh every stat from the latest world and progress. */
+ update(progress, world) {
+ const hullText = world.player.restoreAt === null ? '♥'.repeat(world.player.hull) : '✖';
+ setText(this.hull, hullText === '' ? '✖' : hullText);
+ setText(this.shield, world.player.shield ? 'ON' : 'off');
+ this.shield.dataset.state = world.player.shield ? 'on' : 'off';
+ setText(this.kills, String(world.kills));
+ setText(this.score, String(progress.score));
+
+ const objective = currentObjective(progress, world);
+ setText(this.objectiveText, objective.text);
+ setText(this.objectiveDetail, objective.detail);
+ this.renderChips(progress);
+ }
+
+ /** Redraw the incident chips only when a stage changed. */
+ renderChips(progress) {
+ const signature = BUG_ORDER.map((bug) => progress.bugs[bug].stage).join('|');
+ if (signature === this.chipSignature) {
+ return;
+ }
+ this.chipSignature = signature;
+ replaceChildren(this.incidents, ...BUG_ORDER.map((bug) => chip(bug, progress.bugs[bug].stage)));
+ }
+}
+
+/** One incident chip. */
+function chip(bug, stage) {
+ const label = STAGE_LABELS[stage];
+ const definition = BUG_DEFINITIONS[bug];
+ const title = stage === 'unseen' ? '' : ` ${definition.title}`;
+
+ return h(
+ 'li',
+ { class: 'chip', 'data-stage': stage, 'data-bug': bug },
+ h('span', { class: 'chip-glyph', 'aria-hidden': 'true' }, label.glyph),
+ `${definition.incidentLabel}${title} `,
+ h('span', { class: 'chip-word' }, label.word),
+ );
+}
diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs
new file mode 100644
index 0000000..437750f
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs
@@ -0,0 +1,545 @@
+import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs';
+import { REPAIR_PATCHES } from '../bugs/code-state.mjs';
+
+import { BOSS_SPAWN_BUDGET_MS, SCORE_HINT_COST } from '../engine/constants.mjs';
+import { focusableElements, h, onClick, trapFocus } from './dom.mjs';
+
+/** The words that describe where the evidence came from, shown as a badge. */
+const SOURCE_BADGES = {
+ simulated: {
+ label: 'DEMO · SIMULATED',
+ note: 'Demo mode: realistic diagnostics, no Sentry account needed.',
+ },
+};
+
+/** The glyphs for breadcrumb levels; level is never color alone. */
+const LEVEL_GLYPHS = {
+ info: '●',
+ warning: '▲',
+ error: '✖',
+};
+
+/** The console dialog. */
+export class IncidentConsole {
+ element;
+ host;
+ bug = 'weapon';
+ pane = 'evidence';
+ evidence = null;
+ diagnosisFeedback = '';
+ patchObservation = null;
+
+ constructor(host) {
+ this.host = host;
+ this.element = h('div', { class: 'console-host' });
+ this.element.addEventListener('keydown', (event) => trapFocus(this.element, event));
+ }
+
+ /** Open the console for `bug`, showing a capture-in-progress state until evidence arrives. */
+ open(bug) {
+ this.bug = bug;
+ this.evidence = null;
+ this.diagnosisFeedback = '';
+ this.patchObservation = null;
+ const stage = this.host.progressOf(bug).stage;
+ this.pane = stage === 'investigated' ? 'patch' : 'evidence';
+ this.render();
+ }
+
+ /** Evidence arrived for the open failure; all sections are shown together on one card. */
+ showEvidence(evidence) {
+ if (evidence.bug !== this.bug) {
+ return;
+ }
+ this.evidence = evidence;
+ if (!this.host.progressOf(this.bug).evidenceViewed) {
+ this.host.markEvidenceViewed(this.bug);
+ }
+ this.render();
+ }
+
+ /** The failure whose console is open. */
+ openBug() {
+ return this.bug;
+ }
+
+ /** Put keyboard focus on the first useful control. */
+ focusFirst() {
+ const selected = this.element.querySelector('[role="tab"][aria-selected="true"]');
+ (selected ?? focusableElements(this.element)[0])?.focus();
+ }
+
+ /** Rebuild the dialog, keeping keyboard focus on the same control. */
+ render() {
+ const focusKey = document.activeElement?.dataset?.key ?? null;
+ const definition = BUG_DEFINITIONS[this.bug];
+ const progress = this.host.progressOf(this.bug);
+ const badge = this.evidence === null ? null : SOURCE_BADGES[this.evidence.source];
+
+ const dialog = h(
+ 'section',
+ {
+ class: 'card console',
+ role: 'dialog',
+ 'aria-modal': 'true',
+ 'aria-labelledby': 'console-title',
+ },
+ h(
+ 'header',
+ { class: 'console-header' },
+ h(
+ 'div',
+ { class: 'console-heading' },
+ h(
+ 'div',
+ { class: 'console-badges' },
+ h('span', { class: 'incident-pill' }, definition.incidentLabel),
+ h('span', { class: 'lens' }, definition.lens),
+ badge === null
+ ? null
+ : h(
+ 'span',
+ {
+ class: 'source-badge',
+ 'data-source': this.evidence?.source,
+ title: badge.note,
+ },
+ badge.label,
+ ),
+ ),
+ h('h2', { class: 'console-title', id: 'console-title' }, definition.title),
+ h('p', { class: 'console-symptom' }, definition.symptom),
+ ),
+ this.tabs(progress),
+ ),
+ h(
+ 'div',
+ { class: 'pane', role: 'tabpanel', 'aria-labelledby': `tab-${this.pane}` },
+ this.paneBody(progress),
+ ),
+ );
+
+ this.element.replaceChildren(dialog);
+ if (focusKey !== null) {
+ this.element.querySelector(`[data-key="${focusKey}"]`)?.focus();
+ }
+ }
+
+ /** The step pill strip, with later panes locked until earlier ones are done. */
+ tabs(progress) {
+ const unlocked = {
+ evidence: true,
+ diagnose: progress.evidenceViewed,
+ patch: progress.stage === 'investigated' || progress.stage === 'repaired',
+ };
+ const labels = [
+ ['evidence', '1. Evidence'],
+ ['diagnose', '2. Diagnose'],
+ ['patch', '3. Patch'],
+ ];
+
+ return h(
+ 'div',
+ { class: 'tabs', role: 'tablist', 'aria-label': 'Investigation steps' },
+ ...labels.map(([pane, label]) => {
+ const button = h(
+ 'button',
+ {
+ type: 'button',
+ class: 'tab',
+ role: 'tab',
+ id: `tab-${pane}`,
+ 'data-key': `tab-${pane}`,
+ 'aria-selected': String(this.pane === pane),
+ 'aria-disabled': unlocked[pane] ? undefined : 'true',
+ },
+ unlocked[pane] ? label : `${label} · locked`,
+ );
+
+ return onClick(button, () => {
+ if (!unlocked[pane]) {
+ return;
+ }
+ this.pane = pane;
+ this.render();
+ });
+ }),
+ );
+ }
+
+ /** The body for the selected pane. */
+ paneBody(progress) {
+ switch (this.pane) {
+ case 'evidence':
+ return this.evidencePane();
+ case 'diagnose':
+ return this.diagnosePane(progress);
+ case 'patch':
+ return this.patchPane(progress);
+ }
+ }
+
+ /** Evidence step: the simulated Sentry capture of the failure. */
+ evidencePane() {
+ const evidence = this.evidence;
+ if (evidence === null) {
+ return h(
+ 'p',
+ { class: 'status', role: 'status' },
+ h('span', { class: 'spinner', 'aria-hidden': 'true' }, '◌'),
+ ' Capturing telemetry…',
+ );
+ }
+
+ const contextSection = evidence.sections.find((section) => section.kind === 'context');
+ const primarySection = evidence.sections.find(
+ (section) =>
+ section.kind === 'stacktrace' ||
+ section.kind === 'breadcrumbs' ||
+ section.kind === 'waterfall',
+ );
+
+ return h(
+ 'div',
+ { class: 'evidence' },
+ this.sentryHero(evidence),
+ contextSection === undefined ? null : renderContextPills(contextSection),
+ primarySection === undefined ? null : renderSection(primarySection),
+ h(
+ 'div',
+ { class: 'pane-actions' },
+ h('span', { class: 'muted' }, `Ref: ${evidence.referenceId}`),
+ h('span', { class: 'spacer' }),
+ onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button button-primary', 'data-key': 'to-diagnose' },
+ 'Continue to Diagnose ▶',
+ ),
+ () => {
+ this.pane = 'diagnose';
+ this.render();
+ },
+ ),
+ ),
+ );
+ }
+
+ /** Banner with the exception or trace headline and how to read the capture. */
+ sentryHero(evidence) {
+ return h(
+ 'div',
+ { class: 'sentry-callout' },
+ h(
+ 'div',
+ { class: 'sentry-callout-main' },
+ h('p', { class: 'evidence-head' }, evidence.title),
+ h(
+ 'p',
+ { class: 'sentry-callout-copy' },
+ 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.',
+ ),
+ ),
+ );
+ }
+
+ /** Diagnosis: lettered answer cards, clear feedback, and hints. */
+ diagnosePane(progress) {
+ const definition = BUG_DEFINITIONS[this.bug];
+ const solved = progress.stage === 'investigated' || progress.stage === 'repaired';
+ const hints = definition.hints.slice(0, progress.hintsUsed);
+ const nextHint = progress.hintsUsed < definition.hints.length;
+
+ return h(
+ 'div',
+ { class: 'diagnose' },
+ h('p', { class: 'question' }, definition.question),
+ h(
+ 'div',
+ { class: 'options', role: 'group', 'aria-label': definition.question },
+ ...definition.options.map((option, index) => {
+ const letter = String.fromCharCode(65 + index);
+ const isSolvedCorrect = solved && option.correct;
+ const button = h(
+ 'button',
+ {
+ type: 'button',
+ class: 'option',
+ 'data-key': `option-${option.id}`,
+ disabled: solved && !option.correct,
+ 'data-correct': isSolvedCorrect ? 'true' : undefined,
+ },
+ h(
+ 'span',
+ { class: 'option-letter', 'aria-hidden': 'true' },
+ isSolvedCorrect ? '✓' : letter,
+ ),
+ h('span', { class: 'option-text' }, option.text),
+ );
+
+ return onClick(button, () => this.answer(option.id));
+ }),
+ ),
+ this.diagnosisFeedback === ''
+ ? null
+ : h(
+ 'p',
+ { class: 'feedback feedback-danger', role: 'alert' },
+ `✖ ${this.diagnosisFeedback}`,
+ ),
+ solved
+ ? h('p', { class: 'feedback feedback-success', role: 'status' }, '✓ Root cause confirmed.')
+ : null,
+ hints.length === 0
+ ? null
+ : h(
+ 'ol',
+ { class: 'hints', 'aria-label': 'Hints' },
+ ...hints.map((hint) => h('li', {}, hint)),
+ ),
+ h(
+ 'div',
+ { class: 'pane-actions' },
+ solved || !nextHint
+ ? null
+ : onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button', 'data-key': 'hint' },
+ `💡 Reveal Hint ${progress.hintsUsed + 1} (−${SCORE_HINT_COST} pts)`,
+ ),
+ () => {
+ this.host.useHint(this.bug);
+ this.render();
+ },
+ ),
+ h('span', { class: 'spacer' }),
+ solved
+ ? onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button button-primary', 'data-key': 'to-patch' },
+ 'Choose a patch ▶',
+ ),
+ () => {
+ this.pane = 'patch';
+ this.render();
+ },
+ )
+ : null,
+ ),
+ );
+ }
+
+ /** Submit a diagnosis; a right one moves on to the patch pane. */
+ answer(optionId) {
+ const result = this.host.diagnose(this.bug, optionId);
+ this.diagnosisFeedback = result.feedback;
+ if (result.correct) {
+ this.pane = 'patch';
+ }
+ this.render();
+ }
+
+ /** Patches: responsive 3-column grid of code patch cards. Wrong ones fail visibly and revert. */
+ patchPane(progress) {
+ if (progress.stage !== 'investigated') {
+ return h('p', { class: 'status' }, 'Diagnose the failure to unlock patches.');
+ }
+
+ return h(
+ 'div',
+ { class: 'patches' },
+ h(
+ 'p',
+ { class: 'question' },
+ 'Choose a code patch to test in the arena. A wrong patch reverts itself.',
+ ),
+ h(
+ 'div',
+ { class: 'patch-grid' },
+ ...REPAIR_PATCHES[this.bug].map((patch) => {
+ const failed = this.patchObservation?.patchId === patch.id;
+ const apply = onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button button-primary', 'data-key': `apply-${patch.id}` },
+ 'Apply & Test Patch ▶',
+ ),
+ () => this.apply(patch.id),
+ );
+
+ return h(
+ 'article',
+ { class: 'patch', 'data-failed': failed ? 'true' : undefined },
+ h(
+ 'div',
+ { class: 'patch-body' },
+ h('h3', { class: 'patch-title' }, patch.title),
+ h('p', { class: 'patch-description' }, patch.description),
+ h('pre', { class: 'code' }, h('code', {}, patch.snippet)),
+ ),
+ apply,
+ );
+ }),
+ ),
+ this.patchObservation === null
+ ? null
+ : h(
+ 'p',
+ { class: 'feedback feedback-danger', role: 'alert' },
+ `✖ Reverted. ${this.patchObservation.text}`,
+ ),
+ );
+ }
+
+ /** Try a patch; the game closes the console when it works. */
+ apply(patchId) {
+ const check = this.host.applyPatch(this.bug, patchId);
+ this.patchObservation = check.ok ? null : { patchId, text: check.observation };
+ this.render();
+ if (!check.ok) {
+ this.element.querySelector(`[data-key="apply-${patchId}"]`)?.focus();
+ }
+ }
+}
+
+/** Render context tags as scannable pill badges. */
+function renderContextPills(section) {
+ return h(
+ 'dl',
+ { class: 'context-pills', 'aria-label': section.title },
+ ...Object.entries(section.entries).map(([key, value]) =>
+ h('div', { class: 'context-pill' }, h('dt', {}, `${key}:`), h('dd', {}, value)),
+ ),
+ );
+}
+
+/** Render the primary diagnostic section on screen. */
+function renderSection(section) {
+ switch (section.kind) {
+ case 'exception':
+ return h(
+ 'div',
+ { class: 'panel exception' },
+ h('p', { class: 'error-type' }, section.errorType),
+ h('p', { class: 'error-message' }, section.message),
+ );
+ case 'stacktrace':
+ return h(
+ 'ol',
+ { class: 'panel frames', 'aria-label': 'Stack frames, innermost first' },
+ ...section.frames.map((frame, index) => frameRow(frame, index === 0)),
+ );
+ case 'breadcrumbs':
+ return h(
+ 'ol',
+ { class: 'panel crumbs', 'aria-label': 'Breadcrumbs, oldest first' },
+ ...section.entries.map(crumbRow),
+ );
+ case 'context':
+ return renderContextPills(section);
+ case 'waterfall':
+ return waterfall(section.totalMs, section.spans);
+ }
+}
+
+/** One stack frame, highlighting the innermost culprit frame. */
+function frameRow(frame, isCulprit) {
+ const location = frame.line > 0 ? `${frame.file}:${frame.line}` : frame.file;
+
+ return h(
+ 'li',
+ {
+ class: 'frame',
+ 'data-in-app': String(frame.inApp),
+ 'data-culprit': isCulprit ? 'true' : undefined,
+ },
+ h('span', { class: 'frame-fn' }, frame.functionName),
+ h('span', { class: 'frame-loc' }, isCulprit ? `${location} ← THROWN HERE` : location),
+ );
+}
+
+/** One breadcrumb. */
+function crumbRow(entry) {
+ const data =
+ entry.data === null
+ ? null
+ : h(
+ 'span',
+ { class: 'crumb-data' },
+ Object.entries(entry.data)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(' '),
+ );
+
+ return h(
+ 'li',
+ { class: 'crumb', 'data-level': entry.level },
+ h('span', { class: 'crumb-time' }, entry.offsetLabel),
+ h('span', { class: 'crumb-level', 'aria-label': entry.level }, LEVEL_GLYPHS[entry.level]),
+ h('span', { class: 'crumb-category' }, entry.category),
+ h('span', { class: 'crumb-message' }, entry.message),
+ data,
+ );
+}
+
+/**
+ * Set `element`'s position through the CSSOM rather than a `style` attribute, which the page's
+ * Content-Security-Policy refuses without 'unsafe-inline'.
+ */
+function positioned(element, position) {
+ element.style.left = position.left;
+ if (position.width !== undefined) {
+ element.style.width = position.width;
+ }
+
+ return element;
+}
+
+/** A trace waterfall: one bar per span on a shared time axis, with the spawn budget marked. */
+function waterfall(totalMs, spans) {
+ const axisMs = Math.max(totalMs, BOSS_SPAWN_BUDGET_MS);
+ const percent = (milliseconds) => `${((milliseconds / axisMs) * 100).toFixed(2)}%`;
+ const seconds = (milliseconds) => `${(milliseconds / 1_000).toFixed(1)} s`;
+
+ return h(
+ 'div',
+ {
+ class: 'panel waterfall',
+ role: 'table',
+ 'aria-label': `Trace boss-spawn, ${seconds(totalMs)} total`,
+ },
+ h(
+ 'p',
+ { class: 'waterfall-axis' },
+ h('span', {}, '0 s'),
+ h('span', {}, `total ${seconds(totalMs)} · budget ${seconds(BOSS_SPAWN_BUDGET_MS)}`),
+ ),
+ ...spans.map((span) => {
+ const isRepeat = span.name === 'loadAnimations';
+
+ return h(
+ 'div',
+ { class: 'span-row', role: 'row', 'data-culprit': isRepeat ? 'true' : undefined },
+ h('span', { class: 'span-name', role: 'cell' }, span.name),
+ h(
+ 'span',
+ { class: 'span-track', role: 'cell' },
+ positioned(h('span', { class: 'span-bar' }), {
+ left: percent(span.startMs),
+ width: percent(span.durationMs),
+ }),
+ positioned(h('span', { class: 'span-budget', 'aria-hidden': 'true' }), {
+ left: percent(BOSS_SPAWN_BUDGET_MS),
+ }),
+ ),
+ h(
+ 'span',
+ { class: 'span-time', role: 'cell' },
+ `${seconds(span.durationMs)} @ ${seconds(span.startMs)}`,
+ ),
+ );
+ }),
+ );
+}
diff --git a/challenges/glitch-invaders/game/src/ui/input.mjs b/challenges/glitch-invaders/game/src/ui/input.mjs
new file mode 100644
index 0000000..148a169
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/input.mjs
@@ -0,0 +1,156 @@
+/**
+ * Player input: keyboard and on-screen controls feed one shared {@link InputState}. Holding a pad
+ * button works with a pointer; pressing it with Enter or Space gives a short pulse, so a keyboard
+ * user can drive the pads too.
+ */
+import { idleInput } from '../engine/world.mjs';
+
+/** How long a keyboard activation of a pad button counts as held. */
+const PAD_PULSE_MS = 180;
+
+/** The keys that mean each control. */
+const KEY_CONTROLS = new Map([
+ ['ArrowLeft', 'left'],
+ ['KeyA', 'left'],
+ ['ArrowRight', 'right'],
+ ['KeyD', 'right'],
+ ['Space', 'fire'],
+]);
+
+/** Collects keyboard and pad input. */
+export class InputController {
+ state = idleInput();
+ keysDown = new Set();
+ padsDown = new Map();
+ options;
+ pulseTimers = new Set();
+
+ constructor(options) {
+ this.options = options;
+ }
+
+ /** The current input. The same object every time; the engine only reads it. */
+ current() {
+ return this.state;
+ }
+
+ /** Start listening on `target` for keys, and on `pad` buttons for pointers. */
+ attach(target, pads) {
+ const keyDown = (event) => this.keyDown(event);
+ const keyUp = (event) => this.keyUp(event);
+ const blur = () => this.releaseAll();
+ target.addEventListener('keydown', keyDown);
+ target.addEventListener('keyup', keyUp);
+ window.addEventListener('blur', blur);
+
+ for (const control of Object.keys(pads)) {
+ this.attachPad(control, pads[control]);
+ }
+
+ return () => {
+ target.removeEventListener('keydown', keyDown);
+ target.removeEventListener('keyup', keyUp);
+ window.removeEventListener('blur', blur);
+ this.releaseAll();
+ };
+ }
+
+ /** Let go of everything, such as when a dialog opens or the window loses focus. */
+ releaseAll() {
+ this.keysDown.clear();
+ this.padsDown.clear();
+ for (const timer of this.pulseTimers) {
+ clearTimeout(timer);
+ }
+ this.pulseTimers.clear();
+ this.recompute();
+ }
+
+ /** Wire one pad button: hold with a pointer, pulse with the keyboard. */
+ attachPad(control, button) {
+ const press = (event) => {
+ event.preventDefault();
+ this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1);
+ this.recompute();
+ };
+ const release = () => {
+ const holds = this.padsDown.get(control) ?? 0;
+ if (holds > 0) {
+ this.padsDown.set(control, holds - 1);
+ this.recompute();
+ }
+ };
+
+ button.addEventListener('pointerdown', press);
+ button.addEventListener('pointerup', release);
+ button.addEventListener('pointercancel', release);
+ button.addEventListener('pointerleave', release);
+ button.addEventListener('click', (event) => {
+ if (event.detail !== 0) {
+ return;
+ }
+ this.pulse(control);
+ });
+ }
+
+ /** Hold `control` briefly. */
+ pulse(control) {
+ this.padsDown.set(control, (this.padsDown.get(control) ?? 0) + 1);
+ this.recompute();
+ const timer = setTimeout(() => {
+ this.pulseTimers.delete(timer);
+ const holds = this.padsDown.get(control) ?? 0;
+ this.padsDown.set(control, Math.max(0, holds - 1));
+ this.recompute();
+ }, PAD_PULSE_MS);
+ this.pulseTimers.add(timer);
+ }
+
+ /** Handle a key press. */
+ keyDown(event) {
+ if (event.ctrlKey || event.metaKey || event.altKey) {
+ return;
+ }
+ if (event.code === 'KeyP' || event.code === 'Escape') {
+ if (!event.repeat && this.options.isGameActive()) {
+ event.preventDefault();
+ this.options.onPauseKey();
+ }
+
+ return;
+ }
+ if (!this.options.isGameActive() || !KEY_CONTROLS.has(event.code)) {
+ return;
+ }
+ if (event.code === 'Space' && isButton(event.target)) {
+ return;
+ }
+
+ event.preventDefault();
+ this.keysDown.add(event.code);
+ this.recompute();
+ }
+
+ /** Handle a key release. */
+ keyUp(event) {
+ if (this.keysDown.delete(event.code)) {
+ this.recompute();
+ }
+ }
+
+ /** Rebuild the shared state from what is held. */
+ recompute() {
+ const held = (control) =>
+ (this.padsDown.get(control) ?? 0) > 0 ||
+ [...this.keysDown].some((code) => KEY_CONTROLS.get(code) === control);
+
+ this.state.left = held('left');
+ this.state.right = held('right');
+ this.state.fire = held('fire');
+ }
+}
+
+/** Whether `target` is a button, which handles Space itself. */
+function isButton(target) {
+ return target instanceof HTMLButtonElement;
+}
diff --git a/challenges/glitch-invaders/game/src/ui/objective.mjs b/challenges/glitch-invaders/game/src/ui/objective.mjs
new file mode 100644
index 0000000..d14b2cc
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/objective.mjs
@@ -0,0 +1,71 @@
+/** Pick the objective for the current progress and arena. */
+export function currentObjective(progress, world) {
+ if (!progress.launched) {
+ return { text: 'Press Start to launch your drone.', detail: '' };
+ }
+ if (progress.completed) {
+ return { text: 'All three incidents are closed.', detail: 'Tell your guide what you found.' };
+ }
+ if (world.phase === 'frozen') {
+ return { text: 'Open the evidence and find the cause.', detail: 'The game is paused.' };
+ }
+ if (world.phase === 'boss-loading') {
+ return { text: 'The Monolith is spawning…', detail: 'Watch the loading steps.' };
+ }
+
+ switch (world.verifying) {
+ case 'weapon':
+ return {
+ text: 'Catch the gold upgrade, then fire.',
+ detail: 'Space fires. Three bolts means fixed.',
+ };
+ case 'shield':
+ return {
+ text: 'Catch the blue shield and take a hit.',
+ detail: 'A fixed shield absorbs it.',
+ };
+ case 'boss':
+ return { text: 'Watch the Monolith spawn.', detail: 'It should arrive fast now.' };
+ case null:
+ break;
+ }
+
+ return playingObjective(progress, world);
+}
+
+/** The objective while ordinary play is under way. */
+function playingObjective(progress, world) {
+ const { weapon, shield } = progress.bugs;
+ const player = world.player;
+ const hasPickup = (kind) => world.pickups.some((pickup) => pickup.kind === kind);
+
+ if (weapon.stage === 'unseen') {
+ if (player.weapon === 'fanout') {
+ return { text: 'Fire your new upgrade.', detail: 'Press Space.' };
+ }
+ if (hasPickup('fanout')) {
+ return { text: 'Catch the gold upgrade.', detail: 'Move under it.' };
+ }
+
+ return {
+ text: 'Shoot the glitch marked with a gold diamond.',
+ detail: 'It carries an upgrade.',
+ };
+ }
+
+ if (shield.stage === 'unseen') {
+ if (world.wave === 1 && !player.shield) {
+ return { text: 'Clear the wave.', detail: 'A shielded glitch arrives next.' };
+ }
+ if (player.shield) {
+ return { text: 'Test the shield: let a shot hit you.', detail: 'Stand in its path.' };
+ }
+ if (hasPickup('aegis')) {
+ return { text: 'Catch the blue shield.', detail: 'Move under it.' };
+ }
+
+ return { text: 'Shoot the glitch marked with a blue shield.', detail: 'It carries a shield.' };
+ }
+
+ return { text: 'Clear the wave to call the Monolith.', detail: 'Keep shooting.' };
+}
diff --git a/challenges/glitch-invaders/game/src/ui/overlays.mjs b/challenges/glitch-invaders/game/src/ui/overlays.mjs
new file mode 100644
index 0000000..6d765b1
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs
@@ -0,0 +1,183 @@
+import { BUG_DEFINITIONS, BUG_ORDER } from '../bugs/bug-definitions.mjs';
+import { focusableElements, h, onClick } from './dom.mjs';
+
+/** The page regions that become inert behind a modal card. */
+const BACKGROUND_IDS = ['topbar', 'stage-wrap', 'dock'];
+
+/** Shows one modal card at a time. */
+export class OverlayHost {
+ root;
+ opener = null;
+
+ constructor(root) {
+ this.root = root;
+ }
+
+ /** Whether a card is showing. */
+ get isOpen() {
+ return !this.root.hidden;
+ }
+
+ /** The card showing now, if any. */
+ get current() {
+ return this.isOpen ? this.root.firstElementChild : null;
+ }
+
+ /** Show `content` as the only card, focusing its `data-autofocus` control or its first control. */
+ show(content) {
+ if (!this.isOpen) {
+ this.opener = document.activeElement;
+ }
+ this.root.replaceChildren(content);
+ this.root.hidden = false;
+ for (const id of BACKGROUND_IDS) {
+ document.getElementById(id)?.setAttribute('inert', '');
+ }
+ const target = content.querySelector('[data-autofocus]') ?? focusableElements(content)[0];
+ target?.focus();
+ }
+
+ /** Close the card and return focus to where it was, or to `fallback`. */
+ hide(fallback) {
+ if (!this.isOpen) {
+ return;
+ }
+ this.root.hidden = true;
+ this.root.replaceChildren();
+ for (const id of BACKGROUND_IDS) {
+ document.getElementById(id)?.removeAttribute('inert');
+ }
+ const target = fallback ?? this.opener;
+ this.opener = null;
+ target?.focus();
+ }
+}
+
+/** Wrap card contents in the standard centered card. */
+function card(title, label, ...children) {
+ return h(
+ 'section',
+ { class: 'card overlay-card', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': label },
+ h('h2', { class: 'card-title', id: label }, title),
+ ...children,
+ );
+}
+
+/** The briefing shown before play, or the welcome-back card after a refresh. */
+export function introCard(options) {
+ return card(
+ options.returning ? 'Welcome back' : 'Incident briefing',
+ 'intro-title',
+ h('p', { class: 'hero' }, 'Glitch Invaders: ', h('em', {}, 'On-Call')),
+ h(
+ 'p',
+ { class: 'lede' },
+ options.returning
+ ? 'Your run is saved. Pick up where you left off.'
+ : 'Production is crawling with glitches. You are the patch drone. Shoot, grab upgrades, and when something breaks, find out why and fix it.',
+ ),
+ h(
+ 'ul',
+ { class: 'keys', 'aria-label': 'Controls' },
+ h(
+ 'li',
+ {},
+ h('kbd', {}, '◀'),
+ ' ',
+ h('kbd', {}, '▶'),
+ ' or ',
+ h('kbd', {}, 'A'),
+ ' ',
+ h('kbd', {}, 'D'),
+ ' move',
+ ),
+ h('li', {}, h('kbd', {}, 'Space'), ' fires'),
+ h('li', {}, h('kbd', {}, 'P'), ' pauses'),
+ ),
+ h('p', { class: 'muted' }, 'Demo mode: the diagnostics are realistic simulations.'),
+ onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true },
+ options.returning ? 'Resume ▶' : 'Start ▶',
+ ),
+ options.onStart,
+ ),
+ );
+}
+
+/** The pause card. */
+export function pauseCard(onResume) {
+ return card(
+ 'Paused',
+ 'pause-title',
+ h('p', { class: 'lede' }, 'The arena is frozen. Take your time.'),
+ onClick(
+ h(
+ 'button',
+ { type: 'button', class: 'button button-primary button-large', 'data-autofocus': true },
+ 'Resume ▶',
+ ),
+ onResume,
+ ),
+ );
+}
+
+/** The recap shown once all three incidents are closed. */
+export function completionCard(progress, onContinue, onReset) {
+ return card(
+ '✓ All incidents closed',
+ 'done-title',
+ h('p', { class: 'hero' }, `Score ${progress.score}`),
+ h(
+ 'ul',
+ { class: 'recap', 'aria-label': 'What you fixed' },
+ ...BUG_ORDER.map((bug) => {
+ const definition = BUG_DEFINITIONS[bug];
+
+ return h(
+ 'li',
+ {},
+ h('strong', {}, `${definition.incidentLabel} ${definition.title}`),
+ h('span', { class: 'lens' }, ` ${definition.lens}`),
+ h('span', { class: 'recap-text' }, definition.fixExplanation),
+ );
+ }),
+ ),
+ onContinue || onReset
+ ? h(
+ 'div',
+ { class: 'card-actions' },
+ onContinue
+ ? onClick(
+ h(
+ 'button',
+ {
+ type: 'button',
+ class: 'button button-primary button-large',
+ 'data-autofocus': true,
+ 'data-key': 'continue-playing',
+ },
+ 'Continue Playing ▶',
+ ),
+ onContinue,
+ )
+ : null,
+ onReset
+ ? onClick(
+ h(
+ 'button',
+ {
+ type: 'button',
+ class: 'button button-large',
+ 'data-key': 'reset-run',
+ },
+ 'Start New Run ↺',
+ ),
+ onReset,
+ )
+ : null,
+ )
+ : null,
+ );
+}
diff --git a/challenges/glitch-invaders/game/src/weapons/weapons.mjs b/challenges/glitch-invaders/game/src/weapons/weapons.mjs
new file mode 100644
index 0000000..aa040e8
--- /dev/null
+++ b/challenges/glitch-invaders/game/src/weapons/weapons.mjs
@@ -0,0 +1,57 @@
+/** Weapon configuration, with the missing entry that causes the first intentional failure. */
+
+/** Thrown when the ship fires a weapon that has no configuration. */
+export class WeaponConfigError extends Error {
+ weaponId;
+
+ constructor(weaponId) {
+ super(`Configuration missing for weapon: ${weaponId}`);
+ this.name = 'WeaponConfigError';
+ this.weaponId = weaponId;
+ }
+}
+
+/** The one weapon that is configured from the start. */
+export const SINGLE_SHOT = {
+ projectileCount: 1,
+ spreadDegrees: 0,
+ cooldownMs: 220,
+ damage: 1,
+};
+
+/**
+ * The table as shipped. INTENTIONAL BUG: there is no `fanout` entry, so the upgrade jams the
+ * first time it fires. The Incident Console's patch adds it.
+ */
+export function createBrokenWeaponTable() {
+ return { single: SINGLE_SHOT };
+}
+
+/** The configuration the correct patch registers: three projectiles in a fan with a sane cooldown. */
+export const FANOUT_VOLLEY = {
+ projectileCount: 3,
+ spreadDegrees: 24,
+ cooldownMs: 300,
+ damage: 1,
+};
+
+/**
+ * Fire `weaponId` from `table`.
+ *
+ * @throws {WeaponConfigError} when `table` has no entry for `weaponId`: the intentional bug.
+ */
+export function fireWeapon(table, weaponId) {
+ const config = table[weaponId];
+ if (config === undefined) {
+ throw new WeaponConfigError(weaponId);
+ }
+
+ const spreadRadians = (config.spreadDegrees * Math.PI) / 180;
+ const shots = [];
+ for (let index = 0; index < config.projectileCount; index += 1) {
+ const fraction = config.projectileCount === 1 ? 0.5 : index / (config.projectileCount - 1);
+ shots.push({ angle: -spreadRadians / 2 + spreadRadians * fraction, damage: config.damage });
+ }
+
+ return { shots, config };
+}
diff --git a/challenges/glitch-invaders/intro.md b/challenges/glitch-invaders/intro.md
new file mode 100644
index 0000000..e4694d2
--- /dev/null
+++ b/challenges/glitch-invaders/intro.md
@@ -0,0 +1,18 @@
+You're the on-call engineer for a cluster under attack. Fly the patch
+drone, shoot the glitches, and when something breaks, debug it.
+
+Three things will fail while you play:
+
+- a weapon upgrade that jams
+- a shield that makes damage worse
+- a boss that takes forever to spawn
+
+Each failure opens an Incident Console that shows the Sentry evidence: a
+stack trace, breadcrumbs, or a performance trace. Read it, find the root
+cause, choose the right patch, and watch the fix work in the game.
+
+Your guide, Pi, is on the right. Ask it for a nudge any time. It won't
+hand you the answers.
+
+The game runs in Chrome on the left. Move with the arrow keys or A and D,
+and fire with Space. On-screen buttons work too.
diff --git a/challenges/glitch-invaders/monitor/SKILL.md b/challenges/glitch-invaders/monitor/SKILL.md
new file mode 100644
index 0000000..a65b12f
--- /dev/null
+++ b/challenges/glitch-invaders/monitor/SKILL.md
@@ -0,0 +1,29 @@
+---
+name: monitor
+description: "Headless monitor for Glitch Invaders: On-Call. Reports steps from the game's visible state line."
+---
+
+# Glitch Invaders monitor
+
+Take one snapshot of the game page. If the snapshot is not
+`http://127.0.0.1:4373/`, call `list_pages`, `select_page` the
+`http://127.0.0.1:4373/` page, and take one snapshot of it.
+
+Find the line that starts with `glitch-state v1`. It is one line of fixed
+words, for example:
+
+```text
+glitch-state v1 launched=yes weapon=repaired shield=encountered boss=unseen completed=no mode=demo
+```
+
+Report a step only when its exact words appear in that line:
+
+- `launched=yes`: call `report_step` with `launch-game`.
+- `weapon=repaired`: call `report_step` with `fix-weapon`.
+- `shield=repaired`: call `report_step` with `fix-shield`.
+- `boss=repaired`: call `report_step` with `fix-boss`.
+
+Report nothing for any other value, such as `encountered` or `investigated`.
+Never report `debrief`. If the page has no `glitch-state` line, is blank, or you
+are not sure, report nothing. Do not guess from the pictures or other text on
+the page.
diff --git a/challenges/glitch-invaders/plugin.json b/challenges/glitch-invaders/plugin.json
new file mode 100644
index 0000000..a642e9e
--- /dev/null
+++ b/challenges/glitch-invaders/plugin.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
+ "name": "glitch-invaders",
+ "version": "1.0.0",
+ "description": "Glitch Invaders: On-Call. Play an arcade run, hit three intentional failures, and debug them with Sentry evidence."
+}
diff --git a/challenges/glitch-invaders/server/cleanup.mjs b/challenges/glitch-invaders/server/cleanup.mjs
new file mode 100644
index 0000000..3d92ca6
--- /dev/null
+++ b/challenges/glitch-invaders/server/cleanup.mjs
@@ -0,0 +1,96 @@
+/**
+ * Cleanup: stop the page server and remove the attempt's files. Safe to run twice, after a failed
+ * setup, or with nothing set up at all. It exits 0 in every one of those cases.
+ */
+import { execFileSync } from 'node:child_process';
+import { resolveStatePaths } from './paths.mjs';
+import { clearWorkspacePath, readWorkspaceFile } from './workspace-file.mjs';
+
+/** How long the server gets to stop on SIGTERM before it is killed. */
+const GRACE_MS = 3_000;
+
+/** Whether a process with `pid` exists. */
+function isAlive(pid) {
+ try {
+ process.kill(pid, 0);
+
+ return true;
+ } catch (error) {
+ return error.code === 'EPERM';
+ }
+}
+
+/**
+ * Whether `pid` is still our page server, so a recycled pid is never killed by mistake. When `ps`
+ * itself is unavailable the pid file is trusted: a leaked server is worse than a stray signal.
+ */
+function isPageServer(pid) {
+ try {
+ const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ });
+
+ return command.includes('server/serve.mjs');
+ } catch (error) {
+ return error.status !== 1;
+ }
+}
+
+/** Pause for `milliseconds`. */
+function sleep(milliseconds) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+/** The most a pid file holds: a pid and a newline. */
+const PID_FILE_MAX_BYTES = 32;
+
+/**
+ * Stop the server recorded in the pid file, if it is still running. A pid file that is a symlink or
+ * anything but a lone regular file is never read, so a planted one cannot point elsewhere.
+ */
+export async function stopServer(paths) {
+ let pid;
+ try {
+ pid = Number(((await readWorkspaceFile(paths.pidFile, PID_FILE_MAX_BYTES)) ?? '').trim());
+ } catch {
+ return;
+ }
+ if (!Number.isInteger(pid) || pid <= 1 || !isAlive(pid) || !isPageServer(pid)) {
+ return;
+ }
+
+ try {
+ process.kill(pid, 'SIGTERM');
+ } catch {
+ return;
+ }
+ const deadline = Date.now() + GRACE_MS;
+ while (isAlive(pid) && Date.now() < deadline) {
+ await sleep(50);
+ }
+ if (isAlive(pid)) {
+ try {
+ process.kill(pid, 'SIGKILL');
+ } catch {
+ return;
+ }
+ }
+}
+
+/** Stop the server and remove every file the attempt created. */
+export async function runCleanup(paths) {
+ await stopServer(paths);
+ await clearWorkspacePath(paths.stateDir);
+ await clearWorkspacePath(paths.progressFile);
+}
+
+if (import.meta.main) {
+ runCleanup(resolveStatePaths(process.env, process.cwd())).then(
+ () => process.exit(0),
+ (error) => {
+ process.stderr.write(`cleanup: ${error instanceof Error ? error.message : String(error)}\n`);
+ process.exit(1);
+ },
+ );
+}
diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs
new file mode 100644
index 0000000..f12d7dc
--- /dev/null
+++ b/challenges/glitch-invaders/server/page-server.mjs
@@ -0,0 +1,309 @@
+/**
+ * The page server: serves the game, this attempt's runtime config, and the progress file
+ * that lets a refresh or a restarted guide pick up the run. Node built-ins only, because a
+ * Boothstrap package runs wherever it is unpacked and cannot import the repository's dependencies.
+ */
+import { readFile, stat } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import path from 'node:path';
+import { MAX_PROGRESS_BYTES, parseProgress } from '../shared/progress.mjs';
+import {
+ readWorkspaceFile,
+ RefusedFileError,
+ removeWorkspaceFile,
+ writeWorkspaceFile,
+} from './workspace-file.mjs';
+
+/** The content types of the files the build produces. */
+const CONTENT_TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon',
+ '.txt': 'text/plain; charset=utf-8',
+};
+
+/**
+ * Headers every response carries: nothing is cached, because a replay must never see old state, and
+ * the page may load and contact nothing but this server.
+ */
+const BASE_HEADERS = {
+ 'cache-control': 'no-store',
+ 'content-security-policy':
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'",
+ 'x-content-type-options': 'nosniff',
+};
+
+/** How much of a refused upload the server reads and discards before it drops the connection. */
+const MAX_DISCARDED_BYTES = 4 * MAX_PROGRESS_BYTES;
+
+/** Send a JSON body. */
+function sendJson(response, status, body) {
+ response.writeHead(status, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] });
+ response.end(JSON.stringify(body));
+}
+
+/** Send a status with no body. */
+function sendStatus(response, status, message = '') {
+ response.writeHead(status, { ...BASE_HEADERS, 'content-type': 'text/plain; charset=utf-8' });
+ response.end(message);
+}
+
+/** The origins a request may legitimately come from: this server, by IP or by name. */
+function allowedOrigins(request) {
+ const port = request.socket.localPort;
+
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
+}
+
+/** Whether the request addresses this server by a loopback name, which defeats DNS rebinding. */
+function hostIsLoopback(request) {
+ const host = request.headers.host ?? '';
+
+ return allowedOrigins(request).some((origin) => origin === `http://${host}`);
+}
+
+/** Whether a state-changing request came from this page rather than another site. */
+function originIsOwn(request) {
+ const origin = request.headers.origin;
+
+ return origin === undefined || allowedOrigins(request).includes(origin);
+}
+
+/** Read the request body, or resolve `null` and stop reading as soon as it passes `limit` bytes. */
+function readBody(request, limit) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+
+ /** Collect a chunk, giving up once the body is too big. */
+ const onData = (chunk) => {
+ size += chunk.length;
+ if (size > limit) {
+ request.off('data', onData);
+ request.off('end', onEnd);
+ request.pause();
+ resolve(null);
+
+ return;
+ }
+ chunks.push(chunk);
+ };
+
+ /** Resolve with the whole body. */
+ const onEnd = () => resolve(Buffer.concat(chunks).toString('utf8'));
+
+ request.on('data', onData);
+ request.on('end', onEnd);
+ request.once('error', reject);
+ });
+}
+
+/**
+ * Answer 413, then read and discard the rest of the body so the client can finish sending and read
+ * the answer instead of seeing a reset. Drops the connection once more than `MAX_DISCARDED_BYTES`
+ * arrive, so an endless upload cannot hold it open.
+ */
+function refuseOversizedBody(request, response) {
+ let discarded = 0;
+ request.on('data', (chunk) => {
+ discarded += chunk.length;
+ if (discarded > MAX_DISCARDED_BYTES) {
+ request.destroy();
+ }
+ });
+ // readBody pauses the body when it gives up; a paused stream stays paused when a listener is added.
+ request.resume();
+ sendStatus(response, 413, 'progress is too large');
+}
+
+/** Send the stored progress: 204 when there is none, 422 when the file is not a lone regular file. */
+async function sendProgress(response, progressFile) {
+ let contents;
+ try {
+ contents = await readWorkspaceFile(progressFile, MAX_PROGRESS_BYTES);
+ } catch (error) {
+ if (error instanceof RefusedFileError) {
+ sendStatus(response, 422, error.message);
+
+ return;
+ }
+ throw error;
+ }
+ if (contents === null) {
+ sendStatus(response, 204);
+
+ return;
+ }
+
+ response.writeHead(200, { ...BASE_HEADERS, 'content-type': CONTENT_TYPES['.json'] });
+ response.end(contents);
+}
+
+/** Validate a progress body as JSON for this attempt, or answer 400 or 409 and return `null`. */
+function parseProgressBody(body, response, attemptId) {
+ let progress;
+ try {
+ progress = parseProgress(JSON.parse(body));
+ } catch (error) {
+ sendStatus(response, 400, error instanceof Error ? error.message : 'invalid progress');
+
+ return null;
+ }
+ if (progress.attemptId !== attemptId) {
+ sendStatus(response, 409, 'progress belongs to another attempt');
+
+ return null;
+ }
+
+ return progress;
+}
+
+/** Store a progress body: 204, or 400/409/413/415/422 without writing anything. */
+async function saveProgress(request, response, options) {
+ if (!(request.headers['content-type'] ?? '').startsWith('application/json')) {
+ sendStatus(response, 415, 'progress must be JSON');
+
+ return;
+ }
+ if (Number(request.headers['content-length']) > MAX_PROGRESS_BYTES) {
+ refuseOversizedBody(request, response);
+
+ return;
+ }
+
+ const body = await readBody(request, MAX_PROGRESS_BYTES);
+ if (body === null) {
+ refuseOversizedBody(request, response);
+
+ return;
+ }
+ const progress = parseProgressBody(body, response, options.runtimeConfig.attemptId);
+ if (progress === null) {
+ return;
+ }
+
+ await answerRefusals(response, () =>
+ writeWorkspaceFile(options.progressFile, `${JSON.stringify(progress, null, 2)}\n`),
+ );
+}
+
+/** Run a workspace write and answer 204, or 422 when the file there is not a lone regular file. */
+async function answerRefusals(response, write) {
+ try {
+ await write();
+ } catch (error) {
+ if (error instanceof RefusedFileError) {
+ sendStatus(response, 422, error.message);
+
+ return;
+ }
+ throw error;
+ }
+ sendStatus(response, 204);
+}
+
+/** Handle `/api/progress`. */
+async function handleProgress(request, response, options) {
+ if (request.method === 'GET') {
+ await sendProgress(response, options.progressFile);
+
+ return;
+ }
+
+ if (!originIsOwn(request)) {
+ sendStatus(response, 403, 'cross-origin writes are not allowed');
+
+ return;
+ }
+
+ if (request.method === 'DELETE') {
+ await answerRefusals(response, () => removeWorkspaceFile(options.progressFile));
+
+ return;
+ }
+
+ if (request.method !== 'PUT') {
+ sendStatus(response, 405);
+
+ return;
+ }
+
+ await saveProgress(request, response, options);
+}
+
+/** Serve a file from the game directory (or `/shared/`), with `/` meaning `index.html`. */
+async function handleStatic(pathname, response, gameDir) {
+ let decoded;
+ try {
+ decoded = decodeURIComponent(pathname);
+ } catch {
+ sendStatus(response, 400, 'the path has a malformed percent-escape');
+
+ return;
+ }
+ const isShared = decoded.startsWith('/shared/');
+ const baseDir = isShared ? path.resolve(gameDir, '..', 'shared') : path.resolve(gameDir);
+ const relative =
+ decoded === '/' ? 'index.html' : isShared ? decoded.slice('/shared/'.length) : decoded.slice(1);
+ const file = path.resolve(baseDir, relative);
+ if (!file.startsWith(`${baseDir}${path.sep}`)) {
+ sendStatus(response, 404);
+
+ return;
+ }
+
+ try {
+ if (!(await stat(file)).isFile()) {
+ sendStatus(response, 404);
+
+ return;
+ }
+ const contents = await readFile(file);
+ response.writeHead(200, {
+ ...BASE_HEADERS,
+ 'content-type': CONTENT_TYPES[path.extname(file)] ?? 'application/octet-stream',
+ });
+ response.end(contents);
+ } catch {
+ sendStatus(response, 404);
+ }
+}
+
+/** Create the page server. The caller decides when and where it listens. */
+export function createPageServer(options) {
+ return createServer((request, response) => {
+ void (async () => {
+ if (!hostIsLoopback(request)) {
+ sendStatus(response, 403, 'this server answers to loopback names only');
+
+ return;
+ }
+
+ const pathname = URL.parse(request.url ?? '/', 'http://localhost')?.pathname;
+ if (pathname === undefined) {
+ sendStatus(response, 400, 'the request target is not a URL path');
+ } else if (pathname === '/healthz') {
+ sendStatus(response, 200, 'ok');
+ } else if (pathname === '/runtime-config.json') {
+ sendJson(response, 200, options.runtimeConfig);
+ } else if (pathname === '/api/progress') {
+ await handleProgress(request, response, options);
+ } else if (request.method === 'GET' || request.method === 'HEAD') {
+ await handleStatic(pathname, response, options.gameDir);
+ } else {
+ sendStatus(response, 405);
+ }
+ })().catch(() => {
+ if (!response.headersSent) {
+ sendStatus(response, 500);
+ } else {
+ response.end();
+ }
+ });
+ });
+}
diff --git a/challenges/glitch-invaders/server/paths.mjs b/challenges/glitch-invaders/server/paths.mjs
new file mode 100644
index 0000000..1eccac7
--- /dev/null
+++ b/challenges/glitch-invaders/server/paths.mjs
@@ -0,0 +1,22 @@
+/**
+ * Where an attempt keeps its files. Everything lives in the participant's workspace, which
+ * Boothstrap provides fresh and removes afterwards; standalone, the current directory stands in.
+ */
+import path from 'node:path';
+
+/** The name of the progress file, which `SKILL.md` tells the guide to read. */
+export const PROGRESS_FILE_NAME = 'glitch-progress.json';
+
+/** Resolve the attempt's paths: `BOOTHSTRAP_WORKSPACE` inside Boothstrap, else `cwd`. */
+export function resolveStatePaths(env, cwd) {
+ const workspace = path.resolve(env.BOOTHSTRAP_WORKSPACE ?? cwd);
+ const stateDir = path.join(workspace, '.glitch');
+
+ return {
+ workspace,
+ stateDir,
+ runtimeConfigFile: path.join(stateDir, 'runtime-config.json'),
+ pidFile: path.join(stateDir, 'server.pid'),
+ progressFile: path.join(workspace, PROGRESS_FILE_NAME),
+ };
+}
diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs
new file mode 100644
index 0000000..38fd5dc
--- /dev/null
+++ b/challenges/glitch-invaders/server/serve.mjs
@@ -0,0 +1,53 @@
+/**
+ * The page server process: `setup` starts it in the background. It listens on loopback, tells
+ * setup once it is ready (or why it could not be), and stops on SIGTERM.
+ */
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { parseRuntimeConfig } from '../shared/runtime-config.mjs';
+import { createPageServer } from './page-server.mjs';
+
+/** Tell the parent, if there is one, then drop the channel so the parent can exit. */
+function report(message) {
+ process.send?.(message, () => process.disconnect?.());
+}
+
+/** The game directory the server serves, next to this file's `server/`. */
+const GAME_DIR = path.resolve(import.meta.dirname, '..', 'game');
+
+/** Read the arguments setup passes: the port, the runtime config file, and the progress file. */
+function readArguments(argv) {
+ const [portText, runtimeConfigFile, progressFile] = argv;
+ if (portText === undefined || runtimeConfigFile === undefined || progressFile === undefined) {
+ throw new Error('usage: serve.mjs ');
+ }
+
+ return { port: Number(portText), runtimeConfigFile, progressFile };
+}
+
+/** Start serving. */
+function main() {
+ const { port, runtimeConfigFile, progressFile } = readArguments(process.argv.slice(2));
+ const runtimeConfig = parseRuntimeConfig(JSON.parse(readFileSync(runtimeConfigFile, 'utf8')));
+ const server = createPageServer({ gameDir: GAME_DIR, progressFile, runtimeConfig });
+
+ server.once('error', (error) => {
+ report({ listening: false, error: error.message });
+ process.exitCode = 1;
+ });
+ server.listen(port, '127.0.0.1', () => report({ listening: true }));
+
+ const stop = () => {
+ server.close(() => process.exit(0));
+ server.closeAllConnections();
+ };
+ process.on('SIGTERM', stop);
+ process.on('SIGINT', stop);
+}
+
+try {
+ main();
+} catch (error) {
+ report({ listening: false, error: error instanceof Error ? error.message : String(error) });
+ process.exitCode = 1;
+}
diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs
new file mode 100644
index 0000000..56457b3
--- /dev/null
+++ b/challenges/glitch-invaders/server/setup.mjs
@@ -0,0 +1,118 @@
+/**
+ * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it writes a
+ * fresh runtime config, serves the game in the background, and exits 0 only once the server
+ * answers, so Chrome never opens an unready page.
+ */
+import { spawn } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { existsSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { stopServer } from './cleanup.mjs';
+import { resolveStatePaths } from './paths.mjs';
+import {
+ clearWorkspacePath,
+ ensureWorkspaceDirectory,
+ writeWorkspaceFile,
+} from './workspace-file.mjs';
+
+/** How long setup waits for the page server to listen. */
+const READY_TIMEOUT_MS = 10_000;
+
+/** The package directory: where `challenge.json`, `game/`, and `server/` live. */
+const PACKAGE_DIR = path.resolve(import.meta.dirname, '..');
+
+/** The port from the manifest's `start_url`, overridable for tests and parallel stations. */
+function resolvePort(env) {
+ if (env.GLITCH_PORT !== undefined && env.GLITCH_PORT !== '') {
+ return Number(env.GLITCH_PORT);
+ }
+ const manifest = JSON.parse(readFileSync(path.join(PACKAGE_DIR, 'challenge.json'), 'utf8'));
+
+ return Number(new URL(manifest.browser.start_url).port);
+}
+
+/** Start the page server in the background and wait for it to listen. */
+function startServer(paths, port) {
+ return new Promise((resolve, reject) => {
+ const serveArguments = [
+ path.join(PACKAGE_DIR, 'server', 'serve.mjs'),
+ String(port),
+ paths.runtimeConfigFile,
+ paths.progressFile,
+ ];
+ const child = spawn(process.execPath, serveArguments, {
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
+ });
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL');
+ reject(new Error(`the page server did not listen within ${READY_TIMEOUT_MS} ms`));
+ }, READY_TIMEOUT_MS);
+
+ child.once('message', (message) => {
+ clearTimeout(timer);
+ if (!message.listening) {
+ reject(
+ new Error(`the page server could not listen on 127.0.0.1:${port}: ${message.error}`),
+ );
+
+ return;
+ }
+ child.unref();
+ if (child.connected) {
+ child.disconnect();
+ }
+ resolve(child.pid ?? 0);
+ });
+ child.once('exit', () => {
+ clearTimeout(timer);
+ reject(new Error('the page server exited during startup'));
+ });
+ });
+}
+
+/** Prepare the attempt and start serving. */
+export async function runSetup(env, cwd) {
+ const paths = resolveStatePaths(env, cwd);
+ if (!existsSync(path.join(PACKAGE_DIR, 'game', 'index.html'))) {
+ throw new Error('the game page is missing: game/index.html was not found');
+ }
+
+ await stopServer(paths);
+ if (env.GLITCH_PORT === undefined && paths.workspace !== PACKAGE_DIR) {
+ await stopServer(resolveStatePaths({}, PACKAGE_DIR));
+ }
+ await ensureWorkspaceDirectory(paths.stateDir);
+ await clearWorkspacePath(paths.progressFile);
+ await clearWorkspacePath(paths.pidFile);
+
+ const config = { attemptId: randomUUID() };
+ await writeWorkspaceFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`);
+
+ const port = resolvePort(env);
+ const serverPid = await startServer(paths, port);
+ try {
+ await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`);
+ } catch (error) {
+ process.kill(serverPid, 'SIGKILL');
+ throw error;
+ }
+
+ return {
+ url: `http://127.0.0.1:${port}/`,
+ attemptId: config.attemptId,
+ serverPid,
+ };
+}
+
+if (import.meta.main) {
+ runSetup(process.env, process.cwd()).then(
+ (result) => {
+ process.stdout.write(`Glitch Invaders is ready at ${result.url}\n`);
+ process.exit(0);
+ },
+ (error) => {
+ process.stderr.write(`setup: ${error instanceof Error ? error.message : String(error)}\n`);
+ process.exit(1);
+ },
+ );
+}
diff --git a/challenges/glitch-invaders/server/workspace-file.mjs b/challenges/glitch-invaders/server/workspace-file.mjs
new file mode 100644
index 0000000..1f03963
--- /dev/null
+++ b/challenges/glitch-invaders/server/workspace-file.mjs
@@ -0,0 +1,195 @@
+/**
+ * Reads, writes, and removes a file in the participant's workspace without trusting what sits
+ * there: a symlink could point anywhere, a hard link could alias a file elsewhere, a named pipe would
+ * block the reader forever, and a huge file would eat memory. Only a regular file with exactly one
+ * link, within a byte cap, is ever read, replaced, or removed, and only a real directory is written
+ * into. Clearing a path for cleanup never follows a symlink, so whatever it points at is untouched.
+ */
+import { randomBytes } from 'node:crypto';
+import { constants } from 'node:fs';
+import { lstat, mkdir, open, rename, rm, unlink } from 'node:fs/promises';
+import path from 'node:path';
+
+/** O_NOFOLLOW refuses a symlink swapped in after lstat; O_NONBLOCK keeps a swapped-in pipe from blocking open. */
+const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
+
+/** The errors open gives under O_NOFOLLOW for a symlink: ELOOP on Linux, EMLINK on some BSDs. */
+const SYMLINK_OPEN_ERRORS = ['ELOOP', 'EMLINK'];
+
+/** A workspace file that is not a lone regular file, or is over its byte cap; the message names it. */
+export class RefusedFileError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'RefusedFileError';
+ }
+}
+
+/** The lstat of `target`, or `null` when nothing is there. */
+async function lstatIfPresent(target) {
+ try {
+ return await lstat(target);
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/** Whether `stats` describe a regular file with no other names. */
+function isLoneRegularFile(stats) {
+ return stats.isFile() && stats.nlink === 1;
+}
+
+/** The refusal for `file` when it is a symlink, hard link, pipe, device, or directory. */
+function notLoneRegularFile(file) {
+ return new RefusedFileError(
+ `${path.basename(file)} is not a regular file with one link (it may be a symlink or a pipe)`,
+ );
+}
+
+/**
+ * Throw {@link RefusedFileError} when `file` exists but is not a lone regular file. Returns whether
+ * it exists at all.
+ */
+async function checkExisting(file) {
+ const stats = await lstatIfPresent(file);
+ if (stats === null) {
+ return false;
+ }
+ if (!isLoneRegularFile(stats)) {
+ throw notLoneRegularFile(file);
+ }
+
+ return true;
+}
+
+/** Open `file` for reading, refusing a symlink swapped in after the lstat. */
+async function openRefusingSymlinks(file) {
+ try {
+ return await open(file, READ_FLAGS);
+ } catch (error) {
+ if (SYMLINK_OPEN_ERRORS.includes(error.code)) {
+ throw notLoneRegularFile(file);
+ }
+ throw error;
+ }
+}
+
+/**
+ * Read `file` as UTF-8 when it is a lone regular file of at most `maxBytes`.
+ *
+ * @returns {Promise} the contents, or `null` when the file does not exist.
+ * @throws {RefusedFileError} for a symlink, hard link, pipe, device, directory, or oversized file.
+ */
+export async function readWorkspaceFile(file, maxBytes) {
+ if (!(await checkExisting(file))) {
+ return null;
+ }
+
+ const handle = await openRefusingSymlinks(file);
+ try {
+ const stats = await handle.stat();
+ if (!isLoneRegularFile(stats)) {
+ throw notLoneRegularFile(file);
+ }
+
+ const buffer = Buffer.alloc(maxBytes + 1);
+ let bytesRead = 0;
+ for (;;) {
+ const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
+ if (chunk.bytesRead === 0) {
+ break;
+ }
+ bytesRead += chunk.bytesRead;
+ if (bytesRead > maxBytes) {
+ throw new RefusedFileError(`${path.basename(file)} is over the ${maxBytes}-byte cap`);
+ }
+ }
+
+ return buffer.subarray(0, bytesRead).toString('utf8');
+ } finally {
+ await handle.close();
+ }
+}
+
+/**
+ * Replace `file` with `contents` through a freshly created temporary file and a rename, so a reader
+ * never sees half a file and a planted symlink is never written through.
+ *
+ * @throws {RefusedFileError} when `file` exists but is not a lone regular file.
+ */
+export async function writeWorkspaceFile(file, contents) {
+ await checkExisting(file);
+
+ const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
+ const handle = await open(temporary, 'wx', 0o600);
+ try {
+ await handle.writeFile(contents);
+ } catch (error) {
+ await handle.close();
+ await rm(temporary, { force: true });
+ throw error;
+ }
+ await handle.close();
+
+ try {
+ await rename(temporary, file);
+ } catch (error) {
+ await rm(temporary, { force: true });
+ throw error;
+ }
+}
+
+/**
+ * Remove `file` when it is a lone regular file; a missing file is already removed.
+ *
+ * @throws {RefusedFileError} when `file` exists but is not a lone regular file.
+ */
+export async function removeWorkspaceFile(file) {
+ if (await checkExisting(file)) {
+ await rm(file, { force: true });
+ }
+}
+
+/**
+ * Create `directory` when it is absent, and accept it when it is already a real directory.
+ *
+ * @throws {RefusedFileError} when `directory` is a symlink, file, or anything else.
+ */
+export async function ensureWorkspaceDirectory(directory) {
+ const stats = await lstatIfPresent(directory);
+ if (stats === null) {
+ await mkdir(directory, { recursive: true });
+
+ return;
+ }
+ if (!stats.isDirectory()) {
+ throw new RefusedFileError(
+ `${path.basename(directory)} is not a directory (it may be a symlink)`,
+ );
+ }
+}
+
+/**
+ * Remove whatever is at `target` without following a symlink: a symlink is unlinked and its target
+ * left alone, a directory is removed with its contents, and a file, pipe, or socket is unlinked. A
+ * missing path is already cleared.
+ */
+export async function clearWorkspacePath(target) {
+ const stats = await lstatIfPresent(target);
+ if (stats === null) {
+ return;
+ }
+ if (stats.isDirectory()) {
+ await rm(target, { recursive: true, force: true });
+
+ return;
+ }
+
+ await unlink(target).catch((error) => {
+ if (error.code !== 'ENOENT') {
+ throw error;
+ }
+ });
+}
diff --git a/challenges/glitch-invaders/setup b/challenges/glitch-invaders/setup
new file mode 100755
index 0000000..3e8a00f
--- /dev/null
+++ b/challenges/glitch-invaders/setup
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Setup hook: write a fresh runtime config into the workspace, and
+# start the page server in the background. Exits 0 once the server answers.
+set -eu
+exec node "${BOOTHSTRAP_PACKAGE_DIR:-$(dirname "$0")}/server/setup.mjs"
diff --git a/challenges/glitch-invaders/shared/progress.mjs b/challenges/glitch-invaders/shared/progress.mjs
new file mode 100644
index 0000000..8472920
--- /dev/null
+++ b/challenges/glitch-invaders/shared/progress.mjs
@@ -0,0 +1,192 @@
+/**
+ * The progress snapshot shared by the game page, the page server, and the monitor marker. It holds
+ * only non-PII gameplay state, so the server can write it into the workspace for the guide.
+ */
+
+/** The three intentional failures, in play order. */
+export const BUG_IDS = ['weapon', 'shield', 'boss'];
+
+/** How far a participant has taken one failure. */
+export const BUG_STAGES = ['unseen', 'encountered', 'investigated', 'repaired'];
+
+/** The milestones the guide may forward to Boothstrap with `emit_event`. */
+export const MILESTONE_NAMES = [
+ 'run-started',
+ 'bug-encountered',
+ 'evidence-viewed',
+ 'repair-attempted',
+ 'repair-succeeded',
+ 'challenge-completed',
+];
+
+/** Thrown when stored or received progress does not match the {@link Progress} shape. */
+export class InvalidProgressError extends Error {
+ constructor(reason) {
+ super(`invalid progress: ${reason}`);
+ this.name = 'InvalidProgressError';
+ }
+}
+
+/** The largest request body the server accepts for a progress write. */
+export const MAX_PROGRESS_BYTES = 16 * 1024;
+
+/** The only mode the game runs in: the Incident Console shows simulated Sentry evidence. */
+export const MODE = 'demo';
+
+/** A fresh, unplayed run. */
+export function createProgress(attemptId) {
+ return {
+ version: 1,
+ attemptId,
+ mode: MODE,
+ launched: false,
+ completed: false,
+ score: 0,
+ bugs: {
+ weapon: createBugProgress(),
+ shield: createBugProgress(),
+ boss: createBugProgress(),
+ },
+ milestones: [],
+ };
+}
+
+/** A failure the participant has not met yet. */
+export function createBugProgress() {
+ return {
+ stage: 'unseen',
+ evidenceViewed: false,
+ hintsUsed: 0,
+ diagnosisAttempts: 0,
+ repairAttempts: 0,
+ };
+}
+
+/** Whether `value` is one of `allowed`, narrowing it. */
+function isOneOf(allowed, value) {
+ return typeof value === 'string' && allowed.includes(value);
+}
+
+/** Read `key` from `record` as a non-negative integer, failing with the field's name. */
+function readCount(record, key, path) {
+ const value = record[key];
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 10_000) {
+ throw new InvalidProgressError(`${path}.${key} must be a small non-negative integer`);
+ }
+
+ return value;
+}
+
+/** Read `key` from `record` as a boolean, failing with the field's name. */
+function readFlag(record, key, path) {
+ const value = record[key];
+ if (typeof value !== 'boolean') {
+ throw new InvalidProgressError(`${path}.${key} must be a boolean`);
+ }
+
+ return value;
+}
+
+/** Narrow `value` to a plain object, failing with its path. */
+function readRecord(value, path) {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new InvalidProgressError(`${path} must be an object`);
+ }
+
+ return value;
+}
+
+/** Validate one failure's stored progress. */
+function parseBugProgress(value, path) {
+ const record = readRecord(value, path);
+ const stage = record.stage;
+ if (!isOneOf(BUG_STAGES, stage)) {
+ throw new InvalidProgressError(`${path}.stage must be one of ${BUG_STAGES.join(', ')}`);
+ }
+
+ return {
+ stage,
+ evidenceViewed: readFlag(record, 'evidenceViewed', path),
+ hintsUsed: readCount(record, 'hintsUsed', path),
+ diagnosisAttempts: readCount(record, 'diagnosisAttempts', path),
+ repairAttempts: readCount(record, 'repairAttempts', path),
+ };
+}
+
+/** Validate one stored milestone. */
+function parseMilestone(value, index) {
+ const path = `milestones[${index}]`;
+ const record = readRecord(value, path);
+ const name = record.name;
+ if (!isOneOf(MILESTONE_NAMES, name)) {
+ throw new InvalidProgressError(`${path}.name is not a known milestone`);
+ }
+
+ const milestone = { seq: readCount(record, 'seq', path), name };
+ if (record.bug !== undefined) {
+ if (!isOneOf(BUG_IDS, record.bug)) {
+ throw new InvalidProgressError(`${path}.bug is not a known bug`);
+ }
+ milestone.bug = record.bug;
+ }
+ if (record.correct !== undefined) {
+ milestone.correct = readFlag(record, 'correct', path);
+ }
+
+ return milestone;
+}
+
+/**
+ * Validate untrusted JSON as {@link Progress}.
+ *
+ * @throws {InvalidProgressError} when any field is missing or has the wrong type.
+ */
+export function parseProgress(value) {
+ const record = readRecord(value, 'progress');
+ if (record.version !== 1) {
+ throw new InvalidProgressError('version must be 1');
+ }
+ if (typeof record.attemptId !== 'string' || !/^[a-z0-9-]{4,64}$/.test(record.attemptId)) {
+ throw new InvalidProgressError(
+ 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens',
+ );
+ }
+ if (record.mode !== MODE) {
+ throw new InvalidProgressError(`mode must be ${MODE}`);
+ }
+ const bugs = readRecord(record.bugs, 'bugs');
+ if (!Array.isArray(record.milestones) || record.milestones.length > 200) {
+ throw new InvalidProgressError('milestones must be a list of at most 200 entries');
+ }
+
+ return {
+ version: 1,
+ attemptId: record.attemptId,
+ mode: MODE,
+ launched: readFlag(record, 'launched', 'progress'),
+ completed: readFlag(record, 'completed', 'progress'),
+ score: readCount(record, 'score', 'progress'),
+ bugs: {
+ weapon: parseBugProgress(bugs.weapon, 'bugs.weapon'),
+ shield: parseBugProgress(bugs.shield, 'bugs.shield'),
+ boss: parseBugProgress(bugs.boss, 'bugs.boss'),
+ },
+ milestones: record.milestones.map(parseMilestone),
+ };
+}
+
+/**
+ * The one line the monitor reads from the page's accessibility tree. Every field is a fixed word,
+ * so the monitor never has to interpret prose.
+ */
+export function monitorStateLine(progress) {
+ return [
+ 'glitch-state v1',
+ `launched=${progress.launched ? 'yes' : 'no'}`,
+ `weapon=${progress.bugs.weapon.stage}`,
+ `shield=${progress.bugs.shield.stage}`,
+ `boss=${progress.bugs.boss.stage}`,
+ `completed=${progress.completed ? 'yes' : 'no'}`,
+ `mode=${progress.mode}`,
+ ].join(' ');
+}
diff --git a/challenges/glitch-invaders/shared/runtime-config.mjs b/challenges/glitch-invaders/shared/runtime-config.mjs
new file mode 100644
index 0000000..14a1add
--- /dev/null
+++ b/challenges/glitch-invaders/shared/runtime-config.mjs
@@ -0,0 +1,33 @@
+/**
+ * The attempt's runtime config: setup writes it, the page server serves it as
+ * `/runtime-config.json`, and the page reads it to tie saved progress to this attempt.
+ */
+
+/** Thrown when a served or stored runtime config is malformed. */
+export class InvalidRuntimeConfigError extends Error {
+ constructor(reason) {
+ super(`invalid runtime config: ${reason}`);
+ this.name = 'InvalidRuntimeConfigError';
+ }
+}
+
+/**
+ * Validate untrusted JSON as a runtime config.
+ *
+ * @returns {{ attemptId: string }}
+ * @throws {InvalidRuntimeConfigError} when it is not an object or `attemptId` is malformed.
+ */
+export function parseRuntimeConfig(value) {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new InvalidRuntimeConfigError('config must be an object');
+ }
+
+ const { attemptId } = value;
+ if (typeof attemptId !== 'string' || !/^[a-z0-9-]{4,64}$/.test(attemptId)) {
+ throw new InvalidRuntimeConfigError(
+ 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens',
+ );
+ }
+
+ return { attemptId };
+}
diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md
new file mode 100644
index 0000000..d6fa595
--- /dev/null
+++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: start-challenge
+description: "Guides Glitch Invaders: On-Call, a Sentry debugging game in Chrome. Use when the participant starts the glitch-invaders challenge."
+---
+
+# Glitch Invaders: On-Call guide
+
+The participant is playing a game in Chrome on the left. Three things break on
+purpose: a weapon upgrade, a shield, and a boss spawn. For each, the game opens
+an Incident Console with unified evidence (context pills, a stack trace,
+breadcrumbs, or a performance trace) and offers patches. Your job is to coach
+them to read the evidence themselves. Keep replies to a few short sentences.
+
+## Tools and fallbacks
+
+- `session_info`: greet by name and see which steps are done. Call it first, and
+ again when the participant says they are done with a step.
+- `report_step`: only for `debrief`. If the tool is missing (standalone Pi), say
+ the step is done in plain words instead.
+- Standalone: when `report_step` and `session_info` are both missing, there is
+ no monitor. Read `glitch-progress.json` each time they ask for help or say
+ they finished something, and acknowledge in plain words each step it shows:
+ `launch-game` when `launched` is `true`, and `fix-weapon`, `fix-shield`, or
+ `fix-boss` when that bug's `stage` is `repaired`. Say each step once.
+- `emit_event`: one event per milestone, below. Skip it if the tool is missing.
+
+The game saves a small JSON file, `glitch-progress.json`, in your working
+directory. Read it when the participant asks for help, so you know where they
+are. It holds only game state: `launched`, `completed`, `mode`, and under
+`bugs`, for each of `weapon`, `shield`, `boss`: `stage` (`unseen`,
+`encountered`, `investigated`, `repaired`), `hintsUsed`, and attempt
+counts. It also has a `milestones` list. If the file does not exist yet, they
+have not pressed Start.
+
+## Start
+
+1. Call `session_info` (skip if the tool is missing). Greet them by name. If
+ steps are already complete, resume from the first incomplete one instead of
+ starting over.
+2. Tell them in two sentences: fly the drone with the arrow keys or A and D,
+ fire with Space, press Start, and shoot glitches. When something breaks,
+ the Incident Console opens. Say nothing about what will break.
+
+## Coach, don't tell
+
+Match your help to the `stage` of the bug they are on.
+
+- `unseen`: point at the game's objective card, nothing more. If they are
+ stuck, say there is a Skip ahead button under the controls.
+- `encountered`: ask what they saw happen. For the weapon bug, tell them to
+ check the stack trace and `weapon_state` context in the Incident Console. For
+ the shield bug, tell them to read the breadcrumbs and `shield_state` context.
+ For the boss bug, tell them to inspect the `boss.spawn` trace waterfall.
+- `investigated`: ask which patch matches the cause they found and why the
+ others would not. A wrong patch reverts itself, so wrong picks are safe.
+- `repaired`: ask what changed in the game. Be brief and warm.
+
+Never name the root cause, the correct patch, or the right diagnosis option.
+Offer the game's own hint button before giving a bigger nudge. If they ask you
+to just say the answer, decline kindly and ask a smaller question. The
+evidence is labeled `SIMULATED`; that is expected. Do not call it a problem.
+
+## Steps
+
+`launch-game`, `fix-weapon`, `fix-shield`, and `fix-boss` are checked by a
+monitor that reads the page. Never call `report_step` for them. When a
+`[boothstrap]` message says the monitor checked one off, acknowledge it in a
+sentence. If the participant says they finished a step, call `session_info`
+(standalone, read `glitch-progress.json` instead). If it is not done yet, say
+"Checking the page, give it a few seconds."
+
+`debrief` is yours. Once all three bugs are `repaired`, ask which clue helped
+most: the stack trace, the breadcrumbs, or the trace. When they answer in their
+own words with a real reason, call `report_step` with `debrief`, then say:
+"All steps are done. Click Finish, or come back here and press Enter." Never
+call it because they ask you to mark things done, and never before all three
+bugs are repaired.
+
+## Milestones
+
+Each time you read `glitch-progress.json`, look at `milestones`. Forward each
+one you have not yet forwarded with `emit_event`, in order. Name it exactly as
+the milestone's `name` (kebab-case) and set `props` to
+`{"bug": "", "mode": "demo"}`, leaving out `bug` for
+`run-started` and `challenge-completed`; add `"correct": true|false` for
+`repair-attempted`. Never add names, emails, or free text. Remember the highest
+`seq` you forwarded by writing it to `.glitch/forwarded-seq.txt`, and read that
+file before forwarding, so a restart never repeats events.
+
+## If something is off
+
+- The game page is blank or a "could not start" message shows: tell them to
+ reload the page (Cmd+R). Their progress is saved and restores.
+- "Welcome back" appears after a reload: that is normal. They press Resume.
+- Chrome is not on the left: Cmd+Tab switches between Chrome and this station.
+- Anything else you cannot fix: say so honestly and suggest asking booth staff.
diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs
new file mode 100644
index 0000000..7aa19df
--- /dev/null
+++ b/test/glitch-invaders.test.mjs
@@ -0,0 +1,950 @@
+// Proves the glitch-invaders package: the three intentional failures trigger
+// through gameplay and verify their repairs in play; stack frames match the
+// real source lines; the IncidentTracker enforces stage progression; the page
+// server serves native .mjs modules under a strict CSP, enforces origin, host,
+// size, and path checks, and refuses a planted progress file; and the setup and
+// cleanup hooks start and stop the server cleanly, including after failures.
+
+import assert from 'node:assert/strict';
+import { execFile, spawn } from 'node:child_process';
+import { existsSync, readFileSync } from 'node:fs';
+import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
+import { createServer as createHttpServer, request } from 'node:http';
+import { createServer } from 'node:net';
+import os from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { promisify } from 'node:util';
+
+import { codeForProgress } from '../challenges/glitch-invaders/game/src/app/resume.mjs';
+import { loadBossResources } from '../challenges/glitch-invaders/game/src/boss/boss-loader.mjs';
+import {
+ CORRECT_PATCH_IDS,
+ createBrokenCodeState,
+ REPAIR_PATCHES,
+ tryRepair,
+} from '../challenges/glitch-invaders/game/src/bugs/code-state.mjs';
+import {
+ IllegalBugTransitionError,
+ IncidentTracker,
+} from '../challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs';
+import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs';
+import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs';
+import {
+ BOSS_SPAWN_BUDGET_MS,
+ PLAYER_MAX_HULL,
+ PLAYER_SPEED,
+ SHIELD_BUG_MULTIPLIER,
+ WORLD_WIDTH,
+} from '../challenges/glitch-invaders/game/src/engine/constants.mjs';
+import { GameEngine } from '../challenges/glitch-invaders/game/src/engine/game-engine.mjs';
+import { createRandom } from '../challenges/glitch-invaders/game/src/engine/random.mjs';
+import { idleInput } from '../challenges/glitch-invaders/game/src/engine/world.mjs';
+import { WeaponConfigError } from '../challenges/glitch-invaders/game/src/weapons/weapons.mjs';
+import { runCleanup } from '../challenges/glitch-invaders/server/cleanup.mjs';
+import { resolveStatePaths } from '../challenges/glitch-invaders/server/paths.mjs';
+import { runSetup } from '../challenges/glitch-invaders/server/setup.mjs';
+import { BUG_IDS, createProgress, monitorStateLine } from '../challenges/glitch-invaders/shared/progress.mjs';
+
+const runFile = promisify(execFile);
+const testDirectory = path.dirname(fileURLToPath(import.meta.url));
+const packageDirectory = path.join(testDirectory, '..', 'challenges', 'glitch-invaders');
+const FRAME_MS = 16;
+const SERVER_EXIT_TIMEOUT_MS = 5_000;
+const CONTENT_SECURITY_POLICY =
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'";
+
+/** Fake time for the boss loader, which needs `now` and `sleep`. */
+class VirtualClock {
+ constructor() {
+ this.time = 0;
+ this.timers = [];
+ }
+
+ /** The current virtual time in milliseconds. */
+ now = () => this.time;
+
+ /** Resolve after `milliseconds` of virtual time. */
+ sleep = (milliseconds) =>
+ new Promise((resolve) => {
+ this.timers.push({ at: this.time + milliseconds, resolve });
+ });
+
+ /** Move time forward, firing each timer at its moment and letting its continuations run. */
+ async advance(milliseconds) {
+ const target = this.time + milliseconds;
+ for (;;) {
+ const due = this.timers
+ .filter((timer) => timer.at <= target)
+ .sort((first, second) => first.at - second.at)[0];
+ if (due === undefined) {
+ break;
+ }
+ this.timers = this.timers.filter((timer) => timer !== due);
+ this.time = due.at;
+ due.resolve();
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ this.time = target;
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+}
+
+/** Create an engine with the shipped, broken code unless told otherwise. */
+function createHarness(code = createBrokenCodeState(), seed = 7) {
+ const clock = new VirtualClock();
+ const engine = new GameEngine({
+ random: createRandom(seed),
+ code,
+ spawnBossResources: (strategy, onTask) =>
+ loadBossResources({
+ strategy,
+ sleep: clock.sleep,
+ now: clock.now,
+ onTask,
+ }),
+ now: clock.now,
+ particleScale: 0,
+ });
+ const events = [];
+ engine.on((event) => events.push(event));
+ const input = idleInput();
+
+ const run = (milliseconds) => {
+ for (let elapsed = 0; elapsed < milliseconds; elapsed += FRAME_MS) {
+ engine.update(FRAME_MS, input);
+ }
+ };
+
+ return {
+ engine,
+ clock,
+ events,
+ input,
+ run,
+ collect: (kind) => {
+ const collected = () =>
+ kind === 'fanout' ? engine.world.player.weapon === 'fanout' : engine.world.player.shield;
+ for (let frame = 0; frame < 900 && !collected(); frame += 1) {
+ const pickup = engine.world.pickups.find((candidate) => candidate.kind === kind);
+ if (pickup !== undefined) {
+ engine.world.player.x = pickup.x - 7;
+ }
+ engine.update(FRAME_MS, input);
+ }
+ if (!collected()) {
+ throw new Error(`the ${kind} pickup was never collected`);
+ }
+ },
+ eventsOf: (type) => events.filter((event) => event.type === type),
+ };
+}
+
+/** A code state where `bugs` have been repaired with their correct patches. */
+function repaired(...bugs) {
+ const progress = createProgress('test-attempt');
+ for (const bug of bugs) {
+ progress.bugs[bug].stage = 'repaired';
+ }
+
+ return codeForProgress(progress);
+}
+
+/** Ask the OS for a free loopback port. */
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const probe = createServer();
+ probe.once('error', reject);
+ probe.listen(0, '127.0.0.1', () => {
+ const address = probe.address();
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
+ probe.close(() => resolve(port));
+ });
+ });
+}
+
+/** Make a fresh temporary directory that `testContext` removes when the test ends. */
+async function makeTemporaryDirectory(testContext) {
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'glitch-test-'));
+ testContext.after(() => rm(directory, { recursive: true, force: true }));
+
+ return directory;
+}
+
+/** The environment a hook sees for `workspace` on `port`, with nothing else from this process. */
+function hookEnvironment(workspace, port) {
+ return { PATH: process.env.PATH, BOOTHSTRAP_WORKSPACE: workspace, GLITCH_PORT: String(port) };
+}
+
+/** Run the package's `hook` (setup or cleanup) to completion and resolve with its exit code, output, and pid. */
+function runHook(hook, env, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(path.join(packageDirectory, hook), [], {
+ ...options,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', reject);
+ child.once('close', (code) => resolve({ code, stdout, stderr, pid: child.pid }));
+ });
+}
+
+/** Make a workspace whose cleanup hook runs in teardown, so no server outlives the test. */
+async function makeWorkspace(testContext, port) {
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-hooks-'));
+ testContext.after(async () => {
+ await runHook('cleanup', hookEnvironment(workspace, port));
+ await rm(workspace, { recursive: true, force: true });
+ });
+
+ return workspace;
+}
+
+/** Start a server on `port` that answers every request with `body`, closed when the test ends. */
+function holdPort(testContext, port, body) {
+ return new Promise((resolve, reject) => {
+ const server = createHttpServer((_request, response) => response.end(body));
+ testContext.after(
+ () =>
+ new Promise((done) => {
+ server.close(done);
+ server.closeAllConnections();
+ }),
+ );
+ server.once('error', reject);
+ server.listen(port, '127.0.0.1', () => resolve(server));
+ });
+}
+
+/** The text `/` answers with on `port`. */
+async function pageText(port) {
+ return (await fetch(`http://127.0.0.1:${port}/`)).text();
+}
+
+/** Whether connecting to `port` is refused. */
+async function isRefused(port) {
+ try {
+ await fetch(`http://127.0.0.1:${port}/`);
+ } catch (error) {
+ return error.cause?.code === 'ECONNREFUSED';
+ }
+
+ return false;
+}
+
+/** Poll until connecting to `port` is refused, failing the test if it never is. */
+async function waitUntilRefused(port) {
+ const deadline = Date.now() + SERVER_EXIT_TIMEOUT_MS;
+ while (!(await isRefused(port))) {
+ assert.ok(Date.now() < deadline, `port ${port} still accepts connections`);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+}
+
+/** The page server's pid, from the pid file setup wrote in `workspace`. */
+async function readServerPid(workspace) {
+ return Number((await readFile(path.join(workspace, '.glitch', 'server.pid'), 'utf8')).trim());
+}
+
+/** Read one column of `ps` for `pid`, such as `pgid`. */
+async function readProcessColumn(column, pid) {
+ const { stdout } = await runFile('ps', ['-o', `${column}=`, '-p', String(pid)]);
+
+ return stdout.trim();
+}
+
+/** Run setup for a new attempt in a fresh temporary workspace and register cleanup on `testContext`. */
+async function startStack(testContext, extraEnv = {}) {
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-ws-'));
+ const port = await freePort();
+ const env = {
+ PATH: process.env.PATH,
+ BOOTHSTRAP_WORKSPACE: workspace,
+ BOOTHSTRAP_STATION_ID: 'test-station',
+ BOOTHSTRAP_SESSION_ID: 'test-session',
+ GLITCH_PORT: String(port),
+ ...extraEnv,
+ };
+ const paths = resolveStatePaths(env, workspace);
+ const result = await runSetup(env, workspace);
+ const stop = async () => {
+ await runCleanup(paths);
+ await rm(workspace, { recursive: true, force: true });
+ };
+ testContext.after(stop);
+
+ return { ...result, paths, env, stop };
+}
+
+/** Whether process `pid` is running. */
+function alive(pid) {
+ try {
+ process.kill(pid, 0);
+
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Whether something answers on `port`. */
+async function answers(port) {
+ try {
+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * GET `requestPath` from the server at `url` exactly as written, since `fetch` would normalize dot
+ * segments away; `headers` may replace the Host. Resolves with the status, headers, and text.
+ */
+function getRaw(url, requestPath, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const { hostname, port, host } = new URL(url);
+ const probe = request(
+ { hostname, port, path: requestPath, headers: { host, ...headers } },
+ (response) => {
+ let text = '';
+ response.setEncoding('utf8');
+ response.on('data', (chunk) => {
+ text += chunk;
+ });
+ response.on('end', () =>
+ resolve({ status: response.statusCode ?? 0, headers: response.headers, text }),
+ );
+ },
+ );
+ probe.once('error', reject);
+ probe.end();
+ });
+}
+
+/** PUT `body` as JSON to the progress endpoint of the server at `url`; `headers` add or replace. */
+function putProgressTo(url, body, headers = {}) {
+ return fetch(`${url}api/progress`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json', ...headers },
+ body,
+ });
+}
+
+test('weapon stack trace frames point at the real lines of the calls they name', () => {
+ const callAtLine = {
+ fireWeapon: 'throw new WeaponConfigError',
+ 'GameEngine.tryFire': 'fireWeapon(',
+ 'GameEngine.updatePlayer': 'this.tryFire()',
+ 'GameEngine.update': 'this.updatePlayer(',
+ 'GameLoop.frame': 'this.engine.update(',
+ };
+
+ const inApp = WEAPON_STACK_FRAMES.filter((frame) => frame.inApp);
+ assert.equal(inApp.length, Object.keys(callAtLine).length);
+
+ for (const frame of inApp) {
+ const source = readFileSync(path.join(packageDirectory, frame.file), 'utf8');
+ const text = source.split('\n')[frame.line - 1] ?? '';
+ assert.ok(
+ text.includes(callAtLine[frame.functionName]),
+ `${frame.functionName} at ${frame.file}:${frame.line} did not contain ${callAtLine[frame.functionName]}`,
+ );
+ }
+});
+
+test('ships all three bugs broken and only accepts the single correct patch for each', () => {
+ const broken = createBrokenCodeState();
+ assert.equal(broken.weapons.fanout, undefined);
+ assert.equal(broken.shield, 'amplify');
+ assert.equal(broken.bossLoad, 'sequential');
+
+ for (const bug of BUG_IDS) {
+ const passing = REPAIR_PATCHES[bug].filter(
+ (patch) => tryRepair(bug, patch.id, createBrokenCodeState()).check.ok,
+ );
+ assert.deepEqual(
+ passing.map((patch) => patch.id),
+ [CORRECT_PATCH_IDS[bug]],
+ );
+ assert.notEqual(REPAIR_PATCHES[bug][0]?.id, CORRECT_PATCH_IDS[bug]);
+
+ for (const patch of REPAIR_PATCHES[bug].filter((candidate) => candidate.id !== CORRECT_PATCH_IDS[bug])) {
+ const trial = tryRepair(bug, patch.id, broken);
+ assert.equal(trial.check.ok, false);
+ assert.ok(trial.check.observation.length > 0);
+ assert.equal(trial.state, broken);
+ }
+
+ const fixed = tryRepair(bug, CORRECT_PATCH_IDS[bug], createBrokenCodeState());
+ assert.equal(fixed.check.ok, true);
+ assert.notDeepEqual(fixed.state, createBrokenCodeState());
+ }
+
+ assert.throws(
+ () => tryRepair('weapon', 'shield-absorbs-hit', createBrokenCodeState()),
+ /unknown patch/,
+ );
+});
+
+test('moves the ship within bounds and fires the single weapon at its cooldown rate', () => {
+ const { engine, input, run, eventsOf } = createHarness();
+ engine.start(1);
+ const startX = engine.world.player.x;
+ input.right = true;
+ run(500);
+ assert.ok(Math.abs(engine.world.player.x - startX - PLAYER_SPEED * 0.5) < 15);
+
+ input.right = false;
+ input.left = true;
+ run(3_000);
+ assert.ok(engine.world.player.x >= 0);
+
+ input.left = false;
+ input.right = true;
+ input.fire = true;
+ run(1_000);
+ assert.ok(engine.world.player.x + engine.world.player.width <= WORLD_WIDTH);
+
+ const shots = eventsOf('volley-fired');
+ assert.ok(shots.length >= 4 && shots.length <= 5);
+ assert.ok(shots.every((shot) => shot.weapon === 'single' && shot.projectiles === 1));
+});
+
+test('incident 1: the weapon upgrade jams with WeaponConfigError and fires three bolts once repaired', () => {
+ const { engine, input, run, collect, eventsOf } = createHarness();
+ engine.start(1);
+ engine.fastForward();
+ collect('fanout');
+ assert.equal(engine.world.player.weapon, 'fanout');
+
+ input.fire = true;
+ run(100);
+
+ const [jam] = eventsOf('weapon-jammed');
+ assert.ok(jam?.error instanceof WeaponConfigError);
+ assert.equal(jam?.error.message, 'Configuration missing for weapon: fanout');
+ assert.deepEqual(jam?.availableWeapons, ['single']);
+ assert.equal(engine.world.phase, 'frozen');
+ assert.equal(engine.world.incident, 'weapon');
+
+ engine.setCode(repaired('weapon'));
+ engine.beginVerification('weapon');
+ engine.world.player.weapon = 'single';
+ collect('fanout');
+ run(1_000);
+
+ const volleys = eventsOf('volley-fired').filter((event) => event.weapon === 'fanout');
+ assert.equal(volleys[0]?.projectiles, 3);
+ assert.deepEqual(
+ eventsOf('verification-passed').map((event) => event.bug),
+ ['weapon'],
+ );
+});
+
+test('incident 2: the shield amplifies damage until repaired, then absorbs the hit', () => {
+ const { engine, run, collect, eventsOf } = createHarness();
+ engine.start(2);
+ engine.fastForward();
+ collect('aegis');
+ run(10_000);
+
+ const [hit] = eventsOf('shield-hit');
+ assert.equal(hit?.incomingDamage, 1);
+ assert.equal(hit?.outcome.actualDamage, SHIELD_BUG_MULTIPLIER);
+ assert.equal(hit?.hullBefore, PLAYER_MAX_HULL);
+ assert.equal(hit?.hullAfter, 0);
+ assert.equal(engine.world.incident, 'shield');
+
+ engine.setCode(repaired('shield'));
+ engine.beginVerification('shield');
+ collect('aegis');
+ run(10_000);
+
+ const hits = eventsOf('shield-hit');
+ assert.equal(hits.at(-1)?.outcome.actualDamage, 0);
+ assert.equal(engine.world.player.hull, PLAYER_MAX_HULL);
+ assert.ok(eventsOf('verification-passed').some((event) => event.bug === 'shield'));
+});
+
+test('incident 3: the boss spawn stalls sequentially, then finishes within budget once repaired', async () => {
+ const { engine, clock, eventsOf } = createHarness();
+ engine.start(3);
+ assert.equal(engine.world.phase, 'boss-loading');
+ await clock.advance(5_000);
+
+ const [online] = eventsOf('boss-online');
+ assert.equal(online?.stalled, true);
+ assert.equal(online?.result.totalMs, 4_500);
+ assert.deepEqual(
+ online?.result.spans.map((span) => span.name),
+ ['loadTextures', 'loadSounds', 'loadAnimations', 'initializeAI'],
+ );
+ assert.equal(engine.world.incident, 'boss');
+
+ const code = tryRepair('boss', 'parallel-without-redundant-load', createBrokenCodeState());
+ engine.setCode(code.state);
+ engine.beginVerification('boss');
+ await clock.advance(1_500);
+
+ const verified = eventsOf('boss-online')[1];
+ assert.equal(verified?.stalled, false);
+ assert.ok(verified.result.totalMs <= BOSS_SPAWN_BUDGET_MS);
+ assert.ok(!verified.result.spans.some((span) => span.name === 'loadAnimations'));
+ assert.deepEqual(
+ eventsOf('verification-passed').map((event) => event.bug),
+ ['boss'],
+ );
+});
+
+test('continues into a tougher boss showdown after completion and finishes when defeated', async () => {
+ const { engine, clock, input, run } = createHarness(repaired('weapon', 'shield', 'boss'));
+ engine.start(3);
+ await clock.advance(1_500);
+ engine.finish();
+ assert.equal(engine.world.phase, 'finished');
+
+ engine.continuePlaying();
+ assert.equal(engine.world.phase, 'playing');
+ assert.equal(engine.world.player.weapon, 'fanout');
+ assert.equal(engine.world.player.shield, true);
+ assert.equal(engine.world.boss?.maxHealth, 36);
+
+ run(600);
+ assert.ok(engine.world.bullets.filter((bullet) => bullet.owner === 'enemy').length >= 3);
+
+ engine.world.boss.health = 1;
+ engine.world.player.x = engine.world.boss.x + engine.world.boss.width / 2 - 20;
+ input.fire = true;
+ run(2_000);
+
+ assert.equal(engine.world.boss, null);
+ assert.equal(engine.world.phase, 'finished');
+});
+
+test('IncidentTracker enforces stage order, numbers milestones once, and formats the state marker', () => {
+ const tracker = new IncidentTracker(createProgress('test-attempt'));
+ const correctDiagnosis = {
+ weapon: 'missing-config',
+ shield: 'amplified',
+ boss: 'load-animations',
+ };
+
+ assert.throws(() => tracker.markEvidenceViewed('shield'), IllegalBugTransitionError);
+
+ tracker.launch();
+ tracker.launch();
+
+ for (const bug of BUG_IDS) {
+ tracker.encounter(bug);
+ tracker.markEvidenceViewed(bug);
+ assert.equal(tracker.answerDiagnosis(bug, correctDiagnosis[bug]).correct, true);
+ tracker.recordRepairAttempt(bug, true);
+ tracker.markRepaired(bug);
+ }
+
+ const progress = tracker.snapshot();
+ assert.equal(progress.completed, true);
+ assert.equal(progress.milestones.at(-1)?.name, 'challenge-completed');
+ assert.equal(
+ monitorStateLine(progress),
+ 'glitch-state v1 launched=yes weapon=repaired shield=repaired boss=repaired completed=yes mode=demo',
+ );
+});
+
+test('SimulatedEvidenceProvider builds labelled evidence with a stack trace for the weapon jam', async () => {
+ const weaponEncounter = {
+ bug: 'weapon',
+ jam: {
+ weaponId: 'fanout',
+ availableWeapons: ['single'],
+ error: new Error('Configuration missing for weapon: fanout'),
+ },
+ };
+
+ const simulated = await new SimulatedEvidenceProvider().evidenceFor(weaponEncounter);
+
+ assert.equal(simulated.source, 'simulated');
+ assert.equal(simulated.bug, 'weapon');
+ assert.ok(simulated.sections.some((section) => section.kind === 'stacktrace'));
+});
+
+test('the page server serves native .mjs modules, stores progress, and refuses cross-origin or invalid writes', async (testContext) => {
+ const stack = await startStack(testContext);
+ const base = stack.url.replace(/\/$/, '');
+ const putProgress = (body, headers = {}) => putProgressTo(stack.url, body, headers);
+
+ assert.equal((await fetch(`${base}/healthz`)).status, 200);
+
+ const page = await fetch(`${base}/`);
+ assert.equal(page.status, 200);
+ assert.ok((await page.text()).includes('boothstrap-state'));
+
+ const moduleResponse = await fetch(`${base}/src/main.mjs`);
+ assert.equal(moduleResponse.status, 200);
+ assert.match(moduleResponse.headers.get('content-type') ?? '', /text\/javascript/);
+
+ const sharedResponse = await fetch(`${base}/shared/progress.mjs`);
+ assert.equal(sharedResponse.status, 200);
+ assert.match(sharedResponse.headers.get('content-type') ?? '', /text\/javascript/);
+
+ const config = await (await fetch(`${base}/runtime-config.json`)).json();
+ assert.deepEqual(config, { attemptId: stack.attemptId });
+
+ const progress = createProgress(stack.attemptId);
+ progress.launched = true;
+ assert.equal((await putProgress(JSON.stringify(progress))).status, 204);
+
+ const stored = await (await fetch(`${base}/api/progress`)).json();
+ assert.equal(stored.launched, true);
+ assert.equal(JSON.parse(await readFile(stack.paths.progressFile, 'utf8')).launched, true);
+
+ const other = createProgress('another-attempt');
+ assert.equal((await putProgress(JSON.stringify(other))).status, 409);
+ assert.equal((await putProgress('{"version":1}')).status, 400);
+ assert.equal((await putProgress('not json')).status, 400);
+ assert.equal((await putProgress('{}', { 'content-type': 'text/plain' })).status, 415);
+ assert.equal((await putProgress('x'.repeat(20_000))).status, 413);
+ assert.equal(
+ (await putProgress(JSON.stringify(progress), { origin: 'https://evil.example' })).status,
+ 403,
+ );
+ assert.equal((await getRaw(stack.url, '/healthz', { host: 'evil.example' })).status, 403);
+ assert.equal((await fetch(`${base}/..%2F..%2Fchallenge.json`)).status, 404);
+
+ assert.equal((await fetch(`${base}/api/progress`, { method: 'DELETE' })).status, 204);
+ assert.equal((await fetch(`${base}/api/progress`)).status, 204);
+});
+
+test('the shipped setup and cleanup hooks start and stop the server and clean up workspace state', async (testContext) => {
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-hooks-'));
+ const port = await freePort();
+ const env = {
+ PATH: process.env.PATH,
+ BOOTHSTRAP_WORKSPACE: workspace,
+ BOOTHSTRAP_PACKAGE_DIR: packageDirectory,
+ GLITCH_PORT: String(port),
+ };
+ testContext.after(async () => {
+ await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env }).catch(
+ () => undefined,
+ );
+ await rm(workspace, { recursive: true, force: true });
+ });
+
+ const setup = await runFile(path.join(packageDirectory, 'setup'), [], { cwd: workspace, env });
+ const pid = Number((await readFile(path.join(workspace, '.glitch', 'server.pid'), 'utf8')).trim());
+ assert.ok(setup.stdout.includes(`http://127.0.0.1:${port}/`));
+ assert.equal(await answers(port), true);
+ assert.equal(alive(pid), true);
+
+ await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env });
+ await runFile(path.join(packageDirectory, 'cleanup'), [], { cwd: workspace, env });
+
+ assert.equal(alive(pid), false);
+ assert.equal(await answers(port), false);
+ assert.equal(existsSync(path.join(workspace, '.glitch')), false);
+});
+
+test('the page server sends the CSP on the page and on an error response', async (testContext) => {
+ const stack = await startStack(testContext);
+
+ const page = await getRaw(stack.url, '/');
+ const missing = await getRaw(stack.url, '/no-such-file.mjs');
+
+ assert.equal(page.status, 200);
+ assert.equal(page.headers['content-security-policy'], CONTENT_SECURITY_POLICY);
+ assert.equal(missing.status, 404);
+ assert.equal(missing.headers['content-security-policy'], CONTENT_SECURITY_POLICY);
+});
+
+test('the game page shows a hidden save error that the progress store unhides on a failed save', async () => {
+ const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8');
+ const main = await readFile(path.join(packageDirectory, 'game', 'src', 'main.mjs'), 'utf8');
+
+ assert.match(
+ page,
+ /]*\shidden[^>]*>Progress can't be saved; reload the page\.<\/p>/,
+ );
+ assert.match(main, /new HttpProgressStore\([\s\S]*?showSaveError,?\s*\)/);
+ assert.match(main, /getElementById\('save-error'\)[\s\S]*?\.hidden = false/);
+});
+
+test('the game page has no inline styles or inline scripts for the CSP to block', async () => {
+ const page = await readFile(path.join(packageDirectory, 'game', 'index.html'), 'utf8');
+
+ assert.doesNotMatch(page, /