Back to Obsidian Breaker
SOURCE / PINNED RELEASE

Made of little things.

Obsidian Breaker

Release
44c589c52043…
Author-recorded commit
9ee5b61ce1c9…
License
LICENSE
Author’s source reference
nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-de5dc6a02e7

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

src/main.ts
import './napplet-settings.js';
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { SoundEngine } from './audio.js';
import { runtimeHasDomain } from './domain-availability.js';
import { brickSpec, GRID_COLUMNS, LEVELS, type BrickKind } from './levels.js';
import {
  DEFAULT_SETTINGS,
  onGameSettings,
  type GameSettings,
} from './napplet-settings.js';
import './styles.css';

const TAU = Math.PI * 2;
const AIM_MAX = 1.02;
const STEP = 1 / 120;
const MAX_BALLS = 4;
const MAX_LIVES = 5;
const STORAGE_KEY = 'obsidian-breaker/best-v1';
const BRICK_ASPECT = 0.52;
const BALL_SPEED_SCALE = 1.5;

type Phase = 'docked' | 'playing' | 'paused' | 'levelClear' | 'gameOver' | 'victory';
type PowerKind = 'wide' | 'slow' | 'split' | 'blast';

interface Brick {
  col: number;
  row: number;
  kind: BrickKind;
  hp: number;
  maxHp: number;
  x: number;
  y: number;
  w: number;
  h: number;
  alive: boolean;
  flash: number;
  seed: number;
}

interface Ball {
  x: number;
  y: number;
  vx: number;
  vy: number;
  r: number;
  stuck: boolean;
  trail: Array<{ x: number; y: number; life: number }>;
}

interface Mote {
  x: number;
  y: number;
  radius: number;
  drift: number;
  speed: number;
  phase: number;
  alpha: number;
}

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  maxLife: number;
  size: number;
  color: string;
  gravity: number;
  kind: 'spark' | 'shard';
  rotation: number;
  spin: number;
}

interface Ring {
  x: number;
  y: number;
  life: number;
  maxLife: number;
  from: number;
  to: number;
  color: string;
}

interface FloatText {
  x: number;
  y: number;
  vy: number;
  life: number;
  maxLife: number;
  text: string;
  color: string;
  size: number;
}

interface PowerUp {
  x: number;
  y: number;
  vy: number;
  kind: PowerKind;
  spin: number;
}

interface Burst {
  x: number;
  y: number;
  radius: number;
  delay: number;
}

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

interface Prediction {
  points: Point[];
  hitBrick: Brick | null;
  lost: boolean;
  bounces: number;
  catches: Point[];
  impacts: number;
}

const palette: Record<
  BrickKind,
  { top: string; bottom: string; edge: string; glint: string; shard: string }
> = {
  shale: {
    top: '#d9803f',
    bottom: '#743116',
    edge: '#ffbe84',
    glint: 'rgba(255,225,190,0.55)',
    shard: '#ff9a4d',
  },
  granite: {
    top: '#a37b66',
    bottom: '#4b3228',
    edge: '#d9b7a0',
    glint: 'rgba(255,230,215,0.35)',
    shard: '#c49a82',
  },
  basalt: {
    top: '#7f5d9b',
    bottom: '#31213f',
    edge: '#c3a0e0',
    glint: 'rgba(230,210,255,0.35)',
    shard: '#a97ccc',
  },
  volatile: {
    top: '#3edc8e',
    bottom: '#0f4c33',
    edge: '#b9ffdc',
    glint: 'rgba(210,255,235,0.65)',
    shard: '#5affae',
  },
  obsidian: {
    top: '#46576a',
    bottom: '#141a23',
    edge: '#9db4cc',
    glint: 'rgba(210,230,255,0.3)',
    shard: '#7c93a8',
  },
};

const powerColors: Record<PowerKind, { core: string; glow: string; label: string }> = {
  wide: { core: '#7ce8ff', glow: 'rgba(80,210,255,0.55)', label: 'W' },
  slow: { core: '#c9a2ff', glow: 'rgba(180,120,255,0.55)', label: 'C' },
  split: { core: '#b6ff7a', glow: 'rgba(140,255,120,0.55)', label: 'M' },
  blast: { core: '#ffb45c', glow: 'rgba(255,120,40,0.65)', label: 'B' },
};

const audio = new SoundEngine();

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

const elements = {
  app: requireElement<HTMLElement>('#app'),
  hud: requireElement<HTMLElement>('.hud'),
  canvas: requireElement<HTMLCanvasElement>('#board'),
  score: requireElement<HTMLElement>('#scoreValue'),
  chain: requireElement<HTMLElement>('#chainValue'),
  chainStat: requireElement<HTMLElement>('#chainStat'),
  lives: requireElement<HTMLElement>('#livesValue'),
  level: requireElement<HTMLElement>('#levelValue'),
  best: requireElement<HTMLElement>('#bestValue'),
  guideButton: requireElement<HTMLButtonElement>('#guideButton'),
  soundButton: requireElement<HTMLButtonElement>('#soundButton'),
  warpButton: requireElement<HTMLButtonElement>('#warpButton'),
  bulletButton: requireElement<HTMLButtonElement>('#bulletButton'),
  pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
  bulletMeter: requireElement<HTMLElement>('#bulletMeter'),
  bulletFill: requireElement<HTMLElement>('#bulletFill'),
  banner: requireElement<HTMLElement>('#banner'),
  bannerKicker: requireElement<HTMLElement>('#bannerKicker'),
  bannerTitle: requireElement<HTMLElement>('#bannerTitle'),
  hint: requireElement<HTMLElement>('#hint'),
  toast: requireElement<HTMLElement>('#toast'),
  overlay: requireElement<HTMLElement>('#overlay'),
  overlayKicker: requireElement<HTMLElement>('#overlayKicker'),
  overlayTitle: requireElement<HTMLElement>('#overlayTitle'),
  overlayBody: requireElement<HTMLElement>('#overlayBody'),
  overlayActions: requireElement<HTMLElement>('#overlayActions'),
  overlayNote: requireElement<HTMLElement>('#overlayNote'),
  status: requireElement<HTMLElement>('#gameStatus'),
};

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

const g = {
  width: 0,
  height: 0,
  dpr: 1,
  phase: 'docked' as Phase,
  resumePhase: 'docked' as Phase,
  level: 0,
  score: 0,
  best: 0,
  lives: 3,
  comboTimer: 0,
  combo: 0,
  time: 0,
  aimT: 0,
  aimDir: 1,
  shake: 0,
  flash: 0,
  slowTimer: 0,
  wideTimer: 0,
  blastTimer: 0,
  blastCooldown: 0,
  levelClearTimer: 0,
  bulletCharge: 1,
  bulletActive: false,
  warpActive: false,
  warpReady: false,
  warpHits: 0,
  warpCooldown: 0,
  warpTimeLeft: 0,
  warpCheckTimer: 0,
  warpHintShown: false,
  lastBulletPercent: -1,
  hasSized: false,
  pendingLaunch: false,
  bestDirty: false,
  storageWarned: false,
  lastLives: -1,
  ballRadius: 5,
  paddleBaseW: 90,
  paddleH: 10,
  bricks: [] as Brick[],
  balls: [] as Ball[],
  motes: [] as Mote[],
  particles: [] as Particle[],
  rings: [] as Ring[],
  floats: [] as FloatText[],
  powerups: [] as PowerUp[],
  bursts: [] as Burst[],
  keys: { left: false, right: false },
  bounds: { top: 40, bottom: 560 },
  paddle: { x: 0, y: 0, w: 90, h: 10, targetX: 0, vx: 0 },
  settings: { ...DEFAULT_SETTINGS } as GameSettings,
  guide: DEFAULT_SETTINGS.showGuide,
  soundOn: true,
  bg: null as HTMLCanvasElement | null,
  acc: 0,
};

const bannerTimer = { id: 0 };
const toastTimer = { id: 0 };

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

function formatNumber(value: number): string {
  return Math.round(value).toLocaleString('en-US');
}

function random(seed: number): number {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
}

function prefersReducedMotion(): boolean {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

function setText(element: HTMLElement, value: string): void {
  if (element.textContent !== value) element.textContent = value;
}

function announce(message: string): void {
  elements.status.textContent = message;
}

function showBanner(kicker: string, title: string, duration = 1500): void {
  window.clearTimeout(bannerTimer.id);
  elements.bannerKicker.textContent = kicker;
  elements.bannerTitle.textContent = title;
  elements.banner.classList.add('is-visible');
  bannerTimer.id = window.setTimeout(() => elements.banner.classList.remove('is-visible'), duration);
}

function showToast(message: string, duration = 1500): void {
  window.clearTimeout(toastTimer.id);
  elements.toast.textContent = message;
  elements.toast.classList.add('is-visible');
  toastTimer.id = window.setTimeout(() => elements.toast.classList.remove('is-visible'), duration);
}

function updateGuideButton(): void {
  elements.guideButton.setAttribute('aria-pressed', String(g.guide));
  elements.guideButton.classList.toggle('is-off', !g.guide);
}

function updateSoundButton(): void {
  elements.soundButton.setAttribute('aria-pressed', String(g.soundOn));
  elements.soundButton.classList.toggle('is-off', !g.soundOn);
  elements.soundButton.setAttribute('aria-label', g.soundOn ? 'Mute sound' : 'Unmute sound');
}

function updatePauseButton(): void {
  const paused = g.phase === 'paused';
  elements.pauseButton.setAttribute('aria-label', paused ? 'Resume' : 'Pause');
  elements.pauseButton.title = paused ? 'Resume' : 'Pause';
}

function updateHud(): void {
  setText(elements.score, formatNumber(g.score));
  setText(elements.best, formatNumber(Math.max(g.best, g.score)));
  setText(elements.chain, g.combo > 1 ? `x${Math.min(g.combo, 99)}` : '—');
  elements.chainStat.classList.toggle('is-hot', g.combo > 1);
  setText(
    elements.level,
    `${String(g.level + 1).padStart(2, '0')}/${String(LEVELS.length).padStart(2, '0')}`,
  );
  if (g.lives !== g.lastLives) {
    const filled = clamp(g.lives, 0, MAX_LIVES);
    const empty = Math.max(0, MAX_LIVES - filled);
    elements.lives.textContent = '◆'.repeat(filled) + '◇'.repeat(empty);
    elements.lives.setAttribute('aria-label', `${g.lives} shields`);
    g.lastLives = g.lives;
  }
}

function timeScale(): number {
  let scale = 1;
  if (g.bulletActive) scale *= 0.34;
  if (g.warpActive) scale *= 2.4;
  return scale;
}

function updateAbilityHud(): void {
  const percent = Math.round(g.bulletCharge * 100);
  if (percent !== g.lastBulletPercent) {
    g.lastBulletPercent = percent;
    elements.bulletFill.style.transform = `scaleX(${g.bulletCharge.toFixed(3)})`;
    elements.bulletMeter.setAttribute('aria-valuenow', String(percent));
    elements.bulletButton.setAttribute('aria-label', `Bullet time (B): ${percent}% charged`);
    elements.bulletButton.title = `Bullet time (B): ${percent}% charged`;
  }

  elements.bulletButton.classList.toggle('is-active', g.bulletActive);
  elements.bulletButton.setAttribute('aria-pressed', String(g.bulletActive));
  elements.bulletMeter.classList.toggle('is-active', g.bulletActive);
  elements.bulletMeter.classList.toggle('is-low', g.bulletCharge < 0.22);

  const canWarp = g.warpActive || (g.warpReady && g.warpCooldown <= 0);
  elements.warpButton.classList.toggle('is-ready', g.warpReady && !g.warpActive && g.warpCooldown <= 0);
  elements.warpButton.classList.toggle('is-active', g.warpActive);
  elements.warpButton.setAttribute('aria-pressed', String(g.warpActive));
  elements.warpButton.setAttribute('aria-disabled', String(!canWarp));

  let label: string;
  if (g.warpActive) label = 'Warp active (F to release)';
  else if (g.warpCooldown > 0) label = `Warp recharging (${Math.ceil(g.warpCooldown)}s)`;
  else if (g.warpReady) label = 'Warp ready (F): fast-forward through 3 impacts';
  else label = 'Warp (F): needs 3 projected wall or stone impacts';
  if (elements.warpButton.getAttribute('aria-label') !== label) {
    elements.warpButton.setAttribute('aria-label', label);
    elements.warpButton.title = label;
  }
}

function setWarpReady(ready: boolean): void {
  if (g.warpReady === ready) return;
  g.warpReady = ready;
  updateAbilityHud();
  if (ready && !g.warpHintShown) {
    g.warpHintShown = true;
    showToast('WARP READY · PRESS F OR TAP »', 1500);
  }
}

function toggleBulletTime(): void {
  if (g.bulletActive) {
    g.bulletActive = false;
    audio.play('bullet');
    showToast('Bullet time released', 900);
    updateAbilityHud();
    return;
  }
  if (g.phase !== 'docked' && g.phase !== 'playing') return;
  if (g.bulletCharge < 0.12) {
    showToast('Bullet time is recharging', 1200);
    return;
  }
  if (g.warpActive) stopWarp(false);
  g.bulletActive = true;
  g.flash = Math.max(g.flash, 0.08);
  audio.play('bullet');
  showToast('BULLET TIME', 900);
  updateAbilityHud();
  announce('Bullet time engaged.');
}

function activateWarp(): void {
  if (g.warpActive) {
    stopWarp(false);
    return;
  }
  if (g.phase !== 'playing') {
    showToast('Warp needs a live ball', 1100);
    return;
  }
  if (g.warpCooldown > 0) {
    showToast('Warp is recharging', 1100);
    return;
  }
  if (!g.warpReady) {
    showToast('Warp needs 3 projected impacts', 1300);
    return;
  }
  g.bulletActive = false;
  g.warpActive = true;
  g.warpReady = false;
  g.warpHits = 0;
  g.warpTimeLeft = 4;
  g.flash = Math.max(g.flash, 0.18);
  g.shake = Math.max(g.shake, 2);
  audio.play('warp');
  showToast('WARP · 3 IMPACTS', 1000);
  updateAbilityHud();
  announce('Warp engaged. Fast-forwarding through three impacts.');
}

function stopWarp(spent: boolean): void {
  if (!g.warpActive) return;
  g.warpActive = false;
  g.warpHits = 0;
  g.warpCooldown = spent ? 2.6 : 0.6;
  if (spent) showToast('WARP SPENT', 1000);
  updateAbilityHud();
}

function registerWarpImpact(): void {
  if (!g.warpActive) return;
  g.warpHits += 1;
  if (g.warpHits >= 3) stopWarp(true);
}

function updateWarpReadiness(): void {
  if (g.phase !== 'playing' || g.warpActive || g.warpCooldown > 0) {
    setWarpReady(false);
    return;
  }
  const ball = g.balls[0];
  if (!ball || ball.stuck) {
    setWarpReady(false);
    return;
  }
  const speed = Math.hypot(ball.vx, ball.vy);
  if (speed < 0.001) {
    setWarpReady(false);
    return;
  }
  const prediction = castPrediction(ball.x, ball.y, ball.vx / speed, ball.vy / speed, ball.r, 5);
  setWarpReady(prediction.impacts >= 3);
}

function updateAbilities(dt: number): void {
  if (g.bulletActive) {
    g.bulletCharge = Math.max(0, g.bulletCharge - dt * 0.24);
    if (g.bulletCharge <= 0) {
      g.bulletActive = false;
      showToast('Bullet time depleted', 1200);
      announce('Bullet time depleted.');
    }
  } else if (g.phase === 'docked' || g.phase === 'playing' || g.phase === 'levelClear') {
    g.bulletCharge = Math.min(1, g.bulletCharge + dt * 0.1);
  }

  if (g.warpActive) {
    g.warpTimeLeft -= dt;
    if (g.warpTimeLeft <= 0) stopWarp(true);
  } else if (g.warpCooldown > 0) {
    g.warpCooldown = Math.max(0, g.warpCooldown - dt);
  }

  if (g.blastTimer > 0) g.blastTimer = Math.max(0, g.blastTimer - dt);
  if (g.blastCooldown > 0) g.blastCooldown = Math.max(0, g.blastCooldown - dt);

  g.warpCheckTimer -= dt;
  if (g.warpCheckTimer <= 0) {
    g.warpCheckTimer = 0.12;
    updateWarpReadiness();
  }
  updateAbilityHud();
}

function showOverlay(
  kicker: string,
  title: string,
  body: string,
  actions: Array<{ label: string; primary?: boolean; onClick: () => void }>,
  note = '',
): void {
  elements.overlayKicker.textContent = kicker;
  elements.overlayTitle.textContent = title;
  elements.overlayBody.textContent = body;
  elements.overlayNote.textContent = note;
  elements.overlayActions.replaceChildren();
  for (const action of actions) {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = action.primary ? 'btn btn-primary' : 'btn';
    button.textContent = action.label;
    button.addEventListener('click', action.onClick);
    elements.overlayActions.append(button);
  }
  elements.overlay.hidden = false;
}

function hideOverlay(): void {
  elements.overlay.hidden = true;
}

function livesForDifficulty(): number {
  if (g.settings.difficulty === 'chill') return 5;
  if (g.settings.difficulty === 'ruthless') return 2;
  return 3;
}

function difficultyFactor(): number {
  if (g.settings.difficulty === 'chill') return 0.86;
  if (g.settings.difficulty === 'ruthless') return 1.16;
  return 1;
}

function currentSpeed(): number {
  const base = clamp(g.height * 0.58, 120, 330) * BALL_SPEED_SCALE;
  const levelFactor = LEVELS[g.level]?.speed ?? 1;
  const slow = g.slowTimer > 0 ? 0.74 : 1;
  return base * levelFactor * difficultyFactor() * slow;
}

// ── Layout ────────────────────────────────────────────────────────────────

function buildBricks(levelIndex: number): Brick[] {
  const level = LEVELS[levelIndex];
  if (!level) return [];
  const bricks: Brick[] = [];
  level.rows.forEach((row, rowIndex) => {
    for (let col = 0; col < GRID_COLUMNS; col += 1) {
      const spec = brickSpec(row[col] ?? '.');
      if (!spec) continue;
      bricks.push({
        col,
        row: rowIndex,
        kind: spec.kind,
        hp: spec.hp,
        maxHp: spec.kind === 'obsidian' ? 1 : spec.hp,
        x: 0,
        y: 0,
        w: 0,
        h: 0,
        alive: true,
        flash: 0,
        seed: rowIndex * 37 + col * 61 + levelIndex * 131 + 1,
      });
    }
  });
  return bricks;
}

function computeMetrics(): void {
  // Reserve the real HUD footprint: at short frames the styled header is
  // taller than the proportional inset, and bricks must never spawn beneath it.
  const hudBottom = elements.hud.getBoundingClientRect().bottom - elements.app.getBoundingClientRect().top;
  const guidedTop = clamp(g.height * 0.105, 22, 74);
  const hudGap = clamp(g.height * 0.012, 2, 8);
  g.bounds.top = clamp(Math.max(guidedTop, hudBottom + hudGap), 22, g.height * 0.46);
  g.ballRadius = clamp(Math.min(g.width, g.height) * 0.012, 3.2, 7);
  g.paddleBaseW = clamp(g.width * 0.15, 42, 150);
  g.paddleH = clamp(g.height * 0.02, 7, 14);
  const bottomInset = clamp(g.height * 0.065, 16, 46);
  g.paddle.y = g.height - bottomInset - g.paddleH;
  g.bounds.bottom = g.paddle.y;
}

function applyPaddleWidth(): void {
  g.paddle.w = g.paddleBaseW * (g.wideTimer > 0 ? 1.45 : 1);
  g.paddle.h = g.paddleH;
  const half = g.paddle.w / 2;
  g.paddle.x = clamp(g.paddle.x || g.width / 2, half, g.width - half);
  g.paddle.targetX = clamp(g.paddle.targetX || g.paddle.x, half, g.width - half);
}

function paddleRect(): { x: number; y: number; w: number; h: number } {
  return {
    x: g.paddle.x - g.paddle.w / 2,
    y: g.paddle.y,
    w: g.paddle.w,
    h: g.paddle.h,
  };
}

function layoutBricks(): void {
  const level = LEVELS[g.level];
  if (!level) return;
  const rows = level.rows.length;
  const gap = clamp(g.width * 0.006, 0.8, 5);
  const side = clamp(g.width * 0.02, 3, 24);
  const top = g.bounds.top + clamp(g.height * 0.015, 2, 10);
  const region = Math.max(40, (g.paddle.y - top) * 0.72);
  const maxWidth = (g.width - side * 2 - gap * (GRID_COLUMNS - 1)) / GRID_COLUMNS;
  const maxHeight = (region - gap * (rows - 1)) / rows;
  const brickWidth = Math.max(3, Math.min(maxWidth, maxHeight / BRICK_ASPECT));
  const brickHeight = brickWidth * BRICK_ASPECT;
  const totalWidth = GRID_COLUMNS * brickWidth + gap * (GRID_COLUMNS - 1);
  const totalHeight = rows * brickHeight + gap * (rows - 1);
  const originX = (g.width - totalWidth) / 2;
  const originY = top + Math.max(0, region - totalHeight) * 0.5;

  for (const brick of g.bricks) {
    brick.x = originX + brick.col * (brickWidth + gap);
    brick.y = originY + brick.row * (brickHeight + gap);
    brick.w = brickWidth;
    brick.h = brickHeight;
  }
}

function makeBall(x: number, y: number): Ball {
  return { x, y, vx: 0, vy: 0, r: g.ballRadius, stuck: true, trail: [] };
}

function resetDocked(): void {
  g.balls = [makeBall(g.paddle.x, g.paddle.y - g.ballRadius - 2)];
  g.aimT = 0;
  g.aimDir = 1;
}

function resetMotes(): void {
  const count = Math.round(clamp((g.width * g.height) / 16000, 12, 44));
  g.motes = Array.from({ length: count }, () => ({
    x: Math.random() * g.width,
    y: Math.random() * g.height,
    radius: 0.6 + Math.random() * 1.8,
    drift: 6 + Math.random() * 16,
    speed: 4 + Math.random() * 16,
    phase: Math.random() * TAU,
    alpha: 0.08 + Math.random() * 0.24,
  }));
}

function resizeCanvas(): void {
  const rect = elements.app.getBoundingClientRect();
  const width = Math.max(120, Math.round(rect.width));
  const height = Math.max(100, Math.round(rect.height));
  const oldWidth = g.width;
  const oldHeight = g.height;

  g.width = width;
  g.height = height;
  g.dpr = Math.min(window.devicePixelRatio || 1, 2);
  elements.canvas.width = Math.round(width * g.dpr);
  elements.canvas.height = Math.round(height * g.dpr);

  computeMetrics();
  layoutBricks();

  if (g.hasSized && oldWidth > 0 && oldHeight > 0) {
    const scaleX = width / oldWidth;
    const scaleY = height / oldHeight;
    for (const ball of g.balls) {
      ball.x *= scaleX;
      ball.y *= scaleY;
      ball.r = g.ballRadius;
    }
    g.paddle.x *= scaleX;
    g.paddle.targetX *= scaleX;
    for (const power of g.powerups) {
      power.x *= scaleX;
      power.y *= scaleY;
    }
  }

  applyPaddleWidth();
  if (g.phase === 'docked') resetDocked();

  buildBackground();
  resetMotes();
  g.hasSized = true;
}

function buildBackground(): void {
  const background = document.createElement('canvas');
  background.width = Math.max(1, Math.round(g.width * g.dpr));
  background.height = Math.max(1, Math.round(g.height * g.dpr));
  const paint = background.getContext('2d');
  if (!paint) return;
  paint.scale(g.dpr, g.dpr);
  const width = g.width;
  const height = g.height;

  const base = paint.createLinearGradient(0, 0, 0, height);
  base.addColorStop(0, '#130c14');
  base.addColorStop(0.45, '#0d0810');
  base.addColorStop(1, '#060407');
  paint.fillStyle = base;
  paint.fillRect(0, 0, width, height);

  const topGlow = paint.createRadialGradient(
    width * 0.5,
    -height * 0.12,
    height * 0.04,
    width * 0.5,
    -height * 0.12,
    height * 0.95,
  );
  topGlow.addColorStop(0, 'rgba(255,110,40,0.18)');
  topGlow.addColorStop(0.5, 'rgba(255,80,30,0.05)');
  topGlow.addColorStop(1, 'rgba(255,60,20,0)');
  paint.fillStyle = topGlow;
  paint.fillRect(0, 0, width, height);

  const bottomGlow = paint.createRadialGradient(
    width * 0.5,
    height * 1.06,
    height * 0.02,
    width * 0.5,
    height * 1.06,
    height * 0.7,
  );
  bottomGlow.addColorStop(0, 'rgba(255,90,25,0.22)');
  bottomGlow.addColorStop(1, 'rgba(255,50,10,0)');
  paint.fillStyle = bottomGlow;
  paint.fillRect(0, height * 0.55, width, height * 0.45);

  paint.lineWidth = 1;
  for (let line = 0; line < 24; line += 1) {
    const y = height * (0.14 + (line / 24) * 0.9);
    const alpha = 0.016 + random(line + 3) * 0.04;
    paint.strokeStyle = `rgba(255,150,90,${alpha})`;
    paint.beginPath();
    for (let x = -10; x <= width + 10; x += width / 12) {
      const jitter = Math.sin(x * 0.012 + line * 1.7) * height * 0.006;
      if (x <= -10) paint.moveTo(x, y + jitter);
      else paint.lineTo(x, y + jitter);
    }
    paint.stroke();
  }

  for (let vein = 0; vein < 5; vein += 1) {
    const startX = random(vein * 7) * width;
    const startY = height * (0.35 + random(vein * 11) * 0.45);
    paint.strokeStyle = `rgba(255,92,35,${0.035 + random(vein * 5) * 0.03})`;
    paint.lineWidth = 1 + random(vein * 13) * 1.4;
    paint.beginPath();
    paint.moveTo(startX, startY);
    paint.bezierCurveTo(
      startX + (random(vein + 4) - 0.5) * width * 0.3,
      startY - height * 0.12,
      startX + (random(vein + 9) - 0.5) * width * 0.4,
      startY + height * 0.16,
      startX + (random(vein + 2) - 0.5) * width * 0.5,
      startY + height * 0.24,
    );
    paint.stroke();
  }

  const speckles = Math.round(clamp((width * height) / 9000, 40, 150));
  for (let index = 0; index < speckles; index += 1) {
    const x = random(index * 3 + 1) * width;
    const y = random(index * 7 + 2) * height;
    const alpha = 0.04 + random(index * 5) * 0.12;
    paint.fillStyle = `rgba(255,190,130,${alpha})`;
    paint.fillRect(x, y, 1, 1);
  }

  const vignette = paint.createRadialGradient(
    width / 2,
    height / 2,
    Math.min(width, height) * 0.25,
    width / 2,
    height / 2,
    Math.max(width, height) * 0.78,
  );
  vignette.addColorStop(0, 'rgba(0,0,0,0)');
  vignette.addColorStop(1, 'rgba(0,0,0,0.55)');
  paint.fillStyle = vignette;
  paint.fillRect(0, 0, width, height);

  g.bg = background;
}

// ── Run flow ──────────────────────────────────────────────────────────────

function startRun(): void {
  g.score = 0;
  g.level = 0;
  g.lives = livesForDifficulty();
  g.combo = 0;
  g.comboTimer = 0;
  g.powerups = [];
  g.bursts = [];
  g.floats = [];
  g.rings = [];
  g.particles = [];
  g.bulletCharge = 1;
  g.bulletActive = false;
  g.warpActive = false;
  g.warpReady = false;
  g.warpHits = 0;
  g.warpCooldown = 0;
  g.warpTimeLeft = 0;
  g.warpHintShown = false;
  g.blastTimer = 0;
  g.blastCooldown = 0;
  hideOverlay();
  loadLevel(0);
  updateAbilityHud();
  updatePauseButton();
  announce(`New run. ${g.lives} shields. Seam one, Ashfall.`);
}

function loadLevel(index: number): void {
  g.level = index;
  g.bricks = buildBricks(index);
  g.powerups = [];
  g.bursts = [];
  g.floats = [];
  g.rings = [];
  g.particles = [];
  g.slowTimer = 0;
  g.wideTimer = 0;
  g.blastTimer = 0;
  g.blastCooldown = 0;
  g.bulletActive = false;
  g.warpActive = false;
  g.warpReady = false;
  g.warpHits = 0;
  g.warpCooldown = 0;
  g.warpTimeLeft = 0;
  g.combo = 0;
  g.comboTimer = 0;
  g.phase = 'docked';
  g.acc = 0;
  g.shake = 0;
  g.flash = 0;
  layoutBricks();
  applyPaddleWidth();
  resetDocked();
  updatePauseButton();
  updateHud();
  updateAbilityHud();
  const level = LEVELS[index];
  if (level) {
    showBanner(`SEAM ${String(index + 1).padStart(2, '0')} / ${String(LEVELS.length).padStart(2, '0')}`, level.name.toUpperCase(), 1700);
    announce(`Seam ${index + 1}: ${level.name}. ${level.tagline}.`);
  }
}

function launch(): void {
  if (g.phase !== 'docked') return;
  const ball = g.balls[0];
  if (!ball) return;
  audio.unlock();
  const angle = g.aimT * AIM_MAX;
  const speed = currentSpeed();
  ball.stuck = false;
  ball.vx = Math.sin(angle) * speed;
  ball.vy = -Math.cos(angle) * speed;
  ball.trail = [];
  g.phase = 'playing';
  g.acc = 0;
  audio.play('launch');
  announce('Ball away.');
}

function togglePause(): void {
  if (g.phase === 'paused') {
    resume();
    return;
  }
  if (g.phase !== 'docked' && g.phase !== 'playing') return;
  g.resumePhase = g.phase;
  g.phase = 'paused';
  updatePauseButton();
  showOverlay(
    'Frozen',
    'The seam waits',
    'Every impact is held in place. Resume when you are ready.',
    [
      { label: 'Resume', primary: true, onClick: resume },
      { label: 'Restart run', onClick: startRun },
    ],
    runtimeHasDomain(STORAGE_DOMAIN) ? '' : 'Storage is unavailable here, so best scores last only for this session.',
  );
  announce('Paused.');
}

function resume(): void {
  if (g.phase !== 'paused') return;
  g.phase = g.resumePhase;
  hideOverlay();
  updatePauseButton();
  announce('Resumed.');
}

function endRun(victory: boolean): void {
  g.bulletActive = false;
  stopWarp(false);
  g.warpReady = false;
  if (g.score > g.best) {
    g.best = g.score;
    g.bestDirty = true;
  }
  if (g.bestDirty) void saveBest();
  hideOverlay();
  updateHud();
  audio.play(victory ? 'win' : 'life');
  if (victory) {
    g.phase = 'victory';
    showOverlay(
      'Crown broken',
      'Every seam is dust',
      `Final score ${formatNumber(g.score)} · Best ${formatNumber(g.best)}. All ${LEVELS.length} strata are cleared.`,
      [{ label: 'Run again', primary: true, onClick: startRun }],
      'Best scores save when the host provides storage.',
    );
    announce(`Victory. Final score ${g.score}.`);
  } else {
    g.phase = 'gameOver';
    showOverlay(
      'Run ended',
      'The seam holds',
      `Score ${formatNumber(g.score)} · Best ${formatNumber(g.best)}. The volatile veins reset for the next descent.`,
      [{ label: 'Run again', primary: true, onClick: startRun }],
      runtimeHasDomain(STORAGE_DOMAIN) ? '' : 'Storage is unavailable here, so best scores last only for this session.',
    );
    announce(`Run ended. Score ${g.score}. Best ${g.best}.`);
  }
  updatePauseButton();
}

function clearLevel(): void {
  if (g.phase !== 'playing') return;
  g.bulletActive = false;
  stopWarp(false);
  g.warpReady = false;
  g.phase = 'levelClear';
  g.levelClearTimer = 2.1;
  g.acc = 0;
  const bonus = 400 + (g.level + 1) * 200 + g.lives * 100;
  g.score += bonus;
  if (g.score > g.best) {
    g.best = g.score;
    g.bestDirty = true;
  }
  void saveBest();
  g.flash = Math.max(g.flash, 0.2);
  g.shake = Math.max(g.shake, 4);
  spawnCelebration();
  audio.play('level');
  showBanner(`SEAM ${String(g.level + 1).padStart(2, '0')} CLEAR`, `+${formatNumber(bonus)} FRAGMENTS`, 1900);
  announce(`Seam ${g.level + 1} cleared. Bonus ${bonus}.`);
  updateHud();
}

async function saveBest(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    await storage.setItem(STORAGE_KEY, String(Math.floor(g.best)));
    g.bestDirty = false;
  } catch {
    if (!g.storageWarned) {
      g.storageWarned = true;
      showToast('This host did not save the best score', 2400);
    }
  }
}

async function loadBest(): Promise<void> {
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  try {
    const raw = await Promise.race([
      storage.getItem(STORAGE_KEY),
      new Promise<null>((resolve) => {
        window.setTimeout(() => resolve(null), 2500);
      }),
    ]);
    if (typeof raw === 'string') {
      const parsed = Number.parseInt(raw, 10);
      if (Number.isFinite(parsed)) g.best = Math.max(0, parsed);
    }
  } catch {
    // Optional storage can exist without working methods; keep the run usable.
  }
  updateHud();
}

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

function updateAim(dt: number): void {
  const pace =
    g.settings.aimPace === 'slow' ? 0.34 : g.settings.aimPace === 'quick' ? 0.92 : 0.58;
  g.aimT += g.aimDir * pace * dt;
  if (g.aimT > 1) {
    g.aimT = 1;
    g.aimDir = -1;
  } else if (g.aimT < -1) {
    g.aimT = -1;
    g.aimDir = 1;
  }
}

function updatePaddle(dt: number): void {
  const keyboardSpeed = Math.max(g.width * 1.5, 320);
  if (g.keys.left) g.paddle.targetX -= keyboardSpeed * dt;
  if (g.keys.right) g.paddle.targetX += keyboardSpeed * dt;

  const half = g.paddle.w / 2;
  g.paddle.targetX = clamp(g.paddle.targetX, half, g.width - half);
  const delta = g.paddle.targetX - g.paddle.x;
  const maxMove = Math.max(g.width * 2.4, 620) * dt;
  const move = clamp(delta, -maxMove, maxMove);
  g.paddle.vx = move / Math.max(dt, 0.0001);
  g.paddle.x = clamp(g.paddle.x + move, half, g.width - half);

  if (g.phase === 'docked') {
    const ball = g.balls[0];
    if (ball) {
      ball.x = g.paddle.x;
      ball.y = g.paddle.y - g.ballRadius - 2;
    }
  }
}

function updateBall(ball: Ball, dt: number): void {
  const speed = currentSpeed();
  const magnitude = Math.hypot(ball.vx, ball.vy);
  if (!Number.isFinite(magnitude) || magnitude < 0.001) {
    ball.vx = 0;
    ball.vy = -speed;
  } else {
    ball.vx = (ball.vx / magnitude) * speed;
    ball.vy = (ball.vy / magnitude) * speed;
  }

  const distance = speed * dt;
  const steps = clamp(Math.ceil(distance / Math.max(1, ball.r * 0.65)), 1, 12);
  const stepDt = dt / steps;

  for (let index = 0; index < steps; index += 1) {
    ball.x += ball.vx * stepDt;
    ball.y += ball.vy * stepDt;
    collideWalls(ball);
    collidePaddle(ball);
    resolveBrickCollision(ball);
    if (g.phase !== 'playing') break;
  }
}

function collideWalls(ball: Ball): void {
  const radius = ball.r;
  const top = g.bounds.top + radius;
  if (ball.y < top) {
    ball.y = top;
    ball.vy = Math.abs(ball.vy);
    spawnSparks(ball.x, top, '255,160,90', 2);
    registerWarpImpact();
  }
  if (ball.x < radius) {
    ball.x = radius;
    ball.vx = Math.abs(ball.vx);
    spawnSparks(radius, ball.y, '255,160,90', 2);
    registerWarpImpact();
  } else if (ball.x > g.width - radius) {
    ball.x = g.width - radius;
    ball.vx = -Math.abs(ball.vx);
    spawnSparks(g.width - radius, ball.y, '255,160,90', 2);
    registerWarpImpact();
  }
}

function collidePaddle(ball: Ball): void {
  const rect = paddleRect();
  const closestX = clamp(ball.x, rect.x, rect.x + rect.w);
  const closestY = clamp(ball.y, rect.y, rect.y + rect.h);
  const dx = ball.x - closestX;
  const dy = ball.y - closestY;
  if (dx * dx + dy * dy > ball.r * ball.r) return;

  if (ball.vy > 0 && ball.y < rect.y + rect.h * 0.9) {
    const relative = clamp((ball.x - g.paddle.x) / (g.paddle.w / 2), -1, 1);
    const angle = relative * 1.08;
    const speed = currentSpeed();
    ball.x = clamp(ball.x, rect.x + ball.r, rect.x + rect.w - ball.r);
    ball.y = rect.y - ball.r - 0.5;
    ball.vx = Math.sin(angle) * speed;
    ball.vy = -Math.abs(Math.cos(angle) * speed);
    onPaddleHit();
    return;
  }

  const side = ball.x < g.paddle.x ? -1 : 1;
  const speed = currentSpeed();
  ball.x = g.paddle.x + side * (g.paddle.w / 2 + ball.r + 0.5);
  ball.vx = side * Math.abs(ball.vx || speed * 0.4);
  ball.vy = -Math.abs(ball.vy || speed * 0.4);
}

function onPaddleHit(): void {
  g.combo = 0;
  g.comboTimer = 0;
  g.shake = Math.max(g.shake, 1.2);
  audio.play('paddle');
  spawnSparks(g.paddle.x, g.paddle.y, '255,190,120', 4);
}

function resolveBrickCollision(ball: Ball): void {
  let best: Brick | null = null;
  let bestPenetration = 0;
  let bestX = 0;
  let bestY = 0;

  for (const brick of g.bricks) {
    if (!brick.alive) continue;
    const closestX = clamp(ball.x, brick.x, brick.x + brick.w);
    const closestY = clamp(ball.y, brick.y, brick.y + brick.h);
    const dx = ball.x - closestX;
    const dy = ball.y - closestY;
    const distance = Math.hypot(dx, dy);

    let nx = 0;
    let ny = 0;
    let penetration = 0;
    if (distance > 0.0001) {
      if (distance >= ball.r) continue;
      nx = dx / distance;
      ny = dy / distance;
      penetration = ball.r - distance;
    } else {
      const left = ball.x - brick.x;
      const right = brick.x + brick.w - ball.x;
      const top = ball.y - brick.y;
      const bottom = brick.y + brick.h - ball.y;
      const minimum = Math.min(left, right, top, bottom);
      if (minimum === left) {
        nx = -1;
        penetration = left + ball.r;
      } else if (minimum === right) {
        nx = 1;
        penetration = right + ball.r;
      } else if (minimum === top) {
        ny = -1;
        penetration = top + ball.r;
      } else {
        ny = 1;
        penetration = bottom + ball.r;
      }
    }

    if (penetration > bestPenetration) {
      bestPenetration = penetration;
      best = brick;
      bestX = nx;
      bestY = ny;
    }
  }

  if (!best) return;

  ball.x += bestX * (bestPenetration + 0.05);
  ball.y += bestY * (bestPenetration + 0.05);
  const dot = ball.vx * bestX + ball.vy * bestY;
  if (dot < 0) {
    ball.vx -= 2 * dot * bestX;
    ball.vy -= 2 * dot * bestY;
  }

  registerWarpImpact();

  if (g.blastTimer > 0 && g.blastCooldown <= 0) {
    g.blastCooldown = 0.16;
    const radius = Math.max(ball.r * 7, best.w * 1.8);
    detonate(ball.x, ball.y, radius, 2);
    spawnSparks(ball.x, ball.y, '255,180,100', 9);
  }

  if (best.kind === 'obsidian') {
    if (dot < 0) {
      best.flash = 0.18;
      g.shake = Math.max(g.shake, 1.4);
      spawnSparks(ball.x, ball.y, '170,200,230', 3);
      audio.play('paddle');
    }
    return;
  }

  damageBrick(best, 1);
}

function damageBrick(brick: Brick, amount: number): void {
  if (!brick.alive || brick.kind === 'obsidian') return;
  brick.flash = 0.2;
  brick.hp -= amount;
  if (brick.hp > 0) {
    g.score += 15;
    g.comboTimer = Math.max(g.comboTimer, 1.1);
    audio.play('brick');
    spawnSparks(
      brick.x + brick.w / 2,
      brick.y + brick.h / 2,
      palette[brick.kind].shard === '#ff9a4d' ? '255,160,90' : '220,190,190',
      3,
    );
    return;
  }
  destroyBrick(brick);
}

function destroyBrick(brick: Brick): void {
  brick.alive = false;
  brick.flash = 0;
  g.combo += 1;
  g.comboTimer = 3.2;

  const multiplier = 1 + Math.min(g.combo - 1, 12) * 0.5;
  const base =
    brick.kind === 'volatile' ? 140 : brick.kind === 'basalt' ? 90 : brick.kind === 'granite' ? 70 : 55;
  g.score += Math.round(base * multiplier);

  const centerX = brick.x + brick.w / 2;
  const centerY = brick.y + brick.h / 2;
  spawnShards(centerX, centerY, palette[brick.kind].shard, brick.kind === 'volatile' ? 18 : 10);
  g.shake = Math.max(g.shake, brick.kind === 'volatile' ? 4 : 2.4);
  audio.play('crack');

  if (brick.kind === 'volatile') {
    const radius = Math.max(brick.w, brick.h) * 2.9;
    g.bursts.push({ x: centerX, y: centerY, radius, delay: 0.06 });
  } else if (g.powerups.length < 2 && Math.random() < 0.13) {
    spawnPowerUp(centerX, centerY);
  }

  if (g.combo > 1 && g.combo % 5 === 0) {
    g.floats.push({
      x: centerX,
      y: centerY,
      vy: -34,
      life: 1,
      maxLife: 1,
      text: `CHAIN x${g.combo}`,
      color: '#ffd7a1',
      size: clamp(g.height * 0.024, 9, 16),
    });
  }
}

function detonate(x: number, y: number, radius: number, damage = 3): void {
  g.rings.push({ x, y, life: 0.55, maxLife: 0.55, from: radius * 0.08, to: radius, color: '90,255,165' });
  g.rings.push({ x, y, life: 0.3, maxLife: 0.3, from: radius * 0.05, to: radius * 0.6, color: '255,190,110' });
  g.flash = Math.max(g.flash, 0.24);
  g.shake = Math.max(g.shake, 7);
  audio.play('boom');

  for (let index = 0; index < 22; index += 1) {
    const angle = Math.random() * TAU;
    const speed = 40 + Math.random() * Math.min(260, g.height * 0.5);
    g.particles.push({
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 0.4 + Math.random() * 0.5,
      maxLife: 0.9,
      size: 1.4 + Math.random() * 2.4,
      color: Math.random() < 0.45 ? '120,255,170' : '255,165,80',
      gravity: 120,
      kind: 'spark',
      rotation: 0,
      spin: 0,
    });
  }
  trimParticles();

  const targets = g.bricks.filter((brick) => {
    if (!brick.alive || brick.kind === 'obsidian') return false;
    const dx = brick.x + brick.w / 2 - x;
    const dy = brick.y + brick.h / 2 - y;
    return Math.hypot(dx, dy) <= radius;
  });
  for (const target of targets) damageBrick(target, damage);
}

function spawnSparks(x: number, y: number, color: string, count: number): void {
  for (let index = 0; index < count; index += 1) {
    const angle = Math.random() * TAU;
    const speed = 25 + Math.random() * 120;
    g.particles.push({
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - 20,
      life: 0.22 + Math.random() * 0.32,
      maxLife: 0.55,
      size: 0.8 + Math.random() * 1.4,
      color,
      gravity: 80,
      kind: 'spark',
      rotation: 0,
      spin: 0,
    });
  }
  trimParticles();
}

function spawnShards(x: number, y: number, color: string, count: number): void {
  for (let index = 0; index < count; index += 1) {
    const angle = Math.random() * TAU;
    const speed = 40 + Math.random() * 220;
    g.particles.push({
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - 40,
      life: 0.45 + Math.random() * 0.55,
      maxLife: 1,
      size: 2 + Math.random() * 4,
      color,
      gravity: 320,
      kind: 'shard',
      rotation: Math.random() * TAU,
      spin: (Math.random() - 0.5) * 18,
    });
  }
  trimParticles();
}

function trimParticles(): void {
  if (g.particles.length > 420) g.particles.splice(0, g.particles.length - 420);
}

function spawnCelebration(): void {
  for (let index = 0; index < 60; index += 1) {
    const x = Math.random() * g.width;
    const y = g.bounds.top + Math.random() * (g.paddle.y - g.bounds.top);
    spawnSparks(x, y, index % 3 === 0 ? '120,255,170' : '255,180,90', 1);
  }
}

function spawnPowerUp(x: number, y: number): void {
  const roll = Math.random();
  const kind: PowerKind =
    roll < 0.3 ? 'wide' : roll < 0.56 ? 'split' : roll < 0.8 ? 'blast' : 'slow';
  g.powerups.push({
    x: clamp(x, 16, g.width - 16),
    y,
    vy: clamp(g.height * 0.12, 36, 90),
    kind,
    spin: Math.random() * TAU,
  });
}

function splitBall(): void {
  const source = g.balls.find((ball) => !ball.stuck) ?? g.balls[0];
  if (!source) return;
  if (g.balls.length >= MAX_BALLS) {
    g.score += 150;
    showToast('SHARD CAP · +150', 900);
    return;
  }
  const speed = currentSpeed();
  const base = Math.atan2(source.vy || -speed, source.vx || 0);
  const angle = base + (Math.random() < 0.5 ? 0.45 : -0.45);
  g.balls.push({
    x: source.x,
    y: source.y,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    r: g.ballRadius,
    stuck: false,
    trail: [],
  });
  spawnSparks(source.x, source.y, '182,255,122', 8);
  g.shake = Math.max(g.shake, 1.6);
}

function collectPowerUp(power: PowerUp): void {
  audio.play('power');
  const label =
    power.kind === 'wide'
      ? 'WIDE SLAB'
      : power.kind === 'slow'
        ? 'COOLANT'
        : power.kind === 'split'
          ? 'SPLIT SHARD'
          : 'BLAST CORE';
  g.floats.push({
    x: power.x,
    y: power.y - 8,
    vy: -42,
    life: 1.1,
    maxLife: 1.1,
    text: label,
    color: powerColors[power.kind].core,
    size: clamp(g.height * 0.026, 10, 17),
  });
  showToast(label, 1100);

  if (power.kind === 'wide') {
    g.wideTimer = 20;
    applyPaddleWidth();
  } else if (power.kind === 'slow') {
    g.slowTimer = 14;
  } else if (power.kind === 'split') {
    splitBall();
  } else if (power.kind === 'blast') {
    g.blastTimer = 12;
    g.blastCooldown = 0;
    g.floats.push({
      x: power.x,
      y: power.y - 24,
      vy: -36,
      life: 1.2,
      maxLife: 1.2,
      text: 'BLAST RADIUS',
      color: powerColors.blast.core,
      size: clamp(g.height * 0.026, 10, 17),
    });
  }
}

function updatePowerUps(dt: number): void {
  const rect = paddleRect();
  for (let index = g.powerups.length - 1; index >= 0; index -= 1) {
    const power = g.powerups[index];
    if (!power) continue;
    power.y += power.vy * dt;
    power.spin += dt * 4;
    if (
      power.y + 10 >= rect.y &&
      power.y - 10 <= rect.y + rect.h &&
      power.x + 10 >= rect.x &&
      power.x - 10 <= rect.x + rect.w
    ) {
      collectPowerUp(power);
      g.powerups.splice(index, 1);
      continue;
    }
    if (power.y > g.height + 30) g.powerups.splice(index, 1);
  }
}

function updateBursts(dt: number): void {
  for (const burst of g.bursts) burst.delay -= dt;
  const ready = g.bursts.filter((burst) => burst.delay <= 0);
  if (ready.length === 0) return;
  g.bursts = g.bursts.filter((burst) => burst.delay > 0);
  for (const burst of ready) detonate(burst.x, burst.y, burst.radius);
}

function loseLife(): void {
  g.lives -= 1;
  g.combo = 0;
  g.comboTimer = 0;
  g.wideTimer = 0;
  g.slowTimer = 0;
  g.blastTimer = 0;
  g.blastCooldown = 0;
  g.bulletActive = false;
  stopWarp(false);
  g.warpReady = false;
  g.powerups = [];
  g.shake = Math.max(g.shake, 9);
  g.flash = Math.max(g.flash, 0.4);
  audio.play('life');
  applyPaddleWidth();
  updateHud();

  if (g.lives <= 0) {
    endRun(false);
    return;
  }

  resetDocked();
  g.phase = 'docked';
  showBanner('SHIELD LOST', `${g.lives} REMAIN`, 1300);
  announce(`Shield lost. ${g.lives} remain.`);
  updatePauseButton();
}

function checkLostBalls(): void {
  if (g.phase !== 'playing') return;
  const remaining = g.balls.filter((ball) => ball.y - ball.r < g.height + 30);
  if (remaining.length !== g.balls.length) g.balls = remaining;
  if (g.balls.length === 0) loseLife();
}

function maybeClearLevel(): void {
  if (g.phase !== 'playing') return;
  const remaining = g.bricks.some((brick) => brick.alive && brick.kind !== 'obsidian');
  if (!remaining) clearLevel();
}

function updateTrails(dt: number): void {
  for (const ball of g.balls) {
    if (!ball.stuck) ball.trail.push({ x: ball.x, y: ball.y, life: 1 });
    for (const point of ball.trail) point.life -= dt * 4.2;
    ball.trail = ball.trail.filter((point) => point.life > 0).slice(-24);
  }
}

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

  for (let index = g.rings.length - 1; index >= 0; index -= 1) {
    const ring = g.rings[index];
    if (!ring) continue;
    ring.life -= dt;
    if (ring.life <= 0) g.rings.splice(index, 1);
  }

  for (let index = g.floats.length - 1; index >= 0; index -= 1) {
    const float = g.floats[index];
    if (!float) continue;
    float.life -= dt;
    float.y += float.vy * dt;
    if (float.life <= 0) g.floats.splice(index, 1);
  }

  for (const brick of g.bricks) {
    if (brick.flash > 0) brick.flash = Math.max(0, brick.flash - dt * 4);
  }
}

function updateMotes(dt: number): void {
  for (const mote of g.motes) {
    mote.y -= mote.speed * dt;
    mote.x += Math.sin(g.time * 0.6 + mote.phase) * mote.drift * dt;
    if (mote.y < -6) {
      mote.y = g.height + 6;
      mote.x = Math.random() * g.width;
    }
    if (mote.x < -6) mote.x = g.width + 6;
    if (mote.x > g.width + 6) mote.x = -6;
  }
}

function tick(dt: number): void {
  if (g.phase === 'docked') updateAim(dt);
  updatePaddle(dt);

  if (g.phase === 'playing') {
    for (const ball of g.balls) {
      if (ball.stuck) continue;
      updateBall(ball, dt);
      if (g.phase !== 'playing') break;
    }
  }

  updateTrails(dt);
  updatePowerUps(dt);
  updateBursts(dt);

  if (g.comboTimer > 0) {
    g.comboTimer -= dt;
    if (g.comboTimer <= 0) g.combo = 0;
  }

  if (g.slowTimer > 0) {
    g.slowTimer = Math.max(0, g.slowTimer - dt);
  }

  if (g.wideTimer > 0) {
    g.wideTimer = Math.max(0, g.wideTimer - dt);
    if (g.wideTimer === 0) applyPaddleWidth();
  }

  if (g.phase === 'playing') {
    maybeClearLevel();
    if (g.phase === 'playing') checkLostBalls();
  }
}

function update(dt: number): void {
  g.time += dt;
  const ambient = g.bulletActive ? 0.5 : g.warpActive ? 1.8 : 1;
  updateMotes(dt * ambient);
  updateHud();

  if (g.phase === 'paused') return;

  updateAbilities(dt);

  const scale = timeScale();
  const simDt = dt * scale;
  g.shake = Math.max(0, g.shake - simDt * 26);
  g.flash = Math.max(0, g.flash - simDt * 1.6);
  updateEffects(simDt);

  if (g.phase === 'levelClear') {
    g.levelClearTimer -= dt;
    if (g.levelClearTimer <= 0) {
      if (g.level >= LEVELS.length - 1) endRun(true);
      else loadLevel(g.level + 1);
    }
    return;
  }

  if (g.phase === 'docked' || g.phase === 'playing') {
    g.acc = Math.min(g.acc + simDt, 0.12);
    while (g.acc >= STEP) {
      tick(STEP);
      g.acc -= STEP;
      if (g.phase !== 'docked' && g.phase !== 'playing') {
        g.acc = 0;
        break;
      }
    }
  }
}

// ── Prediction ────────────────────────────────────────────────────────────

function rayRect(
  originX: number,
  originY: number,
  directionX: number,
  directionY: number,
  rectX: number,
  rectY: number,
  rectW: number,
  rectH: number,
): number | null {
  let minimum = Number.NEGATIVE_INFINITY;
  let maximum = Number.POSITIVE_INFINITY;
  const axes: Array<[number, number, number, number]> = [
    [originX, directionX, rectX, rectX + rectW],
    [originY, directionY, rectY, rectY + rectH],
  ];
  for (const [origin, direction, low, high] of axes) {
    if (Math.abs(direction) < 1e-9) {
      if (origin < low || origin > high) return null;
      continue;
    }
    let near = (low - origin) / direction;
    let far = (high - origin) / direction;
    if (near > far) [near, far] = [far, near];
    minimum = Math.max(minimum, near);
    maximum = Math.min(maximum, far);
    if (minimum > maximum) return null;
  }
  return maximum >= Math.max(minimum, 0) ? Math.max(minimum, 0) : null;
}

function castPrediction(
  startX: number,
  startY: number,
  directionX: number,
  directionY: number,
  radius: number,
  maxBounces: number,
): Prediction {
  const magnitude = Math.hypot(directionX, directionY) || 1;
  let dx = directionX / magnitude;
  let dy = directionY / magnitude;
  let x = startX;
  let y = startY;
  const points: Point[] = [{ x, y }];
  const catches: Point[] = [];
  let hitBrick: Brick | null = null;
  let lost = false;
  let bounces = 0;
  let impacts = 0;
  const paddle = paddleRect();

  for (let bounce = 0; bounce <= maxBounces; bounce += 1) {
    let wallT = Number.POSITIVE_INFINITY;
    let wall: 'left' | 'right' | 'top' | 'bottom' | null = null;

    if (dx > 1e-6) {
      const t = (g.width - radius - x) / dx;
      if (t > 1e-4) {
        wallT = t;
        wall = 'right';
      }
    } else if (dx < -1e-6) {
      const t = (radius - x) / dx;
      if (t > 1e-4) {
        wallT = t;
        wall = 'left';
      }
    }

    if (dy > 1e-6) {
      const t = (g.height - radius - y) / dy;
      if (t > 1e-4 && t < wallT) {
        wallT = t;
        wall = 'bottom';
      }
    } else if (dy < -1e-6) {
      const t = (g.bounds.top + radius - y) / dy;
      if (t > 1e-4 && t < wallT) {
        wallT = t;
        wall = 'top';
      }
    }

    let brickT = Number.POSITIVE_INFINITY;
    let brickHit: Brick | null = null;
    for (const brick of g.bricks) {
      if (!brick.alive) continue;
      const t = rayRect(
        x,
        y,
        dx,
        dy,
        brick.x - radius,
        brick.y - radius,
        brick.w + radius * 2,
        brick.h + radius * 2,
      );
      if (t !== null && t > 1e-4 && t < brickT) {
        brickT = t;
        brickHit = brick;
      }
    }

    // The slab is a live collider in the guide: moving it recalculates the
    // return path and the catch marker before the ball ever gets there.
    let paddleT = Number.POSITIVE_INFINITY;
    let paddleHitX = 0;
    if (dy > 1e-6) {
      const top = paddle.y - radius;
      const t = (top - y) / dy;
      if (t > 1e-4) {
        const candidateX = x + dx * t;
        if (candidateX >= paddle.x - radius && candidateX <= paddle.x + paddle.w + radius) {
          paddleT = t;
          paddleHitX = candidateX;
        }
      }
    }

    if (brickHit && brickT <= wallT && brickT <= paddleT) {
      hitBrick = brickHit;
      impacts += 1;
      points.push({ x: x + dx * brickT, y: y + dy * brickT });
      break;
    }

    if (paddleT < wallT && paddleT < brickT) {
      const catchX = clamp(
        paddleHitX,
        paddle.x + radius * 0.2,
        paddle.x + paddle.w - radius * 0.2,
      );
      const catchY = paddle.y - radius - 0.5;
      const point = { x: catchX, y: catchY };
      points.push(point);
      catches.push(point);

      const relative = clamp((catchX - g.paddle.x) / (g.paddle.w / 2), -1, 1);
      const angle = relative * 1.08;
      dx = Math.sin(angle);
      dy = -Math.cos(angle);
      x = catchX + dx * 0.02;
      y = catchY + dy * 0.02;
      bounces += 1;
      continue;
    }

    if (!wall || !Number.isFinite(wallT)) break;
    const hitX = x + dx * wallT;
    const hitY = y + dy * wallT;
    points.push({ x: hitX, y: hitY });
    if (wall === 'bottom') {
      lost = true;
      break;
    }
    x = hitX;
    y = hitY;
    if (wall === 'left' || wall === 'right') dx = -dx;
    else dy = -dy;
    x += dx * 0.02;
    y += dy * 0.02;
    bounces += 1;
    impacts += 1;
  }

  return { points, hitBrick, lost, bounces, catches, impacts };
}

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

function roundedPath(
  paint: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const r = Math.max(0, Math.min(radius, width / 2, height / 2));
  paint.beginPath();
  paint.moveTo(x + r, y);
  paint.lineTo(x + width - r, y);
  paint.quadraticCurveTo(x + width, y, x + width, y + r);
  paint.lineTo(x + width, y + height - r);
  paint.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
  paint.lineTo(x + r, y + height);
  paint.quadraticCurveTo(x, y + height, x, y + height - r);
  paint.lineTo(x, y + r);
  paint.quadraticCurveTo(x, y, x + r, y);
  paint.closePath();
}

function drawMotes(): void {
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';
  for (const mote of g.motes) {
    const flicker = 0.7 + Math.sin(g.time * 2 + mote.phase) * 0.3;
    ctx.fillStyle = `rgba(255,150,80,${mote.alpha * flicker})`;
    ctx.beginPath();
    ctx.arc(mote.x, mote.y, mote.radius, 0, TAU);
    ctx.fill();
  }
  ctx.restore();
}

function drawBricks(time: number): void {
  for (const brick of g.bricks) {
    if (!brick.alive) continue;
    const colors = palette[brick.kind];
    const radius = Math.min(4, brick.h * 0.3);

    ctx.save();
    if (brick.kind === 'volatile') {
      const pulse = 0.55 + Math.sin(time * 3.4 + brick.seed) * 0.25;
      ctx.shadowColor = `rgba(90,255,170,${0.4 * pulse})`;
      ctx.shadowBlur = 9;
    }

    roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
    const fill = ctx.createLinearGradient(0, brick.y, 0, brick.y + brick.h);
    fill.addColorStop(0, colors.top);
    fill.addColorStop(1, colors.bottom);
    ctx.fillStyle = fill;
    ctx.fill();
    ctx.shadowBlur = 0;

    ctx.strokeStyle = colors.edge;
    ctx.lineWidth = Math.max(0.7, brick.h * 0.05);
    ctx.stroke();

    ctx.beginPath();
    ctx.moveTo(brick.x + radius, brick.y + Math.max(0.7, brick.h * 0.09));
    ctx.lineTo(brick.x + brick.w - radius, brick.y + Math.max(0.7, brick.h * 0.09));
    ctx.strokeStyle = colors.glint;
    ctx.lineWidth = Math.max(0.6, brick.h * 0.04);
    ctx.stroke();

    if (brick.kind === 'obsidian') {
      ctx.save();
      roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
      ctx.clip();
      ctx.strokeStyle = 'rgba(190,220,245,0.13)';
      ctx.lineWidth = Math.max(0.6, brick.h * 0.03);
      for (let x = brick.x - brick.h; x < brick.x + brick.w + brick.h; x += brick.h * 0.8) {
        ctx.beginPath();
        ctx.moveTo(x, brick.y + brick.h);
        ctx.lineTo(x + brick.h, brick.y);
        ctx.stroke();
      }
      ctx.restore();
      ctx.fillStyle = 'rgba(215,235,255,0.35)';
      ctx.beginPath();
      ctx.arc(brick.x + brick.w * 0.16, brick.y + brick.h * 0.5, Math.max(0.8, brick.h * 0.07), 0, TAU);
      ctx.arc(brick.x + brick.w * 0.84, brick.y + brick.h * 0.5, Math.max(0.8, brick.h * 0.07), 0, TAU);
      ctx.fill();
    }

    if (brick.kind === 'volatile') {
      const cx = brick.x + brick.w / 2;
      const cy = brick.y + brick.h / 2;
      const size = Math.min(brick.w, brick.h) * 0.22;
      const pulse = 0.7 + Math.sin(time * 5 + brick.seed) * 0.3;
      ctx.fillStyle = `rgba(200,255,225,${0.7 + pulse * 0.3})`;
      ctx.beginPath();
      ctx.moveTo(cx, cy - size);
      ctx.lineTo(cx + size, cy);
      ctx.lineTo(cx, cy + size);
      ctx.lineTo(cx - size, cy);
      ctx.closePath();
      ctx.fill();
    }

    if (brick.maxHp > 1 && brick.maxHp < 10) {
      const pips = Math.max(1, brick.maxHp);
      const pipSize = Math.max(0.8, brick.h * 0.07);
      for (let pip = 0; pip < pips; pip += 1) {
        ctx.fillStyle =
          pip < brick.hp ? 'rgba(255,235,210,0.75)' : 'rgba(0,0,0,0.35)';
        ctx.fillRect(
          brick.x + brick.w * 0.14 + pip * pipSize * 2.6,
          brick.y + brick.h - pipSize * 2.2,
          pipSize,
          pipSize,
        );
      }
    }

    const damage = 1 - brick.hp / Math.max(1, brick.maxHp);
    if (damage > 0.05) {
      const cracks = Math.min(4, Math.ceil(damage * 4));
      ctx.strokeStyle = 'rgba(8,5,8,0.6)';
      ctx.lineWidth = Math.max(0.6, brick.h * 0.035);
      for (let crack = 0; crack < cracks; crack += 1) {
        const seed = brick.seed + crack * 17;
        const startX = brick.x + brick.w * (0.2 + random(seed) * 0.6);
        const startY = brick.y + brick.h * (0.2 + random(seed + 3) * 0.6);
        const angle = random(seed + 7) * TAU;
        const length = brick.w * (0.18 + random(seed + 9) * 0.22);
        ctx.beginPath();
        ctx.moveTo(startX, startY);
        ctx.lineTo(startX + Math.cos(angle) * length, startY + Math.sin(angle) * length * 0.65);
        ctx.stroke();
      }
    }

    if (brick.flash > 0) {
      roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
      ctx.fillStyle = `rgba(255,245,230,${brick.flash * 0.8})`;
      ctx.fill();
    }

    ctx.restore();
  }
}

function drawPrediction(time: number): void {
  if (!g.guide) return;
  if (g.phase !== 'docked' && g.phase !== 'playing') return;
  if (g.balls.length === 0) return;

  const docked = g.phase === 'docked';
  const balls = docked ? g.balls.slice(0, 1) : g.balls.slice(0, 2);

  balls.forEach((ball, index) => {
    if (!ball) return;
    let directionX: number;
    let directionY: number;
    if (docked || ball.stuck) {
      const angle = g.aimT * AIM_MAX;
      directionX = Math.sin(angle);
      directionY = -Math.cos(angle);
    } else {
      const speed = Math.hypot(ball.vx, ball.vy);
      if (speed < 0.001) return;
      directionX = ball.vx / speed;
      directionY = ball.vy / speed;
    }

    const prediction = castPrediction(
      ball.x,
      ball.y,
      directionX,
      directionY,
      ball.r,
      docked ? 5 : 3,
    );
    const alpha = docked ? 0.8 : index === 0 ? 0.32 : 0.16;
    const color = docked ? '255,186,105' : '120,215,245';
    const catchColor = docked ? '125,255,190' : '110,245,200';

    ctx.save();
    ctx.lineCap = 'round';
    ctx.setLineDash(docked ? [5, 7] : [3, 9]);
    for (let segment = 0; segment < prediction.points.length - 1; segment += 1) {
      const from = prediction.points[segment];
      const to = prediction.points[segment + 1];
      if (!from || !to) continue;
      ctx.beginPath();
      ctx.moveTo(from.x, from.y);
      ctx.lineTo(to.x, to.y);
      ctx.strokeStyle = `rgba(${color},${alpha * 0.18})`;
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.strokeStyle = `rgba(${color},${alpha})`;
      ctx.lineWidth = 1;
      ctx.stroke();
    }
    ctx.setLineDash([]);

    for (let point = 1; point < prediction.points.length - 1; point += 1) {
      const bounce = prediction.points[point];
      if (!bounce) continue;
      const isCatch = prediction.catches.some(
        (entry) => Math.abs(entry.x - bounce.x) < 0.5 && Math.abs(entry.y - bounce.y) < 0.5,
      );
      if (isCatch) continue;
      ctx.fillStyle = `rgba(${color},${alpha * 0.55})`;
      ctx.beginPath();
      ctx.arc(bounce.x, bounce.y, 1.6, 0, TAU);
      ctx.fill();
    }

    for (const catchPoint of prediction.catches) {
      const pulse = 1 + Math.sin(time * 5) * 0.18;
      ctx.strokeStyle = `rgba(${catchColor},${alpha})`;
      ctx.fillStyle = `rgba(${catchColor},${alpha * 0.12})`;
      ctx.lineWidth = 1.2;

      ctx.beginPath();
      ctx.arc(catchPoint.x, catchPoint.y, ball.r * 1.9 * pulse, 0, TAU);
      ctx.fill();
      ctx.stroke();

      ctx.beginPath();
      ctx.moveTo(catchPoint.x, catchPoint.y - ball.r * 2.2);
      ctx.lineTo(catchPoint.x, catchPoint.y - ball.r * 6);
      ctx.stroke();

      ctx.fillStyle = `rgba(${catchColor},${Math.min(1, alpha + 0.12)})`;
      ctx.fillRect(
        catchPoint.x - Math.max(1, ball.r * 0.35),
        g.paddle.y - 2,
        Math.max(2, ball.r * 0.7),
        g.paddle.h + 4,
      );

      ctx.beginPath();
      ctx.arc(catchPoint.x, catchPoint.y, Math.max(1, ball.r * 0.42), 0, TAU);
      ctx.fillStyle = `rgba(${catchColor},${alpha})`;
      ctx.fill();
    }

    const end = prediction.points[prediction.points.length - 1];
    if (end && prediction.hitBrick) {
      const pulse = 1 + Math.sin(time * 6) * 0.15;
      ctx.strokeStyle = `rgba(${color},${alpha})`;
      ctx.lineWidth = 1.2;
      ctx.beginPath();
      ctx.arc(end.x, end.y, (docked ? 5 : 3.5) * pulse, 0, TAU);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(end.x, end.y, 1.4, 0, TAU);
      ctx.fillStyle = `rgba(${color},${alpha})`;
      ctx.fill();
    } else if (end && prediction.lost) {
      ctx.strokeStyle = `rgba(255,90,80,${alpha + 0.1})`;
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.moveTo(end.x - 3, end.y - 3);
      ctx.lineTo(end.x + 3, end.y + 3);
      ctx.moveTo(end.x + 3, end.y - 3);
      ctx.lineTo(end.x - 3, end.y + 3);
      ctx.stroke();
    }

    ctx.restore();
  });
}

function drawPowerUps(time: number): void {
  for (const power of g.powerups) {
    const colors = powerColors[power.kind];
    const size = clamp(g.width * 0.018, 6.5, 11);
    const y = power.y + Math.sin(time * 5 + power.spin) * 2;
    ctx.save();
    ctx.translate(power.x, y);
    ctx.rotate(Math.PI / 4);
    ctx.shadowColor = colors.glow;
    ctx.shadowBlur = 10;
    const fill = ctx.createLinearGradient(-size, -size, size, size);
    fill.addColorStop(0, '#ffffff');
    fill.addColorStop(0.35, colors.core);
    fill.addColorStop(1, '#0a1210');
    ctx.fillStyle = fill;
    ctx.fillRect(-size / 2, -size / 2, size, size);
    ctx.shadowBlur = 0;
    ctx.strokeStyle = 'rgba(255,255,255,0.65)';
    ctx.lineWidth = 0.8;
    ctx.strokeRect(-size / 2, -size / 2, size, size);
    ctx.restore();

    ctx.fillStyle = '#061014';
    ctx.font = `700 ${Math.round(size * 1.15)}px ui-monospace, "SFMono-Regular", Menlo, monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(colors.label, power.x, y + 0.5);
  }
}

function drawParticles(): void {
  for (const particle of g.particles) {
    const alpha = clamp(particle.life / particle.maxLife, 0, 1);
    ctx.save();
    ctx.globalAlpha = alpha;
    if (particle.kind === 'spark') {
      ctx.strokeStyle = `rgb(${particle.color})`;
      ctx.lineWidth = particle.size;
      ctx.beginPath();
      ctx.moveTo(particle.x, particle.y);
      ctx.lineTo(particle.x - particle.vx * 0.018, particle.y - particle.vy * 0.018);
      ctx.stroke();
    } else {
      ctx.translate(particle.x, particle.y);
      ctx.rotate(particle.rotation);
      ctx.fillStyle = `rgb(${particle.color})`;
      ctx.fillRect(-particle.size / 2, -particle.size / 2, particle.size, particle.size * 0.7);
    }
    ctx.restore();
  }
}

function drawRings(): void {
  for (const ring of g.rings) {
    const progress = 1 - ring.life / ring.maxLife;
    const radius = ring.from + (ring.to - ring.from) * progress;
    const alpha = Math.max(0, 1 - progress);
    ctx.save();
    ctx.strokeStyle = `rgba(${ring.color},${alpha * 0.85})`;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(ring.x, ring.y, radius, 0, TAU);
    ctx.stroke();
    ctx.restore();
  }
}

function drawFloats(): void {
  for (const float of g.floats) {
    const alpha = clamp(float.life / float.maxLife, 0, 1);
    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.fillStyle = float.color;
    ctx.font = `700 ${Math.round(float.size)}px ui-monospace, "SFMono-Regular", Menlo, monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(float.text, float.x, float.y);
    ctx.restore();
  }
}

function drawPaddle(): void {
  const rect = paddleRect();
  const wide = g.wideTimer > 0;
  ctx.save();
  const body = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);
  if (wide) {
    body.addColorStop(0, '#bdf5ff');
    body.addColorStop(0.5, '#4fbfe0');
    body.addColorStop(1, '#123746');
  } else {
    body.addColorStop(0, '#e6c3a2');
    body.addColorStop(0.42, '#8c5a3c');
    body.addColorStop(1, '#2a1712');
  }
  roundedPath(ctx, rect.x, rect.y, rect.w, rect.h, rect.h * 0.45);
  ctx.fillStyle = body;
  ctx.fill();
  ctx.strokeStyle = wide ? 'rgba(150,240,255,0.8)' : 'rgba(255,190,130,0.75)';
  ctx.lineWidth = 1;
  ctx.stroke();

  const seam = ctx.createLinearGradient(rect.x, 0, rect.x + rect.w, 0);
  seam.addColorStop(0, 'rgba(255,120,40,0)');
  seam.addColorStop(0.5, wide ? 'rgba(170,245,255,0.95)' : 'rgba(255,150,60,0.95)');
  seam.addColorStop(1, 'rgba(255,120,40,0)');
  ctx.fillStyle = seam;
  ctx.fillRect(rect.x + rect.w * 0.1, rect.y + rect.h * 0.42, rect.w * 0.8, Math.max(1, rect.h * 0.16));

  ctx.fillStyle = wide ? 'rgba(200,250,255,0.65)' : 'rgba(255,220,170,0.55)';
  ctx.fillRect(rect.x + rect.w * 0.07, rect.y + rect.h * 0.25, rect.w * 0.03, rect.h * 0.5);
  ctx.fillRect(rect.x + rect.w * 0.9, rect.y + rect.h * 0.25, rect.w * 0.03, rect.h * 0.5);
  ctx.restore();
}

function drawBalls(): void {
  for (const ball of g.balls) {
    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    for (let index = 0; index < ball.trail.length; index += 1) {
      const point = ball.trail[index];
      if (!point) continue;
      const alpha = Math.max(0, point.life) * 0.38;
      const radius = ball.r * (0.35 + Math.max(0, point.life) * 0.65);
      ctx.fillStyle = `rgba(255,120,45,${alpha})`;
      ctx.beginPath();
      ctx.arc(point.x, point.y, radius, 0, TAU);
      ctx.fill();
    }
    ctx.restore();

    const glow = ctx.createRadialGradient(ball.x, ball.y, ball.r * 0.1, ball.x, ball.y, ball.r * 2.6);
    glow.addColorStop(0, 'rgba(255,250,230,1)');
    glow.addColorStop(0.25, 'rgba(255,200,120,0.95)');
    glow.addColorStop(0.62, 'rgba(255,110,40,0.4)');
    glow.addColorStop(1, 'rgba(255,80,20,0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r * 2.6, 0, TAU);
    ctx.fill();

    ctx.fillStyle = '#fff4dd';
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r * 0.68, 0, TAU);
    ctx.fill();

    if (ball.stuck) {
      const pulse = 0.5 + Math.sin(g.time * 4) * 0.2;
      ctx.strokeStyle = `rgba(255,190,110,${pulse})`;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.r * 2.2, 0, TAU);
      ctx.stroke();
    }

    if (g.blastTimer > 0) {
      const pulse = 0.6 + Math.sin(g.time * 8) * 0.18;
      ctx.strokeStyle = `rgba(255,170,85,${pulse})`;
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.r * 2.7, 0, TAU);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(255,215,150,0.9)';
      ctx.lineWidth = 1.2;
      ctx.beginPath();
      ctx.arc(
        ball.x,
        ball.y,
        ball.r * 3.2,
        -Math.PI / 2,
        -Math.PI / 2 + TAU * clamp(g.blastTimer / 12, 0, 1),
      );
      ctx.stroke();
    } else if (g.warpReady && ball === g.balls[0] && !ball.stuck) {
      const pulse = 0.45 + Math.sin(g.time * 6) * 0.15;
      ctx.strokeStyle = `rgba(87,242,168,${pulse})`;
      ctx.lineWidth = 1;
      ctx.setLineDash([3, 4]);
      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.r * 2.4, 0, TAU);
      ctx.stroke();
      ctx.setLineDash([]);
    }
  }
}

function render(time: number): void {
  if (!g.hasSized) return;
  ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);
  ctx.clearRect(0, 0, g.width, g.height);

  const shakeEnabled = g.settings.screenShake && !prefersReducedMotion();
  const shake = shakeEnabled ? Math.min(g.shake, 14) : 0;

  ctx.save();
  if (shake > 0.1) {
    ctx.translate((Math.random() * 2 - 1) * shake, (Math.random() * 2 - 1) * shake);
  }

  if (g.bg) ctx.drawImage(g.bg, 0, 0, g.width, g.height);
  else {
    ctx.fillStyle = '#08060a';
    ctx.fillRect(0, 0, g.width, g.height);
  }

  drawMotes();
  drawBricks(time);
  drawPrediction(time);
  drawPowerUps(time);
  drawParticles();
  drawPaddle();
  drawBalls();
  drawRings();
  drawFloats();
  ctx.restore();

  if (g.bulletActive) {
    const centerX = g.width / 2;
    const centerY = g.height / 2;
    const tint = ctx.createRadialGradient(
      centerX,
      centerY,
      Math.min(g.width, g.height) * 0.24,
      centerX,
      centerY,
      Math.max(g.width, g.height) * 0.74,
    );
    tint.addColorStop(0, 'rgba(70,170,235,0)');
    tint.addColorStop(0.7, 'rgba(70,170,235,0.05)');
    tint.addColorStop(1, 'rgba(120,225,255,0.18)');
    ctx.fillStyle = tint;
    ctx.fillRect(0, 0, g.width, g.height);
  }

  if (g.warpActive) {
    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    ctx.lineWidth = 1.6;
    for (let index = 0; index < 10; index += 1) {
      const left = index % 2 === 0;
      const travel = (g.time * 900 + index * 91) % (g.height + 90);
      const y = travel - 45;
      const length = 28 + (index % 4) * 18;
      const startX = left ? 0 : g.width;
      const endX = left ? length * 2.2 : g.width - length * 2.2;
      const streak = ctx.createLinearGradient(startX, y, endX, y);
      streak.addColorStop(0, 'rgba(255,175,95,0.28)');
      streak.addColorStop(1, 'rgba(255,175,95,0)');
      ctx.strokeStyle = streak;
      ctx.beginPath();
      ctx.moveTo(startX, y);
      ctx.lineTo(endX, y);
      ctx.stroke();
    }
    ctx.restore();
  }

  if (g.flash > 0) {
    ctx.fillStyle = `rgba(255,120,55,${g.flash * 0.16})`;
    ctx.fillRect(0, 0, g.width, g.height);
  }
}

// ── Input ─────────────────────────────────────────────────────────────────

function pointerToPaddle(event: PointerEvent): void {
  const rect = elements.canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  g.paddle.targetX = clamp(x, g.paddle.w / 2, g.width - g.paddle.w / 2);
}

function bindInput(): void {
  elements.canvas.addEventListener('pointermove', (event) => {
    pointerToPaddle(event);
  });

  elements.canvas.addEventListener('pointerdown', (event) => {
    audio.unlock();
    pointerToPaddle(event);
    try {
      elements.canvas.setPointerCapture(event.pointerId);
    } catch {
      // Pointer capture is a convenience, not a requirement.
    }
    if (event.pointerType === 'mouse' && g.phase === 'docked') {
      launch();
    } else if (event.pointerType !== 'mouse') {
      g.pendingLaunch = true;
    }
    event.preventDefault();
  });

  elements.canvas.addEventListener('pointerup', (event) => {
    if (g.pendingLaunch && g.phase === 'docked') launch();
    g.pendingLaunch = false;
    event.preventDefault();
  });

  elements.canvas.addEventListener('pointercancel', () => {
    g.pendingLaunch = false;
  });

  elements.canvas.addEventListener('pointerleave', () => {
    g.pendingLaunch = false;
  });

  elements.canvas.addEventListener('contextmenu', (event) => event.preventDefault());

  window.addEventListener('keydown', (event) => {
    const key = event.key.toLowerCase();
    if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', ' '].includes(key)) {
      event.preventDefault();
    }
    if (key === 'arrowleft' || key === 'a') {
      g.keys.left = true;
      audio.unlock();
    } else if (key === 'arrowright' || key === 'd') {
      g.keys.right = true;
      audio.unlock();
    } else if (key === ' ' || key === 'enter') {
      audio.unlock();
      if (g.phase === 'docked') launch();
      else if (g.phase === 'paused') resume();
      else if (g.phase === 'gameOver' || g.phase === 'victory') startRun();
    } else if (key === 'p' || key === 'escape') {
      togglePause();
    } else if (key === 'r') {
      startRun();
    } else if (key === 'g') {
      toggleGuide();
    } else if (key === 'm') {
      toggleSound();
    } else if (key === 'b') {
      audio.unlock();
      toggleBulletTime();
    } else if (key === 'f') {
      audio.unlock();
      activateWarp();
    }
  });

  window.addEventListener('keyup', (event) => {
    const key = event.key.toLowerCase();
    if (key === 'arrowleft' || key === 'a') g.keys.left = false;
    if (key === 'arrowright' || key === 'd') g.keys.right = false;
  });

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

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

  elements.guideButton.addEventListener('click', () => {
    toggleGuide();
    audio.unlock();
  });
  elements.soundButton.addEventListener('click', () => {
    toggleSound();
  });
  elements.warpButton.addEventListener('click', () => {
    audio.unlock();
    activateWarp();
  });
  elements.bulletButton.addEventListener('click', () => {
    audio.unlock();
    toggleBulletTime();
  });
  elements.pauseButton.addEventListener('click', () => {
    togglePause();
    audio.unlock();
  });
}

function toggleGuide(): void {
  g.guide = !g.guide;
  updateGuideButton();
  showToast(g.guide ? 'Bounce prediction on' : 'Bounce prediction off');
}

function toggleSound(): void {
  g.soundOn = !g.soundOn;
  audio.setMuted(!g.soundOn);
  updateSoundButton();
  showToast(g.soundOn ? 'Sound on' : 'Sound muted');
}

// ── Boot ──────────────────────────────────────────────────────────────────

const stopSettings = onGameSettings((settings) => {
  g.settings = settings;
  g.guide = settings.showGuide;
  updateGuideButton();
});

window.addEventListener('pagehide', stopSettings, { once: true });

g.bricks = buildBricks(0);
resizeCanvas();
resetDocked();
g.phase = 'docked';
g.lives = livesForDifficulty();
updateHud();
updateGuideButton();
updateSoundButton();
updatePauseButton();
updateAbilityHud();
bindInput();
void loadBest();
showBanner(
  `SEAM 01 / ${String(LEVELS.length).padStart(2, '0')}`,
  LEVELS[0]?.name.toUpperCase() ?? 'ASHFALL',
  1900,
);
announce('Obsidian Breaker. Drag to move, tap or press Space to launch. B slows time, F warps.');

if (typeof ResizeObserver !== 'undefined') {
  const observer = new ResizeObserver(() => resizeCanvas());
  observer.observe(elements.app);
}
window.addEventListener('resize', resizeCanvas);

let lastFrame = 0;
function frame(now: number): void {
  requestAnimationFrame(frame);
  if (lastFrame === 0) lastFrame = now;
  const dt = clamp((now - lastFrame) / 1000, 0.001, 0.05);
  lastFrame = now;
  update(dt);
  render(now / 1000);
}

requestAnimationFrame(frame);