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);