Back to blockfall
SOURCE / PINNED RELEASE

Made of little things.

blockfall

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

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

src/game.ts
export type Phase = 'ready' | 'flying' | 'reloading' | 'clear' | 'over';

export interface GameStats {
  score: number;
  level: number;
  rockets: number;
  fuel: number;
  phase: Phase;
}

export interface GameBanner {
  title: string;
  sub: string;
  tone: 'info' | 'win' | 'lose';
}

export interface GameOptions {
  effects: boolean;
  guide: boolean;
}

interface GameCallbacks {
  onStats: (stats: GameStats) => void;
  onBanner: (banner: GameBanner) => void;
}

interface Block {
  x: number;
  y: number;
  w: number;
  h: number;
  angle: number;
  vx: number;
  vy: number;
  av: number;
  hp: number;
  maxHp: number;
  awake: boolean;
  alive: boolean;
  flash: number;
  tone: number;
  mass: number;
  invMass: number;
  inertia: number;
  invInertia: number;
  restTimer: number;
  supported: boolean;
}

interface Rocket {
  x: number;
  y: number;
  vx: number;
  vy: number;
  angle: number;
}

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  maxLife: number;
  size: number;
  kind: 'dust' | 'spark' | 'shard';
  tone: string;
}

interface Shockwave {
  x: number;
  y: number;
  life: number;
  maxLife: number;
  maxRadius: number;
}

interface TrailPoint {
  x: number;
  y: number;
  life: number;
}

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

interface Star {
  x: number;
  y: number;
  size: number;
  alpha: number;
}

interface SkylinePiece {
  x: number;
  w: number;
  h: number;
}

const WORLD_W = 1120;
const WORLD_H = 600;
const VIEW_MIN_H = 430;
const GROUND_Y = 548;
const PAD_X = 82;
const PAD_Y = GROUND_Y - 26;
const ROCKET_RADIUS = 9;
const MAX_FUEL = 100;
const ROCKETS_PER_LEVEL = 8;
const GRAVITY = 270;
const THRUST = 700;
const LAUNCH_SPEED = 388;
const ROTATE_SPEED = 2.9;
const FUEL_RATE = 21;
const BLOCK_GRAVITY = 900;
const MAX_SHARDS = 320;

const PHOSPHOR_DIM = '#3f8a70';
const AMBER = '#ffb457';
const AMBER_HOT = '#ffe0a3';
const HAZARD = '#ff5f4a';

function clamp(value: number, min: number, max: number): number {
  return value < min ? min : value > max ? max : value;
}

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

function overlapX(a: Block, b: Block): number {
  return Math.min(a.x + a.w / 2, b.x + b.w / 2) - Math.max(a.x - a.w / 2, b.x - b.w / 2);
}

interface Corner {
  x: number;
  y: number;
}

interface ContactPoint {
  x: number;
  y: number;
}

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

  private blocks: Block[] = [];
  private rocket: Rocket | null = null;
  private particles: Particle[] = [];
  private shocks: Shockwave[] = [];
  private trail: TrailPoint[] = [];
  private floaters: Floater[] = [];
  private stars: Star[] = [];
  private skyline: SkylinePiece[] = [];

  private phase: Phase = 'ready';
  private level = 1;
  private score = 0;
  private rockets = ROCKETS_PER_LEVEL;
  private fuel = MAX_FUEL;
  private time = 0;
  private reloadTimer = 0;
  private clearTimer = 0;
  private supportTimer = 0;
  private trailTimer = 0;
  private shake = 0;
  private flash = 0;
  private effects = true;
  private guide = true;

  private left = false;
  private right = false;
  private thrustHeld = false;
  private disposed = false;

  private cssW = 0;
  private cssH = 0;
  private dpr = 1;
  private resizeObserver: ResizeObserver | null = null;
  private raf = 0;
  private last = 0;
  private statsTimer = 0;

  constructor(canvas: HTMLCanvasElement, callbacks: GameCallbacks) {
    this.canvas = canvas;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('Canvas 2D is unavailable');
    this.ctx = ctx;
    this.callbacks = callbacks;

    if (typeof ResizeObserver !== 'undefined') {
      this.resizeObserver = new ResizeObserver(() => this.resize());
      this.resizeObserver.observe(canvas);
    }
    this.resize();
    this.seedStars();
    this.startLevel(1, false);
    this.callbacks.onStats(this.stats());
    this.callbacks.onBanner({
      title: 'SECTOR 01',
      sub: 'Aim the rail · bring the skyline down',
      tone: 'info',
    });

    this.last = performance.now();
    this.raf = requestAnimationFrame(this.loop);
  }

  setOptions(options: GameOptions): void {
    this.effects = options.effects;
    this.guide = options.guide;
  }

  setSteer(left: boolean, right: boolean): void {
    this.left = left;
    this.right = right;
  }

  pressPrimary(): void {
    if (this.phase === 'ready') {
      this.launch();
      this.thrustHeld = true;
    } else if (this.phase === 'flying') {
      this.thrustHeld = true;
    }
  }

  releasePrimary(): void {
    this.thrustHeld = false;
  }

  restart(): void {
    this.score = 0;
    this.level = 1;
    this.startLevel(1, true);
  }

  dispose(): void {
    this.disposed = true;
    cancelAnimationFrame(this.raf);
    this.resizeObserver?.disconnect();
  }

  private readonly loop = (now: number): void => {
    if (this.disposed) return;
    const dt = clamp((now - this.last) / 1000, 0.001, 0.05);
    this.last = now;
    this.update(dt);
    this.draw();
    this.statsTimer += dt;
    if (this.statsTimer > 0.08) {
      this.statsTimer = 0;
      this.callbacks.onStats(this.stats());
    }
    this.raf = requestAnimationFrame(this.loop);
  };

  private stats(): GameStats {
    return {
      score: this.score,
      level: this.level,
      rockets: this.rockets,
      fuel: Math.max(0, Math.round(this.fuel)),
      phase: this.phase,
    };
  }

  private resize(): void {
    const width = this.canvas.clientWidth;
    const height = this.canvas.clientHeight;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    if (width === this.cssW && height === this.cssH && dpr === this.dpr) return;
    this.cssW = width;
    this.cssH = height;
    this.dpr = dpr;
    if (width > 0 && height > 0) {
      this.canvas.width = Math.round(width * dpr);
      this.canvas.height = Math.round(height * dpr);
    }
  }

  private seedStars(): void {
    const rng = mulberry32(9127);
    this.stars = [];
    for (let i = 0; i < 110; i += 1) {
      this.stars.push({
        x: rng() * WORLD_W,
        y: -640 + rng() * 1060,
        size: 0.6 + rng() * 1.6,
        alpha: 0.1 + rng() * 0.45,
      });
    }
  }

  private startLevel(level: number, announce: boolean): void {
    this.level = level;
    this.phase = 'ready';
    this.rockets = ROCKETS_PER_LEVEL;
    this.blocks = this.buildLevel(level);
    this.particles = [];
    this.shocks = [];
    this.trail = [];
    this.floaters = [];
    this.deploy();
    this.buildSkyline(level);
    if (announce) {
      this.callbacks.onBanner({
        title: `SECTOR ${String(level).padStart(2, '0')}`,
        sub: `${this.rockets} rockets · no survivors`,
        tone: 'info',
      });
    }
  }

  private buildLevel(level: number): Block[] {
    const rng = mulberry32(level * 977 + 41);
    const blocks: Block[] = [];
    const target = Math.min(5, 3 + Math.ceil(level / 2));
    const pattern = ['scaffold', 'skybridge', 'stack', 'ziggurat', 'scaffold', 'stack', 'ziggurat'];
    const limit = WORLD_W - 28;
    let x = 322;
    let cursor = (level - 1) % pattern.length;

    for (let building = 0; building < target; building += 1) {
      let style = pattern[cursor % pattern.length];
      cursor += 1;

      if (x + this.structureWidth(style) > limit) {
        const fallbacks = ['scaffold', 'stack'];
        style = fallbacks.find((candidate) => x + this.structureWidth(candidate) <= limit) ?? '';
        if (!style) break;
      }

      let width = 0;
      if (style === 'scaffold') width = this.addScaffold(blocks, x, level, rng);
      else if (style === 'skybridge') width = this.addSkybridge(blocks, x, level, rng);
      else if (style === 'ziggurat') width = this.addZiggurat(blocks, x, level, rng);
      else width = this.addBrickStack(blocks, x, level, rng);

      x += width + 12 + Math.floor(rng() * 10);
    }

    if (blocks.length === 0) {
      for (let col = 0; col < 4; col += 1) {
        this.addBlock(blocks, 540 + col * 44, GROUND_Y, 44, 24, rng);
      }
    }

    return blocks;
  }

  private structureWidth(style: string): number {
    if (style === 'scaffold') return 162;
    if (style === 'skybridge') return 222;
    if (style === 'ziggurat') return 192;
    return 122;
  }

  private addScaffold(
    blocks: Block[],
    x: number,
    level: number,
    rng: () => number,
  ): number {
    const span = 136 + Math.floor(rng() * 3) * 12;
    const pillarW = 22;
    const pillarH = 46 + Math.floor(rng() * 3) * 5;
    const beamH = 16;
    const floors = Math.min(4, 2 + Math.floor(level / 2));
    let bottom = GROUND_Y;

    for (let tier = 0; tier < floors; tier += 1) {
      this.addBlock(blocks, x, bottom, pillarW, pillarH, rng);
      this.addBlock(blocks, x + span - pillarW, bottom, pillarW, pillarH, rng);
      this.addBlock(blocks, x, bottom - pillarH, span, beamH, rng);

      const beamTop = bottom - pillarH - beamH;
      const wallW = (span - pillarW * 2 - 12) / 2;
      this.addBlock(blocks, x + pillarW + 4, beamTop, wallW, 22, rng);
      this.addBlock(blocks, x + span - pillarW - 4 - wallW, beamTop, wallW, 22, rng);
      bottom = beamTop;
    }

    this.addBlock(blocks, x + span / 2 - 11, bottom - 22, 22, 18, rng);
    return span;
  }

  private addSkybridge(
    blocks: Block[],
    x: number,
    level: number,
    rng: () => number,
  ): number {
    const span = 196 + Math.floor(rng() * 3) * 12;
    const rows = 3 + Math.min(3, Math.floor(level / 2)) + (rng() < 0.4 ? 1 : 0);
    const towerW = 84;
    const w = 42;
    const h = 22;
    const towerTop = GROUND_Y - rows * h;

    for (let row = 0; row < rows; row += 1) {
      for (let col = 0; col < 2; col += 1) {
        this.addBlock(blocks, x + col * w, GROUND_Y - row * h, w, h, rng);
        this.addBlock(
          blocks,
          x + span - towerW + col * w,
          GROUND_Y - row * h,
          w,
          h,
          rng,
        );
      }
    }

    this.addBlock(blocks, x, towerTop, span, 16, rng);
    const bridgeTop = towerTop - 16;
    this.addBlock(blocks, x + span / 2 - 40, bridgeTop, 32, 24, rng);
    this.addBlock(blocks, x + span / 2 - 4, bridgeTop, 32, 24, rng);
    this.addBlock(blocks, x + span / 2 - 9, bridgeTop - 24, 18, 16, rng);
    return span;
  }

  private addZiggurat(
    blocks: Block[],
    x: number,
    level: number,
    rng: () => number,
  ): number {
    const base = 4 + (level >= 3 && rng() < 0.6 ? 1 : 0);
    const w = 38;
    const h = 22;
    const width = base * w;
    let bottom = GROUND_Y;

    for (let row = 0; row < base; row += 1) {
      const cols = base - row;
      const left = x + (width - cols * w) / 2;
      for (let col = 0; col < cols; col += 1) {
        this.addBlock(blocks, left + col * w, bottom, w, h, rng);
      }
      bottom -= h;
    }

    this.addBlock(blocks, x + width / 2 - 9, bottom, 18, 18, rng);
    return width;
  }

  private addBrickStack(
    blocks: Block[],
    x: number,
    level: number,
    rng: () => number,
  ): number {
    const cols = 2 + (rng() < 0.55 ? 1 : 0);
    const rows = 4 + Math.min(4, Math.floor(level / 2)) + Math.floor(rng() * 2);
    const w = 40;
    const h = 22;
    const width = cols * w;

    for (let row = 0; row < rows; row += 1) {
      for (let col = 0; col < cols; col += 1) {
        this.addBlock(blocks, x + col * w, GROUND_Y - row * h, w, h, rng);
      }
    }

    const top = GROUND_Y - rows * h;
    this.addBlock(blocks, x, top, width, 14, rng);
    this.addBlock(blocks, x + 2, top - 14, 22, 18, rng);
    this.addBlock(blocks, x + width - 24, top - 14, 22, 18, rng);
    return width;
  }

  private addBlock(
    blocks: Block[],
    left: number,
    bottom: number,
    w: number,
    h: number,
    rng: () => number,
  ): void {
    const hp = rng() < 0.14 ? 1.2 : 0.8;
    const mass = w * h;
    blocks.push({
      x: left + w / 2,
      y: bottom - h / 2,
      w,
      h,
      angle: 0,
      vx: 0,
      vy: 0,
      av: 0,
      hp,
      maxHp: hp,
      awake: false,
      alive: true,
      flash: 0,
      tone: Math.floor(rng() * 3),
      mass,
      invMass: 0,
      inertia: (mass * (w * w + h * h)) / 12,
      invInertia: 0,
      restTimer: 0,
      supported: false,
    });
  }

  private wake(block: Block): void {
    if (!block.alive || block.awake) return;
    block.awake = true;
    block.invMass = 1 / block.mass;
    block.invInertia = 1 / block.inertia;
    block.restTimer = 0;
  }

  private sleep(block: Block): void {
    block.awake = false;
    block.vx = 0;
    block.vy = 0;
    block.av = 0;
    block.invMass = 0;
    block.invInertia = 0;
    block.restTimer = 0;
  }

  private corners(block: Block): Corner[] {
    const cos = Math.cos(block.angle);
    const sin = Math.sin(block.angle);
    const hw = block.w / 2;
    const hh = block.h / 2;
    return [
      { x: block.x + hw * cos - hh * sin, y: block.y + hw * sin + hh * cos },
      { x: block.x - hw * cos - hh * sin, y: block.y - hw * sin + hh * cos },
      { x: block.x - hw * cos + hh * sin, y: block.y - hw * sin - hh * cos },
      { x: block.x + hw * cos + hh * sin, y: block.y + hw * sin - hh * cos },
    ];
  }

  private pointInBlock(px: number, py: number, block: Block): boolean {
    const cos = Math.cos(-block.angle);
    const sin = Math.sin(-block.angle);
    const dx = px - block.x;
    const dy = py - block.y;
    const lx = dx * cos - dy * sin;
    const ly = dx * sin + dy * cos;
    return Math.abs(lx) < block.w / 2 && Math.abs(ly) < block.h / 2;
  }

  private obbContact(a: Block, b: Block): { nx: number; ny: number; depth: number } | null {
    const axes: Array<{ x: number; y: number }> = [];
    for (const block of [a, b]) {
      const cos = Math.cos(block.angle);
      const sin = Math.sin(block.angle);
      axes.push({ x: cos, y: sin }, { x: -sin, y: cos });
    }

    let bestDepth = Number.POSITIVE_INFINITY;
    let bestX = 0;
    let bestY = 0;
    const dx = a.x - b.x;
    const dy = a.y - b.y;

    for (const axis of axes) {
      const radiusA = this.projectionRadius(a, axis.x, axis.y);
      const radiusB = this.projectionRadius(b, axis.x, axis.y);
      const distance = Math.abs(dx * axis.x + dy * axis.y);
      const overlap = radiusA + radiusB - distance;
      if (overlap <= 0) return null;
      if (overlap < bestDepth) {
        bestDepth = overlap;
        bestX = axis.x;
        bestY = axis.y;
      }
    }

    if (dx * bestX + dy * bestY < 0) {
      bestX = -bestX;
      bestY = -bestY;
    }
    return { nx: bestX, ny: bestY, depth: bestDepth };
  }

  private projectionRadius(block: Block, axisX: number, axisY: number): number {
    const cos = Math.cos(block.angle);
    const sin = Math.sin(block.angle);
    const ux = cos;
    const uy = sin;
    const vx = -sin;
    const vy = cos;
    return (
      Math.abs(axisX * ux + axisY * uy) * (block.w / 2) +
      Math.abs(axisX * vx + axisY * vy) * (block.h / 2)
    );
  }

  private contactPoints(a: Block, b: Block): ContactPoint[] {
    const points: ContactPoint[] = [];
    const add = (x: number, y: number): void => {
      for (const point of points) {
        if (Math.hypot(point.x - x, point.y - y) < 1.2) return;
      }
      points.push({ x, y });
    };

    for (const corner of this.corners(a)) {
      if (this.pointInBlock(corner.x, corner.y, b)) add(corner.x, corner.y);
    }
    for (const corner of this.corners(b)) {
      if (this.pointInBlock(corner.x, corner.y, a)) add(corner.x, corner.y);
    }

    if (points.length === 0) {
      let best = Number.POSITIVE_INFINITY;
      let bestA = this.corners(a)[0];
      let bestB = this.corners(b)[0];
      for (const cornerA of this.corners(a)) {
        for (const cornerB of this.corners(b)) {
          const distance = Math.hypot(cornerA.x - cornerB.x, cornerA.y - cornerB.y);
          if (distance < best) {
            best = distance;
            bestA = cornerA;
            bestB = cornerB;
          }
        }
      }
      add((bestA.x + bestB.x) / 2, (bestA.y + bestB.y) / 2);
    }

    return points.slice(0, 2);
  }

  private buildSkyline(level: number): void {
    const rng = mulberry32(level * 3301 + 7);
    this.skyline = [];
    let x = -20;
    while (x < WORLD_W + 40) {
      const w = 34 + rng() * 86;
      const h = 26 + rng() * 104;
      this.skyline.push({ x, w, h });
      x += w + 5 + rng() * 14;
    }
  }

  private deploy(): void {
    this.rocket = {
      x: PAD_X,
      y: PAD_Y,
      vx: 0,
      vy: 0,
      angle: -0.62,
    };
    this.phase = 'ready';
    this.fuel = MAX_FUEL;
    this.trail = [];
    this.thrustHeld = false;
    this.trailTimer = 0;
  }

  private launch(): void {
    const rocket = this.rocket;
    if (!rocket || this.phase !== 'ready') return;
    rocket.vx = Math.cos(rocket.angle) * LAUNCH_SPEED;
    rocket.vy = Math.sin(rocket.angle) * LAUNCH_SPEED;
    this.rockets = Math.max(0, this.rockets - 1);
    this.phase = 'flying';
    this.trail = [];
    this.shake = Math.max(this.shake, 2);
    this.spawnBurst(rocket.x, rocket.y, 8, 80, 'dust');
    this.callbacks.onStats(this.stats());
  }

  private update(dt: number): void {
    this.time += dt;
    this.shake = Math.max(0, this.shake - dt * 26);
    this.flash = Math.max(0, this.flash - dt * 2.6);
    this.updateParticles(dt);
    this.updateBlocks(dt);

    if (this.phase === 'ready') {
      const rocket = this.rocket;
      if (rocket) {
        const steer = (this.right ? 1 : 0) - (this.left ? 1 : 0);
        rocket.angle += steer * ROTATE_SPEED * dt;
      }
    } else if (this.phase === 'flying') {
      this.updateFlight(dt);
    } else if (this.phase === 'reloading') {
      this.reloadTimer -= dt;
      if (this.reloadTimer <= 0) {
        if (this.blocks.every((block) => !block.alive)) {
          this.winLevel();
        } else if (this.rockets <= 0) {
          this.gameOver();
        } else {
          this.deploy();
        }
      }
    } else if (this.phase === 'clear') {
      this.clearTimer -= dt;
      if (this.clearTimer <= 0) {
        this.startLevel(this.level + 1, true);
      }
    }

    this.supportTimer += dt;
    if (this.supportTimer > 0.07) {
      this.supportTimer = 0;
      this.checkSupport();
    }

    if (
      (this.phase === 'flying' || this.phase === 'ready' || this.phase === 'reloading') &&
      this.blocks.length > 0 &&
      this.blocks.every((block) => !block.alive)
    ) {
      this.winLevel();
    }

    for (const block of this.blocks) {
      block.flash = Math.max(0, block.flash - dt * 3.5);
    }
  }

  private updateFlight(dt: number): void {
    const rocket = this.rocket;
    if (!rocket) {
      this.finishSortie();
      return;
    }

    const steer = (this.right ? 1 : 0) - (this.left ? 1 : 0);
    rocket.angle += steer * ROTATE_SPEED * dt;

    if (this.thrustHeld && this.fuel > 0) {
      rocket.vx += Math.cos(rocket.angle) * THRUST * dt;
      rocket.vy += Math.sin(rocket.angle) * THRUST * dt;
      this.fuel = Math.max(0, this.fuel - FUEL_RATE * dt);
      if (this.effects) this.spawnExhaust(rocket);
    }

    rocket.vy += GRAVITY * dt;
    const drag = Math.pow(0.9995, dt * 60);
    rocket.vx *= drag;
    rocket.vy *= drag;

    const speed = Math.hypot(rocket.vx, rocket.vy);
    if (speed > 920) {
      rocket.vx = (rocket.vx / speed) * 920;
      rocket.vy = (rocket.vy / speed) * 920;
    }

    rocket.x += rocket.vx * dt;
    rocket.y += rocket.vy * dt;

    this.trailTimer -= dt;
    if (this.trailTimer <= 0 && this.effects) {
      this.trail.push({ x: rocket.x, y: rocket.y, life: 1 });
      if (this.trail.length > 60) this.trail.shift();
      this.trailTimer = 0.028;
    }

    this.collideBlocks(rocket);
    if (!this.rocket) return;

    if (rocket.y + ROCKET_RADIUS >= GROUND_Y) {
      this.explode(rocket.x, GROUND_Y - 5, 1.5);
      return;
    }
    if (rocket.x > WORLD_W + 140 || rocket.x < -180 || rocket.y < -900) {
      this.rocket = null;
      this.finishSortie();
    }
  }

  private collideBlocks(rocket: Rocket): void {
    for (const block of this.blocks) {
      if (!block.alive) continue;
      const cos = Math.cos(block.angle);
      const sin = Math.sin(block.angle);
      const dx = rocket.x - block.x;
      const dy = rocket.y - block.y;
      const lx = dx * cos + dy * sin;
      const ly = -dx * sin + dy * cos;
      const hw = block.w / 2;
      const hh = block.h / 2;
      const nearestX = clamp(lx, -hw, hw);
      const nearestY = clamp(ly, -hh, hh);
      const offX = lx - nearestX;
      const offY = ly - nearestY;
      const dist = Math.hypot(offX, offY);

      if (dist >= ROCKET_RADIUS) continue;

      const pointX = block.x + nearestX * cos - nearestY * sin;
      const pointY = block.y + nearestX * sin + nearestY * cos;
      this.explode(pointX, pointY, 1.85);
      return;
    }
  }

  private updateBlocks(dt: number): void {
    const blocks = this.blocks;
    let awake = 0;

    for (const block of blocks) {
      if (!block.alive || !block.awake) continue;
      awake += 1;
      block.vy += BLOCK_GRAVITY * dt;
      block.x += block.vx * dt;
      block.y += block.vy * dt;
      block.angle += block.av * dt;
      block.vx *= Math.pow(0.999, dt * 60);
      block.vy *= Math.pow(0.9995, dt * 60);
      block.av *= Math.pow(0.985, dt * 60);
      block.av = clamp(block.av, -9, 9);
      block.supported = false;
    }

    if (awake === 0) return;

    for (let iteration = 0; iteration < 6; iteration += 1) {
      const firstPass = iteration === 0;
      for (const block of blocks) {
        if (!block.alive || !block.awake) continue;
        this.resolveGround(block, firstPass);
      }

      for (let i = 0; i < blocks.length; i += 1) {
        const a = blocks[i];
        if (!a.alive || !a.awake) continue;
        for (let j = 0; j < blocks.length; j += 1) {
          if (i === j) continue;
          const b = blocks[j];
          if (!b.alive) continue;
          if (b.awake && j < i) continue;
          this.resolvePair(a, b, firstPass);
        }
      }
    }

    for (const block of blocks) {
      if (!block.alive || !block.awake) continue;
      const speed = Math.hypot(block.vx, block.vy);
      if (block.supported && speed < 11 && Math.abs(block.av) < 0.24) {
        block.restTimer += dt;
        if (block.restTimer > 0.5) this.sleep(block);
      } else {
        block.restTimer = 0;
      }
    }
  }

  private resolveGround(block: Block, allowDamage: boolean): void {
    const corners = this.corners(block);
    const hits = corners.filter((corner) => corner.y > GROUND_Y);
    if (hits.length === 0) return;

    let penetration = 0;
    for (const corner of hits) {
      penetration = Math.max(penetration, corner.y - GROUND_Y);
    }
    block.y -= penetration;
    block.supported = true;

    for (const corner of hits) {
      this.applyContact(block, null, corner.x, GROUND_Y, 0, -1, 0.1, allowDamage);
    }
  }

  private resolvePair(a: Block, b: Block, allowDamage: boolean): void {
    if (!a.alive || !b.alive) return;
    const contact = this.obbContact(a, b);
    if (!contact) return;

    const { nx, ny, depth } = contact;
    const closure = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
    if (!b.awake && closure < -90) this.wake(b);

    const totalInv = a.invMass + b.invMass;
    if (totalInv <= 0) return;

    const correction = (Math.min(depth, 9) * 0.55) / totalInv;
    a.x += nx * correction * a.invMass;
    a.y += ny * correction * a.invMass;
    if (b.awake) {
      b.x -= nx * correction * b.invMass;
      b.y -= ny * correction * b.invMass;
    }

    for (const point of this.contactPoints(a, b)) {
      this.applyContact(a, b, point.x, point.y, nx, ny, 0.03, allowDamage);
    }
  }

  private applyContact(
    a: Block,
    b: Block | null,
    px: number,
    py: number,
    nx: number,
    ny: number,
    restitution: number,
    allowDamage: boolean,
  ): void {
    if (!a.alive) return;
    const dynamicB = b !== null && b.awake && b.alive;
    const invMassB = dynamicB ? b.invMass : 0;
    const invInertiaB = dynamicB ? b.invInertia : 0;

    const rAx = px - a.x;
    const rAy = py - a.y;
    const rBx = b ? px - b.x : 0;
    const rBy = b ? py - b.y : 0;

    const vAx = a.vx - a.av * rAy;
    const vAy = a.vy + a.av * rAx;
    const vBx = dynamicB ? b.vx - b.av * rBy : 0;
    const vBy = dynamicB ? b.vy + b.av * rBx : 0;

    const rvx = vAx - vBx;
    const rvy = vAy - vBy;
    const vn = rvx * nx + rvy * ny;
    if (vn > 0) return;

    const impact = -vn;
    const rnA = rAx * ny - rAy * nx;
    const rnB = rBx * ny - rBy * nx;
    const effective =
      a.invMass + invMassB + rnA * rnA * a.invInertia + rnB * rnB * invInertiaB;
    if (effective <= 0) return;

    const bounce = impact > 120 ? restitution : 0;
    const j = (-(1 + bounce) * vn) / effective;

    a.vx += j * nx * a.invMass;
    a.vy += j * ny * a.invMass;
    a.av += rnA * j * a.invInertia;

    if (dynamicB) {
      b.vx -= j * nx * b.invMass;
      b.vy -= j * ny * b.invMass;
      b.av -= rnB * j * b.invInertia;
    }

    const tx = -ny;
    const ty = nx;
    const rvTx = a.vx - a.av * rAy - (dynamicB ? b.vx - b.av * rBy : 0);
    const rvTy = a.vy + a.av * rAx - (dynamicB ? b.vy + b.av * rBx : 0);
    const vt = rvTx * tx + rvTy * ty;
    const rtA = rAx * ty - rAy * tx;
    const rtB = rBx * ty - rBy * tx;
    const effectiveT =
      a.invMass + invMassB + rtA * rtA * a.invInertia + rtB * rtB * invInertiaB;

    if (effectiveT > 0) {
      const limit = 0.62 * Math.abs(j);
      const jt = clamp(-vt / effectiveT, -limit, limit);
      a.vx += jt * tx * a.invMass;
      a.vy += jt * ty * a.invMass;
      a.av += rtA * jt * a.invInertia;
      if (dynamicB) {
        b.vx -= jt * tx * b.invMass;
        b.vy -= jt * ty * b.invMass;
        b.av -= rtB * jt * b.invInertia;
      }
    }

    if (ny < -0.25) a.supported = true;
    if (dynamicB && ny > 0.25) b.supported = true;

    if (allowDamage && impact > 300) {
      this.damageBlock(a, (impact - 300) * 0.0016, px, py, 0);
      if (b && b.alive) {
        this.damageBlock(b, (impact - 300) * 0.001, px, py, 0);
      }
    }
  }

  private checkSupport(): void {
    for (const block of this.blocks) {
      if (!block.alive || block.awake) continue;
      if (block.y + block.h / 2 >= GROUND_Y - 2) continue;

      const bottom = block.y + block.h / 2;
      let supported = false;
      for (const other of this.blocks) {
        if (other === block || !other.alive) continue;
        const top = other.y - other.h / 2;
        if (Math.abs(top - bottom) < 3 && overlapX(block, other) > 5) {
          supported = true;
          break;
        }
      }

      if (!supported) {
        this.wake(block);
        block.av = (Math.random() - 0.5) * 0.35;
        if (this.effects && this.particles.length < MAX_SHARDS - 4) {
          this.spawnBurst(block.x, block.y + block.h / 2, 2, 26, 'dust');
        }
      }
    }
  }

  private damageArea(
    x: number,
    y: number,
    radius: number,
    power: number,
    impulse: number,
  ): void {
    for (const block of this.blocks) {
      if (!block.alive) continue;
      const distance = Math.hypot(block.x - x, block.y - y);
      if (distance > radius + Math.max(block.w, block.h) * 0.45) continue;
      const falloff = clamp(1 - distance / (radius * 1.5), 0.22, 1);
      this.damageBlock(block, power * falloff, x, y, impulse * falloff);
    }
  }

  private damageBlock(block: Block, amount: number, originX: number, originY: number, impulse: number): void {
    if (!block.alive) return;
    block.hp -= amount;
    block.flash = 1;
    this.wake(block);

    if (impulse > 0) {
      const dx = block.x - originX;
      const dy = block.y - originY;
      const distance = Math.hypot(dx, dy) || 1;
      const massFactor = clamp(900 / block.mass, 0.35, 1.5);
      block.vx += (dx / distance) * impulse * 0.55 * massFactor;
      block.vy += (dy / distance) * impulse * 0.55 * massFactor - impulse * 0.04;
      block.av += (Math.random() - 0.5) * impulse * 0.0035 * massFactor;
    }

    if (block.hp <= 0) this.destroyBlock(block);
  }

  private destroyBlock(block: Block): void {
    if (!block.alive) return;
    block.alive = false;
    this.score += 100;

    for (const other of this.blocks) {
      if (!other.alive || other === block) continue;
      const dx = other.x - block.x;
      const dy = other.y - block.y;
      if (Math.hypot(dx, dy) < 96) this.wake(other);
    }

    if (this.effects) {
      this.spawnBurst(block.x, block.y, 7, 150, 'shard');
    }
    if (this.floaters.length < 18) {
      this.floaters.push({
        x: block.x,
        y: block.y,
        life: 0.85,
        text: '+100',
      });
    }
  }

  private explode(x: number, y: number, power: number): void {
    this.rocket = null;
    this.shake = Math.max(this.shake, 18 * Math.min(power, 2));
    this.flash = Math.max(this.flash, Math.min(1, 0.48 * power));
    if (this.effects) {
      this.shocks.push({
        x,
        y,
        life: 0.65,
        maxLife: 0.65,
        maxRadius: 120 + 70 * power,
      });
      this.shocks.push({
        x,
        y,
        life: 0.34,
        maxLife: 0.34,
        maxRadius: 55 + 40 * power,
      });
      this.spawnBurst(x, y, Math.min(46, Math.round(24 * power)), 330 * power, 'spark');
      this.spawnBurst(x, y, Math.min(26, Math.round(15 * power)), 165 * power, 'dust');
      this.spawnBurst(x, y, Math.min(18, Math.round(10 * power)), 260 * power, 'shard');
    }
    this.damageArea(x, y, 100 + 50 * power, 0.62 * power, 360 * power);
    this.finishSortie();
  }

  private finishSortie(): void {
    if (this.phase !== 'flying') return;
    this.thrustHeld = false;
    if (this.blocks.length > 0 && this.blocks.every((block) => !block.alive)) {
      this.winLevel();
      return;
    }
    this.phase = 'reloading';
    this.reloadTimer = this.rockets <= 0 ? 2.6 : 1;
  }

  private winLevel(): void {
    if (this.phase === 'clear' || this.phase === 'over') return;
    const bonus = 500 + this.rockets * 150;
    this.score += bonus;
    this.phase = 'clear';
    this.clearTimer = 2.4;
    this.rocket = null;
    this.callbacks.onStats(this.stats());
    this.callbacks.onBanner({
      title: 'SECTOR CLEAR',
      sub: `+${bonus} bonus · next sector inbound`,
      tone: 'win',
    });
  }

  private gameOver(): void {
    this.phase = 'over';
    this.rocket = null;
    this.callbacks.onStats(this.stats());
    this.callbacks.onBanner({
      title: 'RANGE CLOSED',
      sub: `Out of rockets · ${this.score} points`,
      tone: 'lose',
    });
  }

  private spawnExhaust(rocket: Rocket): void {
    const back = rocket.angle + Math.PI;
    const spread = (Math.random() - 0.5) * 0.5;
    const speed = 90 + Math.random() * 120;
    this.particles.push({
      x: rocket.x + Math.cos(back) * 10,
      y: rocket.y + Math.sin(back) * 10,
      vx: rocket.vx * 0.35 + Math.cos(back + spread) * speed,
      vy: rocket.vy * 0.35 + Math.sin(back + spread) * speed,
      life: 0.28,
      maxLife: 0.28,
      size: 2 + Math.random() * 3,
      kind: 'dust',
      tone: Math.random() < 0.3 ? AMBER_HOT : AMBER,
    });
  }

  private spawnBurst(x: number, y: number, count: number, speed: number, kind: Particle['kind']): void {
    for (let i = 0; i < count; i += 1) {
      if (this.particles.length > MAX_SHARDS) break;
      const angle = Math.random() * Math.PI * 2;
      const velocity = speed * (0.3 + Math.random() * 0.8);
      this.particles.push({
        x,
        y,
        vx: Math.cos(angle) * velocity,
        vy: Math.sin(angle) * velocity - (kind === 'dust' ? 40 : 20),
        life: 0.4 + Math.random() * 0.7,
        maxLife: 1.1,
        size: kind === 'shard' ? 2 + Math.random() * 4 : 1.5 + Math.random() * 2.5,
        kind,
        tone:
          kind === 'spark'
            ? Math.random() < 0.4
              ? AMBER_HOT
              : HAZARD
            : kind === 'shard'
              ? Math.random() < 0.5
                ? PHOSPHOR_DIM
                : '#8fb7a4'
              : '#6d8c7d',
      });
    }
  }

  private updateParticles(dt: number): void {
    for (const particle of this.particles) {
      particle.life -= dt;
      particle.vy += (particle.kind === 'dust' ? 120 : 620) * dt;
      particle.x += particle.vx * dt;
      particle.y += particle.vy * dt;
      particle.vx *= Math.pow(0.985, dt * 60);
      if (particle.y > GROUND_Y) {
        particle.y = GROUND_Y;
        particle.vy *= -0.25;
        particle.vx *= 0.8;
      }
    }
    this.particles = this.particles.filter((particle) => particle.life > 0);

    for (const shock of this.shocks) shock.life -= dt;
    this.shocks = this.shocks.filter((shock) => shock.life > 0);

    for (const point of this.trail) point.life -= dt * 0.9;
    this.trail = this.trail.filter((point) => point.life > 0);

    for (const floater of this.floaters) {
      floater.life -= dt;
      floater.y -= dt * 26;
    }
    this.floaters = this.floaters.filter((floater) => floater.life > 0);
  }

  private draw(): void {
    if (this.cssW < 2 || this.cssH < 2) return;
    const { ctx } = this;
    const scale = Math.min(this.cssW / WORLD_W, this.cssH / VIEW_MIN_H);
    const ox = (this.cssW - WORLD_W * scale) / 2;
    const ty = this.cssH - WORLD_H * scale;
    const topWorld = -ty / scale;

    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.fillStyle = '#03080a';
    ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    ctx.setTransform(this.dpr * scale, 0, 0, this.dpr * scale, this.dpr * ox, this.dpr * ty);

    const shakeX = this.shake > 0.1 ? (Math.random() - 0.5) * this.shake : 0;
    const shakeY = this.shake > 0.1 ? (Math.random() - 0.5) * this.shake : 0;
    ctx.translate(shakeX, shakeY);

    this.drawSky(topWorld);
    this.drawSkyline();
    this.drawGround();
    if (this.guide) this.drawGuide();
    this.drawPad();
    this.drawBlocks();
    this.drawTrail();
    if (this.rocket) this.drawRocket(this.rocket);
    this.drawParticles();
    this.drawShocks();
    this.drawFloaters();

    if (this.flash > 0.01) {
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.fillStyle = `rgba(255,214,150,${(this.flash * 0.22).toFixed(3)})`;
      ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    }
  }

  private drawSky(topWorld: number): void {
    const { ctx } = this;
    const bottom = GROUND_Y + 20;
    const gradient = ctx.createLinearGradient(0, Math.min(topWorld, -200), 0, bottom);
    gradient.addColorStop(0, '#040b0f');
    gradient.addColorStop(0.62, '#081a1a');
    gradient.addColorStop(1, '#0d2a23');
    ctx.fillStyle = gradient;
    ctx.fillRect(-200, topWorld - 40, WORLD_W + 400, bottom - topWorld + 60);

    ctx.fillStyle = '#9ce9c6';
    for (const star of this.stars) {
      if (star.y < topWorld || star.y > GROUND_Y - 60) continue;
      ctx.globalAlpha = star.alpha;
      ctx.fillRect(star.x, star.y, star.size, star.size);
    }
    ctx.globalAlpha = 1;

    ctx.strokeStyle = 'rgba(125,243,184,0.05)';
    ctx.lineWidth = 1;
    const startX = 0;
    for (let x = startX; x <= WORLD_W; x += 50) {
      ctx.beginPath();
      ctx.moveTo(x, topWorld);
      ctx.lineTo(x, GROUND_Y);
      ctx.stroke();
    }
    const gridTop = Math.floor(topWorld / 50) * 50;
    for (let y = gridTop; y <= GROUND_Y; y += 50) {
      ctx.globalAlpha = y % 200 === 0 ? 0.75 : 0.45;
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(WORLD_W, y);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;

    const ringX = 816;
    const ringY = 128;
    ctx.strokeStyle = 'rgba(125,243,184,0.09)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.arc(ringX, ringY, 48, 0, Math.PI * 2);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(ringX - 58, ringY);
    ctx.lineTo(ringX + 58, ringY);
    ctx.moveTo(ringX, ringY - 58);
    ctx.lineTo(ringX, ringY + 58);
    ctx.stroke();

    const glare = ctx.createRadialGradient(220, 300, 10, 220, 300, 460);
    glare.addColorStop(0, 'rgba(125,243,184,0.05)');
    glare.addColorStop(1, 'rgba(125,243,184,0)');
    ctx.fillStyle = glare;
    ctx.fillRect(0, topWorld, WORLD_W, GROUND_Y - topWorld);
  }

  private drawSkyline(): void {
    const { ctx } = this;
    ctx.fillStyle = '#081614';
    for (const piece of this.skyline) {
      ctx.fillRect(piece.x, GROUND_Y - piece.h, piece.w, piece.h);
    }
    ctx.fillStyle = 'rgba(125,243,184,0.16)';
    for (const piece of this.skyline) {
      if (piece.h < 52) continue;
      ctx.fillRect(piece.x, GROUND_Y - piece.h, piece.w, 1);
    }
    ctx.strokeStyle = 'rgba(125,243,184,0.12)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, GROUND_Y - 0.5);
    ctx.lineTo(WORLD_W, GROUND_Y - 0.5);
    ctx.stroke();
  }

  private drawGround(): void {
    const { ctx } = this;
    const gradient = ctx.createLinearGradient(0, GROUND_Y, 0, WORLD_H);
    gradient.addColorStop(0, '#0b1f1a');
    gradient.addColorStop(1, '#050d0c');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, GROUND_Y, WORLD_W, WORLD_H - GROUND_Y + 80);

    ctx.strokeStyle = 'rgba(125,243,184,0.55)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(0, GROUND_Y);
    ctx.lineTo(WORLD_W, GROUND_Y);
    ctx.stroke();

    ctx.fillStyle = 'rgba(255,180,87,0.45)';
    for (let x = 8; x < WORLD_W; x += 44) {
      ctx.fillRect(x, GROUND_Y + 7, 18, 2.5);
    }

    ctx.strokeStyle = 'rgba(125,243,184,0.13)';
    ctx.lineWidth = 1;
    for (let x = 120; x < WORLD_W; x += 120) {
      ctx.beginPath();
      ctx.moveTo(x, GROUND_Y + 1);
      ctx.lineTo(x - 12, GROUND_Y + 20);
      ctx.stroke();
    }
  }

  private drawGuide(): void {
    const rocket = this.rocket;
    if (this.phase !== 'ready' || !rocket) return;
    const { ctx } = this;
    let x = rocket.x;
    let y = rocket.y;
    let vx = Math.cos(rocket.angle) * LAUNCH_SPEED;
    let vy = Math.sin(rocket.angle) * LAUNCH_SPEED;
    const step = 0.06;

    for (let i = 0; i < 70; i += 1) {
      x += vx * step;
      y += vy * step;
      vy += GRAVITY * step;
      if (y > GROUND_Y || x > WORLD_W + 40 || x < -40) break;
      if (i % 3 === 0) {
        const alpha = 0.5 * (1 - i / 70);
        ctx.fillStyle = `rgba(255,180,87,${alpha.toFixed(3)})`;
        ctx.beginPath();
        ctx.arc(x, y, 1.6, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  private drawPad(): void {
    const { ctx } = this;
    ctx.fillStyle = '#102720';
    ctx.fillRect(PAD_X - 24, GROUND_Y - 6, 48, 6);
    ctx.strokeStyle = PHOSPHOR_DIM;
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(PAD_X - 20, GROUND_Y - 6);
    ctx.lineTo(PAD_X - 20, GROUND_Y - 14);
    ctx.lineTo(PAD_X + 20, GROUND_Y - 14);
    ctx.lineTo(PAD_X + 20, GROUND_Y - 6);
    ctx.stroke();

    ctx.fillStyle = 'rgba(255,180,87,0.5)';
    ctx.fillRect(PAD_X - 16, GROUND_Y - 12, 8, 2);
    ctx.fillRect(PAD_X - 4, GROUND_Y - 12, 8, 2);
    ctx.fillRect(PAD_X + 8, GROUND_Y - 12, 8, 2);

    ctx.strokeStyle = 'rgba(125,243,184,0.3)';
    ctx.beginPath();
    ctx.moveTo(PAD_X + 10, GROUND_Y - 18);
    ctx.lineTo(PAD_X + 26, GROUND_Y - 18);
    ctx.stroke();
  }

  private drawBlocks(): void {
    const { ctx } = this;
    for (const block of this.blocks) {
      if (!block.alive) continue;
      const damage = 1 - block.hp / block.maxHp;
      const w = block.w;
      const h = block.h;

      ctx.save();
      ctx.translate(block.x, block.y);
      ctx.rotate(block.angle);

      ctx.fillStyle =
        block.tone === 0 ? '#143129' : block.tone === 1 ? '#183a30' : '#112a25';
      ctx.fillRect(-w / 2, -h / 2, w, h);

      ctx.strokeStyle = damage > 0.55 ? 'rgba(255,95,74,0.65)' : 'rgba(125,243,184,0.38)';
      ctx.lineWidth = 1;
      ctx.strokeRect(-w / 2 + 0.75, -h / 2 + 0.75, w - 1.5, h - 1.5);

      ctx.fillStyle = 'rgba(125,243,184,0.22)';
      ctx.fillRect(-w / 2 + 2, -h / 2 + 2, w - 4, 1);

      if (w > 26) {
        ctx.fillStyle = 'rgba(8,20,17,0.6)';
        ctx.fillRect(-w / 2 + 4, h * 0.02, w - 8, 1);
        ctx.fillStyle = 'rgba(125,243,184,0.2)';
        ctx.fillRect(-w / 2 + 5, h * 0.02 + 2, 2, 2);
        ctx.fillRect(w / 2 - 8, h * 0.02 + 2, 2, 2);
      }

      if (damage > 0.28) {
        ctx.strokeStyle = 'rgba(255,95,74,0.5)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(-w * 0.28, -h / 2 + 2);
        ctx.lineTo(-w * 0.12, -h * 0.05);
        ctx.lineTo(-w * 0.2, h / 2 - 2);
        ctx.stroke();
      }
      if (damage > 0.6) {
        ctx.beginPath();
        ctx.moveTo(w * 0.2, -h / 2 + 1);
        ctx.lineTo(w * 0.08, h * 0.1);
        ctx.lineTo(w * 0.28, h / 2 - 2);
        ctx.stroke();
      }

      if (block.flash > 0) {
        ctx.fillStyle = `rgba(255,224,163,${(block.flash * 0.22).toFixed(3)})`;
        ctx.fillRect(-w / 2, -h / 2, w, h);
      }

      ctx.restore();
    }
  }

  private drawTrail(): void {
    if (this.trail.length < 2) return;
    const { ctx } = this;
    ctx.strokeStyle = 'rgba(255,180,87,0.16)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    const first = this.trail[0];
    ctx.moveTo(first.x, first.y);
    for (const point of this.trail) ctx.lineTo(point.x, point.y);
    ctx.stroke();

    for (const point of this.trail) {
      ctx.fillStyle = `rgba(255,196,120,${(point.life * 0.25).toFixed(3)})`;
      ctx.beginPath();
      ctx.arc(point.x, point.y, 1.8, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  private drawRocket(rocket: Rocket): void {
    const { ctx } = this;
    ctx.save();
    ctx.translate(rocket.x, rocket.y);
    ctx.rotate(rocket.angle);

    if (this.phase === 'flying' && this.thrustHeld && this.fuel > 0) {
      const flame = 14 + Math.random() * 12 + Math.sin(this.time * 40) * 3;
      const gradient = ctx.createLinearGradient(-8, 0, -8 - flame, 0);
      gradient.addColorStop(0, '#fff2c9');
      gradient.addColorStop(0.4, AMBER);
      gradient.addColorStop(1, 'rgba(255,95,74,0)');
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.moveTo(-8, -4.5);
      ctx.lineTo(-8 - flame, 0);
      ctx.lineTo(-8, 4.5);
      ctx.closePath();
      ctx.fill();
    }

    ctx.fillStyle = '#23372f';
    ctx.beginPath();
    ctx.moveTo(-8, -4);
    ctx.lineTo(-13, -11);
    ctx.lineTo(-6, -7);
    ctx.closePath();
    ctx.fill();
    ctx.beginPath();
    ctx.moveTo(-8, 4);
    ctx.lineTo(-13, 11);
    ctx.lineTo(-6, 7);
    ctx.closePath();
    ctx.fill();

    ctx.fillStyle = AMBER_HOT;
    ctx.beginPath();
    ctx.moveTo(14, 0);
    ctx.lineTo(2, 6.4);
    ctx.lineTo(-7, 6);
    ctx.lineTo(-9, 0);
    ctx.lineTo(-7, -6);
    ctx.lineTo(2, -6.4);
    ctx.closePath();
    ctx.fill();

    ctx.strokeStyle = '#8a4c12';
    ctx.lineWidth = 1;
    ctx.stroke();

    ctx.fillStyle = '#2b6f7c';
    ctx.beginPath();
    ctx.arc(3, 0, 2.1, 0, Math.PI * 2);
    ctx.fill();

    ctx.globalAlpha = 0.35 + 0.15 * Math.sin(this.time * 8);
    ctx.strokeStyle = AMBER;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(0, 0, 15, 0, Math.PI * 2);
    ctx.stroke();
    ctx.globalAlpha = 1;

    ctx.restore();
  }

  private drawParticles(): void {
    const { ctx } = this;
    for (const particle of this.particles) {
      const alpha = clamp(particle.life / Math.max(particle.maxLife, 0.001), 0, 1);
      ctx.globalAlpha = alpha;
      if (particle.kind === 'spark') {
        ctx.strokeStyle = particle.tone;
        ctx.lineWidth = 1.4;
        ctx.beginPath();
        ctx.moveTo(particle.x, particle.y);
        ctx.lineTo(particle.x - particle.vx * 0.02, particle.y - particle.vy * 0.02);
        ctx.stroke();
      } else if (particle.kind === 'shard') {
        ctx.fillStyle = particle.tone;
        ctx.fillRect(
          particle.x - particle.size / 2,
          particle.y - particle.size / 2,
          particle.size,
          particle.size * 0.7,
        );
      } else {
        ctx.fillStyle = particle.tone;
        ctx.beginPath();
        ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    ctx.globalAlpha = 1;
  }

  private drawShocks(): void {
    const { ctx } = this;
    for (const shock of this.shocks) {
      const progress = 1 - shock.life / shock.maxLife;
      ctx.globalAlpha = (1 - progress) * 0.7;
      ctx.strokeStyle = progress < 0.4 ? AMBER_HOT : HAZARD;
      ctx.lineWidth = 2 * (1 - progress) + 0.5;
      ctx.beginPath();
      ctx.arc(shock.x, shock.y, shock.maxRadius * progress, 0, Math.PI * 2);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
  }

  private drawFloaters(): void {
    const { ctx } = this;
    ctx.font = '600 12px "SFMono-Regular", ui-monospace, monospace';
    ctx.textAlign = 'center';
    for (const floater of this.floaters) {
      ctx.globalAlpha = clamp(floater.life / 0.85, 0, 1);
      ctx.fillStyle = AMBER_HOT;
      ctx.fillText(floater.text, floater.x, floater.y);
    }
    ctx.globalAlpha = 1;
    ctx.textAlign = 'left';
  }
}