diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4fa8371
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# VANTA//RELAY
+
+A neon isometric puzzle game about being your own infrastructure.
+
+The vault will not accept the current you as a power source. Walk onto a lower-case signal node, **anchor** a frozen echo, then use the reset present-self to cross the matching upper-case gate. Each chamber asks you to sustain more impossible versions of yourself at once.
+
+## Play
+
+This project is fully static and has no package installation or build step.
+
+```sh
+cd /root/vanta-relay-20260727-095415
+python3 -m http.server 4173
+```
+
+Then open `http://127.0.0.1:4173`.
+
+- `WASD` or arrow keys: move
+- `Space`: anchor an echo while standing on a lower-case node
+- `Z`: remove the most recently anchored echo
+- `R`: reset the current relay
+- `M`: toggle synthesized audio
+- `H`: show the protocol card
+
+The on-screen D-pad and buttons provide touch/click alternatives.
+
+## Design
+
+- **Three authored chambers** — from one echo to three simultaneous signal witnesses.
+- **Actual game rules, separate from rendering** — `game-engine.js` is a deterministic, DOM-free state model.
+- **Pseudo-3D canvas world** — an isometric grid, extruded null-walls, glowing gates, particles, animated portal rings, and procedural starfield; no images, fonts, CDN, or network runtime calls.
+- **Accessible controls** — semantic buttons, canvas label, visible objective/status readouts, and keyboard controls.
+- **Optional local progress** — the highest unlocked chamber is stored locally only when browser storage is available.
+
+## Verify
+
+Node 22+ is sufficient; there are no dependencies to install.
+
+```sh
+npm test
+npm run check
+```
+
+The suite covers engine behavior, invalid maps, closed-gate negative paths, echo capacity/undo/reset, authored-map reachability, staged gates, and a static zero-external-asset shell check.
+
+## Project map
+
+```text
+index.html interface, responsive chrome, and inline style system
+app.js canvas renderer, controls, particles, audio, smoke-test surface
+game-engine.js deterministic relay state machine
+levels.js authored chambers plus graph reachability helper
+tests/ Node test suite
+```
+
+No credentials are stored in this repository.
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..9b2cf0b
--- /dev/null
+++ b/app.js
@@ -0,0 +1,507 @@
+import {
+ anchor,
+ createGame,
+ getActiveGateIds,
+ move,
+ removeLastEcho,
+ reset,
+} from './game-engine.js';
+import { LEVELS } from './levels.js';
+
+const canvas = document.querySelector('#relay-canvas');
+const ctx = canvas.getContext('2d');
+const ui = {
+ levelCode: document.querySelector('#level-code'),
+ levelTitle: document.querySelector('#level-title'),
+ briefing: document.querySelector('#briefing'),
+ echoCount: document.querySelector('#echo-count'),
+ echoCapacity: document.querySelector('#echo-capacity'),
+ moveCount: document.querySelector('#move-count'),
+ gateCount: document.querySelector('#gate-count'),
+ coordinate: document.querySelector('#coordinate-readout'),
+ cycle: document.querySelector('#cycle-readout'),
+ objective: document.querySelector('#objective-text'),
+ event: document.querySelector('#event-feed'),
+ protocol: document.querySelector('#protocol-list'),
+ modal: document.querySelector('#signal-modal'),
+ modalTitle: document.querySelector('#modal-title'),
+ modalCopy: document.querySelector('#modal-copy'),
+ nextLevel: document.querySelector('#next-level'),
+ help: document.querySelector('#help-card'),
+ mute: document.querySelector('[data-action="mute"]'),
+};
+
+let levelIndex = 0;
+let game;
+let muted = false;
+let audioContext;
+let completionQueued = false;
+let lastFrame = 0;
+let elapsed = 0;
+let particles = [];
+let activeEvent = 'AWAITING INPUT';
+let highestUnlocked = readNumber('vanta-relay-unlocked', 0);
+
+function readNumber(key, fallback) {
+ try {
+ const value = Number.parseInt(localStorage.getItem(key) || '', 10);
+ return Number.isInteger(value) ? value : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function writeNumber(key, value) {
+ try {
+ localStorage.setItem(key, String(value));
+ } catch {
+ // Progress is optional. The relay remains fully playable without storage.
+ }
+}
+
+function levelCapacity(index) {
+ return Math.min(index + 1, 3);
+}
+
+function startLevel(index) {
+ levelIndex = index;
+ game = createGame(LEVELS[index].map, { maxEchoes: levelCapacity(index) });
+ completionQueued = false;
+ particles = [];
+ activeEvent = index === 0 ? 'FIND A LOWER-CASE SIGNAL NODE' : 'NEW MEMORY CHAMBER SYNCHRONIZED';
+ document.documentElement.style.setProperty('--cyan', LEVELS[index].accent);
+ updateHud();
+}
+
+function updateHud() {
+ const definition = LEVELS[levelIndex];
+ const activeGates = getActiveGateIds(game);
+ ui.levelCode.textContent = definition.code;
+ ui.levelTitle.textContent = definition.title;
+ ui.briefing.textContent = definition.briefing;
+ ui.echoCount.textContent = String(game.echoes.length).padStart(2, '0');
+ ui.echoCapacity.textContent = String(game.maxEchoes).padStart(2, '0');
+ ui.moveCount.textContent = String(game.moves).padStart(3, '0');
+ ui.gateCount.textContent = `${String(activeGates.length).padStart(2, '0')} ACTIVE`;
+ ui.coordinate.textContent = `POS // ${String(game.player.x).padStart(2, '0')}.${String(game.player.y).padStart(2, '0')}`;
+ ui.cycle.textContent = `CYCLE // ${String(game.moves).padStart(3, '0')}`;
+ ui.objective.textContent = game.status === 'won'
+ ? 'Extraction signature accepted. The next chamber is listening.'
+ : `Only echoes power signal nodes. ${game.echoes.length}/${game.maxEchoes} memories anchored.`;
+ ui.event.textContent = activeEvent;
+
+ ui.protocol.innerHTML = LEVELS.map((level, index) => {
+ const state = index === levelIndex ? 'current' : index <= highestUnlocked ? 'active' : '';
+ const tag = index === levelIndex ? 'LIVE' : index <= highestUnlocked ? 'ARCHIVED' : 'SEALED';
+ return `
${level.code}${tag}
`;
+ }).join('');
+}
+
+function makeTone(frequency, duration, kind = 'sine', volume = 0.025) {
+ if (muted) return;
+ try {
+ audioContext ||= new AudioContext();
+ const oscillator = audioContext.createOscillator();
+ const gain = audioContext.createGain();
+ oscillator.type = kind;
+ oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
+ gain.gain.setValueAtTime(0.0001, audioContext.currentTime);
+ gain.gain.exponentialRampToValueAtTime(volume, audioContext.currentTime + 0.012);
+ gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration);
+ oscillator.connect(gain).connect(audioContext.destination);
+ oscillator.start();
+ oscillator.stop(audioContext.currentTime + duration + 0.02);
+ } catch {
+ muted = true;
+ }
+}
+
+function eventLabel(event) {
+ const labels = {
+ anchor: 'ECHO FROZEN // GATE SIGNATURE POWERED',
+ 'anchor-required': 'ANCHOR REJECTED // STAND ON A VACANT SIGNAL NODE',
+ sealed: 'GATE REJECTED // ONLY AN ECHO MAY SUPPLY CURRENT',
+ wall: 'HARD NULL // PATH TERMINATED',
+ boundary: 'OUTSIDE RELAY GEOMETRY',
+ invalid: 'INVALID VECTOR',
+ undo: 'LAST WITNESS UNWOUND',
+ 'nothing-to-undo': 'NO WITNESS TO UNWIND',
+ ready: 'RELAY PRIMED',
+ extract: 'EXTRACTION SIGNATURE ACCEPTED',
+ };
+ return labels[event] || 'PULSE REGISTERED';
+}
+
+function project(position) {
+ const { width, height } = game.level;
+ const tileWidth = Math.min(92, 1250 / (width + height));
+ const tileHeight = tileWidth * 0.46;
+ return {
+ x: canvas.width / 2 + (position.x - position.y) * tileWidth * 0.5,
+ y: 172 + (position.x + position.y) * tileHeight * 0.5,
+ tileWidth,
+ tileHeight,
+ };
+}
+
+function diamond(x, y, width, height) {
+ ctx.beginPath();
+ ctx.moveTo(x, y - height / 2);
+ ctx.lineTo(x + width / 2, y);
+ ctx.lineTo(x, y + height / 2);
+ ctx.lineTo(x - width / 2, y);
+ ctx.closePath();
+}
+
+function worldBurst(position, color, count = 18, force = 1) {
+ const point = project(position);
+ for (let index = 0; index < count; index += 1) {
+ const angle = (Math.PI * 2 * index) / count + Math.random() * 0.34;
+ const speed = (0.7 + Math.random() * 1.5) * force;
+ particles.push({
+ x: point.x,
+ y: point.y - 30,
+ vx: Math.cos(angle) * speed * 80,
+ vy: Math.sin(angle) * speed * 80 - 24,
+ life: 0.55 + Math.random() * 0.6,
+ age: 0,
+ color,
+ size: 1 + Math.random() * 3,
+ });
+ }
+}
+
+function perform(action) {
+ if (game.status === 'won') return;
+ const before = game;
+ if (action === 'anchor') game = anchor(game);
+ if (action === 'undo') game = removeLastEcho(game);
+ if (action === 'reset') game = reset(game);
+ if (/^[UDLR]$/.test(action)) game = move(game, action);
+
+ activeEvent = eventLabel(game.lastEvent);
+ if (game.lastEvent === 'anchor') {
+ worldBurst(before.player, '#ff62d1', 34, 1.45);
+ makeTone(188, 0.16, 'triangle', 0.045);
+ setTimeout(() => makeTone(380, 0.15, 'sine', 0.035), 80);
+ } else if (game.lastEvent === 'sealed') {
+ worldBurst(before.player, '#a98bff', 10, 0.5);
+ makeTone(92, 0.11, 'square', 0.018);
+ } else if (game.lastEvent === 'move') {
+ worldBurst(game.player, '#65f9ff', 5, 0.36);
+ makeTone(220 + game.moves * 3, 0.04, 'sine', 0.013);
+ } else if (game.lastEvent === 'extract') {
+ worldBurst(game.player, '#fff3a1', 80, 2.1);
+ makeTone(260, 0.24, 'triangle', 0.05);
+ setTimeout(() => makeTone(520, 0.28, 'sine', 0.045), 130);
+ queueCompletion();
+ } else if (game.lastEvent === 'undo' || action === 'reset') {
+ particles = [];
+ makeTone(130, 0.08, 'sine', 0.02);
+ }
+ updateHud();
+}
+
+function queueCompletion() {
+ if (completionQueued) return;
+ completionQueued = true;
+ highestUnlocked = Math.max(highestUnlocked, Math.min(levelIndex + 1, LEVELS.length - 1));
+ writeNumber('vanta-relay-unlocked', highestUnlocked);
+ setTimeout(() => {
+ const lastRelay = levelIndex === LEVELS.length - 1;
+ ui.modalTitle.textContent = lastRelay ? 'YOU ARE THE RELAY' : 'SIGNAL HOLDS';
+ ui.modalCopy.textContent = lastRelay
+ ? 'Three versions of you held the void open. The vault has no owner now — only an exit.'
+ : 'A new chamber has acknowledged your impossible alibi.';
+ ui.nextLevel.textContent = lastRelay ? 'REBOOT THE RELAY' : 'ENTER NEXT CHAMBER';
+ ui.modal.hidden = false;
+ }, 650);
+}
+
+function drawBackground(time) {
+ const gradient = ctx.createRadialGradient(canvas.width * 0.5, 250, 20, canvas.width * 0.5, 370, 890);
+ gradient.addColorStop(0, '#1b3b62');
+ gradient.addColorStop(0.33, '#101c3a');
+ gradient.addColorStop(1, '#050711');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ for (let index = 0; index < 82; index += 1) {
+ const x = ((Math.sin(index * 643.13) + 1) * 0.5) * canvas.width;
+ const y = ((Math.sin(index * 93.77 + 1.1) + 1) * 0.5) * 480;
+ const alpha = 0.12 + ((Math.sin(time * 0.001 + index) + 1) * 0.08);
+ ctx.fillStyle = `rgba(151, 223, 255, ${alpha})`;
+ ctx.fillRect(x, y, index % 5 === 0 ? 2 : 1, index % 5 === 0 ? 2 : 1);
+ }
+
+ const horizon = 132;
+ ctx.save();
+ ctx.strokeStyle = 'rgba(90, 231, 255, 0.11)';
+ ctx.lineWidth = 1;
+ for (let index = -18; index <= 18; index += 1) {
+ ctx.beginPath();
+ ctx.moveTo(canvas.width / 2, horizon);
+ ctx.lineTo(canvas.width / 2 + index * 92, canvas.height);
+ ctx.stroke();
+ }
+ for (let row = 0; row < 12; row += 1) {
+ const progress = row / 12;
+ const y = horizon + progress * progress * 720;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(canvas.width, y);
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
+function drawFloor(point, tile) {
+ const { x, y, tileWidth, tileHeight } = point;
+ const fill = ctx.createLinearGradient(x - tileWidth / 2, y - tileHeight / 2, x + tileWidth / 2, y + tileHeight / 2);
+ fill.addColorStop(0, tile === '#' ? '#15213b' : '#123550');
+ fill.addColorStop(1, tile === '#' ? '#0a1021' : '#071424');
+ diamond(x, y, tileWidth, tileHeight);
+ ctx.fillStyle = fill;
+ ctx.fill();
+ ctx.strokeStyle = tile === '#' ? 'rgba(115, 164, 210, .25)' : 'rgba(98, 238, 255, .24)';
+ ctx.stroke();
+}
+
+function drawWall(point, depth) {
+ const { x, y, tileWidth, tileHeight } = point;
+ const left = x - tileWidth / 2;
+ const right = x + tileWidth / 2;
+ const bottom = y + tileHeight / 2;
+ ctx.fillStyle = '#081226';
+ ctx.beginPath();
+ ctx.moveTo(left, y);
+ ctx.lineTo(x, bottom);
+ ctx.lineTo(x, bottom + depth);
+ ctx.lineTo(left, y + depth);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = '#112a46';
+ ctx.beginPath();
+ ctx.moveTo(right, y);
+ ctx.lineTo(x, bottom);
+ ctx.lineTo(x, bottom + depth);
+ ctx.lineTo(right, y + depth);
+ ctx.closePath();
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(91, 232, 255, .14)';
+ ctx.stroke();
+}
+
+function drawNode(point, id, active, time) {
+ const { x, y, tileWidth } = point;
+ const pulse = 1 + Math.sin(time * 0.006 + id.charCodeAt(0)) * 0.16;
+ ctx.save();
+ ctx.translate(x, y - 4);
+ ctx.scale(1, 0.48);
+ ctx.beginPath();
+ ctx.arc(0, 0, tileWidth * 0.23 * pulse, 0, Math.PI * 2);
+ ctx.fillStyle = active ? 'rgba(255, 98, 209, .46)' : 'rgba(246, 196, 66, .24)';
+ ctx.fill();
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = active ? '#ff62d1' : '#ffc45a';
+ ctx.shadowBlur = active ? 26 : 10;
+ ctx.shadowColor = ctx.strokeStyle;
+ ctx.stroke();
+ ctx.restore();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = active ? '#fff0fb' : '#ffe1a0';
+ ctx.font = '700 13px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(id.toUpperCase(), x, y + 4);
+}
+
+function drawGate(point, id, active, time) {
+ const { x, y, tileWidth, tileHeight } = point;
+ const height = tileWidth * 0.84;
+ const glow = active ? '#3df2ff' : '#a98bff';
+ ctx.save();
+ ctx.globalAlpha = active ? 0.72 : 0.46;
+ const fade = ctx.createLinearGradient(x, y - height, x, y + tileHeight / 2);
+ fade.addColorStop(0, active ? 'rgba(152,255,255,.24)' : 'rgba(185,135,255,.1)');
+ fade.addColorStop(1, active ? 'rgba(61,242,255,.05)' : 'rgba(169,139,255,.03)');
+ ctx.fillStyle = fade;
+ ctx.beginPath();
+ ctx.moveTo(x - tileWidth * 0.28, y);
+ ctx.lineTo(x, y - height);
+ ctx.lineTo(x + tileWidth * 0.28, y);
+ ctx.lineTo(x, y + tileHeight * 0.16);
+ ctx.closePath();
+ ctx.fill();
+ ctx.strokeStyle = glow;
+ ctx.lineWidth = active ? 2.5 : 2;
+ ctx.shadowBlur = active ? 28 : 12;
+ ctx.shadowColor = glow;
+ ctx.stroke();
+ ctx.globalAlpha = active ? 0.34 + Math.sin(time * 0.008) * 0.1 : 0.3;
+ ctx.beginPath();
+ ctx.moveTo(x - tileWidth * 0.2, y - 2);
+ ctx.lineTo(x + tileWidth * 0.2, y - height + 2);
+ ctx.stroke();
+ ctx.restore();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = active ? '#dfffff' : '#d7bdff';
+ ctx.font = '700 14px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(id, x, y - height * 0.46);
+}
+
+function drawExtraction(point, time) {
+ const { x, y, tileWidth } = point;
+ ctx.save();
+ ctx.translate(x, y - tileWidth * 0.42);
+ ctx.scale(1, 0.43);
+ for (let ring = 0; ring < 4; ring += 1) {
+ ctx.beginPath();
+ ctx.arc(0, 0, tileWidth * (0.15 + ring * 0.07) + Math.sin(time * 0.008 + ring) * 2, 0, Math.PI * 2);
+ ctx.strokeStyle = `hsla(${52 + ring * 18}, 100%, 70%, ${0.62 - ring * 0.1})`;
+ ctx.lineWidth = 2;
+ ctx.shadowBlur = 16;
+ ctx.shadowColor = '#fff39c';
+ ctx.stroke();
+ }
+ ctx.restore();
+ ctx.shadowBlur = 0;
+}
+
+function drawEntity(position, kind, time, index = 0) {
+ const point = project(position);
+ const bob = Math.sin(time * 0.005 + index) * 5;
+ const color = kind === 'echo' ? '#ff62d1' : '#64faff';
+ const core = kind === 'echo' ? '#ffe4fa' : '#ecffff';
+ ctx.save();
+ ctx.translate(point.x, point.y - point.tileWidth * 0.55 + bob);
+ ctx.scale(1, 0.42);
+ ctx.beginPath();
+ ctx.arc(0, point.tileWidth * 0.55 / 0.42, point.tileWidth * 0.18, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(0,0,0,.32)';
+ ctx.fill();
+ ctx.restore();
+
+ const radius = point.tileWidth * (kind === 'echo' ? 0.16 : 0.19);
+ const gradient = ctx.createRadialGradient(point.x - radius * 0.25, point.y - point.tileWidth * 0.55 + bob - radius * 0.25, 1, point.x, point.y - point.tileWidth * 0.55 + bob, radius);
+ gradient.addColorStop(0, core);
+ gradient.addColorStop(0.35, color);
+ gradient.addColorStop(1, kind === 'echo' ? 'rgba(255,98,209,.08)' : 'rgba(61,242,255,.08)');
+ ctx.beginPath();
+ ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius, 0, Math.PI * 2);
+ ctx.fillStyle = gradient;
+ ctx.shadowBlur = kind === 'echo' ? 30 : 36;
+ ctx.shadowColor = color;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ if (kind === 'echo') {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius * 1.45 + Math.sin(time * 0.008) * 2, 0, Math.PI * 2);
+ ctx.strokeStyle = 'rgba(255, 161, 225, .52)';
+ ctx.stroke();
+ }
+}
+
+function drawParticles(delta) {
+ particles = particles.filter((particle) => {
+ particle.age += delta;
+ particle.x += particle.vx * delta;
+ particle.y += particle.vy * delta;
+ particle.vy += 56 * delta;
+ const ratio = 1 - particle.age / particle.life;
+ if (ratio <= 0) return false;
+ ctx.fillStyle = particle.color;
+ ctx.globalAlpha = ratio * ratio;
+ ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
+ ctx.globalAlpha = 1;
+ return true;
+ });
+}
+
+function drawBoard(time, delta) {
+ const level = game.level;
+ const activeGates = new Set(getActiveGateIds(game));
+ const cells = [];
+ for (let y = 0; y < level.height; y += 1) {
+ for (let x = 0; x < level.width; x += 1) cells.push({ x, y, tile: level.grid[y][x] });
+ }
+ cells.sort((left, right) => (left.x + left.y) - (right.x + right.y));
+
+ for (const cell of cells) {
+ const point = project(cell);
+ drawFloor(point, cell.tile);
+ if (cell.tile === '#') drawWall(point, point.tileWidth * 0.52);
+ }
+
+ for (const [id, position] of level.pads) drawNode(project(position), id, activeGates.has(id.toUpperCase()), time);
+ for (const [id, position] of level.gates) drawGate(project(position), id, activeGates.has(id), time);
+ drawExtraction(project(level.goal), time);
+
+ game.echoes.forEach((echo, index) => drawEntity(echo.position, 'echo', time, index));
+ drawEntity(game.player, 'player', time, game.echoes.length);
+ drawParticles(delta);
+}
+
+function frame(timestamp) {
+ const delta = Math.min((timestamp - lastFrame) / 1000 || 0, 0.05);
+ lastFrame = timestamp;
+ elapsed += delta;
+ drawBackground(timestamp);
+ drawBoard(timestamp, delta);
+ requestAnimationFrame(frame);
+}
+
+function toggleMute() {
+ muted = !muted;
+ ui.mute.textContent = muted ? '◌ AUDIO: OFF' : '◌ AUDIO: ON';
+ activeEvent = muted ? 'AUDIO LINK MUTED' : 'AUDIO LINK RESTORED';
+ updateHud();
+}
+
+for (const button of document.querySelectorAll('[data-move]')) {
+ button.addEventListener('click', () => perform(button.dataset.move));
+}
+for (const button of document.querySelectorAll('[data-action]')) {
+ button.addEventListener('click', () => {
+ const action = button.dataset.action;
+ if (action === 'mute') toggleMute();
+ else if (action === 'help') ui.help.hidden = !ui.help.hidden;
+ else perform(action);
+ });
+}
+
+ui.nextLevel.addEventListener('click', () => {
+ ui.modal.hidden = true;
+ startLevel(levelIndex === LEVELS.length - 1 ? 0 : levelIndex + 1);
+});
+
+window.addEventListener('keydown', (event) => {
+ const keys = {
+ ArrowUp: 'U', ArrowDown: 'D', ArrowLeft: 'L', ArrowRight: 'R',
+ w: 'U', W: 'U', s: 'D', S: 'D', a: 'L', A: 'L', d: 'R', D: 'R',
+ ' ': 'anchor', z: 'undo', Z: 'undo', r: 'reset', R: 'reset',
+ };
+ if (!(event.key in keys)) {
+ if (event.key === 'm' || event.key === 'M') toggleMute();
+ if (event.key === 'h' || event.key === 'H') ui.help.hidden = !ui.help.hidden;
+ return;
+ }
+ event.preventDefault();
+ perform(keys[event.key]);
+});
+
+window.__VANTA_TEST__ = {
+ getState: () => ({
+ level: LEVELS[levelIndex].id,
+ player: { ...game.player },
+ echoes: game.echoes.map((echo) => ({ id: echo.id, position: { ...echo.position } })),
+ gates: getActiveGateIds(game),
+ status: game.status,
+ moves: game.moves,
+ }),
+ act: (action) => perform(action),
+ loadLevel: (index) => startLevel(index),
+};
+
+startLevel(0);
+requestAnimationFrame(frame);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..63c2611
--- /dev/null
+++ b/index.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+ VANTA//RELAY — Echo puzzle
+
+
+
+
+
+
+
+
+
+
+
+
+ POS // 01.01CYCLE // 000
+ Walk onto a lower-case signal node, then press ANCHOR. Your new echo stays there while you restart.
AWAITING INPUT
+
+
+
+
+
+
+
+
+
relay status // verified
+
SIGNAL HOLDS
+
The next memory chamber is now responsive.
+
+
+
+
+
+
+
+
diff --git a/levels.js b/levels.js
new file mode 100644
index 0000000..a38ab9c
--- /dev/null
+++ b/levels.js
@@ -0,0 +1,84 @@
+export const LEVELS = [
+ {
+ id: 'cold-boot',
+ code: '01 / COLD BOOT',
+ title: 'Wake the first witness',
+ briefing: 'The exit only remembers a body that is no longer there. Leave one echo on the amber node.',
+ accent: '#3df2ff',
+ map: [
+ '#########',
+ '#@..aA.X#',
+ '#.#######',
+ '#.#######',
+ '#########',
+ ],
+ },
+ {
+ id: 'prism-drift',
+ code: '02 / PRISM DRIFT',
+ title: 'Hold two impossible places',
+ briefing: 'One self is a memory. Two selves are an alibi. Sustain both locks at once.',
+ accent: '#ff62d1',
+ map: [
+ '#############',
+ '#@..aA...B.X#',
+ '#.###########',
+ '#.....b######',
+ '#############',
+ ],
+ },
+ {
+ id: 'cathedral-null',
+ code: '03 / CATHEDRAL NULL',
+ title: 'Outnumber the silence',
+ briefing: 'The vault was built for one mind. Fill three vacant memories and walk through its contradiction.',
+ accent: '#b48cff',
+ map: [
+ '###############',
+ '#@..aA..B..C.X#',
+ '#.#############',
+ '#.....b########',
+ '#.#############',
+ '#.....c########',
+ '###############',
+ ],
+ },
+];
+
+function key(position) {
+ return `${position.x},${position.y}`;
+}
+
+function isWalkable(level, position, openGates) {
+ if (position.x < 0 || position.y < 0 || position.x >= level.width || position.y >= level.height) {
+ return false;
+ }
+ const tile = level.grid[position.y][position.x];
+ if (tile === '#') return false;
+ return !level.gates.has(tile) || openGates.has(tile);
+}
+
+export function canReachTarget(level, target, openGates = new Set()) {
+ const queue = [{ ...level.start }];
+ const visited = new Set([key(level.start)]);
+ const vectors = [
+ { x: 0, y: -1 },
+ { x: 1, y: 0 },
+ { x: 0, y: 1 },
+ { x: -1, y: 0 },
+ ];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (current.x === target.x && current.y === target.y) return true;
+
+ for (const vector of vectors) {
+ const next = { x: current.x + vector.x, y: current.y + vector.y };
+ if (!isWalkable(level, next, openGates) || visited.has(key(next))) continue;
+ visited.add(key(next));
+ queue.push(next);
+ }
+ }
+
+ return false;
+}
diff --git a/package.json b/package.json
index dff5b6a..5861750 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,6 @@
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
- "check": "node --check game-engine.js"
+ "check": "node --check game-engine.js && node --check levels.js && node --check app.js"
}
}
diff --git a/tests/levels.test.mjs b/tests/levels.test.mjs
new file mode 100644
index 0000000..ec335f6
--- /dev/null
+++ b/tests/levels.test.mjs
@@ -0,0 +1,48 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { LEVELS, canReachTarget } from '../levels.js';
+import { parseLevel } from '../game-engine.js';
+
+test('every authored relay has reachable signal pads before any echo is anchored', () => {
+ for (const definition of LEVELS) {
+ const level = parseLevel(definition.map);
+ for (const [padId, position] of level.pads) {
+ assert.equal(
+ canReachTarget(level, position, new Set()),
+ true,
+ `${definition.id}: pad ${padId} should be initially reachable`,
+ );
+ }
+ }
+});
+
+test('each relay keeps extraction sealed until its full gate sequence is sustained', () => {
+ for (const definition of LEVELS) {
+ const level = parseLevel(definition.map);
+ const allGates = new Set(level.gates.keys());
+
+ assert.equal(
+ canReachTarget(level, level.goal, new Set()),
+ false,
+ `${definition.id}: extraction must not be reachable with all gates closed`,
+ );
+ assert.equal(
+ canReachTarget(level, level.goal, allGates),
+ true,
+ `${definition.id}: extraction should be reachable with all gates active`,
+ );
+ }
+});
+
+test('multi-gate relays preserve their intended staged locks', () => {
+ const double = parseLevel(LEVELS.find((level) => level.id === 'prism-drift').map);
+ const triple = parseLevel(LEVELS.find((level) => level.id === 'cathedral-null').map);
+
+ assert.equal(canReachTarget(double, double.goal, new Set(['A'])), false);
+ assert.equal(canReachTarget(double, double.goal, new Set(['A', 'B'])), true);
+
+ assert.equal(canReachTarget(triple, triple.goal, new Set(['A'])), false);
+ assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B'])), false);
+ assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B', 'C'])), true);
+});
diff --git a/tests/shell.test.mjs b/tests/shell.test.mjs
new file mode 100644
index 0000000..69ea0c6
--- /dev/null
+++ b/tests/shell.test.mjs
@@ -0,0 +1,22 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const root = new URL('../', import.meta.url);
+const readProjectFile = (name) => readFileSync(new URL(name, root), 'utf8');
+
+test('the playable shell uses a local canvas entry point and no external runtime assets', () => {
+ const html = readProjectFile('index.html');
+
+ assert.match(html, /