Back to super-platformer
SOURCE / PINNED RELEASE

Made of little things.

super-platformer

Release
b61593abc2c6…
Author-recorded commit
bbd35ca510f6…
License
LICENSE
Author’s source reference
nostr://npub16uawymqtmdg4j6zcl6snt5apzfp4ek2g8lpasnqa75tspdpglcqsgpatvc/wss%3A%2F%2Fgit.napplet.soy%2F/n-01288b1ab38

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

src/game.ts
/**
 * A tiny tile-based platformer engine.
 *
 * World units are pixels; one tile is TILE px. Levels are authored as ASCII
 * rows:
 *   #  solid ground        =  solid platform
 *   o  coin                ^  spike
 *   P  spawn               G  goal
 *   ?  power-up crate      c  checkpoint
 *   w  walker enemy        j  jumper enemy
 *   f  flyer enemy         b  brute enemy (two stomps)
 *   B  boss (many stomps)
 * Moving platforms and saw hazards are declared per level with `movers` and
 * `hazards`.
 *
 * Stomp an enemy from above to defeat it and collect a coin. Touching one from
 * the side or below is fatal. Break crates to gain a temporary power-up, touch
 * checkpoints to set your respawn, and defeat the boss to clear the last level.
 * The renderer scales the world to fill the frame and follows the player.
 */

import { sfx } from './audio.js';

export const TILE = 24;
const VIEW_ROWS = 9;
const MIN_VIEW_TILES = 14;
const FIXED_STEP = 1 / 120;

const PLAYER_W = 15;
const PLAYER_H = 21;

const GRAVITY = 1250;
const MAX_FALL = 900;
const MAX_RUN = 145;
const RUN_ACCEL = 1300;
const RUN_DECEL = 1700;
const AIR_ACCEL = 1000;
const JUMP_VELOCITY = 520;
const JUMP_CUT = 200;
const COYOTE_TIME = 0.1;
const JUMP_BUFFER_TIME = 0.12;
const STOMP_BOUNCE = 380;

const SPEED_MULTIPLIER = 1.55;
const SHOT_SPEED = 360;
const SHOT_COOLDOWN = 0.22;
const MAGNET_RADIUS = 92;

export type GameStatus =
  | 'ready'
  | 'playing'
  | 'paused'
  | 'levelComplete'
  | 'allComplete'
  | 'gameOver';
export type InputAction = 'left' | 'right' | 'jump' | 'fire';

export type PowerType = 'magnet' | 'fire' | 'shield' | 'doubleJump' | 'speed';

export interface PowerInfo {
  type: PowerType;
  timeLeft: number;
}

export interface MoverDefinition {
  /** Top-left tile position. */
  x: number;
  y: number;
  /** Width in tiles. */
  w: number;
  /** Height in tiles (default 0.55). */
  h?: number;
  axis: 'x' | 'y';
  /** Travel distance in tiles. */
  range: number;
  /** Tiles per second. */
  speed: number;
  /** 0..1 phase offset. */
  phase?: number;
}

export interface SawDefinition {
  /** Centre tile position. */
  x: number;
  y: number;
  /** Radius in tiles (default 0.6). */
  r?: number;
  axis?: 'x' | 'y';
  /** Travel distance in tiles. */
  range?: number;
  speed?: number;
  phase?: number;
}

export interface LevelDefinition {
  name: string;
  rows: string[];
  movers?: MoverDefinition[];
  hazards?: SawDefinition[];
}

export const LEVELS: LevelDefinition[] = [
  {
    name: 'Meadow',
    rows: [
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '    oow             f     ooo            f      ooj             ',
      '  P ===     ?  w      b   ===?    ^    ?      ^ === c         G ',
      '####################  ##################   #####################',
    ],
    hazards: [
      { x: 8, y: 10 },
      { x: 57, y: 10 },
    ],
  },
  {
    name: 'Cavern',
    rows: [
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '                                                                    ',
      '     ooo      f       oow    f   ooj     f     oob          ooo     ',
      '  P  ===b        ?  ^ ===  ^    j===?      ?  ^===  w       === c G ',
      '##############   ###########  ##########   ###########  ############',
    ],
    hazards: [
      { x: 4, y: 10 },
      { x: 51, y: 10 },
      { x: 59, y: 10 },
    ],
  },
  {
    name: 'Spires',
    rows: [
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                                                                ',
      '                  f                   f                         ',
      '                                                                ',
      '     oow                ooj                 oob         f       ',
      '     ===     ooo        ===     ooo         ===      ooo        ',
      ' P   ===?   w=== ?    ^ ===?   ^===  c     ^===    j ===      G ',
      '##################  ##################   #######################',
    ],
    hazards: [
      { x: 4, y: 10 },
      { x: 50, y: 10 },
    ],
    movers: [
      { x: 20, y: 8, w: 2, axis: 'y', range: 3, speed: 1.8, phase: 0 },
    ],
  },
  {
    name: 'Foundry',
    rows: [
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                f                f               f                      ',
      '     ooo              oow             ooj             oob       ooo     ',
      '  P  ===       ^      ===  ^   j      ===w  c  ^     ^=== b     === w G ',
      '################   #############  ##############   #####################',
    ],
    hazards: [
      { x: 4, y: 10 },
      { x: 20, y: 10 },
      { x: 37, y: 10 },
      { x: 63, y: 10 },
    ],
    movers: [
      { x: 26, y: 9, w: 2, axis: 'x', range: 3, speed: 2.2, phase: 0 },
    ],
  },
  {
    name: 'Ruins',
    rows: [
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                                                                        ',
      '                          f                 f                           ',
      '    oow           ooo         oob               ooj                     ',
      '    ===    ooj    ===         ===    oow        ===     oob f   ooo     ',
      ' P  ===    ===  ^ ===c       b===    ===?  ^   j===  ^  ===  B  === w G ',
      '##########################  ################  ##########################',
    ],
    hazards: [
      { x: 8, y: 10 },
      { x: 36, y: 10 },
    ],
  },
  {
    name: 'Gauntlet',
    rows: [
      '                                                                                ',
      '                                                                                ',
      '                                                                                ',
      '                                                                                ',
      '                                                                                ',
      '                                                                                ',
      '                                                                                ',
      '                                          f                         f           ',
      '              f    oob      f                  oow      f                       ',
      '     oow           ===           ooj           ===           oob         ooo    ',
      ' P   ===j    ^    b===b         ^===w         w===c         ^===?  ^    B===c G ',
      '##############   ###########  ############   ###########  ##########   #########',
    ],
    hazards: [
      { x: 4, y: 10 },
    ],
  },
];

export interface HudState {
  levelIndex: number;
  levelCount: number;
  levelName: string;
  coins: number;
  coinTotal: number;
  time: number;
  deaths: number;
  hearts: number;
  status: GameStatus;
  power: PowerType | null;
  powerTime: number;
}

export interface CompletionInfo {
  levelIndex: number;
  levelCount: number;
  levelName: string;
  levelTime: number;
  totalTime: number;
  isLast: boolean;
  deaths: number;
}

export interface GameCallbacks {
  onHud(state: HudState): void;
  onStatusChange(status: GameStatus, info: CompletionInfo): void;
}

interface Coin {
  col: number;
  row: number;
  x: number;
  y: number;
  taken: boolean;
}

interface Crate {
  col: number;
  row: number;
  broken: boolean;
  power: PowerType;
  wobble: number;
}

interface Checkpoint {
  col: number;
  row: number;
  active: boolean;
}

interface Saw {
  x: number;
  y: number;
  r: number;
  axis: 'x' | 'y' | null;
  originX: number;
  originY: number;
  range: number;
  speed: number;
  phase: number;
  angle: number;
}

interface Shot {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
}

type EnemyType = 'walker' | 'jumper' | 'flyer' | 'brute' | 'boss';

interface EnemySpawn {
  type: EnemyType;
  col: number;
  row: number;
}

interface EnemyStats {
  w: number;
  h: number;
  speed: number;
  hp: number;
  coins: number;
  color: string;
  shade: string;
}

const ENEMY_STATS: Record<EnemyType, EnemyStats> = {
  walker: { w: 18, h: 18, speed: 55, hp: 1, coins: 1, color: '#e5484d', shade: '#b4232c' },
  jumper: { w: 18, h: 18, speed: 28, hp: 1, coins: 1, color: '#a259ff', shade: '#7b34d6' },
  flyer: { w: 20, h: 15, speed: 70, hp: 1, coins: 1, color: '#2fb344', shade: '#1f8a33' },
  brute: { w: 26, h: 26, speed: 36, hp: 2, coins: 2, color: '#8e1f2b', shade: '#661520' },
  boss: { w: 44, h: 44, speed: 34, hp: 5, coins: 10, color: '#7a1f2b', shade: '#4d1119' },
};

interface Body {
  x: number;
  y: number;
  vx: number;
  vy: number;
  w: number;
  h: number;
  onGround: boolean;
}

interface Enemy extends Body {
  type: EnemyType;
  dir: number;
  hp: number;
  maxHp: number;
  coinValue: number;
  alive: boolean;
  squash: number;
  hitFlash: number;
  baseY: number;
  originX: number;
  timer: number;
  bob: number;
}

interface Mover {
  x: number;
  y: number;
  w: number;
  h: number;
  axis: 'x' | 'y';
  originX: number;
  originY: number;
  range: number;
  speed: number;
  phase: number;
  dx: number;
  dy: number;
  prevX: number;
  prevY: number;
}

interface Pop {
  x: number;
  y: number;
  vy: number;
  life: number;
  max: number;
  text: string;
  color: string;
}

interface ParsedLevel {
  name: string;
  cols: number;
  rows: number;
  tiles: Uint8Array;
  coins: Coin[];
  crates: Crate[];
  checkpoints: Checkpoint[];
  enemies: EnemySpawn[];
  movers: MoverDefinition[];
  hazards: SawDefinition[];
  spawn: { x: number; y: number };
  goal: { x: number; y: number };
}

interface Palette {
  skyTop: string;
  skyBottom: string;
  hillFar: string;
  hillNear: string;
  solid: string;
  solidTop: string;
  spike: string;
  coin: string;
  coinShine: string;
  player: string;
  playerShine: string;
  eye: string;
  pupil: string;
  goal: string;
  goalFlag: string;
  mover: string;
  moverTop: string;
  saw: string;
  sawShine: string;
  crate: string;
  crateDark: string;
}

const FALLBACK_PALETTE: Palette = {
  skyTop: '#eef3f8',
  skyBottom: '#dbe6f2',
  hillFar: '#d3dde9',
  hillNear: '#bccbdc',
  solid: '#8a97a8',
  solidTop: '#a9b5c5',
  spike: '#e5484d',
  coin: '#f5b301',
  coinShine: '#ffe08a',
  player: '#2563eb',
  playerShine: '#7ba4f6',
  eye: '#ffffff',
  pupil: '#17212b',
  goal: '#2563eb',
  goalFlag: '#7ba4f6',
  mover: '#c98a3a',
  moverTop: '#e0aa5c',
  saw: '#8b93a3',
  sawShine: '#c9d1de',
  crate: '#c98a3a',
  crateDark: '#8a5a22',
};

type Rgb = [number, number, number];

function parseColor(input: string): Rgb | null {
  const value = input.trim();
  if (!value) return null;
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
  if (hex) {
    const raw = hex[1];
    const full = raw.length === 3 ? raw.split('').map((c) => c + c).join('') : raw;
    return [
      Number.parseInt(full.slice(0, 2), 16),
      Number.parseInt(full.slice(2, 4), 16),
      Number.parseInt(full.slice(4, 6), 16),
    ];
  }
  const rgb = /^rgba?\(([^)]+)\)$/i.exec(value);
  if (rgb) {
    const parts = rgb[1].split(/[\s,/]+/).filter(Boolean).map(Number);
    if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
      return [parts[0], parts[1], parts[2]];
    }
  }
  return null;
}

function mix(a: Rgb, b: Rgb, t: number): Rgb {
  return [
    Math.round(a[0] + (b[0] - a[0]) * t),
    Math.round(a[1] + (b[1] - a[1]) * t),
    Math.round(a[2] + (b[2] - a[2]) * t),
  ];
}

function css(color: Rgb): string {
  return `rgb(${color[0]} ${color[1]} ${color[2]})`;
}

function luminance(color: Rgb): number {
  return (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255;
}

export function buildPalette(bgRaw: string, fgRaw: string, primaryRaw: string): Palette {
  const bg = parseColor(bgRaw) ?? [244, 246, 248];
  const fg = parseColor(fgRaw) ?? [23, 33, 43];
  const primary = parseColor(primaryRaw) ?? [37, 99, 235];
  const dark = luminance(bg) < 0.5;
  return {
    skyTop: css(bg),
    skyBottom: css(mix(bg, primary, dark ? 0.08 : 0.12)),
    hillFar: css(mix(bg, fg, dark ? 0.12 : 0.06)),
    hillNear: css(mix(bg, fg, dark ? 0.22 : 0.12)),
    solid: css(mix(bg, fg, dark ? 0.34 : 0.24)),
    solidTop: css(mix(bg, fg, dark ? 0.5 : 0.36)),
    spike: '#e5484d',
    coin: '#f5b301',
    coinShine: '#ffe08a',
    player: css(primary),
    playerShine: css(mix(primary, [255, 255, 255], 0.38)),
    eye: '#ffffff',
    pupil: css(mix(primary, fg, 0.55)),
    goal: css(primary),
    goalFlag: css(mix(primary, [255, 255, 255], 0.25)),
    mover: '#c98a3a',
    moverTop: '#e0aa5c',
    saw: css(mix(bg, fg, dark ? 0.72 : 0.62)),
    sawShine: css(mix(bg, fg, dark ? 0.95 : 0.85)),
    crate: '#c98a3a',
    crateDark: '#8a5a22',
  };
}

const POWER_POOL: PowerType[] = ['magnet', 'fire', 'shield', 'doubleJump', 'speed'];

function parseLevel(definition: LevelDefinition): ParsedLevel {
  const rows = definition.rows.length;
  const cols = Math.max(...definition.rows.map((row) => row.length));
  const tiles = new Uint8Array(cols * rows);
  const coins: Coin[] = [];
  const crates: Crate[] = [];
  const checkpoints: Checkpoint[] = [];
  const enemies: EnemySpawn[] = [];
  let spawn = { x: 1, y: rows - 2 };
  let goal = { x: cols - 2, y: rows - 2 };

  const enemyChars: Record<string, EnemyType> = {
    w: 'walker',
    j: 'jumper',
    f: 'flyer',
    b: 'brute',
    B: 'boss',
  };

  for (let row = 0; row < rows; row += 1) {
    const line = definition.rows[row];
    for (let col = 0; col < cols; col += 1) {
      const ch = line[col] ?? ' ';
      const index = row * cols + col;
      if (ch === '#' || ch === '=') tiles[index] = 1;
      else if (ch === '^') tiles[index] = 2;
      else if (ch === 'o') {
        coins.push({ col, row, x: (col + 0.5) * TILE, y: (row + 0.5) * TILE, taken: false });
      } else if (ch === '?') {
        crates.push({
          col,
          row,
          broken: false,
          power: POWER_POOL[(col * 3 + row) % POWER_POOL.length],
          wobble: 0,
        });
      } else if (ch === 'c') {
        checkpoints.push({ col, row, active: false });
      } else if (ch === 'P') spawn = { x: col, y: row };
      else if (ch === 'G') goal = { x: col, y: row };
      else if (enemyChars[ch]) enemies.push({ type: enemyChars[ch], col, row });
    }
  }

  return {
    name: definition.name,
    cols,
    rows,
    tiles,
    coins,
    crates,
    checkpoints,
    enemies,
    movers: definition.movers ?? [],
    hazards: definition.hazards ?? [],
    spawn,
    goal,
  };
}

function approach(current: number, target: number, delta: number): number {
  if (current < target) return Math.min(current + delta, target);
  if (current > target) return Math.max(current - delta, target);
  return current;
}

function roundRectPath(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  radius: number,
): void {
  const r = Math.max(0, Math.min(radius, w / 2, h / 2));
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

export class Platformer {
  private readonly canvas: HTMLCanvasElement;
  private readonly ctx: CanvasRenderingContext2D;
  private readonly callbacks: GameCallbacks;

  private level: ParsedLevel;
  private levelIndex = 0;
  private status: GameStatus = 'ready';

  private player = {
    x: 0,
    y: 0,
    vx: 0,
    vy: 0,
    w: PLAYER_W,
    h: PLAYER_H,
    onGround: false,
    facing: 1,
    stretch: 0,
    walk: 0,
  };

  private enemies: Enemy[] = [];
  private movers: Mover[] = [];
  private ridingMover: Mover | null = null;
  private saws: Saw[] = [];
  private shots: Shot[] = [];
  private pops: Pop[] = [];
  private coinTotal = 0;
  private power: PowerInfo | null = null;
  private extraJump = false;
  private fireCooldown = 0;
  private respawnPoint: { x: number; y: number } | null = null;
  private lastSafe: { x: number; y: number } | null = null;

  private input = { left: false, right: false, jump: false, fire: false };
  private coyote = 0;
  private jumpBuffer = 0;
  private dead = false;
  private deadTimer = 0;
  private grace = 0;
  private shake = 0;
  private levelTime = 0;
  private totalTime = 0;
  private elapsed = 0;
  private simTime = 0;
  private deaths = 0;
  private hearts = 3;
  private coinsCollected = 0;

  private palette: Palette = FALLBACK_PALETTE;
  private scale = 1;
  private viewW = 0;
  private viewH = 0;
  private camX = 0;
  private camY = 0;
  private cssW = 0;
  private cssH = 0;
  private dpr = 1;

  private raf = 0;
  private last = 0;
  private accumulator = 0;
  private lastHudKey = '';
  private destroyed = false;

  constructor(canvas: HTMLCanvasElement, callbacks: GameCallbacks) {
    this.canvas = canvas;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('2D canvas context unavailable');
    this.ctx = ctx;
    this.callbacks = callbacks;
    this.level = parseLevel(LEVELS[0]);
    this.respawnPoint = null;
    this.resetEntities();
    this.refreshPalette();
    this.resize();
    this.last = performance.now();
    this.raf = requestAnimationFrame(this.frame);
  }

  // ── Public control surface ──────────────────────────────────────────

  press(action: InputAction): void {
    if (action === 'jump') {
      this.input.jump = true;
      this.jumpBuffer = JUMP_BUFFER_TIME;
    } else {
      this.input[action] = true;
    }
  }

  release(action: InputAction): void {
    this.input[action] = false;
  }

  releaseAll(): void {
    this.input.left = false;
    this.input.right = false;
    this.input.jump = false;
    this.input.fire = false;
  }

  primary(): void {
    switch (this.status) {
      case 'ready':
        this.startRun();
        break;
      case 'paused':
        this.setStatus('playing');
        break;
      case 'levelComplete':
        this.advance();
        break;
      case 'allComplete':
        this.startRun();
        break;
      case 'gameOver':
        this.retryLevel();
        break;
      default:
        break;
    }
  }

  togglePause(): void {
    if (this.status === 'playing') this.setStatus('paused');
    else if (this.status === 'paused') this.setStatus('playing');
  }

  restartLevel(): void {
    if (this.status === 'gameOver') {
      this.retryLevel();
      return;
    }
    if (this.status === 'allComplete') {
      this.startRun();
      return;
    }
    this.resetLevelState();
    this.setStatus('playing');
  }

  startRun(): void {
    this.totalTime = 0;
    this.deaths = 0;
    this.hearts = 3;
    this.loadLevel(0);
    this.setStatus('playing');
  }

  resize(): void {
    const width = this.canvas.clientWidth;
    const height = this.canvas.clientHeight;
    if (width <= 0 || height <= 0) return;
    this.dpr = Math.min(window.devicePixelRatio || 1, 3);
    this.cssW = width;
    this.cssH = height;
    const pixelW = Math.round(width * this.dpr);
    const pixelH = Math.round(height * this.dpr);
    if (this.canvas.width !== pixelW) this.canvas.width = pixelW;
    if (this.canvas.height !== pixelH) this.canvas.height = pixelH;
    this.scale = Math.min(height / (VIEW_ROWS * TILE), width / (MIN_VIEW_TILES * TILE));
    this.viewW = width / this.scale;
    this.viewH = height / this.scale;
    this.updateCamera();
  }

  refreshPalette(): void {
    const style = getComputedStyle(document.documentElement);
    this.palette = buildPalette(
      style.getPropertyValue('--bg'),
      style.getPropertyValue('--fg'),
      style.getPropertyValue('--primary'),
    );
  }

  destroy(): void {
    this.destroyed = true;
    cancelAnimationFrame(this.raf);
  }

  // ── Run / level state ───────────────────────────────────────────────

  private loadLevel(index: number): void {
    this.levelIndex = index;
    this.hearts = 3;
    this.level = parseLevel(LEVELS[index]);
    this.respawnPoint = null;
    this.lastSafe = null;
    this.resetEntities();
  }

  /** Restart the current level from the very beginning (checkpoint cleared). */
  private resetLevelState(): void {
    this.level = parseLevel(LEVELS[this.levelIndex]);
    this.respawnPoint = null;
    this.lastSafe = null;
    this.resetEntities();
  }

  /** Retry the current level from the beginning with a full set of hearts. */
  private retryLevel(): void {
    this.hearts = 3;
    this.resetLevelState();
    this.setStatus('playing');
  }

  /**
   * Continue after losing a heart: keep coins, enemies, crates and saws as they
   * are and put the player back on the last checkpoint (or where they last
   * stood safely). The level is not reset.
   */
  private respawnPlayer(): void {
    this.resetPlayer();
    const point = this.respawnPoint ?? this.lastSafe;
    if (point) {
      this.player.x = point.x;
      this.player.y = point.y;
    }
    // Brief invulnerability so respawning next to a hazard is survivable.
    this.grace = 1.5;
  }

  /**
   * Reset coins, crates, enemies, saws and power-ups and put the player back on
   * the last checkpoint (or the spawn). Checkpoints stay lit between deaths.
   */
  private resetEntities(): void {
    for (const coin of this.level.coins) {
      coin.taken = false;
      coin.x = (coin.col + 0.5) * TILE;
      coin.y = (coin.row + 0.5) * TILE;
    }
    for (const crate of this.level.crates) crate.broken = false;
    this.enemies = this.level.enemies.map((spawn) => this.createEnemy(spawn));
    this.movers = this.level.movers.map((definition) => this.createMover(definition));
    this.saws = this.level.hazards.map((definition) => this.createSaw(definition));
    this.shots = [];
    this.power = null;
    this.extraJump = false;
    this.fireCooldown = 0;
    this.ridingMover = null;
    this.coinsCollected = 0;
    this.levelTime = 0;
    this.dead = false;
    this.deadTimer = 0;
    this.pops = [];
    this.coinTotal =
      this.level.coins.length + this.enemies.reduce((sum, enemy) => sum + enemy.coinValue, 0);
    this.resetPlayer();
    if (this.respawnPoint) {
      this.player.x = this.respawnPoint.x;
      this.player.y = this.respawnPoint.y;
    }
  }

  private createEnemy(spawn: EnemySpawn): Enemy {
    const stats = ENEMY_STATS[spawn.type];
    const ground = spawn.type !== 'flyer';
    const y = ground
      ? (spawn.row + 1) * TILE - stats.h
      : spawn.row * TILE + (TILE - stats.h) / 2;
    return {
      type: spawn.type,
      x: spawn.col * TILE + (TILE - stats.w) / 2,
      y,
      vx: 0,
      vy: 0,
      w: stats.w,
      h: stats.h,
      onGround: false,
      dir: -1,
      hp: stats.hp,
      maxHp: stats.hp,
      coinValue: stats.coins,
      alive: true,
      squash: 0,
      hitFlash: 0,
      baseY: y,
      originX: spawn.col * TILE + (TILE - stats.w) / 2,
      timer: 0.6 + (spawn.col % 3) * 0.25,
      bob: (spawn.col % 4) * 0.5,
    };
  }

  private createMover(definition: MoverDefinition): Mover {
    const h = (definition.h ?? 0.55) * TILE;
    const x = definition.x * TILE;
    const y = definition.y * TILE;
    return {
      x,
      y,
      w: definition.w * TILE,
      h,
      axis: definition.axis,
      originX: x,
      originY: y,
      range: definition.range * TILE,
      speed: definition.speed,
      phase: definition.phase ?? 0,
      dx: 0,
      dy: 0,
      prevX: x,
      prevY: y,
    };
  }

  private createSaw(definition: SawDefinition): Saw {
    const x = (definition.x + 0.5) * TILE;
    const y = (definition.y + 0.5) * TILE;
    return {
      x,
      y,
      r: (definition.r ?? 0.6) * TILE,
      axis: definition.axis ?? null,
      originX: x,
      originY: y,
      range: (definition.range ?? 0) * TILE,
      speed: definition.speed ?? 0,
      phase: definition.phase ?? 0,
      angle: 0,
    };
  }

  private resetPlayer(): void {
    const player = this.player;
    player.x = this.level.spawn.x * TILE + (TILE - PLAYER_W) / 2;
    player.y = (this.level.spawn.y + 1) * TILE - PLAYER_H;
    player.vx = 0;
    player.vy = 0;
    player.onGround = false;
    player.facing = 1;
    player.stretch = 0;
    player.walk = 0;
    this.coyote = 0;
    this.jumpBuffer = 0;
    this.dead = false;
    this.deadTimer = 0;
    this.ridingMover = null;
    this.power = null;
    this.extraJump = false;
    this.fireCooldown = 0;
    this.shots = [];
  }

  private advance(): void {
    if (this.levelIndex >= LEVELS.length - 1) {
      this.startRun();
      return;
    }
    this.loadLevel(this.levelIndex + 1);
    this.setStatus('playing');
  }

  private setStatus(status: GameStatus): void {
    this.status = status;
    this.callbacks.onStatusChange(status, this.completionInfo());
    this.emitHud(true);
  }

  private completionInfo(): CompletionInfo {
    return {
      levelIndex: this.levelIndex,
      levelCount: LEVELS.length,
      levelName: this.level.name,
      levelTime: this.levelTime,
      totalTime: this.totalTime + this.levelTime,
      isLast: this.levelIndex >= LEVELS.length - 1,
      deaths: this.deaths,
    };
  }

  private completeLevel(): void {
    if (this.status !== 'playing') return;
    this.totalTime += this.levelTime;
    sfx.complete();
    this.setStatus(this.levelIndex >= LEVELS.length - 1 ? 'allComplete' : 'levelComplete');
  }

  private die(force = false): void {
    if (this.dead || (this.invulnerable() && !force)) return;
    this.dead = true;
    this.deadTimer = 0.6;
    this.deaths += 1;
    this.hearts = Math.max(0, this.hearts - 1);
    this.shake = 9;
    this.player.vy = 0;
    this.player.vx = 0;
    sfx.hurt();
    this.emitHud(true);
  }

  private spawnPop(x: number, y: number, text: string, color: string): void {
    this.pops.push({ x, y, vy: -46, life: 0.8, max: 0.8, text, color });
  }

  // ── Simulation ──────────────────────────────────────────────────────

  private step(h: number): void {
    this.simTime += h;

    if (this.dead) {
      this.deadTimer -= h;
      this.updateMovers();
      this.updateSaws(h);
      this.updateShots(h);
      this.updatePops(h);
      if (this.deadTimer <= 0) {
        if (this.hearts <= 0) {
          this.dead = false;
          this.setStatus('gameOver');
        } else {
          this.respawnPlayer();
        }
      }
      return;
    }

    this.updateMovers();
    if (this.ridingMover && this.player.onGround) {
      this.player.x += this.ridingMover.dx;
    }
    this.ridingMover = null;

    this.grace = Math.max(0, this.grace - h);

    const player = this.player;
    const dir = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0);
    if (dir !== 0) player.facing = dir;

    if (this.power) {
      this.power.timeLeft -= h;
      if (this.power.timeLeft <= 0) {
        this.power = null;
        this.emitHud(true);
      }
    }
    const speedMul = this.power?.type === 'speed' ? SPEED_MULTIPLIER : 1;
    const maxRun = MAX_RUN * speedMul;

    const accel = (player.onGround ? RUN_ACCEL : AIR_ACCEL) * speedMul;
    if (dir !== 0) player.vx = approach(player.vx, dir * maxRun, accel * h);
    else player.vx = approach(player.vx, 0, RUN_DECEL * h);

    if (player.onGround) {
      this.coyote = COYOTE_TIME;
      this.extraJump = true;
    } else {
      this.coyote = Math.max(0, this.coyote - h);
    }
    this.jumpBuffer = Math.max(0, this.jumpBuffer - h);

    if (this.jumpBuffer > 0) {
      if (this.coyote > 0) {
        player.vy = -JUMP_VELOCITY;
        player.onGround = false;
        this.jumpBuffer = 0;
        this.coyote = 0;
        sfx.jump();
      } else if (this.power?.type === 'doubleJump' && this.extraJump) {
        player.vy = -JUMP_VELOCITY * 0.92;
        this.extraJump = false;
        this.jumpBuffer = 0;
        this.spawnPop(player.x + PLAYER_W / 2, player.y + PLAYER_H, 'hop!', this.palette.playerShine);
        sfx.jump();
      }
    }
    if (!this.input.jump && player.vy < -JUMP_CUT) player.vy = -JUMP_CUT;

    this.fireCooldown = Math.max(0, this.fireCooldown - h);
    if (this.input.fire && this.power?.type === 'fire' && this.fireCooldown <= 0) {
      this.fireCooldown = SHOT_COOLDOWN;
      this.shots.push({
        x: player.x + PLAYER_W / 2 + player.facing * 10,
        y: player.y + PLAYER_H * 0.42,
        vx: player.facing * SHOT_SPEED,
        vy: 0,
        life: 0.9,
      });
      sfx.fire();
    }

    player.vy = Math.min(player.vy + GRAVITY * h, MAX_FALL);
    const falling = player.vy > 0;
    const prevBottom = player.y + PLAYER_H;
    player.onGround = false;

    this.moveBodyX(player, h);
    this.moveBodyY(player, h);

    if (!player.onGround && falling) {
      for (const mover of this.movers) {
        if (player.x + PLAYER_W > mover.x && player.x < mover.x + mover.w) {
          const feet = player.y + PLAYER_H;
          if (prevBottom <= mover.y + 3 && feet >= mover.y) {
            player.y = mover.y - PLAYER_H;
            player.vy = 0;
            player.onGround = true;
            this.ridingMover = mover;
            break;
          }
        }
      }
    }
    if (this.ridingMover) player.y = this.ridingMover.y - PLAYER_H;

    if (player.onGround) {
      player.walk += Math.abs(player.vx) * h * 0.22;
      this.lastSafe = { x: player.x, y: player.y };
    }
    const targetStretch = player.onGround
      ? 0
      : Math.max(-0.08, Math.min(0.12, player.vy / 2600));
    player.stretch += (targetStretch - player.stretch) * Math.min(1, h * 16);

    this.breakCrates();
    this.checkCheckpoints();
    this.applyMagnet(h);
    this.updateSaws(h);
    this.updateShots(h);
    this.updateEnemies(h);
    this.collectCoins();
    this.checkEnemies();
    this.checkSpikes();
    this.checkSaws();
    this.checkGoal();
    this.updatePops(h);

    if (player.y > this.level.rows * TILE + 80) this.die(true);
  }

  private checkCheckpoints(): void {
    const player = this.player;
    for (const checkpoint of this.level.checkpoints) {
      if (checkpoint.active) continue;
      const cx = checkpoint.col * TILE + 4;
      const cy = checkpoint.row * TILE + 4;
      const cw = TILE - 8;
      const ch = TILE - 4;
      if (
        player.x < cx + cw &&
        player.x + PLAYER_W > cx &&
        player.y < cy + ch &&
        player.y + PLAYER_H > cy
      ) {
        checkpoint.active = true;
        this.respawnPoint = {
          x: checkpoint.col * TILE + (TILE - PLAYER_W) / 2,
          y: (checkpoint.row + 1) * TILE - PLAYER_H,
        };
        this.spawnPop((checkpoint.col + 0.5) * TILE, checkpoint.row * TILE, 'Checkpoint!', this.palette.goalFlag);
        sfx.checkpoint();
        this.emitHud(true);
      }
    }
  }

  private invulnerable(): boolean {
    return this.power?.type === 'shield' || this.grace > 0;
  }

  private grantPower(type: PowerType, col: number, row: number): void {
    const durations: Record<PowerType, number> = {
      magnet: 9,
      fire: 9,
      shield: 8,
      doubleJump: 12,
      speed: 8,
    };
    const labels: Record<PowerType, string> = {
      magnet: 'Coin magnet!',
      fire: 'Fire shot!',
      shield: 'Shield!',
      doubleJump: 'Double jump!',
      speed: 'Speed!',
    };
    this.power = { type, timeLeft: durations[type] };
    this.extraJump = true;
    this.fireCooldown = 0;
    this.shake = Math.max(this.shake, 3);
    this.spawnPop((col + 0.5) * TILE, (row + 0.4) * TILE, labels[type], this.palette.coinShine);
    sfx.power();
    this.emitHud(true);
  }

  private breakCrates(): void {
    const player = this.player;
    const pad = 3;
    const left = Math.floor((player.x - pad) / TILE);
    const right = Math.floor((player.x + PLAYER_W + pad - 0.01) / TILE);
    const top = Math.floor((player.y - pad) / TILE);
    const bottom = Math.floor((player.y + PLAYER_H + pad - 0.01) / TILE);
    for (const crate of this.level.crates) {
      if (crate.broken) continue;
      if (crate.col < left || crate.col > right || crate.row < top || crate.row > bottom) continue;
      crate.broken = true;
      this.grantPower(crate.power, crate.col, crate.row);
    }
  }

  private applyMagnet(h: number): void {
    if (this.power?.type !== 'magnet') return;
    const cx = this.player.x + PLAYER_W / 2;
    const cy = this.player.y + PLAYER_H / 2;
    for (const coin of this.level.coins) {
      if (coin.taken) continue;
      const dx = cx - coin.x;
      const dy = cy - coin.y;
      const dist = Math.hypot(dx, dy);
      if (dist < MAGNET_RADIUS && dist > 0.5) {
        const pull = Math.min(280 * h, dist);
        coin.x += (dx / dist) * pull;
        coin.y += (dy / dist) * pull;
      }
    }
  }

  private updateSaws(h: number): void {
    for (const saw of this.saws) {
      saw.angle += h * 9;
      if (saw.axis && saw.range > 0 && saw.speed > 0) {
        const period = (saw.range * 2) / saw.speed;
        const phaseT = (this.simTime / period + saw.phase) % 1;
        const triangle = phaseT < 0.5 ? phaseT * 2 : (1 - phaseT) * 2;
        const offset = triangle * saw.range;
        if (saw.axis === 'x') {
          saw.x = saw.originX + offset;
          saw.y = saw.originY;
        } else {
          saw.y = saw.originY + offset;
          saw.x = saw.originX;
        }
      }
    }
  }

  private updateShots(h: number): void {
    for (const shot of this.shots) {
      shot.x += shot.vx * h;
      shot.y += shot.vy * h;
      shot.life -= h;
      if (shot.life <= 0) continue;
      if (this.solidAt(Math.floor(shot.x / TILE), Math.floor(shot.y / TILE))) {
        shot.life = 0;
        continue;
      }
      for (const enemy of this.enemies) {
        if (!enemy.alive) continue;
        if (
          shot.x > enemy.x &&
          shot.x < enemy.x + enemy.w &&
          shot.y > enemy.y &&
          shot.y < enemy.y + enemy.h
        ) {
          enemy.hp -= 1;
          enemy.hitFlash = 0.18;
          shot.life = 0;
          if (enemy.hp <= 0) {
            enemy.alive = false;
            enemy.squash = 0.3;
            this.coinsCollected += enemy.coinValue;
            this.spawnPop(
              enemy.x + enemy.w / 2,
              enemy.y,
              `+${enemy.coinValue}`,
              this.palette.coinShine,
            );
            this.emitHud(true);
          }
          break;
        }
      }
    }
    this.shots = this.shots.filter((shot) => shot.life > 0);
  }

  private checkSaws(): void {
    if (this.invulnerable()) return;
    const player = this.player;
    for (const saw of this.saws) {
      const nx = Math.max(player.x, Math.min(saw.x, player.x + PLAYER_W));
      const ny = Math.max(player.y, Math.min(saw.y, player.y + PLAYER_H));
      const dx = saw.x - nx;
      const dy = saw.y - ny;
      const radius = Math.max(2, saw.r - 1);
      if (dx * dx + dy * dy < radius * radius) {
        this.die();
        return;
      }
    }
  }

  private solidAt(col: number, row: number): boolean {
    if (col < 0 || col >= this.level.cols) return true;
    if (row < 0 || row >= this.level.rows) return false;
    return this.level.tiles[row * this.level.cols + col] === 1;
  }

  private moveBodyX(body: Body, h: number): void {
    body.x += body.vx * h;
    const top = Math.floor(body.y / TILE);
    const bottom = Math.floor((body.y + body.h - 0.01) / TILE);
    if (body.vx > 0) {
      const col = Math.floor((body.x + body.w - 0.01) / TILE);
      for (let row = top; row <= bottom; row += 1) {
        if (this.solidAt(col, row)) {
          body.x = col * TILE - body.w;
          body.vx = 0;
          break;
        }
      }
    } else if (body.vx < 0) {
      const col = Math.floor(body.x / TILE);
      for (let row = top; row <= bottom; row += 1) {
        if (this.solidAt(col, row)) {
          body.x = (col + 1) * TILE;
          body.vx = 0;
          break;
        }
      }
    }
  }

  private moveBodyY(body: Body, h: number): void {
    body.y += body.vy * h;
    const left = Math.floor(body.x / TILE);
    const right = Math.floor((body.x + body.w - 0.01) / TILE);
    if (body.vy > 0) {
      const row = Math.floor((body.y + body.h - 0.01) / TILE);
      for (let col = left; col <= right; col += 1) {
        if (this.solidAt(col, row)) {
          body.y = row * TILE - body.h;
          body.vy = 0;
          body.onGround = true;
          break;
        }
      }
    } else if (body.vy < 0) {
      const row = Math.floor(body.y / TILE);
      for (let col = left; col <= right; col += 1) {
        if (this.solidAt(col, row)) {
          body.y = (row + 1) * TILE;
          body.vy = 0;
          break;
        }
      }
    }
  }

  private updateMovers(): void {
    for (const mover of this.movers) {
      mover.prevX = mover.x;
      mover.prevY = mover.y;
      const period = (mover.range * 2) / mover.speed;
      const phaseT = (this.simTime / period + mover.phase) % 1;
      const triangle = phaseT < 0.5 ? phaseT * 2 : (1 - phaseT) * 2;
      const offset = triangle * mover.range;
      if (mover.axis === 'x') {
        mover.x = mover.originX + offset;
        mover.y = mover.originY;
      } else {
        mover.y = mover.originY + offset;
        mover.x = mover.originX;
      }
      mover.dx = mover.x - mover.prevX;
      mover.dy = mover.y - mover.prevY;
    }
  }

  private updateEnemies(h: number): void {
    for (const enemy of this.enemies) {
      if (!enemy.alive) {
        if (enemy.squash > 0) enemy.squash = Math.max(0, enemy.squash - h);
        continue;
      }
      if (enemy.hitFlash > 0) enemy.hitFlash = Math.max(0, enemy.hitFlash - h);

      if (enemy.type === 'flyer') {
        enemy.bob += h;
        enemy.x += enemy.dir * ENEMY_STATS.flyer.speed * h;
        const aheadX = enemy.dir > 0 ? enemy.x + enemy.w + 2 : enemy.x - 2;
        const row = Math.floor((enemy.y + enemy.h / 2) / TILE);
        if (this.solidAt(Math.floor(aheadX / TILE), row)) enemy.dir *= -1;
        const patrol = 2 * TILE;
        const minX = Math.max(TILE, enemy.originX - patrol);
        const maxX = Math.min(this.level.cols * TILE - enemy.w - TILE, enemy.originX + patrol);
        if (enemy.x < minX) {
          enemy.x = minX;
          enemy.dir = 1;
        } else if (enemy.x > maxX) {
          enemy.x = maxX;
          enemy.dir = -1;
        }
        enemy.y = enemy.baseY + Math.sin(enemy.bob * 2.4) * 7;
        enemy.onGround = false;
        continue;
      }

      const bruteSpeed = enemy.hp < enemy.maxHp ? 1.7 : 1;
      const speed =
        enemy.type === 'boss'
          ? ENEMY_STATS.boss.speed
          : enemy.type === 'brute'
            ? ENEMY_STATS.brute.speed * bruteSpeed
            : enemy.type === 'jumper'
              ? ENEMY_STATS.jumper.speed
              : ENEMY_STATS.walker.speed;

      enemy.vy = Math.min(enemy.vy + GRAVITY * h, MAX_FALL);
      enemy.vx = enemy.dir * speed;
      if ((enemy.type === 'jumper' || enemy.type === 'boss') && enemy.onGround) {
        enemy.timer -= h;
        if (enemy.timer <= 0) {
          enemy.vy = enemy.type === 'boss' ? -470 : -430;
          enemy.timer = enemy.type === 'boss' ? 2.4 : 1.5;
        }
      }
      enemy.onGround = false;
      const wasVx = enemy.vx;
      this.moveBodyX(enemy, h);
      if (wasVx !== 0 && enemy.vx === 0) enemy.dir *= -1;
      this.moveBodyY(enemy, h);
      this.avoidLedge(enemy);

      if (enemy.y > this.level.rows * TILE + 100) enemy.alive = false;
    }
  }

  private avoidLedge(enemy: Enemy): void {
    if (!enemy.onGround) return;
    const frontX = enemy.dir > 0 ? enemy.x + enemy.w + 1 : enemy.x - 1;
    const col = Math.floor(frontX / TILE);
    const rowBelow = Math.floor((enemy.y + enemy.h + 1) / TILE);
    if (!this.solidAt(col, rowBelow)) enemy.dir *= -1;
  }

  private collectCoins(): void {
    const cx = this.player.x + PLAYER_W / 2;
    const cy = this.player.y + PLAYER_H / 2;
    for (const coin of this.level.coins) {
      if (coin.taken) continue;
      if (Math.abs(cx - coin.x) < 12 && Math.abs(cy - coin.y) < 13) {
        coin.taken = true;
        this.coinsCollected += 1;
        this.spawnPop(coin.x, coin.y, '+1', this.palette.coinShine);
        sfx.coin();
        this.emitHud(true);
      }
    }
  }

  private killEnemy(enemy: Enemy, bounce: boolean): void {
    enemy.alive = false;
    enemy.squash = 0.3;
    this.coinsCollected += enemy.coinValue;
    this.spawnPop(
      enemy.x + enemy.w / 2,
      enemy.y + enemy.h / 2,
      `+${enemy.coinValue}`,
      this.palette.coinShine,
    );
    this.shake = Math.max(this.shake, enemy.type === 'boss' ? 9 : 4);
    if (enemy.type === 'boss') {
      sfx.bossDown();
      this.spawnPop(enemy.x + enemy.w / 2, enemy.y - 12, 'Boss defeated!', this.palette.spike);
    } else {
      sfx.stomp();
    }
    if (bounce) {
      this.player.vy = -STOMP_BOUNCE;
      this.player.onGround = false;
    }
    this.emitHud(true);
  }

  private checkEnemies(): void {
    const player = this.player;
    for (const enemy of this.enemies) {
      if (!enemy.alive) continue;
      if (
        player.x >= enemy.x + enemy.w ||
        player.x + PLAYER_W <= enemy.x ||
        player.y >= enemy.y + enemy.h ||
        player.y + PLAYER_H <= enemy.y
      ) {
        continue;
      }

      const stomping = player.vy > 0 && player.y + PLAYER_H < enemy.y + enemy.h * 0.6;
      if (!stomping) {
        if (this.invulnerable()) {
          this.killEnemy(enemy, true);
          continue;
        }
        this.die();
        return;
      }

      enemy.hp -= 1;
      enemy.hitFlash = 0.18;
      player.vy = -STOMP_BOUNCE;
      player.onGround = false;
      if (enemy.hp <= 0) {
        this.killEnemy(enemy, false);
      } else {
        player.y = enemy.y - PLAYER_H;
        if (enemy.type === 'boss') sfx.bossHit();
        else sfx.stomp();
      }
    }
  }

  private checkSpikes(): void {
    if (this.invulnerable()) return;
    const player = this.player;
    const left = Math.floor(player.x / TILE);
    const right = Math.floor((player.x + PLAYER_W - 0.01) / TILE);
    const top = Math.floor(player.y / TILE);
    const bottom = Math.floor((player.y + PLAYER_H - 0.01) / TILE);
    for (let row = top; row <= bottom; row += 1) {
      for (let col = left; col <= right; col += 1) {
        if (row < 0 || row >= this.level.rows) continue;
        if (col < 0 || col >= this.level.cols) continue;
        if (this.level.tiles[row * this.level.cols + col] !== 2) continue;
        const sx = col * TILE + 5;
        const sy = row * TILE + 9;
        const sw = TILE - 10;
        const sh = TILE - 9;
        if (
          player.x < sx + sw &&
          player.x + PLAYER_W > sx &&
          player.y < sy + sh &&
          player.y + PLAYER_H > sy
        ) {
          this.die();
          return;
        }
      }
    }
  }

  private checkGoal(): void {
    const goal = this.level.goal;
    const gx = goal.x * TILE + 3;
    const gy = goal.y * TILE + 2;
    const gw = TILE - 6;
    const gh = TILE - 2;
    const player = this.player;
    if (
      player.x < gx + gw &&
      player.x + PLAYER_W > gx &&
      player.y < gy + gh &&
      player.y + PLAYER_H > gy
    ) {
      this.completeLevel();
    }
  }

  private updatePops(h: number): void {
    for (const pop of this.pops) {
      pop.life -= h;
      pop.y += pop.vy * h;
      pop.vy += 130 * h;
    }
    this.pops = this.pops.filter((pop) => pop.life > 0);
  }

  // ── Camera & frame ──────────────────────────────────────────────────

  private updateCamera(): void {
    const levelW = this.level.cols * TILE;
    const levelH = this.level.rows * TILE;
    const targetX = this.player.x + PLAYER_W / 2 - this.viewW / 2;
    if (levelW <= this.viewW) this.camX = (levelW - this.viewW) / 2;
    else this.camX = Math.max(0, Math.min(levelW - this.viewW, targetX));
    if (levelH <= this.viewH) {
      this.camY = levelH - this.viewH;
    } else {
      const targetY = this.player.y + PLAYER_H / 2 - this.viewH / 2;
      this.camY = Math.max(0, Math.min(levelH - this.viewH, targetY));
    }
  }

  private readonly frame = (now: number): void => {
    if (this.destroyed) return;
    this.raf = requestAnimationFrame(this.frame);
    const dt = Math.min(0.05, Math.max(0, (now - this.last) / 1000));
    this.last = now;
    this.elapsed += dt;

    if (this.status === 'playing') {
      this.accumulator += dt;
      while (this.accumulator >= FIXED_STEP) {
        this.step(FIXED_STEP);
        if (!this.dead) this.levelTime += FIXED_STEP;
        this.accumulator -= FIXED_STEP;
      }
    } else {
      this.accumulator = 0;
    }

    if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 26);
    this.updateCamera();
    this.render();
    this.emitHud(false);
  };

  // ── Rendering ───────────────────────────────────────────────────────

  private render(): void {
    const ctx = this.ctx;
    const palette = this.palette;
    const { cssW, cssH, dpr, scale } = this;

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const sky = ctx.createLinearGradient(0, 0, 0, cssH);
    sky.addColorStop(0, palette.skyTop);
    sky.addColorStop(1, palette.skyBottom);
    ctx.fillStyle = sky;
    ctx.fillRect(0, 0, cssW, cssH);

    let shakeX = 0;
    let shakeY = 0;
    if (this.shake > 0) {
      shakeX = (Math.random() - 0.5) * this.shake;
      shakeY = (Math.random() - 0.5) * this.shake;
    }

    const groundScreenY = ((this.level.rows - 1) * TILE - this.camY) * scale;
    this.drawHills(ctx, cssW, cssH, groundScreenY, 0.35, 30, palette.hillFar);
    this.drawHills(ctx, cssW, cssH, groundScreenY + 6, 0.6, 18, palette.hillNear);

    ctx.setTransform(
      dpr * scale,
      0,
      0,
      dpr * scale,
      (-this.camX * scale + shakeX) * dpr,
      (-this.camY * scale + shakeY) * dpr,
    );

    this.drawTiles(ctx);
    this.drawMovers(ctx);
    this.drawSaws(ctx);
    this.drawCoins(ctx);
    this.drawCrates(ctx);
    this.drawCheckpoints(ctx);
    this.drawGoal(ctx);
    for (const enemy of this.enemies) {
      if (enemy.alive || enemy.squash > 0) this.drawEnemy(ctx, enemy);
    }
    this.drawShots(ctx);
    if (!this.dead) this.drawPlayer(ctx);
    if (this.power?.type === 'shield') this.drawShield(ctx);
    this.drawPops(ctx);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  private drawHills(
    ctx: CanvasRenderingContext2D,
    width: number,
    height: number,
    baseline: number,
    parallax: number,
    amplitude: number,
    color: string,
  ): void {
    ctx.fillStyle = color;
    ctx.beginPath();
    const offset = -this.camX * this.scale * parallax;
    ctx.moveTo(0, height);
    for (let x = 0; x <= width + 8; x += 8) {
      const t = (x - offset) / 150;
      const y = baseline - (amplitude + amplitude * Math.sin(t));
      ctx.lineTo(x, y);
    }
    ctx.lineTo(width, height);
    ctx.closePath();
    ctx.fill();
  }

  private drawTiles(ctx: CanvasRenderingContext2D): void {
    const { cols, rows } = this.level;
    const c0 = Math.max(0, Math.floor(this.camX / TILE) - 1);
    const c1 = Math.min(cols - 1, Math.ceil((this.camX + this.viewW) / TILE) + 1);
    const r0 = Math.max(0, Math.floor(this.camY / TILE) - 1);
    const r1 = Math.min(rows - 1, Math.ceil((this.camY + this.viewH) / TILE) + 1);
    for (let row = r0; row <= r1; row += 1) {
      for (let col = c0; col <= c1; col += 1) {
        const kind = this.level.tiles[row * cols + col];
        const x = col * TILE;
        const y = row * TILE;
        if (kind === 1) {
          ctx.fillStyle = this.palette.solid;
          ctx.fillRect(x, y, TILE, TILE);
          if (row === 0 || this.level.tiles[(row - 1) * cols + col] !== 1) {
            ctx.fillStyle = this.palette.solidTop;
            ctx.fillRect(x, y, TILE, 4);
          }
        } else if (kind === 2) {
          this.drawSpike(ctx, x, y);
        }
      }
    }
  }

  private drawSpike(ctx: CanvasRenderingContext2D, x: number, y: number): void {
    ctx.fillStyle = this.palette.spike;
    const base = y + TILE;
    for (const [ax, bx] of [
      [x + 2, x + TILE / 2],
      [x + TILE / 2, x + TILE - 2],
    ]) {
      ctx.beginPath();
      ctx.moveTo(ax, base);
      ctx.lineTo((ax + bx) / 2, y + 4);
      ctx.lineTo(bx, base);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
    ctx.fillRect(x + 2, base - 3, TILE - 4, 3);
  }

  private drawMovers(ctx: CanvasRenderingContext2D): void {
    for (const mover of this.movers) {
      ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
      ctx.fillRect(mover.x + 2, mover.y + mover.h, mover.w - 4, 3);
      ctx.fillStyle = this.palette.mover;
      roundRectPath(ctx, mover.x, mover.y, mover.w, mover.h, 4);
      ctx.fill();
      ctx.fillStyle = this.palette.moverTop;
      roundRectPath(ctx, mover.x + 2, mover.y + 2, mover.w - 4, mover.h * 0.45, 3);
      ctx.fill();
      ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
      for (let i = 1; i < Math.round(mover.w / TILE); i += 1) {
        ctx.beginPath();
        ctx.arc(mover.x + i * TILE, mover.y + mover.h / 2, 1.6, 0, Math.PI * 2);
        ctx.fill();
      }
      const cx = mover.x + mover.w / 2;
      const cy = mover.y + mover.h / 2;
      const pulse = 0.6 + 0.4 * Math.sin(this.elapsed * 5);
      ctx.globalAlpha = pulse;
      ctx.fillStyle = '#ffffff';
      ctx.beginPath();
      if (mover.axis === 'x') {
        const s = Math.sign(mover.x - mover.prevX) || 1;
        ctx.moveTo(cx + s * 5, cy);
        ctx.lineTo(cx - s * 3, cy - 3.5);
        ctx.lineTo(cx - s * 3, cy + 3.5);
      } else {
        const s = Math.sign(mover.y - mover.prevY) || 1;
        ctx.moveTo(cx, cy + s * 4);
        ctx.lineTo(cx - 3.5, cy - s * 3);
        ctx.lineTo(cx + 3.5, cy - s * 3);
      }
      ctx.closePath();
      ctx.fill();
      ctx.globalAlpha = 1;
    }
  }

  private drawCoins(ctx: CanvasRenderingContext2D): void {
    for (const coin of this.level.coins) {
      if (coin.taken) continue;
      const bob = Math.sin(this.elapsed * 4 + coin.col) * 1.6;
      const cx = coin.x;
      const cy = coin.y + bob;
      const r = 5.5;
      ctx.fillStyle = this.palette.coin;
      ctx.beginPath();
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = this.palette.coinShine;
      ctx.beginPath();
      ctx.arc(cx - 1.4, cy - 1.4, r * 0.42, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  private drawCheckpoints(ctx: CanvasRenderingContext2D): void {
    for (const checkpoint of this.level.checkpoints) {
      const x = (checkpoint.col + 0.5) * TILE;
      const y = checkpoint.row * TILE;
      const base = (checkpoint.row + 1) * TILE;
      ctx.fillStyle = checkpoint.active ? this.palette.goalFlag : this.palette.mover;
      ctx.fillRect(x - 1.5, y + 3, 3, TILE - 3);
      ctx.fillStyle = checkpoint.active ? this.palette.goal : this.palette.moverTop;
      const wave = checkpoint.active ? Math.sin(this.elapsed * 6) * 1.2 : 0;
      ctx.beginPath();
      ctx.moveTo(x + 1.5, y + 4);
      ctx.lineTo(x + 11, y + 8 + wave);
      ctx.lineTo(x + 1.5, y + 12);
      ctx.closePath();
      ctx.fill();
      ctx.fillStyle = checkpoint.active ? this.palette.goalFlag : this.palette.mover;
      ctx.fillRect(x - 5, base - 3, 10, 3);
    }
  }

  private drawSaws(ctx: CanvasRenderingContext2D): void {
    for (const saw of this.saws) {
      ctx.save();
      ctx.translate(saw.x, saw.y);
      ctx.rotate(saw.angle);
      const teeth = 8;
      ctx.fillStyle = this.palette.saw;
      ctx.beginPath();
      for (let i = 0; i < teeth; i += 1) {
        const a = (i / teeth) * Math.PI * 2;
        const a2 = ((i + 0.5) / teeth) * Math.PI * 2;
        ctx.lineTo(Math.cos(a) * saw.r, Math.sin(a) * saw.r);
        ctx.lineTo(Math.cos(a2) * saw.r * 0.72, Math.sin(a2) * saw.r * 0.72);
      }
      ctx.closePath();
      ctx.fill();
      ctx.fillStyle = this.palette.sawShine;
      ctx.beginPath();
      ctx.arc(0, 0, saw.r * 0.42, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = this.palette.saw;
      ctx.beginPath();
      ctx.arc(0, 0, saw.r * 0.16, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    }
  }

  private drawCrates(ctx: CanvasRenderingContext2D): void {
    for (const crate of this.level.crates) {
      if (crate.broken) continue;
      const x = crate.col * TILE;
      const y = crate.row * TILE;
      const pad = 2;
      const size = TILE - pad * 2;
      const pulse = 0.5 + 0.5 * Math.sin(this.elapsed * 4 + crate.col);
      ctx.fillStyle = this.palette.crateDark;
      roundRectPath(ctx, x + pad, y + pad, size, size, 4);
      ctx.fill();
      ctx.fillStyle = this.palette.crate;
      roundRectPath(ctx, x + pad + 2, y + pad + 2, size - 4, size - 4, 3);
      ctx.fill();
      ctx.strokeStyle = this.palette.crateDark;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(x + pad + 2, y + TILE / 2);
      ctx.lineTo(x + TILE - pad - 2, y + TILE / 2);
      ctx.moveTo(x + TILE / 2, y + pad + 2);
      ctx.lineTo(x + TILE / 2, y + TILE - pad - 2);
      ctx.stroke();
      ctx.globalAlpha = 0.55 + pulse * 0.45;
      ctx.fillStyle = '#ffffff';
      ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('?', x + TILE / 2, y + TILE / 2 + 1);
      ctx.globalAlpha = 1;
      ctx.textAlign = 'start';
      ctx.textBaseline = 'alphabetic';
    }
  }

  private drawShots(ctx: CanvasRenderingContext2D): void {
    for (const shot of this.shots) {
      const r = 5;
      ctx.fillStyle = '#ff8a3d';
      ctx.beginPath();
      ctx.arc(shot.x, shot.y, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#ffe08a';
      ctx.beginPath();
      ctx.arc(shot.x - 1, shot.y - 1, r * 0.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 0.5;
      ctx.fillStyle = '#ff5a1f';
      ctx.beginPath();
      ctx.arc(shot.x - Math.sign(shot.vx) * 6, shot.y, r * 0.7, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
    }
  }

  private drawShield(ctx: CanvasRenderingContext2D): void {
    const player = this.player;
    const cx = player.x + PLAYER_W / 2;
    const cy = player.y + PLAYER_H / 2;
    const pulse = 1 + Math.sin(this.elapsed * 8) * 0.08;
    ctx.strokeStyle = this.palette.playerShine;
    ctx.lineWidth = 2;
    ctx.globalAlpha = 0.75;
    ctx.beginPath();
    ctx.arc(cx, cy, (PLAYER_W * 0.95) * pulse, 0, Math.PI * 2);
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  private drawGoal(ctx: CanvasRenderingContext2D): void {
    const goal = this.level.goal;
    const x = (goal.x + 0.5) * TILE;
    const y = goal.y * TILE;
    const base = (goal.y + 1) * TILE;
    ctx.fillStyle = this.palette.goal;
    ctx.fillRect(x - 1.5, y + 2, 3, TILE - 2);
    ctx.fillStyle = this.palette.goalFlag;
    const wave = Math.sin(this.elapsed * 5) * 1.5;
    ctx.beginPath();
    ctx.moveTo(x + 1.5, y + 3);
    ctx.lineTo(x + 13, y + 7 + wave);
    ctx.lineTo(x + 1.5, y + 12);
    ctx.closePath();
    ctx.fill();
    ctx.fillStyle = this.palette.goal;
    ctx.fillRect(x - 6, base - 3, 12, 3);
  }

  private drawPlayer(ctx: CanvasRenderingContext2D): void {
    const player = this.player;
    const w = PLAYER_W;
    const h = PLAYER_H;
    const cx = player.x + w / 2;
    const bottom = player.y + h;
    const sy = 1 + player.stretch;
    const sx = 1 - player.stretch * 0.7;
    const step = Math.sin(player.walk) * (player.onGround ? 1 : 0);

    ctx.save();
    ctx.translate(cx, bottom);
    ctx.scale(sx, sy);
    ctx.translate(0, -h / 2);

    ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
    roundRectPath(ctx, -w / 2 - 3 + step * 2, h / 2 - 3, 6, 3, 1.5);
    ctx.fill();
    roundRectPath(ctx, w / 2 - 3 - step * 2, h / 2 - 3, 6, 3, 1.5);
    ctx.fill();

    ctx.fillStyle = this.palette.player;
    roundRectPath(ctx, -w / 2, -h / 2, w, h, 5);
    ctx.fill();

    ctx.fillStyle = this.palette.playerShine;
    roundRectPath(ctx, -w / 2 + 2.5, -h / 2 + 2.5, w * 0.42, h * 0.5, 3);
    ctx.fill();

    const eyeY = -h / 2 + 7;
    const look = player.facing * 1.4;
    for (const ex of [-3.6, 3.6]) {
      ctx.fillStyle = this.palette.eye;
      ctx.beginPath();
      ctx.arc(ex, eyeY, 3.1, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = this.palette.pupil;
      ctx.beginPath();
      ctx.arc(ex + look, eyeY + 0.4, 1.6, 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.restore();
  }

  // ── Enemy rendering ─────────────────────────────────────────────────

  private drawEnemy(ctx: CanvasRenderingContext2D, enemy: Enemy): void {
    const w = enemy.w;
    const h = enemy.h;
    const cx = enemy.x + w / 2;
    const bottom = enemy.y + h;
    const dying = !enemy.alive;
    const progress = dying ? 1 - enemy.squash / 0.3 : 0;
    const scaleY = dying ? 1 - progress * 0.85 : 1;
    const alpha = dying ? Math.max(0, 1 - progress) : 1;

    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.translate(cx, bottom);
    ctx.scale(1, scaleY);
    ctx.translate(0, -h / 2);

    ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
    ctx.beginPath();
    ctx.ellipse(0, h / 2 - 1, w * 0.5, 2.6, 0, 0, Math.PI * 2);
    ctx.fill();

    if (enemy.type === 'flyer') {
      this.drawFlyer(ctx, enemy, w, h);
    } else if (enemy.type === 'jumper') {
      this.drawJumper(ctx, enemy, w, h);
    } else if (enemy.type === 'brute') {
      this.drawBrute(ctx, enemy, w, h);
    } else if (enemy.type === 'boss') {
      this.drawBoss(ctx, enemy, w, h);
    } else {
      this.drawWalker(ctx, enemy, w, h);
    }

    if (enemy.hitFlash > 0) {
      ctx.globalAlpha = alpha * Math.min(1, enemy.hitFlash / 0.18) * 0.75;
      ctx.fillStyle = '#ffffff';
      roundRectPath(ctx, -w / 2, -h / 2, w, h, 6);
      ctx.fill();
      ctx.globalAlpha = alpha;
    }

    ctx.restore();
  }

  private drawEvilFace(
    ctx: CanvasRenderingContext2D,
    w: number,
    h: number,
    look: number,
  ): void {
    const eyeY = -h * 0.12;
    const eyeX = w * 0.22;
    const eyeR = Math.max(2.6, w * 0.16);
    for (const side of [-1, 1]) {
      ctx.fillStyle = this.palette.eye;
      ctx.beginPath();
      ctx.arc(side * eyeX, eyeY, eyeR, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = this.palette.pupil;
      ctx.beginPath();
      ctx.arc(side * eyeX + look, eyeY + eyeR * 0.15, eyeR * 0.52, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.strokeStyle = this.palette.pupil;
    ctx.lineWidth = Math.max(1.6, w * 0.1);
    ctx.lineCap = 'round';
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * eyeX - side * eyeR, eyeY - eyeR * 1.1);
      ctx.lineTo(side * eyeX + side * eyeR * 0.7, eyeY - eyeR * 1.7);
      ctx.stroke();
    }
    ctx.beginPath();
    ctx.arc(0, h * 0.22, w * 0.2, Math.PI * 1.15, Math.PI * 1.85);
    ctx.stroke();
  }

  private drawWalker(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
    const stats = ENEMY_STATS.walker;
    const step = Math.sin(this.elapsed * 9 + enemy.x * 0.1) * 1.4;
    ctx.fillStyle = stats.shade;
    roundRectPath(ctx, -w * 0.42 + step, h / 2 - 4, w * 0.34, 4, 2);
    ctx.fill();
    roundRectPath(ctx, w * 0.08 - step, h / 2 - 4, w * 0.34, 4, 2);
    ctx.fill();
    ctx.fillStyle = stats.color;
    roundRectPath(ctx, -w / 2, -h / 2, w, h, 6);
    ctx.fill();
    ctx.fillStyle = stats.color;
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * w * 0.34, -h / 2 + 1);
      ctx.lineTo(side * w * 0.5, -h / 2 - 4);
      ctx.lineTo(side * w * 0.14, -h / 2 + 1);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(255, 255, 255, 0.22)';
    roundRectPath(ctx, -w / 2 + 2, -h / 2 + 2, w * 0.36, h * 0.4, 3);
    ctx.fill();
    this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
  }

  private drawJumper(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
    const stats = ENEMY_STATS.jumper;
    const squash = enemy.onGround ? 1 : 0.92;
    ctx.fillStyle = stats.shade;
    for (let i = 0; i < 3; i += 1) {
      ctx.beginPath();
      ctx.ellipse(0, h / 2 - 1 - i * 2.2, w * 0.32, 1.6, 0, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.save();
    ctx.scale(1, squash);
    ctx.fillStyle = stats.color;
    roundRectPath(ctx, -w / 2, -h / 2 - 2, w, h + 2, 7);
    ctx.fill();
    ctx.fillStyle = 'rgba(255, 255, 255, 0.24)';
    roundRectPath(ctx, -w / 2 + 2, -h / 2, w * 0.36, h * 0.4, 3);
    ctx.fill();
    ctx.strokeStyle = stats.shade;
    ctx.lineWidth = 1.6;
    ctx.beginPath();
    ctx.moveTo(0, -h / 2 - 2);
    ctx.lineTo(0, -h / 2 - 6);
    ctx.stroke();
    ctx.fillStyle = stats.shade;
    ctx.beginPath();
    ctx.arc(0, -h / 2 - 7, 2.4, 0, Math.PI * 2);
    ctx.fill();
    this.drawEvilFace(ctx, w, h + 2, enemy.dir * 1.2);
    ctx.restore();
  }

  private drawFlyer(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
    const stats = ENEMY_STATS.flyer;
    const flap = Math.sin(this.elapsed * 16 + enemy.bob) * 0.5;
    ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
    for (const side of [-1, 1]) {
      ctx.save();
      ctx.translate(side * w * 0.4, -h * 0.1);
      ctx.rotate(side * (0.5 + flap));
      ctx.beginPath();
      ctx.ellipse(0, 0, w * 0.34, h * 0.2, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    }
    ctx.fillStyle = stats.color;
    roundRectPath(ctx, -w / 2, -h / 2, w, h, 7);
    ctx.fill();
    ctx.fillStyle = 'rgba(255, 255, 255, 0.24)';
    roundRectPath(ctx, -w / 2 + 2, -h / 2 + 2, w * 0.34, h * 0.4, 3);
    ctx.fill();
    ctx.strokeStyle = stats.shade;
    ctx.lineWidth = 1.4;
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * w * 0.2, -h / 2);
      ctx.lineTo(side * w * 0.3, -h / 2 - 5);
      ctx.stroke();
      ctx.fillStyle = stats.shade;
      ctx.beginPath();
      ctx.arc(side * w * 0.3, -h / 2 - 6, 1.8, 0, Math.PI * 2);
      ctx.fill();
    }
    this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
  }

  private drawBrute(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
    const stats = ENEMY_STATS.brute;
    const angry = enemy.hp < enemy.maxHp;
    ctx.fillStyle = stats.shade;
    roundRectPath(ctx, -w * 0.46, h / 2 - 5, w * 0.38, 5, 2);
    ctx.fill();
    roundRectPath(ctx, w * 0.08, h / 2 - 5, w * 0.38, 5, 2);
    ctx.fill();
    ctx.fillStyle = stats.color;
    roundRectPath(ctx, -w / 2, -h / 2, w, h, 8);
    ctx.fill();
    ctx.fillStyle = stats.shade;
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * w * 0.32, -h / 2 + 1);
      ctx.lineTo(side * w * 0.52, -h / 2 - 7);
      ctx.lineTo(side * w * 0.1, -h / 2 + 1);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
    roundRectPath(ctx, -w / 2 + 3, -h / 2 + 3, w * 0.34, h * 0.38, 3);
    ctx.fill();
    this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
    ctx.fillStyle = '#ffffff';
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * w * 0.16, h * 0.3);
      ctx.lineTo(side * w * 0.06, h * 0.3);
      ctx.lineTo(side * w * 0.11, h * 0.14);
      ctx.closePath();
      ctx.fill();
    }
    if (angry) {
      ctx.fillStyle = '#ffd166';
      ctx.beginPath();
      ctx.arc(0, -h * 0.34, 2, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  private drawBoss(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
    const stats = ENEMY_STATS.boss;
    const hurt = enemy.hp < enemy.maxHp;
    // Stubby arms.
    ctx.fillStyle = stats.shade;
    for (const side of [-1, 1]) {
      roundRectPath(ctx, side * w * 0.5 - (side > 0 ? 0 : 6), -h * 0.15, 6, h * 0.4, 3);
      ctx.fill();
    }
    // Feet.
    roundRectPath(ctx, -w * 0.44, h / 2 - 6, w * 0.36, 6, 3);
    ctx.fill();
    roundRectPath(ctx, w * 0.08, h / 2 - 6, w * 0.36, 6, 3);
    ctx.fill();
    // Body.
    ctx.fillStyle = stats.color;
    roundRectPath(ctx, -w / 2, -h / 2, w, h, 10);
    ctx.fill();
    // Big horns.
    ctx.fillStyle = stats.shade;
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.moveTo(side * w * 0.3, -h / 2 + 1);
      ctx.lineTo(side * w * 0.6, -h / 2 - 12);
      ctx.lineTo(side * w * 0.06, -h / 2 + 1);
      ctx.closePath();
      ctx.fill();
    }
    // Crown when hurt (rage mode).
    if (hurt) {
      ctx.fillStyle = '#ffd166';
      ctx.beginPath();
      ctx.moveTo(-w * 0.22, -h / 2 + 2);
      ctx.lineTo(-w * 0.14, -h / 2 - 8);
      ctx.lineTo(-w * 0.05, -h / 2 - 1);
      ctx.lineTo(0, -h / 2 - 10);
      ctx.lineTo(w * 0.05, -h / 2 - 1);
      ctx.lineTo(w * 0.14, -h / 2 - 8);
      ctx.lineTo(w * 0.22, -h / 2 + 2);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
    roundRectPath(ctx, -w / 2 + 4, -h / 2 + 4, w * 0.32, h * 0.34, 4);
    ctx.fill();
    this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
    // Teeth.
    ctx.fillStyle = '#ffffff';
    for (const side of [-1, 1]) {
      for (let i = 0; i < 2; i += 1) {
        const tx = side * (w * 0.2) - side * i * w * 0.1;
        ctx.beginPath();
        ctx.moveTo(tx, h * 0.32);
        ctx.lineTo(tx - side * w * 0.08, h * 0.32);
        ctx.lineTo(tx - side * w * 0.04, h * 0.14);
        ctx.closePath();
        ctx.fill();
      }
    }
    // Health pips.
    const pipW = 5;
    const total = enemy.maxHp;
    const startX = -((total - 1) * pipW) / 2;
    for (let i = 0; i < total; i += 1) {
      ctx.fillStyle = i < enemy.hp ? '#ffd166' : 'rgba(0, 0, 0, 0.35)';
      ctx.fillRect(startX + i * pipW - 2, -h / 2 - 18, 4, 4);
    }
  }

  private drawPops(ctx: CanvasRenderingContext2D): void {
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    for (const pop of this.pops) {
      const alpha = Math.max(0, pop.life / pop.max);
      ctx.globalAlpha = alpha;
      ctx.fillStyle = pop.color;
      ctx.font = 'bold 11px ui-sans-serif, system-ui, sans-serif';
      ctx.fillText(pop.text, pop.x, pop.y);
    }
    ctx.globalAlpha = 1;
    ctx.textAlign = 'start';
    ctx.textBaseline = 'alphabetic';
  }

  // ── HUD ─────────────────────────────────────────────────────────────

  private emitHud(force: boolean): void {
    const power = this.power?.type ?? null;
    const powerTime = this.power ? Math.max(0, this.power.timeLeft) : 0;
    const state: HudState = {
      levelIndex: this.levelIndex,
      levelCount: LEVELS.length,
      levelName: this.level.name,
      coins: this.coinsCollected,
      coinTotal: this.coinTotal,
      time: this.levelTime,
      deaths: this.deaths,
      hearts: this.hearts,
      status: this.status,
      power,
      powerTime,
    };
    const key = `${state.levelIndex}|${state.coins}|${state.coinTotal}|${state.time.toFixed(1)}|${state.deaths}|${state.hearts}|${state.status}|${power}|${powerTime.toFixed(1)}`;
    if (!force && key === this.lastHudKey) return;
    this.lastHudKey = key;
    this.callbacks.onHud(state);
  }
}