(() => { "use strict"; const canvas = document.getElementById("gameCanvas"); const ctx = canvas.getContext("2d"); const W = canvas.width; const H = canvas.height; const core = { x: W * 0.5, y: H * 0.5 }; const keys = new Set(); const pointer = { x: core.x, y: core.y, down: false, active: false }; const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const dom = { startScreen: document.getElementById("startScreen"), pauseScreen: document.getElementById("pauseScreen"), endScreen: document.getElementById("endScreen"), startButton: document.getElementById("startButton"), resumeButton: document.getElementById("resumeButton"), restartButton: document.getElementById("restartButton"), soundButton: document.getElementById("soundButton"), statusText: document.getElementById("statusText"), lightValue: document.getElementById("lightValue"), heartValue: document.getElementById("heartValue"), energyValue: document.getElementById("energyValue"), energyBar: document.getElementById("energyBar"), timeValue: document.getElementById("timeValue"), endEyebrow: document.getElementById("endEyebrow"), endTitle: document.getElementById("endTitle"), endCopy: document.getElementById("endCopy"), finalScore: document.getElementById("finalScore"), bestLine: document.getElementById("bestLine"), }; let mode = "menu"; let lastTime = 0; let score = 0; let delivered = 0; let cargo = 0; let hearts = 3; let energy = 100; let timeLeft = 90; let combo = 0; let invincible = 0; let pulseTimer = 0; let soundOn = true; let audioContext = null; let best = Number(localStorage.getItem("mothwire-best") || 0); let player = { x: core.x, y: H * 0.8, vx: 0, vy: 0, angle: -Math.PI / 2 }; let orbs = []; let hazards = []; let particles = []; let stars = []; let shake = 0; const TAU = Math.PI * 2; const rand = (min, max) => min + Math.random() * (max - min); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); const encased = (value) => String(value).padStart(2, "0"); function setScreen(screen, visible) { screen.classList.toggle("is-visible", visible); } function setStatus(value) { dom.statusText.textContent = value; } function updateHud() { dom.lightValue.textContent = `${delivered} / 18`; dom.heartValue.textContent = String(hearts); dom.energyValue.textContent = String(Math.round(energy)); dom.energyBar.style.width = `${energy}%`; dom.timeValue.textContent = String(Math.max(0, Math.ceil(timeLeft))); } function ensureAudio() { if (!soundOn) return null; if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)(); if (audioContext.state === "suspended") audioContext.resume(); return audioContext; } function tone(frequency, duration, type = "sine", volume = 0.035) { const audio = ensureAudio(); if (!audio) return; const oscillator = audio.createOscillator(); const gain = audio.createGain(); oscillator.type = type; oscillator.frequency.setValueAtTime(frequency, audio.currentTime); oscillator.frequency.exponentialRampToValueAtTime(Math.max(40, frequency * 0.72), audio.currentTime + duration); gain.gain.setValueAtTime(volume, audio.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + duration); oscillator.connect(gain).connect(audio.destination); oscillator.start(); oscillator.stop(audio.currentTime + duration); } function createStars() { stars = Array.from({ length: 80 }, () => ({ x: rand(20, W - 20), y: rand(20, H - 20), radius: rand(0.5, 1.7), phase: rand(0, TAU), speed: rand(0.4, 1.3), })); } function makeOrb(index) { const angle = (index / 8) * TAU + rand(-0.18, 0.18); const radius = rand(150, 300); return { x: core.x + Math.cos(angle) * radius, y: core.y + Math.sin(angle) * radius * 0.72, homeAngle: angle, homeRadius: radius, phase: rand(0, TAU), active: true, respawn: 0, }; } function makeHazard(index) { return { angle: (index / 5) * TAU + rand(-0.2, 0.2), radius: rand(148, 255), speed: rand(0.15, 0.36) * (index % 2 ? -1 : 1), size: rand(23, 34), phase: rand(0, TAU), stunned: 0, }; } function resetWorld() { score = 0; delivered = 0; cargo = 0; hearts = 3; energy = 100; timeLeft = 90; combo = 0; invincible = 0; pulseTimer = 0; shake = 0; player = { x: core.x, y: H * 0.8, vx: 0, vy: 0, angle: -Math.PI / 2 }; orbs = Array.from({ length: 8 }, (_, index) => makeOrb(index)); hazards = Array.from({ length: 5 }, (_, index) => makeHazard(index)); particles = []; updateHud(); } function startGame() { resetWorld(); mode = "playing"; setScreen(dom.startScreen, false); setScreen(dom.endScreen, false); setScreen(dom.pauseScreen, false); setStatus("FIND THE LIGHT"); canvas.focus(); tone(260, 0.14, "triangle", 0.04); tone(390, 0.2, "triangle", 0.03); } function pauseGame() { if (mode !== "playing") return; mode = "paused"; setScreen(dom.pauseScreen, true); setStatus("THE NIGHT IS HOLDING"); } function resumeGame() { if (mode !== "paused") return; mode = "playing"; setScreen(dom.pauseScreen, false); setStatus("FIND THE LIGHT"); canvas.focus(); } function finishGame(won, reason) { mode = won ? "won" : "lost"; const finalScore = Math.max(0, Math.round(score)); if (finalScore > best) { best = finalScore; localStorage.setItem("mothwire-best", String(best)); } dom.endEyebrow.textContent = won ? "THE NIGHT REMEMBERS" : "THE NIGHT GOT THERE FIRST"; dom.endTitle.textContent = won ? "THE RELAY IS LIT" : "THE RELAY WENT DARK"; dom.endCopy.textContent = won ? "You brought the glow back." : reason; dom.finalScore.textContent = String(finalScore); dom.bestLine.textContent = `BEST ${best}`; setScreen(dom.endScreen, true); setStatus(won ? "RELAY ONLINE" : "SIGNAL LOST"); tone(won ? 560 : 130, won ? 0.28 : 0.5, won ? "triangle" : "sawtooth", 0.045); if (won) burst(core.x, core.y, 70, 36); } function spawnParticle(x, y, options = {}) { particles.push({ x, y, vx: options.vx ?? rand(-1, 1), vy: options.vy ?? rand(-1, 1), life: options.life ?? rand(0.35, 0.8), maxLife: options.life ?? rand(0.35, 0.8), size: options.size ?? rand(1, 3), hue: options.hue ?? "accent", }); } function burst(x, y, radius, count) { for (let index = 0; index < count; index += 1) { const angle = rand(0, TAU); const speed = rand(0.8, 4.4); spawnParticle(x, y, { vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: rand(0.45, 1.3), size: rand(1, radius / 16), }); } } function pulse() { if (mode !== "playing" || energy < 25) return; energy -= 25; pulseTimer = 0.45; burst(player.x, player.y, 24, 18); let cleared = 0; for (const hazard of hazards) { const position = hazardPosition(hazard); if (distance(player, position) < 142) { hazard.stunned = 2.5; cleared += 1; burst(position.x, position.y, hazard.size, 10); } } score += cleared * 14; tone(220, 0.18, "square", 0.035); tone(480, 0.25, "sine", 0.025); updateHud(); } function hazardPosition(hazard) { const wobble = Math.sin(hazard.phase + performance.now() * 0.001) * 18; return { x: core.x + Math.cos(hazard.angle) * (hazard.radius + wobble), y: core.y + Math.sin(hazard.angle) * (hazard.radius + wobble) * 0.72, }; } function updatePlayer(dt) { let xAxis = 0; let yAxis = 0; if (keys.has("ArrowLeft") || keys.has("a")) xAxis -= 1; if (keys.has("ArrowRight") || keys.has("d")) xAxis += 1; if (keys.has("ArrowUp") || keys.has("w")) yAxis -= 1; if (keys.has("ArrowDown") || keys.has("s")) yAxis += 1; if (pointer.down && pointer.active) { const dx = pointer.x - player.x; const dy = pointer.y - player.y; const length = Math.max(1, Math.hypot(dx, dy)); xAxis += dx / length; yAxis += dy / length; } const magnitude = Math.max(1, Math.hypot(xAxis, yAxis)); const speed = pointer.down ? 3.7 : 4.3; player.vx += (xAxis / magnitude) * speed * dt * 10; player.vy += (yAxis / magnitude) * speed * dt * 10; player.vx *= Math.pow(0.002, dt); player.vy *= Math.pow(0.002, dt); player.x += player.vx * dt; player.y += player.vy * dt; player.x = clamp(player.x, 42, W - 42); player.y = clamp(player.y, 42, H - 42); if (Math.hypot(player.vx, player.vy) > 0.1) player.angle = Math.atan2(player.vy, player.vx); } function updateOrbs(dt, now) { for (const orb of orbs) { if (!orb.active) { orb.respawn -= dt; if (orb.respawn <= 0) { orb.active = true; orb.phase = rand(0, TAU); } continue; } const angle = orb.homeAngle + Math.sin(now * 0.0004 + orb.phase) * 0.15; const radius = orb.homeRadius + Math.sin(now * 0.0011 + orb.phase) * 16; orb.x += (core.x + Math.cos(angle) * radius - orb.x) * dt * 1.2; orb.y += (core.y + Math.sin(angle) * radius * 0.72 - orb.y) * dt * 1.2; if (distance(player, orb) < 28) { orb.active = false; orb.respawn = rand(0.7, 1.25); cargo += 1; combo += 1; score += 12 + Math.min(60, combo * 3); energy = clamp(energy + 9, 0, 100); burst(orb.x, orb.y, 18, 12); tone(300 + combo * 25, 0.12, "sine", 0.026); } } if (cargo > 0 && distance(player, core) < 48) { delivered += cargo; score += cargo * 30 + combo * 9; cargo = 0; combo = 0; burst(core.x, core.y, 46, 22); tone(540, 0.16, "triangle", 0.035); if (delivered >= 18) finishGame(true, ""); } } function updateHazards(dt) { for (const hazard of hazards) { hazard.angle += hazard.speed * dt; hazard.stunned = Math.max(0, hazard.stunned - dt); if (hazard.stunned > 0 || invincible > 0) continue; const position = hazardPosition(hazard); if (distance(player, position) < hazard.size + 16) { hearts -= 1; cargo = 0; combo = 0; invincible = 1.5; shake = 0.35; player.x = core.x; player.y = H * 0.78; player.vx = 0; player.vy = 0; burst(position.x, position.y, hazard.size, 22); tone(110, 0.22, "sawtooth", 0.04); if (hearts <= 0) finishGame(false, "The snags took the last of your hearts."); } } } function updateParticles(dt) { particles = particles.filter((particle) => { particle.life -= dt; particle.x += particle.vx * dt * 50; particle.y += particle.vy * dt * 50; particle.vx *= 0.97; particle.vy *= 0.97; return particle.life > 0; }); } function update(dt, now) { if (mode !== "playing") return; timeLeft -= dt; invincible = Math.max(0, invincible - dt); pulseTimer = Math.max(0, pulseTimer - dt); shake = Math.max(0, shake - dt); energy = clamp(energy + dt * 3.2, 0, 100); updatePlayer(dt); updateOrbs(dt, now); updateHazards(dt); updateParticles(dt); if (timeLeft <= 0 && mode === "playing") finishGame(false, "The night folded before the relay was full."); updateHud(); } function drawBackground(now) { const gradient = ctx.createRadialGradient(core.x, core.y, 30, core.x, core.y, 600); gradient.addColorStop(0, "#193b35"); gradient.addColorStop(0.42, "#0b2424"); gradient.addColorStop(1, "#071316"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.globalAlpha = 0.18; ctx.strokeStyle = "#b8d4bd"; ctx.lineWidth = 1; for (let x = 0; x < W; x += 48) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = 0; y < H; y += 48) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } ctx.restore(); for (const star of stars) { const alpha = 0.25 + Math.sin(now * 0.001 * star.speed + star.phase) * 0.16; ctx.fillStyle = `rgba(221,255,228,${Math.max(0.05, alpha)})`; ctx.beginPath(); ctx.arc(star.x, star.y, star.radius, 0, TAU); ctx.fill(); } } function drawCore(now) { const breathing = 1 + Math.sin(now * 0.003) * 0.06; ctx.save(); ctx.translate(core.x, core.y); ctx.scale(breathing, breathing); const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, 96); glow.addColorStop(0, "rgba(199,255,73,0.42)"); glow.addColorStop(0.38, "rgba(199,255,73,0.08)"); glow.addColorStop(1, "rgba(199,255,73,0)"); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(0, 0, 96, 0, TAU); ctx.fill(); ctx.strokeStyle = "rgba(199,255,73,0.22)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 53, 0, TAU); ctx.stroke(); ctx.strokeStyle = "rgba(199,255,73,0.55)"; ctx.beginPath(); ctx.arc(0, 0, 35, -Math.PI * 0.18, Math.PI * 1.1); ctx.stroke(); ctx.fillStyle = "#dfff8b"; ctx.shadowColor = "rgba(199,255,73,0.95)"; ctx.shadowBlur = 20; ctx.beginPath(); ctx.arc(0, 0, 8, 0, TAU); ctx.fill(); ctx.restore(); } function drawOrbs(now) { for (const orb of orbs) { if (!orb.active) continue; const pulseScale = 1 + Math.sin(now * 0.004 + orb.phase) * 0.16; ctx.save(); ctx.translate(orb.x, orb.y); ctx.scale(pulseScale, pulseScale); ctx.globalCompositeOperation = "lighter"; const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, 32); glow.addColorStop(0, "rgba(199,255,73,0.85)"); glow.addColorStop(0.2, "rgba(199,255,73,0.38)"); glow.addColorStop(1, "rgba(199,255,73,0)"); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(0, 0, 32, 0, TAU); ctx.fill(); ctx.fillStyle = "#ddff8a"; ctx.beginPath(); ctx.arc(0, 0, 4, 0, TAU); ctx.fill(); ctx.restore(); } } function drawHazards(now) { for (const hazard of hazards) { const position = hazardPosition(hazard); const disabled = hazard.stunned > 0; ctx.save(); ctx.translate(position.x, position.y); ctx.rotate(hazard.angle + now * 0.0003); ctx.globalAlpha = disabled ? 0.23 : 0.74; ctx.strokeStyle = disabled ? "#b8d4bd" : "#6f9186"; ctx.lineWidth = disabled ? 2 : 3; ctx.beginPath(); ctx.moveTo(-hazard.size, -hazard.size * 0.35); ctx.lineTo(hazard.size, hazard.size * 0.35); ctx.moveTo(-hazard.size * 0.7, hazard.size * 0.7); ctx.lineTo(hazard.size * 0.7, -hazard.size * 0.7); ctx.stroke(); ctx.globalAlpha = disabled ? 0.12 : 0.25; ctx.strokeStyle = "#d7f4d0"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, hazard.size + 10, 0, TAU); ctx.stroke(); ctx.restore(); } } function drawPlayer(now) { const flicker = invincible > 0 && Math.floor(now / 90) % 2 === 0; if (flicker) return; ctx.save(); ctx.translate(player.x, player.y); ctx.rotate(player.angle); ctx.globalCompositeOperation = "lighter"; const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, 46); glow.addColorStop(0, "rgba(199,255,73,0.3)"); glow.addColorStop(1, "rgba(199,255,73,0)"); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(0, 0, 46, 0, TAU); ctx.fill(); ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = "#dfff8b"; ctx.beginPath(); ctx.moveTo(18, 0); ctx.lineTo(-9, -9); ctx.lineTo(-4, 0); ctx.lineTo(-9, 9); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#83a58e"; ctx.beginPath(); ctx.ellipse(-3, -9, 15, 5, -0.5, 0, TAU); ctx.ellipse(-3, 9, 15, 5, 0.5, 0, TAU); ctx.fill(); ctx.strokeStyle = "rgba(223,255,139,0.8)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-8, -5); ctx.lineTo(-17, -15); ctx.moveTo(-8, 5); ctx.lineTo(-17, 15); ctx.stroke(); ctx.restore(); if (cargo > 0) { ctx.save(); ctx.globalCompositeOperation = "lighter"; for (let index = 0; index < cargo; index += 1) { const angle = now * 0.0013 + (index / Math.max(1, cargo)) * TAU; const x = player.x + Math.cos(angle) * (18 + index * 3); const y = player.y + Math.sin(angle) * (18 + index * 3); ctx.fillStyle = "#e7ffad"; ctx.beginPath(); ctx.arc(x, y, 3, 0, TAU); ctx.fill(); } ctx.restore(); } } function drawPulse() { if (pulseTimer <= 0) return; const progress = 1 - pulseTimer / 0.45; ctx.save(); ctx.globalAlpha = 1 - progress; ctx.strokeStyle = "#dfff8b"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(player.x, player.y, 18 + progress * 126, 0, TAU); ctx.stroke(); ctx.restore(); } function drawParticles() { ctx.save(); ctx.globalCompositeOperation = "lighter"; for (const particle of particles) { const alpha = clamp(particle.life / particle.maxLife, 0, 1); ctx.fillStyle = `rgba(199,255,73,${alpha * 0.9})`; ctx.beginPath(); ctx.arc(particle.x, particle.y, particle.size * alpha, 0, TAU); ctx.fill(); } ctx.restore(); } function render(now) { ctx.save(); if (shake > 0 && !prefersReducedMotion) ctx.translate(rand(-shake * 10, shake * 10), rand(-shake * 8, shake * 8)); drawBackground(now); drawCore(now); drawOrbs(now); drawHazards(now); drawPlayer(now); drawPulse(); drawParticles(); ctx.restore(); } function loop(now) { if (!lastTime) lastTime = now; const dt = Math.min(0.035, (now - lastTime) / 1000); lastTime = now; update(dt, now); render(now); requestAnimationFrame(loop); } function setPointer(event) { const rect = canvas.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * W; pointer.y = ((event.clientY - rect.top) / rect.height) * H; pointer.active = true; } window.addEventListener("keydown", (event) => { const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(event.key)) event.preventDefault(); keys.add(key); if (event.key === " ") pulse(); if (key === "p") { if (mode === "playing") pauseGame(); else if (mode === "paused") resumeGame(); } if (key === "r" && (mode === "won" || mode === "lost")) startGame(); }); window.addEventListener("keyup", (event) => { const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; keys.delete(key); }); canvas.addEventListener("pointermove", setPointer); canvas.addEventListener("pointerdown", (event) => { setPointer(event); pointer.down = true; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointerup", (event) => { pointer.down = false; canvas.releasePointerCapture(event.pointerId); }); canvas.addEventListener("pointercancel", () => { pointer.down = false; }); dom.startButton.addEventListener("click", startGame); dom.resumeButton.addEventListener("click", resumeGame); dom.restartButton.addEventListener("click", startGame); dom.soundButton.addEventListener("click", () => { soundOn = !soundOn; dom.soundButton.textContent = soundOn ? "SOUND ON" : "SOUND OFF"; dom.soundButton.setAttribute("aria-pressed", String(soundOn)); if (soundOn) tone(420, 0.12, "sine", 0.03); }); createStars(); resetWorld(); updateHud(); requestAnimationFrame(loop); })();