Back to Murder Mice
SOURCE / PINNED RELEASE

Made of little things.

Murder Mice

Release
d2fa0e0e36c1…
Author-recorded commit
13dc3200cde5…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-26115e0d25b

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

src/main.ts
import { keys } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { createGamepadInput, type GamepadSample } from './gamepad.js';
import './styles.css';

const canvas = document.querySelector<HTMLCanvasElement>('#table')!;
const ctx = canvas.getContext('2d')!;
const leftButton = document.querySelector<HTMLButtonElement>('#turnLeft')!;
const rightButton = document.querySelector<HTMLButtonElement>('#turnRight')!;
const shootButton = document.querySelector<HTMLButtonElement>('#shoot')!;
const pauseButton = document.querySelector<HTMLButtonElement>('#pause')!;
const menu = document.querySelector<HTMLDivElement>('#menu')!;
const menuTitle = document.querySelector<HTMLDivElement>('#menuTitle')!;
const playButton = document.querySelector<HTMLButtonElement>('#playButton')!;
const restartButton = document.querySelector<HTMLButtonElement>('#restartButton')!;

/** How fast the gun turns. Math.PI is half a circle per second. */
const TURN_SPEED = Math.PI * 0.8;

/** Which way the gun points. 0 is straight up. */
let gunAngle = 0;
/** How fast the gun is turning right now. It speeds up and slows down gently. */
let gunSpin = 0;
/** How quickly the turning catches up to what you press. Bigger is snappier. */
const SPIN_EASE = 14;

// Everything on the table is measured in "gun sizes" from the middle of the table,
// so the game looks the same when the window changes size.

/** How fast bullets fly, in gun sizes per second. */
const BULLET_SPEED = 14;
type Weapon = 'rifle' | 'shotgun' | 'revolver' | 'rocket' | 'beam';
/**
 * Each gun: the level that unlocks it, how many shots before it has to reload,
 * how many seconds reloading takes, and where bullets come out (from the gun's middle).
 */
const GUNS: Record<Weapon, { name: string; unlock: number; shots: number; reload: number; muzzle: number }> = {
  rifle: { name: 'Rifle', unlock: 1, shots: 1, reload: 0, muzzle: 1.95 },
  revolver: { name: 'Revolver', unlock: 2, shots: 6, reload: 3, muzzle: 1.25 },
  shotgun: { name: 'Shotgun', unlock: 4, shots: 1, reload: 3, muzzle: 1.55 },
  rocket: { name: 'Rocket Launcher', unlock: 7, shots: 1, reload: 3, muzzle: 1.6 },
  // The beam's reload starts after the beam stops.
  beam: { name: 'Energy Beam', unlock: 9, shots: 1, reload: 1, muzzle: 1.5 },
};
const WEAPONS = Object.keys(GUNS) as Weapon[];
/** Shortest wait between two shots from a gun that holds more than one, in seconds. */
const SHOT_GAP = 0.25;
let weapon: Weapon = 'rifle';
/** The shotgun shoots 3 bullets: straight, and this far to the left and right. */
const SHOTGUN_SPREAD = (Math.PI / 180) * 15;
/** You can't switch guns: you always hold the newest gun you've unlocked. */
const newestGun = () => WEAPONS.filter((w) => level >= GUNS[w].unlock).at(-1) ?? 'rifle';

/** Each gun keeps its own bullets and reloads on its own, even when you're not holding it. */
const ammo: Record<Weapon, number> = { rifle: 1, shotgun: 1, revolver: 6, rocket: 1, beam: 1 };
const reloadLeft: Record<Weapon, number> = { rifle: 0, shotgun: 0, revolver: 0, rocket: 0, beam: 0 };

/** How long the energy beam keeps firing, in seconds. */
const BEAM_TIME = 1;
/** How wide the beam is. Mice touching it die. */
const BEAM_WIDTH = 0.22;
let beamLeft = 0;

/** Stop the beam and start its reload. */
function stopBeam(): void {
  if (beamLeft <= 0) return;
  beamLeft = 0;
  reloadLeft.beam = GUNS.beam.reload;
}

/** Rockets fly slower than bullets. */
const ROCKET_SPEED = 8;
/** Every mouse this close to an explosion dies. */
const BLAST_RADIUS = 2.4;
/** How long the fire and smoke last, in seconds. */
const BLAST_TIME = 1.6;
/** How long the screen shakes after a blast, in seconds. */
const SHAKE_TIME = 0.35;
let shakeLeft = 0;
let shotGap = 0;

/** How close a bullet has to get to a mouse to hit it. */
const MOUSE_RADIUS = 0.3;
const LEVELS = 10;
/** Mice run this much faster each level: level 2 is 12% faster than level 1, and so on. */
const SPEED_UP_PER_LEVEL = 0.12;
/** Level 1 has 10 mice, and every level after that has 10 more. Dead mice don't come back. */
const miceForLevel = (n: number) => 10 * n;
/** The rifle can't turn further than this to either side, so it never points off the bottom. */
const MAX_TURN = (Math.PI / 180) * 85;
/** How far the middle of the rifle sits above the bottom edge. */
const GUN_FROM_BOTTOM = 1.2;
const MAX_SPLATS = 40;

type Bullet = { x: number; y: number; dx: number; dy: number; rocket: boolean };
/** A ball of fire that turns into smoke. */
type Puff = { x: number; y: number; vx: number; vy: number; size: number; delay: number };
/** A hot bit flying out of the explosion. */
type Spark = { vx: number; vy: number; life: number };
type Blast = { x: number; y: number; age: number; puffs: Puff[]; sparks: Spark[] };
/** Burnt black marks left on the table. */
type Scorch = { x: number; y: number; blobs: { x: number; y: number; r: number }[] };
/**
 * What a mouse is doing:
 * - scurry: running around the table until its timer runs out
 * - toHole: running straight for the nearest mouse hole
 * - hiding: inside a hole (can't be hit) until its timer runs out
 */
type MouseState = 'scurry' | 'toHole' | 'hiding';
type Mouse = {
  x: number;
  y: number;
  heading: number;
  speed: number;
  turn: number;
  wiggle: number;
  state: MouseState;
  timer: number;
  hole: number;
};

/** Seconds a mouse runs around before heading for a hole: 5 on level 1, half a second less each level. */
const scurryTime = () => Math.max(0.5, 5 - 0.5 * (level - 1));
/** Seconds a mouse hides in a hole: 0 on level 1, half a second more each level. */
const hideTime = () => 0.5 * (level - 1);
/** How far inside the table edge a hole's middle is. */
const HOLE_INSET = 0.25;
const HOLE_SIZE = 0.35;

/** The three mouse holes: middle of the left, top and right edges (none behind the rifle). */
function holes(): { x: number; y: number; inward: number }[] {
  return [
    { x: -tableHalfW + HOLE_INSET, y: 0, inward: 0 },
    { x: 0, y: -tableHalfH + HOLE_INSET, inward: Math.PI / 2 },
    { x: tableHalfW - HOLE_INSET, y: 0, inward: Math.PI },
  ];
}
type Splat = { x: number; y: number; blobs: { x: number; y: number; r: number }[] };

const bullets: Bullet[] = [];
const blasts: Blast[] = [];
const scorches: Scorch[] = [];
const mice: Mouse[] = [];
const splats: Splat[] = [];
let shootQueued = false;
let nextMouseIn = 0.5;
let level = 1;
let miceStillToCome = miceForLevel(level);

/** Half the table's width and height, in gun sizes. */
let tableHalfW = 5;
let tableHalfH = 4;
/** Where the middle of the rifle is: at the bottom of the table. */
const gunY = () => tableHalfH - GUN_FROM_BOTTOM;

// ---------- input ----------

/** Arrow keys being held down right now. */
const heldKeys = { left: false, right: false };
/** Screen buttons being held. */
const heldButtons = { left: false, right: false };
/** Shell key actions only say "pressed", so each press keeps turning a little while (for keys the shell remaps). */
const actionUntil = { left: 0, right: 0 };
const ACTION_HOLD_MS = 120;

if (runtimeHasDomain('keys')) {
  void keys
    // The shell matches the key's name, and the spacebar's name is a single space.
    .register({ id: 'gun.shoot', label: 'Shoot', defaultKey: ' ' }, () => {
      shootQueued = true;
    })
    .catch(() => useLocalKeys());
  void keys
    .register({ id: 'game.pause', label: 'Pause', defaultKey: 'p' }, togglePause)
    .catch(() => useLocalKeys());
  void keys
    .register({ id: 'gun.turnLeft', label: 'Turn gun left', defaultKey: 'ArrowLeft' }, () => {
      actionUntil.left = performance.now() + ACTION_HOLD_MS;
    })
    .catch(() => useLocalKeys());
  void keys
    .register({ id: 'gun.turnRight', label: 'Turn gun right', defaultKey: 'ArrowRight' }, () => {
      actionUntil.right = performance.now() + ACTION_HOLD_MS;
    })
    .catch(() => useLocalKeys());
} else {
  useLocalKeys();
}

// Escape pauses too. Shells keep Escape for themselves, so the game listens for it itself.
window.addEventListener('keydown', (event) => {
  if (event.code === 'Escape' && !event.repeat) togglePause();
});

// Shells can't give Shift on its own as a shortcut, so the game listens for it itself.
// It also watches the arrow keys go down and up, so holding one turns the rifle smoothly
// instead of in bursts from key repeat.
window.addEventListener('keydown', (event) => {
  if ((event.code === 'ShiftLeft' || event.code === 'ShiftRight') && !event.repeat) shootQueued = true;
  if (event.code === 'ArrowLeft') heldKeys.left = true;
  if (event.code === 'ArrowRight') heldKeys.right = true;
});
window.addEventListener('keyup', (event) => {
  if (event.code === 'ArrowLeft') heldKeys.left = false;
  if (event.code === 'ArrowRight') heldKeys.right = false;
});
window.addEventListener('blur', () => {
  heldKeys.left = false;
  heldKeys.right = false;
});

let localKeysOn = false;
function useLocalKeys(): void {
  if (localKeysOn) return;
  localKeysOn = true;
  window.addEventListener('keydown', (event) => {
    if (event.code === 'Space') {
      if (!event.repeat) shootQueued = true;
    } else if (event.code === 'KeyP') {
      if (!event.repeat) togglePause();
    } else if (event.code !== 'ArrowLeft' && event.code !== 'ArrowRight') return;
    event.preventDefault();
  });
}

function holdButton(button: HTMLButtonElement, side: 'left' | 'right'): void {
  button.addEventListener('pointerdown', (event) => {
    heldButtons[side] = true;
    button.setPointerCapture(event.pointerId);
  });
  for (const type of ['pointerup', 'pointercancel', 'lostpointercapture'] as const) {
    button.addEventListener(type, () => (heldButtons[side] = false));
  }
}
holdButton(leftButton, 'left');
holdButton(rightButton, 'right');
shootButton.addEventListener('pointerdown', () => (shootQueued = true));

// ---------- menu ----------

/** The game waits on the menu when it first opens, and whenever you pause. */
let paused = true;
let started = false;

function showMenu(): void {
  paused = true;
  menu.hidden = false;
  menuTitle.textContent = started ? 'Paused' : 'Murder Mice';
  playButton.textContent = started ? 'Resume' : 'Play';
  restartButton.hidden = !started;
  playButton.focus();
}

function hideMenu(): void {
  paused = false;
  started = true;
  menu.hidden = true;
  shootQueued = false;
  canvas.focus();
}

function togglePause(): void {
  if (paused) {
    if (started) hideMenu();
  } else showMenu();
}

/** Start again from level 1 with a clean table. */
function restartGame(): void {
  level = 0;
  mice.length = 0;
  newRound();
  gunAngle = 0;
  gunSpin = 0;
  hideMenu();
}

playButton.addEventListener('click', hideMenu);
restartButton.addEventListener('click', restartGame);
pauseButton.addEventListener('click', showMenu);

// Controllers: left stick or d-pad turns the gun.
type ShellGamepad = {
  getGamepads(): ArrayLike<GamepadSample | null>;
  available?: boolean | (() => boolean);
  focused?: boolean | (() => boolean);
};
const shellGamepad = (window as Window & { napplet?: { gamepad?: ShellGamepad } }).napplet?.gamepad;
const flag = (value: boolean | (() => boolean) | undefined, fallback: boolean) =>
  typeof value === 'function' ? value.call(shellGamepad) : (value ?? fallback);

// A (bottom button) or the right trigger shoots.
const pad = createGamepadInput(
  {
    turn: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
    shoot: [{ button: 0 }, { button: 7 }],
    // Start pauses and unpauses.
    pause: [{ button: 9 }],
  },
  shellGamepad
    ? {
        read: () => {
          if (!flag(shellGamepad.available, true)) throw new Error('Controllers unavailable');
          return shellGamepad.getGamepads();
        },
        active: () => flag(shellGamepad.focused, document.hasFocus()) && !document.hidden,
      }
    : {},
);

/** -1 turns left, 1 turns right, in between turns slower. */
function turnInput(now: number, players: ReturnType<typeof pad.poll>['players']): number {
  let turn = 0;
  if (heldKeys.left || heldButtons.left || now < actionUntil.left) turn -= 1;
  if (heldKeys.right || heldButtons.right || now < actionUntil.right) turn += 1;
  for (const player of players) {
    const value = player.actions.turn?.value ?? 0;
    if (Math.abs(value) > 0.2) turn += value;
  }
  return Math.max(-1, Math.min(1, turn));
}

// ---------- shooting, mice and splats ----------

function shoot(): void {
  if (weapon === 'beam') {
    ammo.beam = 0;
    beamLeft = BEAM_TIME;
    shotGap = SHOT_GAP;
    return;
  }
  const dx = Math.sin(gunAngle);
  const dy = -Math.cos(gunAngle);
  const muzzle = GUNS[weapon].muzzle;
  const angles = weapon === 'shotgun' ? [-SHOTGUN_SPREAD, 0, SHOTGUN_SPREAD] : [0];
  for (const spread of angles) {
    const bx = Math.sin(gunAngle + spread);
    const by = -Math.cos(gunAngle + spread);
    const rocket = weapon === 'rocket';
    const speed = rocket ? ROCKET_SPEED : BULLET_SPEED;
    bullets.push({ x: dx * muzzle, y: gunY() + dy * muzzle, dx: bx * speed, dy: by * speed, rocket });
  }
  ammo[weapon]--;
  if (ammo[weapon] === 0) {
    // A gun with no reload is ready again right away (only the short gap between shots).
    if (GUNS[weapon].reload > 0) reloadLeft[weapon] = GUNS[weapon].reload;
    else ammo[weapon] = GUNS[weapon].shots;
  }
  shotGap = SHOT_GAP;
}

const canShoot = () => shotGap <= 0 && reloadLeft[weapon] <= 0 && ammo[weapon] > 0;

/** Put a mouse at a random hole, running out onto the table. */
function comeOutOfHole(mouse: Mouse): void {
  const index = Math.floor(Math.random() * 3);
  const hole = holes()[index];
  mouse.x = hole.x;
  mouse.y = hole.y;
  mouse.heading = hole.inward + (Math.random() - 0.5);
  mouse.turn = 0;
  mouse.hole = index;
  mouse.state = 'scurry';
  mouse.timer = scurryTime();
}

/** A new mouse runs out of one of the holes. */
function spawnMouse(): void {
  const mouse: Mouse = {
    x: 0,
    y: 0,
    heading: 0,
    speed: (1.2 + Math.random() * 1.3) * (1 + SPEED_UP_PER_LEVEL * (level - 1)),
    turn: 0,
    wiggle: Math.random() * 10,
    state: 'scurry',
    timer: 0,
    hole: 0,
  };
  comeOutOfHole(mouse);
  mice.push(mouse);
}

/** Where the beam starts (the gun's tip) and which way it points. */
function beamStart(): { x: number; y: number; dx: number; dy: number } {
  const dx = Math.sin(gunAngle);
  const dy = -Math.cos(gunAngle);
  return { x: dx * GUNS.beam.muzzle, y: gunY() + dy * GUNS.beam.muzzle, dx, dy };
}

/** Every mouse touching the beam dies. It goes right through them, all the way across the table. */
function zapMice(): void {
  const b = beamStart();
  for (let i = mice.length - 1; i >= 0; i--) {
    const mouse = mice[i];
    if (mouse.state === 'hiding') continue;
    const along = (mouse.x - b.x) * b.dx + (mouse.y - b.y) * b.dy;
    const across = Math.abs((mouse.x - b.x) * b.dy - (mouse.y - b.y) * b.dx);
    if (along > 0 && across < MOUSE_RADIUS + BEAM_WIDTH / 2) {
      addSplat(mouse.x, mouse.y);
      mice.splice(i, 1);
    }
  }
}

/** Boom! Every mouse near the explosion dies (except ones hiding in holes). */
function explode(x: number, y: number): void {
  const puffs: Puff[] = [];
  for (let i = 0; i < 22; i++) {
    const a = Math.random() * Math.PI * 2;
    const d = Math.random() * BLAST_RADIUS * 0.35;
    const speed = 0.5 + Math.random() * 2.2;
    puffs.push({
      x: Math.cos(a) * d,
      y: Math.sin(a) * d,
      vx: Math.cos(a) * speed,
      vy: Math.sin(a) * speed,
      size: 0.45 + Math.random() * 0.6,
      delay: Math.random() * 0.12,
    });
  }
  const sparks: Spark[] = [];
  for (let i = 0; i < 26; i++) {
    const a = Math.random() * Math.PI * 2;
    const speed = 4 + Math.random() * 6;
    sparks.push({ vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, life: 0.25 + Math.random() * 0.35 });
  }
  blasts.push({ x, y, age: 0, puffs, sparks });
  const blobs = [{ x: 0, y: 0, r: BLAST_RADIUS * 0.45 }];
  for (let i = 0; i < 8; i++) {
    const a = Math.random() * Math.PI * 2;
    const d = BLAST_RADIUS * (0.2 + Math.random() * 0.3);
    blobs.push({ x: Math.cos(a) * d, y: Math.sin(a) * d, r: 0.15 + Math.random() * 0.3 });
  }
  scorches.push({ x, y, blobs });
  if (scorches.length > 12) scorches.shift();
  shakeLeft = SHAKE_TIME;
  for (let i = mice.length - 1; i >= 0; i--) {
    const mouse = mice[i];
    if (mouse.state !== 'hiding' && Math.hypot(mouse.x - x, mouse.y - y) < BLAST_RADIUS) {
      addSplat(mouse.x, mouse.y);
      mice.splice(i, 1);
    }
  }
}

function addSplat(x: number, y: number): void {
  const blobs = [{ x: 0, y: 0, r: 0.32 }];
  for (let i = 0; i < 9; i++) {
    const a = Math.random() * Math.PI * 2;
    const d = 0.2 + Math.random() * 0.45;
    blobs.push({ x: Math.cos(a) * d, y: Math.sin(a) * d, r: 0.04 + Math.random() * 0.12 });
  }
  splats.push({ x, y, blobs });
  if (splats.length > MAX_SPLATS) splats.shift();
}

const roundOver = () => miceStillToCome === 0 && mice.length === 0;

/** Clean the table and send in the mice for the next level. After the last level, start over. */
function newRound(): void {
  level = level >= LEVELS ? 1 : level + 1;
  weapon = newestGun();
  for (const gun of WEAPONS) {
    ammo[gun] = GUNS[gun].shots;
    reloadLeft[gun] = 0;
  }
  splats.length = 0;
  bullets.length = 0;
  blasts.length = 0;
  scorches.length = 0;
  miceStillToCome = miceForLevel(level);
  nextMouseIn = 0.5;
  // Short pause so the shot that started the level doesn't also fire.
  shotGap = 0.3;
  beamLeft = 0;
}

function updateWorld(dt: number): void {
  shotGap -= dt;
  for (const gun of WEAPONS) {
    if (reloadLeft[gun] <= 0) continue;
    reloadLeft[gun] -= dt;
    if (reloadLeft[gun] <= 0) ammo[gun] = GUNS[gun].shots;
  }
  if (shootQueued) {
    if (roundOver()) {
      if (shotGap <= 0) newRound();
    } else if (canShoot()) shoot();
  }
  shootQueued = false;

  nextMouseIn -= dt;
  if (nextMouseIn <= 0 && miceStillToCome > 0) {
    spawnMouse();
    miceStillToCome--;
    nextMouseIn = 0.4 + Math.random() * 0.8;
  }

  for (const mouse of mice) {
    mouse.timer -= dt;

    if (mouse.state === 'hiding') {
      if (mouse.timer <= 0) comeOutOfHole(mouse);
      continue;
    }

    if (mouse.state === 'scurry' && mouse.timer <= 0) {
      // Time's up: run for the nearest hole, but never the one it came out of.
      const all = holes();
      let nearest = -1;
      all.forEach((hole, i) => {
        if (i === mouse.hole) return;
        const distance = Math.hypot(hole.x - mouse.x, hole.y - mouse.y);
        if (nearest < 0 || distance < Math.hypot(all[nearest].x - mouse.x, all[nearest].y - mouse.y)) nearest = i;
      });
      mouse.hole = nearest;
      mouse.state = 'toHole';
    }

    if (mouse.state === 'toHole') {
      const hole = holes()[mouse.hole];
      const dx = hole.x - mouse.x;
      const dy = hole.y - mouse.y;
      const step = mouse.speed * dt;
      mouse.wiggle += dt * mouse.speed * 6;
      if (Math.hypot(dx, dy) <= step) {
        mouse.state = 'hiding';
        mouse.timer = hideTime();
        continue;
      }
      mouse.heading = Math.atan2(dy, dx);
      mouse.x += Math.cos(mouse.heading) * step;
      mouse.y += Math.sin(mouse.heading) * step;
      continue;
    }

    // Now and then pick a new way to scurry.
    if (Math.random() < dt * 1.2) mouse.turn = (Math.random() - 0.5) * 4;
    mouse.heading += mouse.turn * dt;
    mouse.x += Math.cos(mouse.heading) * mouse.speed * dt;
    mouse.y += Math.sin(mouse.heading) * mouse.speed * dt;
    mouse.wiggle += dt * mouse.speed * 6;
    // Bounce off the table edges.
    if (Math.abs(mouse.x) > tableHalfW - 0.3) {
      mouse.x = Math.sign(mouse.x) * (tableHalfW - 0.3);
      mouse.heading = Math.PI - mouse.heading;
    }
    if (Math.abs(mouse.y) > tableHalfH - 0.3) {
      mouse.y = Math.sign(mouse.y) * (tableHalfH - 0.3);
      mouse.heading = -mouse.heading;
    }
    // Keep away from the rifle.
    const awayY = mouse.y - gunY();
    const fromGun = Math.hypot(mouse.x, awayY);
    if (fromGun < 1.2) {
      mouse.x = (mouse.x / fromGun) * 1.2;
      mouse.y = gunY() + (awayY / fromGun) * 1.2;
      mouse.heading = Math.atan2(awayY, mouse.x);
    }
  }

  for (let b = bullets.length - 1; b >= 0; b--) {
    const bullet = bullets[b];
    bullet.x += bullet.dx * dt;
    bullet.y += bullet.dy * dt;
    const hit = mice.findIndex((m) => m.state !== 'hiding' && Math.hypot(m.x - bullet.x, m.y - bullet.y) < MOUSE_RADIUS);
    if (bullet.rocket) {
      // Rockets blow up when they hit a mouse or the edge of the table.
      const atEdge = Math.abs(bullet.x) > tableHalfW || Math.abs(bullet.y) > tableHalfH;
      if (hit >= 0 || atEdge) {
        explode(
          Math.max(-tableHalfW, Math.min(tableHalfW, bullet.x)),
          Math.max(-tableHalfH, Math.min(tableHalfH, bullet.y)),
        );
        bullets.splice(b, 1);
      }
    } else if (hit >= 0) {
      addSplat(mice[hit].x, mice[hit].y);
      mice.splice(hit, 1);
      bullets.splice(b, 1);
    } else if (Math.abs(bullet.x) > tableHalfW + 1 || Math.abs(bullet.y) > tableHalfH + 1) {
      bullets.splice(b, 1);
    }
  }

  if (beamLeft > 0) {
    zapMice();
    if (beamLeft <= dt) stopBeam();
    else beamLeft -= dt;
  }

  shakeLeft = Math.max(0, shakeLeft - dt);
  for (let i = blasts.length - 1; i >= 0; i--) {
    blasts[i].age += dt;
    for (const puff of blasts[i].puffs) {
      if (blasts[i].age < puff.delay) continue;
      puff.x += puff.vx * dt;
      puff.y += puff.vy * dt;
      // Puffs slow down as they spread out, like real smoke.
      puff.vx *= Math.exp(-3 * dt);
      puff.vy *= Math.exp(-3 * dt);
    }
    if (blasts[i].age > BLAST_TIME) blasts.splice(i, 1);
  }
}

// ---------- drawing ----------

function resize(): void {
  const ratio = window.devicePixelRatio || 1;
  canvas.width = Math.round(canvas.clientWidth * ratio);
  canvas.height = Math.round(canvas.clientHeight * ratio);
}
window.addEventListener('resize', resize);
resize();

function drawTable(w: number, h: number): void {
  // Wooden tabletop with planks.
  ctx.fillStyle = '#8a5a34';
  ctx.fillRect(0, 0, w, h);
  const plank = Math.max(40, h / 8);
  for (let y = 0, i = 0; y < h; y += plank, i++) {
    ctx.fillStyle = i % 2 ? '#7f5230' : '#93613a';
    ctx.fillRect(0, y, w, plank - 2);
    ctx.fillStyle = '#5e3b20';
    ctx.fillRect(0, y + plank - 2, w, 2);
  }
  // Dark edge so it feels like you're looking down at a table.
  const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);
  edge.addColorStop(0, 'rgba(0,0,0,0)');
  edge.addColorStop(1, 'rgba(0,0,0,0.55)');
  ctx.fillStyle = edge;
  ctx.fillRect(0, 0, w, h);
}

/** Fill and outline a rectangle with rounded corners. */
function block(x: number, y: number, w: number, h: number, r: number, fill: string, line: number): void {
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = '#15171a';
  ctx.lineWidth = line;
  ctx.stroke();
}

function drawGun(x: number, y: number, size: number): void {
  if (weapon === 'shotgun') drawShotgun(x, y, size);
  else if (weapon === 'revolver') drawRevolver(x, y, size);
  else if (weapon === 'rocket') drawRocketLauncher(x, y, size);
  else if (weapon === 'beam') drawBeamGun(x, y, size);
  else drawRifle(x, y, size);
}

/** A sci-fi energy gun seen from above, pointing up, with glowing rings. */
function drawBeamGun(x: number, y: number, size: number): void {
  const s = size;
  const line = s * 0.04;
  const ready = reloadLeft.beam <= 0;
  const glow = beamLeft > 0 ? '#e8fdff' : ready ? '#4de8ff' : '#2a6670';
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(gunAngle);

  // Shadow on the table.
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(-s * 0.2 + s * 0.08, -s * 1.5 + s * 0.1, s * 0.4, s * 2.3);

  // Grip and back end.
  block(-s * 0.13, s * 0.2, s * 0.26, s * 0.6, s * 0.08, '#2b2f3a', line);
  // Main body.
  block(-s * 0.2, -s * 0.6, s * 0.4, s * 0.9, s * 0.12, '#5b4b8a', line);
  // Barrel with glowing rings.
  block(-s * 0.1, -s * 1.45, s * 0.2, s * 0.9, s * 0.05, '#3a3550', line);
  for (let i = 0; i < 3; i++) block(-s * 0.15, -s * 1.3 + i * s * 0.25, s * 0.3, s * 0.08, s * 0.03, glow, line);
  // Glowing tip.
  ctx.fillStyle = glow;
  ctx.beginPath();
  ctx.arc(0, -s * 1.48, s * 0.09, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();
  // Power window on the body.
  block(-s * 0.08, -s * 0.4, s * 0.16, s * 0.45, s * 0.04, glow, line);

  ctx.restore();
}

/** The energy beam: a wide glow with a bright flickering core. */
function drawBeam(): void {
  if (beamLeft <= 0) return;
  const b = beamStart();
  const length = 60;
  const flicker = 0.85 + Math.random() * 0.3;
  // It fades in quickly at the start and out at the very end.
  const fade = Math.min(1, (BEAM_TIME - beamLeft) / 0.05, beamLeft / 0.1);
  ctx.save();
  ctx.translate(b.x, b.y);
  ctx.rotate(Math.atan2(b.dy, b.dx));
  ctx.globalAlpha = fade;
  for (const [width, color] of [
    [BEAM_WIDTH * 3.2, 'rgba(77,232,255,0.15)'],
    [BEAM_WIDTH * 1.8, 'rgba(77,232,255,0.35)'],
    [BEAM_WIDTH, 'rgba(160,245,255,0.85)'],
    [BEAM_WIDTH * 0.4, 'rgba(255,255,255,1)'],
  ] as const) {
    const w = width * flicker;
    ctx.fillStyle = color;
    ctx.fillRect(0, -w / 2, length, w);
  }
  // Bright glow at the gun's tip.
  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, BEAM_WIDTH * 3);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(1, 'rgba(77,232,255,0)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(0, 0, BEAM_WIDTH * 3, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

/** A rocket launcher seen from above, pointing up: a big green tube with a rocket peeking out. */
function drawRocketLauncher(x: number, y: number, size: number): void {
  const s = size;
  const line = s * 0.04;
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(gunAngle);

  // Shadow on the table.
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(-s * 0.2 + s * 0.08, -s * 1.6 + s * 0.1, s * 0.4, s * 2.4);

  // Main tube.
  block(-s * 0.18, -s * 1.5, s * 0.36, s * 2.2, s * 0.08, '#5d6b3a', line);
  // Wider ends.
  block(-s * 0.22, -s * 1.6, s * 0.44, s * 0.18, s * 0.05, '#3f4a26', line);
  block(-s * 0.22, s * 0.58, s * 0.44, s * 0.18, s * 0.05, '#3f4a26', line);
  // Red rocket tip ready in the front.
  if (ammo.rocket > 0) {
    ctx.fillStyle = '#c0392b';
    ctx.beginPath();
    ctx.moveTo(-s * 0.12, -s * 1.6);
    ctx.lineTo(0, -s * 1.78);
    ctx.lineTo(s * 0.12, -s * 1.6);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  }
  // Sight on the side, and a yellow danger stripe.
  block(s * 0.18, -s * 0.55, s * 0.12, s * 0.3, s * 0.03, '#23272b', line);
  ctx.fillStyle = '#e0b23c';
  ctx.fillRect(-s * 0.18, -s * 1.15, s * 0.36, s * 0.08);

  ctx.restore();
}

/** A revolver seen from above, pointing up: short barrel, a round cylinder and a wooden grip. */
function drawRevolver(x: number, y: number, size: number): void {
  const s = size;
  const line = s * 0.04;
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(gunAngle);

  // Shadow on the table.
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(-s * 0.12 + s * 0.08, -s * 1.25 + s * 0.1, s * 0.3, s * 2.0);

  // Wooden grip at the back, curving a little.
  ctx.beginPath();
  ctx.moveTo(-s * 0.11, s * 0.15);
  ctx.lineTo(s * 0.11, s * 0.15);
  ctx.quadraticCurveTo(s * 0.2, s * 0.55, s * 0.14, s * 0.8);
  ctx.quadraticCurveTo(0, s * 0.9, -s * 0.14, s * 0.8);
  ctx.quadraticCurveTo(-s * 0.2, s * 0.55, -s * 0.11, s * 0.15);
  ctx.closePath();
  ctx.fillStyle = '#6b3a1f';
  ctx.fill();
  ctx.strokeStyle = '#15171a';
  ctx.lineWidth = line;
  ctx.stroke();

  // Barrel and front sight.
  block(-s * 0.06, -s * 1.25, s * 0.12, s * 1.05, s * 0.03, '#9aa3ab', line);
  block(-s * 0.03, -s * 1.22, s * 0.06, s * 0.1, s * 0.02, '#4c535a', line);

  // Frame and the hammer at the back.
  block(-s * 0.12, -s * 0.3, s * 0.24, s * 0.5, s * 0.05, '#7d868e', line);
  block(-s * 0.04, s * 0.12, s * 0.08, s * 0.14, s * 0.02, '#4c535a', line);

  // Round cylinder with 6 holes. Gold ones still have a bullet in them.
  ctx.fillStyle = '#8e979f';
  ctx.beginPath();
  ctx.arc(0, -s * 0.05, s * 0.22, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();
  for (let i = 0; i < 6; i++) {
    const a = (i / 6) * Math.PI * 2 - Math.PI / 2;
    ctx.fillStyle = i < ammo.revolver ? '#e0b23c' : '#1b1d20';
    ctx.beginPath();
    ctx.arc(Math.cos(a) * s * 0.13, -s * 0.05 + Math.sin(a) * s * 0.13, s * 0.045, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.restore();
}

/** A pump shotgun seen from above, pointing up: shorter, with two fat barrels. */
function drawShotgun(x: number, y: number, size: number): void {
  const s = size;
  const line = s * 0.04;
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(gunAngle);

  // Shadow on the table.
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(-s * 0.18 + s * 0.08, -s * 1.5 + s * 0.1, s * 0.4, s * 2.6);

  // Wooden stock at the back.
  ctx.beginPath();
  ctx.moveTo(-s * 0.13, s * 0.2);
  ctx.lineTo(s * 0.13, s * 0.2);
  ctx.lineTo(s * 0.21, s * 1.0);
  ctx.quadraticCurveTo(0, s * 1.1, -s * 0.21, s * 1.0);
  ctx.closePath();
  ctx.fillStyle = '#7a4320';
  ctx.fill();
  ctx.strokeStyle = '#15171a';
  ctx.lineWidth = line;
  ctx.stroke();
  block(-s * 0.22, s * 0.94, s * 0.44, s * 0.12, s * 0.05, '#23272b', line);

  // Two barrels side by side.
  block(-s * 0.12, -s * 1.55, s * 0.12, s * 1.35, s * 0.03, '#3a3f45', line);
  block(0, -s * 1.55, s * 0.12, s * 1.35, s * 0.03, '#3a3f45', line);
  // Dark barrel holes at the front.
  ctx.fillStyle = '#0c0d0f';
  ctx.fillRect(-s * 0.09, -s * 1.53, s * 0.06, s * 0.05);
  ctx.fillRect(s * 0.03, -s * 1.53, s * 0.06, s * 0.05);

  // Wooden pump under the barrels.
  block(-s * 0.17, -s * 0.95, s * 0.34, s * 0.45, s * 0.07, '#8a5228', line);
  for (let i = 0; i < 4; i++) {
    ctx.fillStyle = '#5a3218';
    ctx.fillRect(-s * 0.14, -s * 0.88 + i * s * 0.1, s * 0.28, s * 0.03);
  }

  // Metal body in the middle, with a red stripe so it looks different from the rifle.
  block(-s * 0.15, -s * 0.25, s * 0.3, s * 0.5, s * 0.05, '#4c535a', line);
  ctx.fillStyle = '#c0392b';
  ctx.fillRect(-s * 0.15, -s * 0.05, s * 0.3, s * 0.08);

  ctx.restore();
}

/** A rifle seen from above, pointing up, turning around its middle. */
function drawRifle(x: number, y: number, size: number): void {
  const s = size;
  const line = s * 0.04;
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(gunAngle);

  // Shadow on the table.
  ctx.save();
  ctx.translate(s * 0.08, s * 0.1);
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(-s * 0.2, -s * 1.9, s * 0.4, s * 3.0);
  ctx.restore();

  // Wooden stock at the back.
  ctx.beginPath();
  ctx.moveTo(-s * 0.13, s * 0.2);
  ctx.lineTo(s * 0.13, s * 0.2);
  ctx.lineTo(s * 0.2, s * 1.05);
  ctx.quadraticCurveTo(0, s * 1.15, -s * 0.2, s * 1.05);
  ctx.closePath();
  ctx.fillStyle = '#5a2e17';
  ctx.fill();
  ctx.strokeStyle = '#15171a';
  ctx.lineWidth = line;
  ctx.stroke();
  // Butt pad.
  block(-s * 0.21, s * 0.98, s * 0.42, s * 0.12, s * 0.05, '#23272b', line);

  // Wooden grip under the barrel.
  block(-s * 0.12, -s * 0.85, s * 0.24, s * 0.7, s * 0.06, '#6b3a1f', line);

  // Long barrel.
  block(-s * 0.05, -s * 1.85, s * 0.1, s * 1.2, s * 0.02, '#3a3f45', line);
  // Front sight and muzzle.
  block(-s * 0.08, -s * 1.95, s * 0.16, s * 0.12, s * 0.02, '#23272b', line);

  // Metal body in the middle.
  block(-s * 0.14, -s * 0.25, s * 0.28, s * 0.5, s * 0.05, '#4c535a', line);
  // Bolt handle sticking out the side.
  block(s * 0.12, -s * 0.05, s * 0.18, s * 0.07, s * 0.03, '#4c535a', line);
  ctx.fillStyle = '#4c535a';
  ctx.beginPath();
  ctx.arc(s * 0.32, -s * 0.015, s * 0.06, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();

  // Scope on top.
  block(-s * 0.08, -s * 0.55, s * 0.16, s * 0.6, s * 0.06, '#1f2226', line);
  block(-s * 0.1, -s * 0.6, s * 0.2, s * 0.1, s * 0.03, '#2c3035', line);
  // Shiny glass at the front of the scope.
  ctx.fillStyle = '#7fc3e8';
  ctx.fillRect(-s * 0.06, -s * 0.58, s * 0.12, s * 0.04);

  ctx.restore();
}

/** Dark arched holes in the table edges. */
function drawHoles(): void {
  for (const hole of holes()) {
    ctx.save();
    ctx.translate(hole.x, hole.y);
    ctx.rotate(hole.inward);
    // The flat side sits against the edge, the round side faces the table.
    ctx.beginPath();
    ctx.arc(-HOLE_INSET, 0, HOLE_SIZE, -Math.PI / 2, Math.PI / 2);
    ctx.closePath();
    ctx.fillStyle = '#4a2c17';
    ctx.fill();
    ctx.beginPath();
    ctx.arc(-HOLE_INSET, 0, HOLE_SIZE * 0.8, -Math.PI / 2, Math.PI / 2);
    ctx.closePath();
    ctx.fillStyle = '#120a05';
    ctx.fill();
    ctx.restore();
  }
}

function drawSplats(): void {
  for (const splat of splats) {
    ctx.fillStyle = '#9e0f12';
    for (const blob of splat.blobs) {
      ctx.beginPath();
      ctx.arc(splat.x + blob.x, splat.y + blob.y, blob.r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(255,255,255,0.18)';
    ctx.beginPath();
    ctx.arc(splat.x - 0.1, splat.y - 0.1, 0.08, 0, Math.PI * 2);
    ctx.fill();
  }
}

/** A mouse seen from above, nose pointing the way it runs. */
function drawMouse(mouse: Mouse): void {
  ctx.save();
  ctx.translate(mouse.x, mouse.y);
  ctx.rotate(mouse.heading);
  ctx.lineWidth = 0.03;
  ctx.strokeStyle = '#3a3533';

  // Wiggly tail.
  ctx.beginPath();
  ctx.moveTo(-0.25, 0);
  const sway = Math.sin(mouse.wiggle) * 0.12;
  ctx.quadraticCurveTo(-0.45, sway, -0.65, -sway);
  ctx.strokeStyle = '#d99a9a';
  ctx.lineWidth = 0.04;
  ctx.stroke();

  // Body.
  ctx.fillStyle = '#8d8682';
  ctx.strokeStyle = '#3a3533';
  ctx.lineWidth = 0.03;
  ctx.beginPath();
  ctx.ellipse(0, 0, 0.28, 0.17, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();

  // Ears.
  for (const side of [-1, 1]) {
    ctx.fillStyle = '#e8a5a5';
    ctx.beginPath();
    ctx.arc(0.15, side * 0.12, 0.07, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
  }

  // Head and pink nose.
  ctx.fillStyle = '#8d8682';
  ctx.beginPath();
  ctx.ellipse(0.27, 0, 0.12, 0.09, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.stroke();
  ctx.fillStyle = '#f07a8a';
  ctx.beginPath();
  ctx.arc(0.39, 0, 0.03, 0, Math.PI * 2);
  ctx.fill();

  ctx.restore();
}

function drawBullets(): void {
  for (const bullet of bullets) {
    if (bullet.rocket) {
      drawRocket(bullet);
      continue;
    }
    ctx.fillStyle = '#ffd34d';
    ctx.beginPath();
    ctx.arc(bullet.x, bullet.y, 0.06, 0, Math.PI * 2);
    ctx.fill();
  }
}

/** A little rocket with a flickering flame out the back. */
function drawRocket(rocket: Bullet): void {
  ctx.save();
  ctx.translate(rocket.x, rocket.y);
  ctx.rotate(Math.atan2(rocket.dy, rocket.dx));
  ctx.scale(1.7, 1.7);
  ctx.fillStyle = Math.random() < 0.5 ? '#ffb02e' : '#ff5a1f';
  ctx.beginPath();
  ctx.moveTo(-0.15, -0.06);
  ctx.lineTo(-0.3 - Math.random() * 0.15, 0);
  ctx.lineTo(-0.15, 0.06);
  ctx.fill();
  ctx.fillStyle = '#5d6b3a';
  ctx.fillRect(-0.15, -0.06, 0.25, 0.12);
  ctx.fillStyle = '#c0392b';
  ctx.beginPath();
  ctx.moveTo(0.1, -0.06);
  ctx.lineTo(0.2, 0);
  ctx.lineTo(0.1, 0.06);
  ctx.fill();
  ctx.restore();
}

/** Black burn marks where rockets went off. */
function drawScorches(): void {
  for (const scorch of scorches) {
    for (const blob of scorch.blobs) {
      const x = scorch.x + blob.x;
      const y = scorch.y + blob.y;
      const g = ctx.createRadialGradient(x, y, 0, x, y, blob.r);
      g.addColorStop(0, 'rgba(20,12,6,0.6)');
      g.addColorStop(1, 'rgba(20,12,6,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(x, y, blob.r, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

/** Fire color for a puff: white-hot, then yellow, orange, red, and finally dark smoke. */
function puffColor(heat: number): [number, number, number] {
  const stops: [number, [number, number, number]][] = [
    [1, [255, 250, 220]],
    [0.8, [255, 210, 80]],
    [0.6, [255, 130, 30]],
    [0.4, [190, 50, 20]],
    [0.2, [70, 60, 55]],
    [0, [40, 38, 36]],
  ];
  for (let i = 1; i < stops.length; i++) {
    const [h1, c1] = stops[i - 1];
    const [h2, c2] = stops[i];
    if (heat >= h2) {
      const k = (heat - h2) / (h1 - h2);
      return [0, 1, 2].map((j) => Math.round(c2[j] + (c1[j] - c2[j]) * k)) as [number, number, number];
    }
  }
  return stops[stops.length - 1][1];
}

/** Explosions: a flash, a shock ring, flying sparks, and puffs of fire that turn into smoke. */
function drawBlasts(): void {
  for (const blast of blasts) {
    const t = blast.age;

    // Bright flash right at the start.
    if (t < 0.12) {
      const flash = 1 - t / 0.12;
      const g = ctx.createRadialGradient(blast.x, blast.y, 0, blast.x, blast.y, BLAST_RADIUS * 1.6);
      g.addColorStop(0, `rgba(255,255,230,${flash})`);
      g.addColorStop(1, 'rgba(255,200,120,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(blast.x, blast.y, BLAST_RADIUS * 1.6, 0, Math.PI * 2);
      ctx.fill();
    }

    // Shock ring racing outward.
    if (t < 0.3) {
      const k = t / 0.3;
      ctx.strokeStyle = `rgba(255,240,210,${0.7 * (1 - k)})`;
      ctx.lineWidth = 0.12 * (1 - k) + 0.02;
      ctx.beginPath();
      ctx.arc(blast.x, blast.y, BLAST_RADIUS * (0.3 + 0.9 * k), 0, Math.PI * 2);
      ctx.stroke();
    }

    // Smoke and fire puffs. Drawn oldest-looking (smoke) first so fire stays on top.
    for (const puff of blast.puffs) {
      const age = t - puff.delay;
      if (age < 0) continue;
      const life = age / (BLAST_TIME - puff.delay);
      if (life >= 1) continue;
      const heat = Math.max(0, 1 - life * 1.8 - puff.size * 0.1);
      const [r, g, b] = puffColor(heat);
      const radius = puff.size * (0.5 + 1.1 * Math.sqrt(life));
      const alpha = (heat > 0.2 ? 0.95 : 0.6) * (1 - life);
      const x = blast.x + puff.x;
      const y = blast.y + puff.y;
      const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
      grad.addColorStop(0, `rgba(${r},${g},${b},${alpha})`);
      grad.addColorStop(0.6, `rgba(${r},${g},${b},${alpha * 0.7})`);
      grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    // Sparks: short glowing streaks flying out.
    ctx.lineCap = 'round';
    for (const spark of blast.sparks) {
      if (t > spark.life) continue;
      const k = t / spark.life;
      const x = blast.x + spark.vx * t;
      const y = blast.y + spark.vy * t;
      ctx.strokeStyle = `rgba(255,${Math.round(220 - 120 * k)},80,${1 - k})`;
      ctx.lineWidth = 0.05;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x - spark.vx * 0.04, y - spark.vy * 0.04);
      ctx.stroke();
    }
    ctx.lineCap = 'butt';
  }
}

function drawRoundOver(w: number, h: number): void {
  const big = Math.max(18, Math.min(w, h) * 0.07);
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  const won = level >= LEVELS;
  const newGun = won ? undefined : WEAPONS.find((gun) => GUNS[gun].unlock === level + 1);
  ctx.fillRect(0, h * 0.3, w, big * (newGun ? 4.3 : 3));
  ctx.fillStyle = '#f4e3c1';
  ctx.font = `bold ${big}px sans-serif`;
  ctx.fillText(won ? `You beat all ${LEVELS} levels!` : `Level ${level} done!`, w / 2, h * 0.3 + big);
  ctx.font = `${big * 0.5}px sans-serif`;
  ctx.fillText(won ? 'Shoot to play again' : `Shoot to start level ${level + 1}`, w / 2, h * 0.3 + big * 2.1);
  if (newGun) {
    ctx.fillStyle = '#ffd34d';
    ctx.font = `bold ${big * 0.55}px sans-serif`;
    ctx.fillText(`You unlocked the ${GUNS[newGun].name.toUpperCase()}!`, w / 2, h * 0.3 + big * 3.6);
  }
}

/** Level and mice left, in the top left corner. */
function drawLevel(w: number, h: number): void {
  const text = Math.max(14, Math.min(w, h) * 0.04);
  const pad = text * 0.6;
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.font = `bold ${text}px sans-serif`;
  const left = miceStillToCome + mice.length;
  const reloading = reloadLeft[weapon] > 0 || beamLeft > 0;
  const gun = GUNS[weapon];
  const lines = [
    `Level ${level}/${LEVELS}`,
    `Mice left: ${left}`,
    beamLeft > 0
      ? 'Firing beam!'
      : reloading
      ? `Reloading: ${Math.ceil(reloadLeft[weapon])}s`
      : `Ready to shoot!${gun.shots > 1 ? ` (${ammo[weapon]}/${gun.shots})` : ''}`,
  ];
  lines.push(`Gun: ${gun.name}`);
  const boxW = Math.max(...lines.map((line) => ctx.measureText(line).width)) + pad * 2;
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  ctx.beginPath();
  ctx.roundRect(pad * 0.5, pad * 0.5, boxW, text * 1.3 * lines.length + pad, pad * 0.6);
  ctx.fill();
  lines.forEach((line, i) => {
    ctx.fillStyle = i === 2 ? (reloading ? '#ff8a6b' : '#9be37a') : '#f4e3c1';
    ctx.fillText(line, pad * 1.5, pad + i * text * 1.3);
  });
}

// ---------- game loop ----------

let last = performance.now();
function frame(now: number): void {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;

  const w = canvas.width;
  const h = canvas.height;
  const size = Math.min(w, h) * 0.12;
  tableHalfW = w / 2 / size;
  tableHalfH = h / 2 / size;

  const controllers = pad.poll();
  const players = controllers.status === 'ready' ? controllers.players : [];
  if (players.some((player) => player.actions.pause?.pressed)) togglePause();
  if (paused) {
    // On the menu, A or Start picks the highlighted button.
    if (players.some((player) => player.actions.shoot?.pressed) && menu.contains(document.activeElement)) {
      (document.activeElement as HTMLButtonElement).click();
    }
    shootQueued = false;
    gunSpin = 0;
    last = now;
    drawScene(w, h, size);
    requestAnimationFrame(frame);
    return;
  }
  if (players.some((player) => player.actions.shoot?.pressed)) shootQueued = true;
  const wantSpin = turnInput(now, players) * TURN_SPEED;
  gunSpin += (wantSpin - gunSpin) * Math.min(1, SPIN_EASE * dt);
  gunAngle += gunSpin * dt;
  if (Math.abs(gunAngle) > MAX_TURN) {
    gunAngle = Math.sign(gunAngle) * MAX_TURN;
    gunSpin = 0;
  }
  updateWorld(dt);
  drawScene(w, h, size);
  requestAnimationFrame(frame);
}

function drawScene(w: number, h: number, size: number): void {
  // Shake everything for a moment after an explosion.
  ctx.save();
  if (shakeLeft > 0) {
    const shake = size * 0.15 * (shakeLeft / SHAKE_TIME);
    ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
  }
  drawTable(w, h);
  // Splats, mice and bullets are drawn in gun sizes around the middle of the table.
  ctx.save();
  ctx.translate(w / 2, h / 2);
  ctx.scale(size, size);
  drawHoles();
  drawScorches();
  drawSplats();
  for (const mouse of mice) if (mouse.state !== 'hiding') drawMouse(mouse);
  drawBullets();
  drawBlasts();
  drawBeam();
  ctx.restore();
  drawGun(w / 2, h / 2 + gunY() * size, size);
  ctx.restore();
  drawLevel(w, h);
  if (roundOver() && !paused) drawRoundOver(w, h);
}
requestAnimationFrame(frame);
showMenu();