Back to Truffle Rush
SOURCE / PINNED RELEASE

Made of little things.

Truffle Rush

Release
bf9d57a12f82…
Author-recorded commit
bf11c370d93c…
License
LICENSE
Author’s source reference
nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-dad0644224b

Archive hash verified: 261cbd0dce1d32f5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { onSettingsChanged, type GameSettings } from './napplet-settings.js';
import './styles.css';

/* ─────────────────────────────────────────────────────────────────────
   Truffle Rush — a moonlit truffle-orchard arcade.
   Catch snacks, dodge crows, fill the belly for a golden frenzy.
   Everything is drawn locally; no network, no remote assets.
   ───────────────────────────────────────────────────────────────────── */

type Phase = 'ready' | 'playing' | 'paused' | 'over';
type ItemKind = 'acorn' | 'truffle' | 'golden' | 'crow';
type OverlayMode = 'start' | 'pause' | 'over';

interface Item {
  kind: ItemKind;
  x: number;
  y: number;
  vy: number;
  drift: number;
  phase: number;
  spin: number;
  r: number;
}

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  maxLife: number;
  size: number;
  color: string;
  gravity: number;
}

interface Floater {
  x: number;
  y: number;
  text: string;
  life: number;
  maxLife: number;
  color: string;
}

interface Firefly {
  baseX: number;
  baseY: number;
  phase: number;
  speed: number;
  amp: number;
}

const FRENZY_TIME = 5;
const BEST_KEY = 'truffle-rush:best';
const MUTE_KEY = 'truffle-rush:muted';

const elements = {
  app: requireElement<HTMLElement>('#app'),
  canvas: requireElement<HTMLCanvasElement>('#game'),
  score: requireElement<HTMLElement>('#score'),
  best: requireElement<HTMLElement>('#best'),
  hearts: requireElement<HTMLElement>('#hearts'),
  combo: requireElement<HTMLElement>('#combo'),
  toast: requireElement<HTMLElement>('#toast'),
  bellyFill: requireElement<HTMLElement>('#bellyFill'),
  overlay: requireElement<HTMLElement>('#overlay'),
  overlayKicker: requireElement<HTMLElement>('#overlayKicker'),
  overlayTitle: requireElement<HTMLElement>('#overlayTitle'),
  overlayText: requireElement<HTMLElement>('#overlayText'),
  overlayStats: requireElement<HTMLElement>('#overlayStats'),
  playButton: requireElement<HTMLButtonElement>('#playButton'),
  hint: requireElement<HTMLElement>('#hint'),
  soundButton: requireElement<HTMLButtonElement>('#soundButton'),
  pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
};

const canvas = elements.canvas;
const context2d = canvas.getContext('2d');
if (!context2d) throw new Error('Canvas 2D context is unavailable');
const ctx: CanvasRenderingContext2D = context2d;

const game = {
  phase: 'ready' as Phase,
  w: 0,
  h: 0,
  groundY: 0,
  unit: 1,
  pigW: 64,
  pigH: 46,
  pigX: 0,
  pigTarget: 0,
  pigVX: 0,
  mouth: 0,
  blink: 0,
  invuln: 0,
  score: 0,
  best: 0,
  bestBefore: 0,
  hearts: 3,
  belly: 0,
  combo: 0,
  maxCombo: 0,
  level: 1,
  foodCaught: 0,
  frenzy: 0,
  elapsed: 0,
  time: 0,
  spawnTimer: 0.6,
  shake: 0,
  flash: 0,
  items: [] as Item[],
  particles: [] as Particle[],
  floaters: [] as Floater[],
  fireflies: [] as Firefly[],
  keys: { left: false, right: false },
  pointerDown: false,
};

let settings: GameSettings = { sound: true, screenShake: true };
let muted = false;
let configSoundEnabled = true;
let audio: AudioContext | null = null;
let background: HTMLCanvasElement | null = null;
let toastTimer = 0;
let bestDirty = false;
let lastFrame = performance.now();
let resizeObserver: ResizeObserver | null = null;

const reduceMotion =
  typeof window.matchMedia === 'function' &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const hud = {
  score: -1,
  best: -1,
  hearts: -1,
  mult: -1,
  frenzy: -1,
  belly: -1,
  phase: '' as Phase | '',
};

/* ── Utilities ───────────────────────────────────────────────────────── */

function requireElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function rand(min: number, max: number): number {
  return min + Math.random() * (max - min);
}

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function multiplier(): number {
  return Math.min(5, 1 + Math.floor(game.combo / 5));
}

/* ── Sound ──────────────────────────────────────────────────────────── */

function soundEnabled(): boolean {
  return !muted && configSoundEnabled;
}

function ensureAudio(): boolean {
  if (!soundEnabled()) return false;
  try {
    audio ??= new AudioContext();
    if (audio.state === 'suspended') void audio.resume();
    return true;
  } catch {
    return false;
  }
}

function playTone(
  freq: number,
  duration = 0.1,
  type: OscillatorType = 'triangle',
  volume = 0.05,
  slideTo?: number,
  delay = 0,
): void {
  if (!ensureAudio() || !audio) return;
  const ac = audio;
  const start = ac.currentTime + delay;
  const osc = ac.createOscillator();
  const gain = ac.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(Math.max(30, freq), start);
  if (slideTo !== undefined) {
    osc.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), start + duration);
  }
  gain.gain.setValueAtTime(0.0001, start);
  gain.gain.exponentialRampToValueAtTime(volume, start + 0.012);
  gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  osc.connect(gain);
  gain.connect(ac.destination);
  osc.start(start);
  osc.stop(start + duration + 0.03);
}

function soundCatch(kind: ItemKind): void {
  if (kind === 'acorn') {
    playTone(520 + Math.min(game.combo, 14) * 22, 0.08, 'triangle', 0.05, 780);
  } else if (kind === 'truffle') {
    playTone(560, 0.08, 'triangle', 0.055);
    playTone(840, 0.1, 'triangle', 0.045, 1080, 0.05);
  } else if (kind === 'golden') {
    playTone(660, 0.1, 'sine', 0.06);
    playTone(990, 0.1, 'sine', 0.05, undefined, 0.06);
    playTone(1320, 0.16, 'sine', 0.045, undefined, 0.12);
  }
}

function soundMiss(): void {
  playTone(230, 0.1, 'sine', 0.025, 170);
}

function soundHurt(): void {
  playTone(200, 0.18, 'sawtooth', 0.05, 70);
  playTone(120, 0.22, 'triangle', 0.04, 55, 0.03);
}

function soundFrenzy(): void {
  playTone(520, 0.12, 'square', 0.035);
  playTone(660, 0.12, 'square', 0.035, undefined, 0.1);
  playTone(880, 0.12, 'square', 0.035, undefined, 0.2);
  playTone(1170, 0.22, 'triangle', 0.05, undefined, 0.3);
}

function soundGameOver(): void {
  playTone(440, 0.16, 'triangle', 0.05, undefined, 0);
  playTone(330, 0.18, 'triangle', 0.05, undefined, 0.16);
  playTone(220, 0.34, 'triangle', 0.05, 140, 0.34);
}

function soundStart(): void {
  playTone(392, 0.1, 'triangle', 0.04);
  playTone(587, 0.14, 'triangle', 0.045, undefined, 0.09);
}

/* ── Persistence (optional storage domain) ──────────────────────────── */

function persist(key: string, value: string): void {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    void storage.setItem(key, value).catch(() => undefined);
  } catch {
    // A present domain may still deny an operation; the game stays playable.
  }
}

async function loadProgress(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    const [bestRaw, muteRaw] = await Promise.all([
      storage.getItem(BEST_KEY),
      storage.getItem(MUTE_KEY),
    ]);
    const storedBest = Number(bestRaw);
    if (Number.isFinite(storedBest) && storedBest > 0) {
      game.best = Math.floor(storedBest);
    }
    if (muteRaw === '1') muted = true;
    updateSoundButton();
    updateHud(true);
    if (game.phase === 'ready') showOverlay('start');
  } catch {
    // Optional persistence is unavailable; defaults remain.
  }
}

function saveBest(): void {
  if (!bestDirty) return;
  bestDirty = false;
  persist(BEST_KEY, String(game.best));
}

/* ── Sizing and background ──────────────────────────────────────────── */

function resize(): void {
  const rect = canvas.getBoundingClientRect();
  const w = Math.max(1, rect.width);
  const h = Math.max(1, rect.height);
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const pixelW = Math.round(w * dpr);
  const pixelH = Math.round(h * dpr);

  if (pixelW === canvas.width && pixelH === canvas.height && background) {
    return;
  }

  game.w = w;
  game.h = h;
  canvas.width = pixelW;
  canvas.height = pixelH;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

  game.groundY = h - clamp(h * 0.12, 14, 58);
  game.unit = clamp(Math.min(w / 500, h / 700), 0.7, 1.7);
  game.pigW = clamp(w * 0.2, 44, 120);
  game.pigH = game.pigW * 0.72;

  if (game.pigX === 0 && game.pigTarget === 0) {
    game.pigX = w / 2;
    game.pigTarget = w / 2;
  } else {
    game.pigX = clamp(game.pigX, game.pigW / 2, w - game.pigW / 2);
    game.pigTarget = clamp(game.pigTarget, game.pigW / 2, w - game.pigW / 2);
  }

  for (const item of game.items) {
    item.x = clamp(item.x, item.r, w - item.r);
    item.y = Math.min(item.y, h + item.r);
  }

  game.fireflies = Array.from({ length: 9 }, (_, index) => {
    const seed = mulberry32(9001 + index * 131);
    return {
      baseX: 0.08 + seed() * 0.84,
      baseY: 0.2 + seed() * 0.58,
      phase: seed() * Math.PI * 2,
      speed: 0.35 + seed() * 0.6,
      amp: 6 + seed() * 14,
    };
  });

  background = buildBackground(w, h, dpr);
}

function hillPath(
  b: CanvasRenderingContext2D,
  w: number,
  h: number,
  baseY: number,
  amp: number,
  color: string,
  phase: number,
): void {
  b.fillStyle = color;
  b.beginPath();
  b.moveTo(0, baseY);
  const step = Math.max(8, w / 48);
  for (let x = 0; x <= w + step; x += step) {
    const y =
      baseY -
      Math.sin((x / w) * Math.PI * 2 + phase) * amp -
      Math.sin((x / w) * Math.PI * 4 + phase * 1.7) * amp * 0.32;
    b.lineTo(x, y);
  }
  b.lineTo(w, h);
  b.lineTo(0, h);
  b.closePath();
  b.fill();
}

function pine(
  b: CanvasRenderingContext2D,
  x: number,
  baseY: number,
  size: number,
  color: string,
): void {
  b.fillStyle = color;
  b.fillRect(x - size * 0.06, baseY - size * 0.3, size * 0.12, size * 0.32);
  for (let layer = 0; layer < 3; layer += 1) {
    const layerY = baseY - size * (0.18 + layer * 0.23);
    const half = size * (0.34 - layer * 0.07);
    b.beginPath();
    b.moveTo(x, layerY - size * 0.34);
    b.lineTo(x + half, layerY);
    b.lineTo(x - half, layerY);
    b.closePath();
    b.fill();
  }
}

function buildBackground(w: number, h: number, dpr: number): HTMLCanvasElement {
  const bg = document.createElement('canvas');
  bg.width = Math.round(w * dpr);
  bg.height = Math.round(h * dpr);
  const b = bg.getContext('2d');
  if (!b) return bg;
  b.setTransform(dpr, 0, 0, dpr, 0, 0);

  const sky = b.createLinearGradient(0, 0, 0, h);
  sky.addColorStop(0, '#0e0622');
  sky.addColorStop(0.45, '#231138');
  sky.addColorStop(0.78, '#3a1b4c');
  sky.addColorStop(1, '#472357');
  b.fillStyle = sky;
  b.fillRect(0, 0, w, h);

  const rng = mulberry32(1337 + Math.round(w) * 7 + Math.round(h) * 13);

  // Stars
  b.fillStyle = '#ffeccb';
  for (let i = 0; i < 64; i += 1) {
    const x = rng() * w;
    const y = rng() * Math.max(16, game.groundY * 0.72);
    const r = 0.4 + rng() * 1.15;
    b.globalAlpha = 0.2 + rng() * 0.65;
    b.beginPath();
    b.arc(x, y, r, 0, Math.PI * 2);
    b.fill();
  }
  b.globalAlpha = 1;

  // Moon and its halo
  const mx = w * 0.77;
  const my = Math.max(26, h * 0.2);
  const mr = Math.max(13, Math.min(w, h) * 0.085);
  const halo = b.createRadialGradient(mx, my, mr * 0.35, mx, my, mr * 3.4);
  halo.addColorStop(0, 'rgba(255, 226, 176, 0.3)');
  halo.addColorStop(0.5, 'rgba(255, 208, 150, 0.1)');
  halo.addColorStop(1, 'rgba(255, 208, 150, 0)');
  b.fillStyle = halo;
  b.beginPath();
  b.arc(mx, my, mr * 3.4, 0, Math.PI * 2);
  b.fill();
  b.fillStyle = '#ffe9c2';
  b.beginPath();
  b.arc(mx, my, mr, 0, Math.PI * 2);
  b.fill();
  b.fillStyle = 'rgba(207, 168, 116, 0.34)';
  const craters: Array<[number, number, number]> = [
    [-0.35, -0.2, 0.24],
    [0.22, 0.3, 0.18],
    [0.34, -0.38, 0.12],
    [-0.1, 0.5, 0.1],
  ];
  for (const [cx, cy, cr] of craters) {
    b.beginPath();
    b.arc(mx + cx * mr, my + cy * mr, cr * mr, 0, Math.PI * 2);
    b.fill();
  }

  // Distant hills and pines
  hillPath(b, w, h, game.groundY + h * 0.02, h * 0.075, '#2b1643', 0.8);
  hillPath(b, w, h, game.groundY + h * 0.05, h * 0.055, '#241139', 2.4);
  for (let i = 0; i < 5; i += 1) {
    const tx = w * (0.08 + rng() * 0.84);
    pine(b, tx, game.groundY + 2, Math.max(12, h * (0.035 + rng() * 0.035)), '#1b0d2e');
  }

  // Ground
  const soil = b.createLinearGradient(0, game.groundY, 0, h);
  soil.addColorStop(0, '#341b45');
  soil.addColorStop(0.45, '#261234');
  soil.addColorStop(1, '#1a0c26');
  b.fillStyle = soil;
  b.fillRect(0, game.groundY, w, h - game.groundY);

  b.strokeStyle = '#7a4792';
  b.lineWidth = Math.max(1, h * 0.005);
  b.beginPath();
  b.moveTo(0, game.groundY + 0.5);
  b.lineTo(w, game.groundY + 0.5);
  b.stroke();

  // Grass tufts
  const tuftCount = Math.max(8, Math.floor(w / 26));
  for (let i = 0; i < tuftCount; i += 1) {
    const x = (i + 0.5) * (w / tuftCount) + (rng() - 0.5) * 8;
    const height = 2 + rng() * 4;
    b.strokeStyle = i % 3 === 0 ? '#9a63b5' : '#5c3275';
    b.lineWidth = 1.2;
    b.beginPath();
    b.moveTo(x, game.groundY + 1);
    b.lineTo(x + (rng() - 0.5) * 3, game.groundY + 1 - height);
    b.stroke();
  }

  // A few glowing orchard flowers
  for (let i = 0; i < 4; i += 1) {
    const x = (0.12 + rng() * 0.76) * w;
    const y = game.groundY + 6 + rng() * Math.max(4, (h - game.groundY) * 0.45);
    b.fillStyle = 'rgba(255, 207, 107, 0.55)';
    b.beginPath();
    b.arc(x, y, 1.4, 0, Math.PI * 2);
    b.fill();
  }

  const vignette = b.createRadialGradient(
    w / 2,
    h * 0.45,
    Math.min(w, h) * 0.18,
    w / 2,
    h * 0.45,
    Math.max(w, h) * 0.78,
  );
  vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
  vignette.addColorStop(1, 'rgba(6, 2, 14, 0.55)');
  b.fillStyle = vignette;
  b.fillRect(0, 0, w, h);

  return bg;
}

/* ── HUD and overlays ───────────────────────────────────────────────── */

function bump(element: HTMLElement): void {
  if (reduceMotion) return;
  element.classList.remove('bump');
  void element.offsetWidth;
  element.classList.add('bump');
}

function updateHearts(): void {
  if (hud.hearts === game.hearts) return;
  hud.hearts = game.hearts;
  const lives: HTMLElement[] = [];
  for (let i = 0; i < 3; i += 1) {
    const span = document.createElement('span');
    span.className = i < game.hearts ? 'life' : 'life off';
    span.textContent = '\u2665\uFE0E';
    span.setAttribute('aria-hidden', 'true');
    lives.push(span);
  }
  elements.hearts.replaceChildren(...lives);
  elements.hearts.setAttribute(
    'aria-label',
    `${game.hearts} ${game.hearts === 1 ? 'life' : 'lives'} remaining`,
  );
}

function updateHud(force = false): void {
  const score = game.score;
  const best = game.best;
  const mult = multiplier();

  if (force || score !== hud.score) {
    if (!force && score > hud.score && hud.score >= 0) bump(elements.score);
    elements.score.textContent = String(score);
    hud.score = score;
  }
  if (force || best !== hud.best) {
    elements.best.textContent = String(best);
    hud.best = best;
  }
  updateHearts();

  const frenzyOn = game.frenzy > 0;
  if (force || mult !== hud.mult || Number(frenzyOn) !== hud.frenzy) {
    elements.combo.hidden = mult <= 1 && !frenzyOn;
    elements.combo.textContent = frenzyOn ? `Frenzy ×2` : `×${mult}`;
    elements.combo.dataset.hot = String(frenzyOn);
    hud.mult = mult;
    hud.frenzy = Number(frenzyOn);
    elements.app.classList.toggle('is-frenzy', frenzyOn);
  }

  const belly = frenzyOn ? clamp(game.frenzy / FRENZY_TIME, 0, 1) : game.belly;
  const percent = Math.round(belly * 100);
  if (force || percent !== hud.belly) {
    elements.bellyFill.style.width = `${percent}%`;
    hud.belly = percent;
  }

  if (force || hud.phase !== game.phase) {
    elements.pauseButton.disabled = game.phase !== 'playing' && game.phase !== 'paused';
    elements.pauseButton.setAttribute(
      'aria-label',
      game.phase === 'paused' ? 'Resume' : 'Pause',
    );
    elements.pauseButton.querySelector('span')!.textContent =
      game.phase === 'paused' ? '\u25B6\uFE0E' : '\u275A\u275A';
    hud.phase = game.phase;
    canvas.style.cursor = game.phase === 'playing' ? 'crosshair' : 'default';
  }
}

function updateSoundButton(): void {
  const enabled = soundEnabled();
  elements.soundButton.classList.toggle('muted', !enabled);
  elements.soundButton.disabled = !configSoundEnabled;
  if (!configSoundEnabled) {
    elements.soundButton.setAttribute('aria-label', 'Sound disabled in settings');
    elements.soundButton.title = 'Sound disabled in settings';
    return;
  }
  elements.soundButton.setAttribute('aria-label', enabled ? 'Mute sound' : 'Enable sound');
  elements.soundButton.title = enabled ? 'Sound on (M)' : 'Sound off (M)';
}

function miniStat(label: string, value: number): HTMLElement {
  const box = document.createElement('div');
  box.className = 'mini-stat';
  const labelEl = document.createElement('span');
  labelEl.className = 'mini-label';
  labelEl.textContent = label;
  const valueEl = document.createElement('span');
  valueEl.className = 'mini-value';
  valueEl.textContent = String(value);
  box.append(labelEl, valueEl);
  return box;
}

function showOverlay(mode: OverlayMode): void {
  elements.overlay.hidden = false;
  elements.overlay.dataset.mode = mode;
  elements.overlayStats.hidden = true;
  elements.overlayStats.replaceChildren();

  if (mode === 'start') {
    elements.overlayKicker.textContent = 'Moonlit piggy arcade';
    elements.overlayTitle.textContent = 'Truffle Rush';
    elements.overlayText.textContent =
      'Catch the falling snacks. Dodge the crows. Fill your belly for a golden frenzy.';
    elements.playButton.textContent = 'Play';
    elements.hint.hidden = false;
    if (game.best > 0) {
      elements.overlayStats.hidden = false;
      elements.overlayStats.replaceChildren(miniStat('Best', game.best));
    }
  } else if (mode === 'pause') {
    elements.overlayKicker.textContent = 'Take a breath';
    elements.overlayTitle.textContent = 'Paused';
    elements.overlayText.textContent = 'The truffles can wait. Your pig is safe here.';
    elements.playButton.textContent = 'Resume';
    elements.hint.hidden = true;
  } else {
    const newBest = game.score > game.bestBefore;
    elements.overlayKicker.textContent = newBest ? 'A new orchard record' : 'Snout out';
    elements.overlayTitle.textContent = newBest ? 'New best!' : 'Snout out!';
    elements.overlayText.textContent = newBest
      ? `You snuffled up ${game.score} points. The orchard will remember that snout.`
      : `The orchard got the better of you at ${game.score} points. One more run?`;
    elements.playButton.textContent = 'Play again';
    elements.hint.hidden = true;
    elements.overlayStats.hidden = false;
    elements.overlayStats.replaceChildren(
      miniStat('Score', game.score),
      miniStat('Best', game.best),
      miniStat('Max combo', game.maxCombo),
    );
  }
}

function showToast(message: string): void {
  window.clearTimeout(toastTimer);
  elements.toast.textContent = message;
  elements.toast.classList.remove('show');
  void elements.toast.offsetWidth;
  elements.toast.classList.add('show');
  toastTimer = window.setTimeout(() => {
    elements.toast.classList.remove('show');
  }, 1400);
}

/* ── Game state ─────────────────────────────────────────────────────── */

function startGame(): void {
  ensureAudio();
  game.phase = 'playing';
  game.score = 0;
  game.bestBefore = game.best;
  game.hearts = 3;
  game.belly = 0;
  game.combo = 0;
  game.maxCombo = 0;
  game.level = 1;
  game.foodCaught = 0;
  game.frenzy = 0;
  game.elapsed = 0;
  game.spawnTimer = 0.55;
  game.invuln = 1;
  game.mouth = 0;
  game.shake = 0;
  game.flash = 0;
  game.items = [];
  game.particles = [];
  game.floaters = [];
  game.keys.left = false;
  game.keys.right = false;
  game.pigX = game.w / 2;
  game.pigTarget = game.pigX;
  elements.overlay.hidden = true;
  elements.playButton.blur();
  elements.toast.classList.remove('show');
  updateHud(true);
  soundStart();
}

function gameOver(): void {
  game.phase = 'over';
  game.frenzy = 0;
  game.shake = reduceMotion ? 0 : 10;
  elements.app.classList.remove('is-frenzy');
  if (game.score > game.best) {
    game.best = game.score;
    bestDirty = true;
    saveBest();
  }
  updateHud(true);
  showOverlay('over');
  soundGameOver();
}

function togglePause(): void {
  if (game.phase === 'playing') {
    game.phase = 'paused';
    showOverlay('pause');
  } else if (game.phase === 'paused') {
    game.phase = 'playing';
    elements.overlay.hidden = true;
    elements.playButton.blur();
    lastFrame = performance.now();
  }
  updateHud(true);
}

function toggleSound(): void {
  if (!configSoundEnabled) return;
  muted = !muted;
  persist(MUTE_KEY, muted ? '1' : '0');
  updateSoundButton();
  if (!muted) {
    ensureAudio();
    playTone(660, 0.08, 'triangle', 0.04);
  }
}

/* ── Spawning ───────────────────────────────────────────────────────── */

function spawnItem(): void {
  const roll = Math.random();
  let kind: ItemKind;

  if (game.frenzy > 0) {
    if (roll < 0.14) kind = 'golden';
    else if (roll < 0.72) kind = 'truffle';
    else kind = 'acorn';
  } else {
    const crowChance = Math.min(0.29, 0.07 + game.level * 0.027);
    if (roll < crowChance) kind = 'crow';
    else if (roll < crowChance + 0.03) kind = 'golden';
    else if (roll < crowChance + 0.03 + 0.24) kind = 'truffle';
    else kind = 'acorn';
  }

  const unit = game.unit;
  const radius =
    kind === 'acorn'
      ? 8 * unit
      : kind === 'truffle'
        ? 10 * unit
        : kind === 'golden'
          ? 11 * unit
          : 14 * unit;

  const margin = radius + 7;
  const x = game.w <= margin * 2 ? game.w / 2 : rand(margin, game.w - margin);
  const base = game.h * (0.3 + game.level * 0.012) * (game.frenzy > 0 ? 1.18 : 1);
  const vy =
    kind === 'crow'
      ? base * rand(1.02, 1.18)
      : base * rand(0.84, 1.14);

  game.items.push({
    kind,
    x,
    y: -radius - 6,
    vy,
    drift: kind === 'crow' ? rand(-26, 26) : rand(-8, 8),
    phase: rand(0, Math.PI * 2),
    spin: rand(-1.6, 1.6),
    r: radius,
  });
}

function spawnInterval(): number {
  if (game.frenzy > 0) return rand(0.24, 0.36);
  const base = Math.max(0.34, 0.95 - game.level * 0.05);
  return base * rand(0.74, 1.24);
}

/* ── Effects ────────────────────────────────────────────────────────── */

function burst(x: number, y: number, colors: string[], count: number, power: number): void {
  const amount = reduceMotion ? Math.ceil(count / 2) : count;
  for (let i = 0; i < amount; i += 1) {
    const angle = rand(0, Math.PI * 2);
    const speed = rand(power * 0.3, power);
    game.particles.push({
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - power * 0.25,
      life: rand(0.35, 0.8),
      maxLife: 0.8,
      size: rand(1.5, 4) * game.unit,
      color: colors[Math.floor(Math.random() * colors.length)],
      gravity: 260,
    });
  }
}

function addFloater(x: number, y: number, text: string, color: string): void {
  game.floaters.push({
    x: clamp(x, 28, game.w - 28),
    y,
    text,
    life: 0.85,
    maxLife: 0.85,
    color,
  });
}

function triggerFrenzy(): void {
  game.frenzy = FRENZY_TIME;
  game.belly = 0;
  game.score += 100;
  game.shake = settings.screenShake && !reduceMotion ? 9 : 0;
  game.flash = 0.35;
  addFloater(game.pigX, game.groundY - game.pigH * 1.5, '+100', '#ffd97a');
  showToast('Truffle frenzy!');
  soundFrenzy();
  burst(game.pigX, game.groundY - game.pigH * 0.6, ['#ffe9a8', '#ffcf6b', '#fff6d8'], 26, 260);

  for (let i = game.items.length - 1; i >= 0; i -= 1) {
    const item = game.items[i];
    if (item.kind === 'crow') {
      burst(item.x, item.y, ['#3a2148', '#241231', '#7a4d8f'], 6, 140);
      game.items.splice(i, 1);
    }
  }
}

function catchFood(item: Item): void {
  const previousMult = multiplier();
  game.combo += 1;
  game.maxCombo = Math.max(game.maxCombo, game.combo);
  game.foodCaught += 1;
  game.mouth = 0.22;

  const mult = multiplier();
  const base = item.kind === 'acorn' ? 5 : item.kind === 'truffle' ? 25 : 75;
  const points = Math.round(base * mult * (game.frenzy > 0 ? 2 : 1));
  game.score += points;

  const bellyGain = item.kind === 'acorn' ? 0.085 : item.kind === 'truffle' ? 0.2 : 0.45;
  if (game.frenzy <= 0) {
    game.belly = Math.min(1, game.belly + bellyGain);
  }

  const colors =
    item.kind === 'golden'
      ? ['#fff3c4', '#ffcf6b', '#ffb03e']
      : item.kind === 'truffle'
        ? ['#e7b07f', '#9a6244', '#ffcf6b']
        : ['#e0a968', '#9c6a3d', '#ffd98f'];

  burst(item.x, item.y, colors, item.kind === 'acorn' ? 7 : 12, item.kind === 'golden' ? 240 : 170);
  addFloater(
    item.x,
    item.y - 8,
    `+${points}`,
    item.kind === 'golden' ? '#ffe9a8' : item.kind === 'truffle' ? '#ffd98a' : '#ffe9cf',
  );

  if (item.kind === 'golden' && game.frenzy > 0) {
    game.frenzy = Math.min(FRENZY_TIME + 2, game.frenzy + 1.2);
  }

  soundCatch(item.kind);

  if (mult > previousMult) {
    addFloater(game.pigX, game.groundY - game.pigH * 1.35, `×${mult}`, '#ffcf6b');
    if (mult >= 3) {
      showToast(`×${mult} snout streak`);
    }
  }

  if (game.belly >= 1 && game.frenzy <= 0) triggerFrenzy();

  game.level = Math.min(9, 1 + Math.floor(game.foodCaught / 12));
}

function hitByCrow(item: Item): void {
  game.hearts -= 1;
  game.combo = 0;
  game.invuln = 1.5;
  game.shake = settings.screenShake && !reduceMotion ? 13 : 0;
  game.flash = 0.55;
  burst(item.x, item.y, ['#2b1739', '#472a5e', '#ff7d86'], 14, 210);
  soundHurt();
  if (game.hearts <= 0) gameOver();
}

/* ── Simulation ─────────────────────────────────────────────────────── */

function updateGame(dt: number): void {
  game.elapsed += dt;
  game.mouth = Math.max(0, game.mouth - dt);
  game.invuln = Math.max(0, game.invuln - dt);
  game.flash = Math.max(0, game.flash - dt);
  game.shake = Math.max(0, game.shake - dt * 34);

  if (game.frenzy > 0) {
    game.frenzy = Math.max(0, game.frenzy - dt);
    if (game.frenzy === 0) showToast('Frenzy over');
  }

  // Movement: keyboard nudges the target, pointer/touch sets it directly.
  const maxTargetSpeed = game.w * 1.5 + 180;
  if (game.keys.left) game.pigTarget -= maxTargetSpeed * dt;
  if (game.keys.right) game.pigTarget += maxTargetSpeed * dt;
  game.pigTarget = clamp(game.pigTarget, game.pigW / 2, game.w - game.pigW / 2);

  const maxStep = (game.w * 1.8 + 260) * dt;
  const step = clamp(game.pigTarget - game.pigX, -maxStep, maxStep);
  game.pigX += step;
  game.pigVX = game.pigVX * 0.8 + (step / Math.max(dt, 0.001)) * 0.2;

  // Spawning
  game.spawnTimer -= dt;
  if (game.spawnTimer <= 0 && game.items.length < 46) {
    spawnItem();
    game.spawnTimer = spawnInterval();
  }

  const pigCenterY = game.groundY - game.pigH * 0.38;
  const halfW = game.pigW * 0.56;
  const halfH = game.pigH * 0.62;

  for (let i = game.items.length - 1; i >= 0; i -= 1) {
    const item = game.items[i];
    item.y += item.vy * dt;
    item.x += item.drift * dt + Math.sin(game.elapsed * 2.2 + item.phase) * 10 * dt;
    item.x = clamp(item.x, item.r, game.w - item.r);
    item.spin += (item.kind === 'crow' ? 2.4 : 1.2) * dt;

    const hit =
      Math.abs(item.x - game.pigX) < halfW + item.r * 0.6 &&
      Math.abs(item.y - pigCenterY) < halfH + item.r * 0.6;

    if (hit) {
      if (item.kind === 'crow') {
        if (game.invuln <= 0) {
          game.items.splice(i, 1);
          hitByCrow(item);
          if (game.phase !== 'playing') return;
        }
      } else {
        game.items.splice(i, 1);
        catchFood(item);
      }
      continue;
    }

    if (item.y > game.groundY + item.r * 0.4) {
      game.items.splice(i, 1);
      if (item.kind === 'crow') {
        burst(item.x, game.groundY, ['#33204a', '#4c2d63'], 5, 110);
      } else {
        game.combo = 0;
        burst(item.x, game.groundY, ['#4a2a5e', '#2c153f'], 4, 90);
        soundMiss();
      }
    }
  }
}

function updateEffects(dt: number): void {
  for (let i = game.particles.length - 1; i >= 0; i -= 1) {
    const p = game.particles[i];
    p.life -= dt;
    if (p.life <= 0) {
      game.particles.splice(i, 1);
      continue;
    }
    p.vy += p.gravity * dt;
    p.x += p.vx * dt;
    p.y += p.vy * dt;
  }

  for (let i = game.floaters.length - 1; i >= 0; i -= 1) {
    const f = game.floaters[i];
    f.life -= dt;
    if (f.life <= 0) {
      game.floaters.splice(i, 1);
      continue;
    }
    f.y -= 34 * dt;
  }

  if (game.particles.length > 240) {
    game.particles.splice(0, game.particles.length - 240);
  }
}

/* ── Rendering ──────────────────────────────────────────────────────── */

function drawAcorn(x: number, y: number, r: number, rotation: number): void {
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rotation * 0.25);
  ctx.fillStyle = '#d99a58';
  ctx.beginPath();
  ctx.ellipse(0, r * 0.18, r * 0.72, r * 0.82, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(55, 26, 30, 0.7)';
  ctx.lineWidth = Math.max(1, r * 0.12);
  ctx.stroke();
  ctx.fillStyle = '#7a4426';
  ctx.beginPath();
  ctx.ellipse(0, -r * 0.38, r * 0.82, r * 0.44, 0, Math.PI, Math.PI * 2);
  ctx.fill();
  ctx.strokeRect(-r * 0.06, -r * 1.12, r * 0.12, r * 0.3);
  ctx.fillStyle = 'rgba(255, 240, 210, 0.5)';
  ctx.beginPath();
  ctx.arc(-r * 0.26, r * 0.05, r * 0.16, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function lumpyTruffle(x: number, y: number, r: number, fill: string, speckle: string): void {
  ctx.fillStyle = fill;
  ctx.beginPath();
  ctx.arc(x - r * 0.3, y + r * 0.1, r * 0.68, 0, Math.PI * 2);
  ctx.arc(x + r * 0.34, y + r * 0.05, r * 0.6, 0, Math.PI * 2);
  ctx.arc(x, y - r * 0.32, r * 0.72, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = speckle;
  const dots: Array<[number, number, number]> = [
    [-0.36, 0.24, 0.1],
    [0.28, 0.34, 0.08],
    [0.05, -0.28, 0.09],
    [-0.08, 0.42, 0.07],
    [0.42, -0.12, 0.07],
  ];
  for (const [dx, dy, dr] of dots) {
    ctx.beginPath();
    ctx.arc(x + dx * r, y + dy * r, dr * r, 0, Math.PI * 2);
    ctx.fill();
  }
}

function drawTruffle(x: number, y: number, r: number, golden: boolean): void {
  ctx.save();
  if (golden) {
    ctx.shadowColor = 'rgba(255, 196, 90, 0.9)';
    ctx.shadowBlur = r * 1.7;
    ctx.fillStyle = 'rgba(255, 220, 130, 0.22)';
    ctx.beginPath();
    ctx.arc(x, y, r * 1.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.shadowBlur = 0;
    lumpyTruffle(x, y, r, '#ffce62', '#fff1b8');
    ctx.strokeStyle = 'rgba(255, 248, 214, 0.9)';
  } else {
    ctx.shadowColor = 'rgba(255, 193, 110, 0.35)';
    ctx.shadowBlur = r * 0.9;
    lumpyTruffle(x, y, r, '#8a573d', '#d8a274');
    ctx.strokeStyle = 'rgba(255, 219, 176, 0.35)';
  }
  ctx.lineWidth = Math.max(1, r * 0.1);
  ctx.beginPath();
  ctx.arc(x - r * 0.15, y - r * 0.1, r * 0.85, 0, Math.PI * 2);
  ctx.stroke();
  ctx.restore();

  if (golden) {
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(game.time * 1.4);
    ctx.strokeStyle = 'rgba(255, 249, 214, 0.9)';
    ctx.lineWidth = 1.4;
    for (let i = 0; i < 4; i += 1) {
      const angle = (i / 4) * Math.PI * 2;
      const inner = r * 1.15;
      const outer = r * 1.55;
      ctx.beginPath();
      ctx.moveTo(Math.cos(angle) * inner, Math.sin(angle) * inner);
      ctx.lineTo(Math.cos(angle) * outer, Math.sin(angle) * outer);
      ctx.stroke();
    }
    ctx.restore();
  }
}

function drawCrow(item: Item): void {
  const { x, y, r } = item;
  const flap = Math.sin(game.elapsed * 15 + item.phase);
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(clamp(item.drift * 0.004, -0.18, 0.18));

  ctx.fillStyle = '#1d102c';
  ctx.beginPath();
  ctx.ellipse(0, 0, r * 0.72, r * 0.54, 0, 0, Math.PI * 2);
  ctx.fill();

  ctx.beginPath();
  ctx.ellipse(-r * 0.62, -r * 0.1 + flap * r * 0.34, r * 0.55, r * 0.26, -0.5, 0, Math.PI * 2);
  ctx.fill();
  ctx.beginPath();
  ctx.ellipse(r * 0.62, -r * 0.1 - flap * r * 0.34, r * 0.55, r * 0.26, 0.5, 0, Math.PI * 2);
  ctx.fill();

  ctx.beginPath();
  ctx.arc(r * 0.42, -r * 0.3, r * 0.34, 0, Math.PI * 2);
  ctx.fillStyle = '#241436';
  ctx.fill();

  ctx.fillStyle = '#ffb457';
  ctx.beginPath();
  ctx.moveTo(r * 0.72, -r * 0.3);
  ctx.lineTo(r * 1.16, -r * 0.18);
  ctx.lineTo(r * 0.72, -r * 0.08);
  ctx.closePath();
  ctx.fill();

  ctx.fillStyle = '#ffe9cf';
  ctx.beginPath();
  ctx.arc(r * 0.5, -r * 0.38, r * 0.08, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function drawPig(): void {
  const w = game.pigW;
  const h = game.pigH;
  const bob =
    game.phase === 'playing' && !reduceMotion ? Math.sin(game.elapsed * 9) * 1.8 : 0;
  const catching = game.mouth > 0;
  const frenzy = game.frenzy > 0;
  const centerY = game.groundY - h * 0.38 + bob;
  const blink = game.invuln > 0 && Math.floor(game.time * 16) % 2 === 0;

  if (frenzy) {
    const glow = ctx.createRadialGradient(
      game.pigX,
      centerY,
      w * 0.2,
      game.pigX,
      centerY,
      w * 1.15,
    );
    glow.addColorStop(0, 'rgba(255, 219, 130, 0.4)');
    glow.addColorStop(1, 'rgba(255, 190, 80, 0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(game.pigX, centerY, w * 1.15, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.save();
  ctx.globalAlpha = blink ? 0.4 : 1;
  ctx.translate(game.pigX, centerY);
  const tilt = clamp(game.pigVX * 0.00022, -0.14, 0.14);
  ctx.rotate(tilt);

  // Tail
  ctx.strokeStyle = '#d96385';
  ctx.lineWidth = Math.max(1.5, w * 0.035);
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.arc(-w * 0.5, -h * 0.02, w * 0.12, 0.4, Math.PI * 1.7);
  ctx.stroke();

  // Legs
  ctx.fillStyle = '#e8799a';
  for (const side of [-1, 1]) {
    ctx.beginPath();
    ctx.roundRect(side * w * 0.26 - w * 0.09, h * 0.28, w * 0.18, h * 0.3, w * 0.06);
    ctx.fill();
  }

  // Ears
  ctx.fillStyle = '#ffb0c4';
  ctx.strokeStyle = '#7c2c49';
  ctx.lineWidth = Math.max(1.4, w * 0.025);
  for (const side of [-1, 1]) {
    ctx.beginPath();
    ctx.moveTo(side * w * 0.17, -h * 0.36);
    ctx.lineTo(side * w * 0.42, -h * 0.64);
    ctx.lineTo(side * w * 0.46, -h * 0.24);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  }

  // Body
  const body = ctx.createLinearGradient(0, -h * 0.5, 0, h * 0.5);
  body.addColorStop(0, '#ffc0cf');
  body.addColorStop(0.55, '#ffabc0');
  body.addColorStop(1, '#f28aa7');
  ctx.fillStyle = body;
  ctx.strokeStyle = '#7c2c49';
  ctx.lineWidth = Math.max(1.6, w * 0.03);
  ctx.beginPath();
  ctx.ellipse(0, 0, w * 0.5, h * 0.48, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();

  // Snout
  ctx.fillStyle = '#ffd4dd';
  ctx.beginPath();
  ctx.ellipse(0, h * 0.12, w * 0.24, h * 0.17, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(124, 44, 73, 0.55)';
  ctx.lineWidth = Math.max(1, w * 0.018);
  ctx.stroke();
  ctx.fillStyle = '#a64667';
  for (const side of [-1, 1]) {
    ctx.beginPath();
    ctx.ellipse(side * w * 0.075, h * 0.12, w * 0.028, h * 0.05, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  // Cheeks
  ctx.fillStyle = 'rgba(233, 105, 140, 0.4)';
  for (const side of [-1, 1]) {
    ctx.beginPath();
    ctx.ellipse(side * w * 0.32, h * 0.1, w * 0.075, h * 0.05, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  // Eyes
  const eyeY = -h * 0.12;
  ctx.strokeStyle = '#3c1428';
  ctx.fillStyle = '#3c1428';
  ctx.lineWidth = Math.max(1.4, w * 0.022);
  if (frenzy) {
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.arc(side * w * 0.19, eyeY, w * 0.06, Math.PI * 0.15, Math.PI * 0.85);
      ctx.stroke();
    }
  } else {
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.ellipse(side * w * 0.19, eyeY, w * 0.045, h * 0.055, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
      ctx.beginPath();
      ctx.arc(side * w * 0.19 + w * 0.016, eyeY - h * 0.018, w * 0.014, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#3c1428';
    }
  }

  // Mouth
  if (catching || frenzy) {
    ctx.fillStyle = '#7a2544';
    ctx.beginPath();
    ctx.ellipse(0, h * 0.3, w * 0.1, h * 0.075, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#ff9fb6';
    ctx.beginPath();
    ctx.ellipse(0, h * 0.325, w * 0.05, h * 0.035, 0, 0, Math.PI * 2);
    ctx.fill();
  } else {
    ctx.strokeStyle = 'rgba(124, 44, 73, 0.7)';
    ctx.beginPath();
    ctx.arc(0, h * 0.2, w * 0.13, Math.PI * 0.18, Math.PI * 0.82);
    ctx.stroke();
  }

  ctx.restore();
}

function drawItems(): void {
  for (const item of game.items) {
    ctx.save();
    if (item.kind === 'crow') {
      // Ground shadow telegraphs the dive.
      const depth = clamp(item.y / Math.max(1, game.groundY), 0, 1);
      ctx.globalAlpha = 0.12 + depth * 0.26;
      ctx.fillStyle = '#05020c';
      ctx.beginPath();
      ctx.ellipse(item.x, game.groundY + 3, item.r * 1.5 * depth, item.r * 0.35 * depth, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
      drawCrow(item);
    } else if (item.kind === 'acorn') {
      drawAcorn(item.x, item.y, item.r, item.spin);
    } else {
      drawTruffle(item.x, item.y, item.r, item.kind === 'golden');
    }
    ctx.restore();
  }
}

function drawFireflies(t: number): void {
  ctx.save();
  for (const fly of game.fireflies) {
    const x = fly.baseX * game.w + Math.sin(t * fly.speed + fly.phase) * fly.amp;
    const y =
      fly.baseY * game.h +
      Math.cos(t * fly.speed * 0.8 + fly.phase * 1.3) * fly.amp * 0.6;
    const pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + fly.phase);
    ctx.globalAlpha = 0.35 + pulse * 0.5;
    ctx.fillStyle = '#ffe3a0';
    ctx.shadowColor = 'rgba(255, 214, 120, 0.9)';
    ctx.shadowBlur = 8;
    ctx.beginPath();
    ctx.arc(x, y, 1.5 + pulse, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.restore();
}

function drawParticles(): void {
  for (const p of game.particles) {
    const alpha = clamp(p.life / p.maxLife, 0, 1);
    ctx.globalAlpha = alpha;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * (0.4 + alpha * 0.6), 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
}

function drawFloaters(): void {
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.font = `900 ${Math.max(11, 14 * game.unit)}px "Trebuchet MS", "Avenir Next", sans-serif`;
  for (const f of game.floaters) {
    const alpha = clamp(f.life / f.maxLife, 0, 1);
    ctx.globalAlpha = alpha;
    ctx.fillStyle = f.color;
    ctx.shadowColor = 'rgba(10, 4, 18, 0.85)';
    ctx.shadowBlur = 4;
    ctx.fillText(f.text, f.x, f.y);
  }
  ctx.restore();
}

function render(t: number): void {
  const { w, h } = game;
  if (w <= 0 || h <= 0) return;
  ctx.clearRect(0, 0, w, h);

  ctx.save();
  if (game.shake > 0.2) {
    const strength = game.shake;
    ctx.translate(rand(-strength, strength) * 0.5, rand(-strength, strength) * 0.5);
  }

  if (background) ctx.drawImage(background, 0, 0, w, h);
  drawFireflies(t);
  drawItems();
  drawPig();
  drawParticles();
  drawFloaters();

  ctx.restore();

  if (game.flash > 0) {
    ctx.fillStyle = `rgba(255, 120, 120, ${clamp(game.flash * 0.35, 0, 0.3)})`;
    ctx.fillRect(0, 0, w, h);
  }
}

/* ── Main loop ──────────────────────────────────────────────────────── */

function frame(now: number): void {
  const dt = clamp((now - lastFrame) / 1000, 0, 0.05);
  lastFrame = now;
  game.time += dt;

  if (game.phase === 'playing') updateGame(dt);
  updateEffects(dt);
  updateHud();
  render(game.time);

  window.requestAnimationFrame(frame);
}

/* ── Input ──────────────────────────────────────────────────────────── */

function pointToPig(clientX: number): void {
  const rect = canvas.getBoundingClientRect();
  game.pigTarget = clamp(
    clientX - rect.left,
    game.pigW / 2,
    game.w - game.pigW / 2,
  );
}

function isMoveKey(key: string): 'left' | 'right' | null {
  if (key === 'ArrowLeft' || key === 'a' || key === 'A') return 'left';
  if (key === 'ArrowRight' || key === 'd' || key === 'D') return 'right';
  return null;
}

window.addEventListener('keydown', (event) => {
  if (event.metaKey || event.ctrlKey || event.altKey) return;
  const move = isMoveKey(event.key);
  if (move) {
    game.keys[move] = true;
    ensureAudio();
    event.preventDefault();
    return;
  }
  if (event.key === 'm' || event.key === 'M') {
    event.preventDefault();
    toggleSound();
    return;
  }
  if (event.key === ' ' || event.key === 'Enter') {
    event.preventDefault();
    ensureAudio();
    if (game.phase === 'ready' || game.phase === 'over') startGame();
    else togglePause();
    return;
  }
  if (event.key === 'p' || event.key === 'P' || event.key === 'Escape') {
    event.preventDefault();
    if (game.phase === 'playing' || game.phase === 'paused') togglePause();
  }
});

window.addEventListener('keyup', (event) => {
  const move = isMoveKey(event.key);
  if (move) game.keys[move] = false;
});

window.addEventListener('blur', () => {
  game.keys.left = false;
  game.keys.right = false;
  game.pointerDown = false;
  if (game.phase === 'playing') togglePause();
});

canvas.addEventListener('pointerdown', (event) => {
  ensureAudio();
  game.pointerDown = true;
  pointToPig(event.clientX);
  if (canvas.setPointerCapture) {
    try {
      canvas.setPointerCapture(event.pointerId);
    } catch {
      // Pointer capture is best-effort.
    }
  }
  event.preventDefault();
});

canvas.addEventListener('pointermove', (event) => {
  if (event.pointerType === 'mouse' || game.pointerDown) {
    pointToPig(event.clientX);
  }
});

function endPointer(): void {
  game.pointerDown = false;
}

canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
canvas.addEventListener('pointerleave', (event) => {
  if (event.pointerType === 'mouse') endPointer();
});
canvas.addEventListener('contextmenu', (event) => event.preventDefault());

elements.playButton.addEventListener('click', () => {
  if (game.phase === 'paused') {
    togglePause();
  } else {
    startGame();
  }
});

elements.pauseButton.addEventListener('click', () => {
  if (game.phase === 'playing' || game.phase === 'paused') togglePause();
});

elements.soundButton.addEventListener('click', () => {
  toggleSound();
});

document.addEventListener('visibilitychange', () => {
  if (document.hidden && game.phase === 'playing') togglePause();
});

window.addEventListener('pagehide', () => {
  saveBest();
  resizeObserver?.disconnect();
});

onSettingsChanged((next) => {
  settings = { ...next };
  configSoundEnabled = next.sound;
  updateSoundButton();
});

/* ── Boot ───────────────────────────────────────────────────────────── */

elements.hearts.setAttribute('aria-label', '3 lives remaining');
updateSoundButton();
updateHud(true);
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(canvas);
resize();
void loadProgress();
window.requestAnimationFrame(frame);