hypeorcode
Network offOpen alone (new tab)

Run details

Model
claude-fable-5-1
Route
Claude Code CLI
Reasoning
provider default
Limits
60 min · 128,000 output tokens
Date
21 Oct 2026
Time
5 min 38 s
Size
29 kB
Tokens
98 in, 28,396 out
List price
$1.42 ($10 in / $50 out per M)
Paid
Not billed per run (CLI plan)

Raw files: output · meta.json

Exact prompt

Make a Breakout game I can play right now with the keyboard or the mouse. Give it several levels, a score and some juice.

It must be one complete, self-contained HTML file: inline all CSS and JavaScript, and make no network requests of any kind (no CDNs, web fonts or remote images). Save it as index.html in the current directory, then stop. Do not ask questions.

Source

29 kB 756 lines Raw

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Breakout</title>
<style>
  html, body { margin:0; height:100%; background:#070914; overflow:hidden;
    font-family: ui-monospace, Menlo, Consolas, "Courier New", monospace; color:#e8ecff; user-select:none; -webkit-user-select:none; }
  #wrap { position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
    background: radial-gradient(ellipse at 50% 30%, #141a33 0%, #070914 70%); }
  canvas { display:block; border-radius:8px; box-shadow:0 0 80px rgba(90,120,255,.22), 0 0 0 1px rgba(255,255,255,.06); touch-action:none; }
</style>
</head>
<body>
<div id="wrap"><canvas id="c" width="800" height="600"></canvas></div>
<script>
(() => {
'use strict';
const W = 800, H = 600;
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');

function fit() {
  const s = Math.min(innerWidth / W, innerHeight / H) * 0.98;
  cv.style.width = Math.floor(W * s) + 'px';
  cv.style.height = Math.floor(H * s) + 'px';
}
addEventListener('resize', fit); fit();

const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const rnd = (a, b) => a + Math.random() * (b - a);

/* ---------------- Audio (synthesized, no assets) ---------------- */
let AC = null;
function audio() {
  if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { AC = null; } }
  if (AC && AC.state === 'suspended') AC.resume();
}
function beep(freq, dur, type, vol, slide) {
  if (!AC) return;
  type = type || 'square'; vol = vol == null ? 0.07 : vol; slide = slide || 0;
  const t = AC.currentTime;
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = type;
  o.frequency.setValueAtTime(freq, t);
  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  o.connect(g); g.connect(AC.destination);
  o.start(t); o.stop(t + dur + 0.03);
}
const sfx = {
  paddle() { beep(200, 0.09, 'square', 0.07, 160); },
  brick(c) { beep(380 + Math.min(c, 12) * 55, 0.08, 'square', 0.06, 220); },
  crack()  { beep(260, 0.06, 'triangle', 0.06, -60); },
  wall()   { beep(150, 0.05, 'triangle', 0.045); },
  steel()  { beep(110, 0.12, 'sawtooth', 0.05, -50); },
  lose()   { beep(320, 0.6, 'sawtooth', 0.1, -280); },
  power()  { beep(520, 0.14, 'sine', 0.09, 420); setTimeout(() => beep(820, 0.22, 'sine', 0.09, 320), 90); },
  launch() { beep(280, 0.14, 'sine', 0.08, 360); },
  life()   { [0, 90, 180].forEach((d, i) => setTimeout(() => beep(620 + i * 180, 0.16, 'sine', 0.08), d)); },
  clear()  { [0, 110, 220, 330, 440].forEach((d, i) => setTimeout(() => beep(392 * Math.pow(1.19, i), 0.26, 'square', 0.07), d)); },
  over()   { [0, 250, 500].forEach((d, i) => setTimeout(() => beep(300 - i * 70, 0.45, 'sawtooth', 0.08, -60), d)); },
};

/* ---------------- Levels ---------------- */
/* '.' empty  '1'-'6' colored (1 hit)  'H' hard (2 hits)  'X' steel (unbreakable) */
const LEVELS = [
  { name: 'FIRST STEPS', rows: [
    '..............',
    '.111111111111.',
    '.222222222222.',
    '.333333333333.',
    '.444444444444.',
  ]},
  { name: 'CHECKERS', rows: [
    '1.1.1.1.1.1.1.',
    '.2.2.2.2.2.2.2',
    '3.3.3.3.3.3.3.',
    '.4.4.4.4.4.4.4',
    '5.5.5.5.5.5.5.',
    '.6.6.6.6.6.6.6',
  ]},
  { name: 'FORTRESS', rows: [
    '..HHHHHHHHHH..',
    '..H........H..',
    '..H.X2222X.H..',
    '..H.2.33.2.H..',
    '..H.X2222X.H..',
    '..H........H..',
    '..HHHHHHHHHH..',
  ]},
  { name: 'DIAMOND', rows: [
    '......11......',
    '.....1221.....',
    '....123321....',
    '...12344321...',
    '..1234566321..',
    '...12344321...',
    '....123321....',
    '.....1221.....',
    '......11......',
  ]},
  { name: 'PILLARS', rows: [
    'X.HH.X..X.HH.X',
    'X.11.X..X.11.X',
    'X.22.X..X.22.X',
    'X.33.X..X.33.X',
    'X.44.X..X.44.X',
    '..............',
    '..5555..5555..',
    '...66....66...',
  ]},
  { name: 'INVADER', rows: [
    '...4......4...',
    '....4....4....',
    '...44444444...',
    '..44.4444.44..',
    '.444444444444.',
    '.4.4444444H.4.',
    '.4.4......4.4.',
    '....55..55....',
  ]},
  { name: 'THE VAULT', rows: [
    'HHHHHHHHHHHHHH',
    'H6X5X4X3X2X1XH',
    'H.6.5.4.3.2.1H',
    'HX6X5X4X3X2X1H',
    'H............H',
    'HH..HH..HH..HH',
    '..............',
    '.1.2.3.4.5.6..',
  ]},
];

const COLS = 14, BW = 52, BH = 22, GAP = 4;
const GX = (W - (COLS * BW + (COLS - 1) * GAP)) / 2, GY = 78;
const COLORS = { '1': '#ff4d6d', '2': '#ff9f43', '3': '#ffd93d', '4': '#5ee07a', '5': '#4dd0ff', '6': '#b388ff' };

/* ---------------- State ---------------- */
const state = { mode: 'title', level: 0, score: 0, lives: 3, hi: 0, combo: 0, time: 0 };
let bricks = [], balls = [], drops = [], parts = [], texts = [];
const paddle = { x: W / 2 - 50, y: H - 42, w: 100, h: 14, squash: 0 };
let shake = 0, flash = 0, flashColor = '#ffffff';
let effects = { wide: 0, slow: 0, fire: 0 };
let levelStart = 0, clearAt = 0, prevMode = 'playing', clearBonus = 0;
const keys = new Set();
const mouse = { x: W / 2, active: false };

try { state.hi = parseInt(localStorage.getItem('breakout.hi') || '0', 10) || 0; } catch (e) {}
function saveHi() { try { localStorage.setItem('breakout.hi', String(state.hi)); } catch (e) {} }

/* ---------------- Setup helpers ---------------- */
function buildLevel(i) {
  bricks = [];
  LEVELS[i].rows.forEach((row, r) => {
    [...row].forEach((ch, c) => {
      if (ch === '.') return;
      const b = { x: GX + c * (BW + GAP), y: GY + r * (BH + GAP), w: BW, h: BH, ch,
        hp: 1, color: COLORS[ch] || '#9aa3b5', steel: false, flash: 0, alive: true,
        delay: r * 0.06 + c * 0.025 };
      if (ch === 'H') { b.hp = 2; b.color = '#aab3c5'; }
      if (ch === 'X') { b.steel = true; b.color = '#3a4256'; }
      b.maxhp = b.hp;
      bricks.push(b);
    });
  });
}
function speedFor() {
  let s = 330 + state.level * 38;
  if (effects.slow > state.time) s *= 0.65;
  return s;
}
function newBall(x, y, stuck) { return { x, y, r: 7, vx: 0, vy: 0, stuck, trail: [] }; }
function resetBall() { balls = [newBall(paddle.x + paddle.w / 2, paddle.y - 8, true)]; }

function startGame() {
  state.score = 0; state.lives = 3; state.combo = 0; state.time = 0;
  parts = []; texts = [];
  loadLevel(0);
}
function loadLevel(i) {
  state.level = i;
  buildLevel(i);
  drops = [];
  effects = { wide: 0, slow: 0, fire: 0 };
  paddle.w = 100; paddle.x = clamp(paddle.x, 0, W - paddle.w);
  resetBall();
  levelStart = state.time;
  state.combo = 0;
  state.mode = 'playing';
}
function launchBalls() {
  if (state.time - levelStart < 0.7) return;
  let any = false;
  for (const b of balls) {
    if (!b.stuck) continue;
    any = true;
    b.stuck = false;
    const a = -Math.PI / 2 + rnd(-0.45, 0.45);
    const sp = speedFor();
    b.vx = Math.cos(a) * sp; b.vy = Math.sin(a) * sp;
  }
  if (any) { sfx.launch(); spawnParts(paddle.x + paddle.w / 2, paddle.y, 10, '#ffffff', 120, 2); }
}
function loseLife() {
  state.lives--;
  state.combo = 0;
  shake = 14; flash = 0.5; flashColor = '#ff3355';
  effects = { wide: 0, slow: 0, fire: 0 };
  if (state.lives < 0) {
    sfx.over();
    state.mode = 'gameover';
    state.hi = Math.max(state.hi, state.score); saveHi();
  } else {
    sfx.lose();
    addText(W / 2, H / 2, state.lives === 0 ? 'LAST BALL!' : 'BALL LOST', '#ff6b81', 26);
    resetBall();
  }
}

/* ---------------- Juice helpers ---------------- */
function spawnParts(x, y, n, color, speed, size) {
  size = size || 3;
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2, v = speed * rnd(0.3, 1.1);
    const life = rnd(0.35, 0.8);
    parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.2, life, max: life, color, size: size * rnd(0.5, 1.3), rot: rnd(0, 6.28), spin: rnd(-8, 8) });
  }
}
function addText(x, y, text, color, size) {
  texts.push({ x, y, text, color, size: size || 16, life: 1.1, max: 1.1, vy: -45 });
}
const DROP_TYPES = [
  { t: 'W', color: '#4dd0ff', label: 'WIDE PADDLE' },
  { t: 'M', color: '#b388ff', label: 'MULTIBALL' },
  { t: 'S', color: '#5ee07a', label: 'SLOW BALL' },
  { t: 'F', color: '#ff9f43', label: 'FIREBALL' },
  { t: 'L', color: '#ff4d6d', label: 'EXTRA LIFE' },
];
function maybeDrop(k) {
  if (Math.random() > 0.16) return;
  let pool = DROP_TYPES;
  if (state.lives >= 5) pool = pool.filter(d => d.t !== 'L');
  const d = pool[Math.floor(Math.random() * pool.length)];
  drops.push({ x: k.x + k.w / 2, y: k.y + k.h / 2, type: d, vy: 40, wob: rnd(0, 6.28) });
}
function applyDrop(d) {
  const t = d.type.t;
  sfx.power();
  shake = Math.max(shake, 4);
  flash = 0.25; flashColor = d.type.color;
  addText(paddle.x + paddle.w / 2, paddle.y - 30, d.type.label, d.type.color, 20);
  spawnParts(d.x, paddle.y, 18, d.type.color, 160, 3);
  state.score += 50;
  if (t === 'W') effects.wide = state.time + 12;
  else if (t === 'S') effects.slow = state.time + 8;
  else if (t === 'F') effects.fire = state.time + 7;
  else if (t === 'L') { state.lives++; sfx.life(); }
  else if (t === 'M') {
    const src = balls.find(b => !b.stuck) || balls[0];
    if (!src) return;
    const sp = speedFor();
    const base = src.stuck ? -Math.PI / 2 : Math.atan2(src.vy, src.vx);
    for (const off of [-0.55, 0.55]) {
      if (balls.length >= 6) break;
      const nb = newBall(src.x, src.y, false);
      nb.vx = Math.cos(base + off) * sp; nb.vy = Math.sin(base + off) * sp;
      if (nb.vy > -sp * 0.2) { nb.vy = -Math.abs(nb.vy) - sp * 0.2; }
      balls.push(nb);
    }
  }
}

/* ---------------- Collision ---------------- */
function hitBrick(k, b, sp) {
  const fire = effects.fire > state.time && !k.steel;
  const kcx = k.x + k.w / 2, kcy = k.y + k.h / 2;
  if (!fire) {
    const ox = (b.r + k.w / 2) - Math.abs(b.x - kcx);
    const oy = (b.r + k.h / 2) - Math.abs(b.y - kcy);
    if (ox < oy) {
      const left = b.x < kcx;
      b.vx = left ? -Math.abs(b.vx) : Math.abs(b.vx);
      b.x += left ? -ox : ox;
    } else {
      const above = b.y < kcy;
      b.vy = above ? -Math.abs(b.vy) : Math.abs(b.vy);
      b.y += above ? -oy : oy;
    }
    // keep the ball from getting too horizontal
    const minVy = sp * 0.18;
    if (Math.abs(b.vy) < minVy) { b.vy = (b.vy < 0 ? -1 : 1) * minVy; const m = Math.hypot(b.vx, b.vy); b.vx = b.vx / m * sp; b.vy = b.vy / m * sp; }
  }
  if (k.steel) {
    sfx.steel(); k.flash = 1; shake = Math.max(shake, 3);
    spawnParts(b.x, b.y, 6, '#cfd6e6', 140, 2);
    return;
  }
  k.hp--; k.flash = 1;
  if (k.hp > 0) {
    sfx.crack(); shake = Math.max(shake, 2);
    spawnParts(b.x, b.y, 8, k.color, 120, 2.5);
    state.score += 5;
    return;
  }
  k.alive = false;
  state.combo++;
  const mult = Math.min(state.combo, 8);
  const pts = 10 * k.maxhp * mult;
  state.score += pts;
  sfx.brick(state.combo);
  shake = Math.max(shake, fire ? 5 : 4);
  flash = Math.max(flash, 0.12); flashColor = k.color;
  spawnParts(kcx, kcy, 16, k.color, 190, 4);
  spawnParts(kcx, kcy, 6, '#ffffff', 90, 2);
  addText(kcx, kcy, '+' + pts + (mult > 1 ? '  x' + mult : ''), mult > 1 ? '#ffd93d' : '#ffffff', mult > 3 ? 20 : 15);
  maybeDrop(k);
  if (!bricks.some(q => q.alive && !q.steel)) {
    state.mode = 'clear'; clearAt = state.time;
    clearBonus = 500 + 100 * Math.max(0, state.lives) + 50 * state.level;
    state.score += clearBonus;
    sfx.clear();
    shake = 10; flash = 0.6; flashColor = '#ffffff';
    for (let i = 0; i < 60; i++) spawnParts(rnd(100, W - 100), rnd(80, 300), 1, COLORS[String(1 + i % 6)], 240, 4);
  }
}

/* ---------------- Update ---------------- */
function updateFx(dt) {
  shake = Math.max(0, shake - dt * 40);
  flash = Math.max(0, flash - dt * 2.4);
  paddle.squash = Math.max(0, paddle.squash - dt * 6);
  for (const p of parts) { p.life -= dt; p.vy += 520 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= (1 - 1.5 * dt); p.rot += p.spin * dt; }
  parts = parts.filter(p => p.life > 0);
  for (const t of texts) { t.life -= dt; t.y += t.vy * dt; t.vy *= (1 - 2 * dt); }
  texts = texts.filter(t => t.life > 0);
  for (const k of bricks) if (k.flash > 0) k.flash = Math.max(0, k.flash - dt * 5);
}

function update(dt) {
  state.time += dt;
  updateFx(dt);

  // paddle size (centered growth)
  const targetW = effects.wide > state.time ? 160 : 100;
  if (Math.abs(paddle.w - targetW) > 0.5) {
    const cx = paddle.x + paddle.w / 2;
    paddle.w += (targetW - paddle.w) * Math.min(1, dt * 10);
    paddle.x = cx - paddle.w / 2;
  }
  // paddle movement
  const kdir = ((keys.has('ArrowRight') || keys.has('KeyD')) ? 1 : 0) - ((keys.has('ArrowLeft') || keys.has('KeyA')) ? 1 : 0);
  if (kdir) { paddle.x += kdir * 680 * dt; mouse.active = false; }
  else if (mouse.active) { paddle.x += (mouse.x - paddle.w / 2 - paddle.x) * Math.min(1, dt * 30); }
  paddle.x = clamp(paddle.x, 0, W - paddle.w);

  if (state.mode !== 'playing') {
    if (state.mode === 'clear' && state.time - clearAt > 2.4) {
      if (state.level + 1 < LEVELS.length) loadLevel(state.level + 1);
      else { state.mode = 'win'; state.hi = Math.max(state.hi, state.score); saveHi(); }
    }
    return;
  }

  // balls
  const sp = speedFor();
  for (const b of balls) {
    if (b.stuck) { b.x = paddle.x + paddle.w / 2; b.y = paddle.y - b.r - 1; b.trail.length = 0; continue; }
    const m = Math.hypot(b.vx, b.vy) || 1;
    b.vx = b.vx / m * sp; b.vy = b.vy / m * sp;
    const steps = Math.max(1, Math.ceil(sp * dt / 4));
    const sdt = dt / steps;
    for (let s = 0; s < steps; s++) {
      b.x += b.vx * sdt; b.y += b.vy * sdt;
      if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx); sfx.wall(); shake = Math.max(shake, 1.5); spawnParts(b.x, b.y, 4, '#8fa3ff', 80, 2); }
      if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx); sfx.wall(); shake = Math.max(shake, 1.5); spawnParts(b.x, b.y, 4, '#8fa3ff', 80, 2); }
      if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy); sfx.wall(); shake = Math.max(shake, 1.5); spawnParts(b.x, b.y, 4, '#8fa3ff', 80, 2); }
      // paddle
      if (b.vy > 0 && b.y + b.r >= paddle.y && b.y - b.r <= paddle.y + paddle.h &&
          b.x >= paddle.x - b.r && b.x <= paddle.x + paddle.w + b.r) {
        b.y = paddle.y - b.r;
        const rel = clamp((b.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
        const ang = rel * 1.1;
        b.vx = Math.sin(ang) * sp; b.vy = -Math.cos(ang) * sp;
        paddle.squash = 1;
        sfx.paddle();
        state.combo = 0;
        shake = Math.max(shake, 2);
        spawnParts(b.x, paddle.y, 7, '#ffffff', 90, 2);
      }
      // bricks
      const appearedBy = state.time - levelStart;
      for (const k of bricks) {
        if (!k.alive || k.delay > appearedBy) continue;
        if (b.x + b.r > k.x && b.x - b.r < k.x + k.w && b.y + b.r > k.y && b.y - b.r < k.y + k.h) {
          hitBrick(k, b, sp);
          if (state.mode !== 'playing') break;
          break;
        }
      }
      if (state.mode !== 'playing') break;
    }
    b.trail.push({ x: b.x, y: b.y });
    if (b.trail.length > 12) b.trail.shift();
    if (effects.fire > state.time && Math.random() < 0.6) {
      parts.push({ x: b.x + rnd(-3, 3), y: b.y + rnd(-3, 3), vx: rnd(-20, 20), vy: rnd(-10, 30), life: 0.35, max: 0.35, color: Math.random() < 0.5 ? '#ff9f43' : '#ffd93d', size: rnd(2, 4), rot: 0, spin: 0 });
    }
    if (state.mode !== 'playing') break;
  }
  if (state.mode !== 'playing') return;

  const before = balls.length;
  balls = balls.filter(b => b.y - b.r < H + 10);
  if (balls.length < before && balls.length > 0) { sfx.crack(); shake = Math.max(shake, 5); }
  if (balls.length === 0) { loseLife(); return; }

  // drops
  for (const d of drops) {
    d.vy = Math.min(d.vy + 160 * dt, 170);
    d.y += d.vy * dt; d.wob += dt * 4;
    if (d.y + 12 >= paddle.y && d.y - 12 <= paddle.y + paddle.h && d.x >= paddle.x - 12 && d.x <= paddle.x + paddle.w + 12) {
      d.dead = true; applyDrop(d);
    }
  }
  drops = drops.filter(d => !d.dead && d.y < H + 30);
}

/* ---------------- Render ---------------- */
function rr(x, y, w, h, r) {
  r = Math.min(r, w / 2, h / 2);
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}
function easeOutBack(t) { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }
function text(str, x, y, size, color, align, weight) {
  ctx.font = (weight || 'bold') + ' ' + size + 'px ui-monospace, Menlo, Consolas, monospace';
  ctx.textAlign = align || 'center'; ctx.textBaseline = 'middle';
  ctx.fillStyle = color; ctx.fillText(str, x, y);
}
function pad(n, w) { let s = String(n); while (s.length < w) s = '0' + s; return s; }

function drawBackground(now) {
  const g = ctx.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0, '#0d1226'); g.addColorStop(1, '#070914');
  ctx.fillStyle = g; ctx.fillRect(-20, -20, W + 40, H + 40);
  ctx.strokeStyle = 'rgba(120,140,255,0.06)'; ctx.lineWidth = 1;
  const off = (now * 12) % 40;
  ctx.beginPath();
  for (let x = -40; x < W + 40; x += 40) { ctx.moveTo(x + off * 0, 0); ctx.lineTo(x, H); }
  for (let y = -40 + off; y < H + 40; y += 40) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
  ctx.stroke();
  // HUD separator line
  ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.fillRect(0, 60, W, 1);
}

function drawBricks() {
  const t0 = state.time - levelStart;
  for (const k of bricks) {
    if (!k.alive) continue;
    const t = clamp((t0 - k.delay) / 0.4, 0, 1);
    const s = state.mode === 'title' ? 1 : easeOutBack(t);
    if (s <= 0.01) continue;
    const cx = k.x + k.w / 2, cy = k.y + k.h / 2;
    const w = k.w * s, h = k.h * s, x = cx - w / 2, y = cy - h / 2;
    if (k.steel) {
      ctx.fillStyle = '#2b3245'; rr(x, y, w, h, 4); ctx.fill();
      ctx.strokeStyle = '#596483'; ctx.lineWidth = 2; rr(x + 1, y + 1, w - 2, h - 2, 3); ctx.stroke();
      ctx.fillStyle = '#7b87a8';
      for (const dx of [8, w - 8]) { ctx.beginPath(); ctx.arc(x + dx, cy, 2, 0, 6.283); ctx.fill(); }
    } else {
      ctx.fillStyle = k.color; rr(x, y, w, h, 4); ctx.fill();
      ctx.fillStyle = 'rgba(255,255,255,0.28)'; rr(x + 2, y + 2, w - 4, h * 0.38, 3); ctx.fill();
      ctx.fillStyle = 'rgba(0,0,0,0.22)'; rr(x + 2, y + h - 5, w - 4, 3, 2); ctx.fill();
      if (k.maxhp > 1 && k.hp < k.maxhp) {
        ctx.strokeStyle = 'rgba(20,20,30,0.7)'; ctx.lineWidth = 1.5;
        ctx.beginPath(); ctx.moveTo(x + w * 0.3, y + 2); ctx.lineTo(x + w * 0.45, y + h * 0.5); ctx.lineTo(x + w * 0.35, y + h - 2);
        ctx.moveTo(x + w * 0.45, y + h * 0.5); ctx.lineTo(x + w * 0.7, y + h * 0.6); ctx.lineTo(x + w * 0.8, y + h - 2); ctx.stroke();
      }
    }
    if (k.flash > 0) { ctx.fillStyle = 'rgba(255,255,255,' + (k.flash * 0.8) + ')'; rr(x, y, w, h, 4); ctx.fill(); }
  }
}

function drawPaddle() {
  const sq = paddle.squash;
  const cx = paddle.x + paddle.w / 2, cy = paddle.y + paddle.h / 2;
  ctx.save();
  ctx.translate(cx, cy);
  ctx.scale(1 + 0.22 * sq, 1 - 0.38 * sq);
  const wide = effects.wide > state.time, fire = effects.fire > state.time, slow = effects.slow > state.time;
  const glow = fire ? '#ff9f43' : slow ? '#5ee07a' : wide ? '#4dd0ff' : '#8fa3ff';
  ctx.shadowColor = glow; ctx.shadowBlur = 18 + sq * 20;
  const g = ctx.createLinearGradient(0, -paddle.h / 2, 0, paddle.h / 2);
  g.addColorStop(0, '#ffffff'); g.addColorStop(0.5, '#c9d4ff'); g.addColorStop(1, '#6f82d8');
  ctx.fillStyle = g; rr(-paddle.w / 2, -paddle.h / 2, paddle.w, paddle.h, 7); ctx.fill();
  ctx.shadowBlur = 0;
  ctx.fillStyle = glow; rr(-paddle.w / 2 + 4, -paddle.h / 2 + paddle.h - 5, paddle.w - 8, 3, 2); ctx.fill();
  ctx.restore();
}

function drawBalls() {
  const fire = effects.fire > state.time;
  for (const b of balls) {
    for (let i = 0; i < b.trail.length; i++) {
      const p = b.trail[i], f = (i + 1) / b.trail.length;
      ctx.fillStyle = fire ? 'rgba(255,159,67,' + (f * 0.35) + ')' : 'rgba(143,163,255,' + (f * 0.35) + ')';
      ctx.beginPath(); ctx.arc(p.x, p.y, b.r * f * 0.9, 0, 6.283); ctx.fill();
    }
    ctx.save();
    ctx.shadowColor = fire ? '#ff9f43' : '#ffffff'; ctx.shadowBlur = fire ? 24 : 14;
    const g = ctx.createRadialGradient(b.x - 2, b.y - 2, 1, b.x, b.y, b.r);
    g.addColorStop(0, '#ffffff'); g.addColorStop(1, fire ? '#ff7a1a' : '#9fb2ff');
    ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, 6.283); ctx.fill();
    ctx.restore();
  }
}

function drawDrops() {
  for (const d of drops) {
    const bob = Math.sin(d.wob) * 2;
    ctx.save();
    ctx.translate(d.x, d.y + bob);
    ctx.rotate(Math.sin(d.wob * 0.7) * 0.15);
    ctx.shadowColor = d.type.color; ctx.shadowBlur = 14;
    ctx.fillStyle = d.type.color; rr(-13, -13, 26, 26, 6); ctx.fill();
    ctx.shadowBlur = 0;
    ctx.fillStyle = 'rgba(255,255,255,0.3)'; rr(-11, -11, 22, 9, 4); ctx.fill();
    text(d.type.t, 0, 1, 16, '#101428');
    ctx.restore();
  }
}

function drawParts() {
  for (const p of parts) {
    const a = clamp(p.life / p.max, 0, 1);
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
    ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
    ctx.restore();
  }
  ctx.globalAlpha = 1;
}
function drawTexts() {
  for (const t of texts) {
    const a = clamp(t.life / t.max, 0, 1);
    ctx.globalAlpha = Math.min(1, a * 1.6);
    const sc = 1 + (1 - a) * 0.25;
    ctx.save(); ctx.translate(t.x, t.y); ctx.scale(sc, sc);
    ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 6;
    text(t.text, 0, 0, t.size, t.color);
    ctx.restore();
  }
  ctx.globalAlpha = 1;
}

function drawHUD() {
  text('SCORE', 20, 22, 11, '#8fa3ff', 'left');
  text(pad(state.score, 6), 20, 42, 22, '#ffffff', 'left');
  text('HIGH ' + pad(Math.max(state.hi, state.score), 6), 150, 42, 12, '#8a93b5', 'left', 'normal');
  const L = LEVELS[state.level];
  text('LEVEL ' + (state.level + 1) + ' / ' + LEVELS.length, W / 2, 22, 11, '#8fa3ff');
  text(L.name, W / 2, 42, 18, '#ffffff');
  text('LIVES', W - 20, 22, 11, '#8fa3ff', 'right');
  for (let i = 0; i < Math.min(state.lives, 8); i++) {
    ctx.fillStyle = '#c9d4ff'; rr(W - 20 - (i + 1) * 20 + 2, 36, 16, 6, 3); ctx.fill();
    ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(W - 20 - (i + 1) * 20 + 10, 46, 3, 0, 6.283); ctx.fill();
  }
  if (state.combo >= 2) {
    const pulse = 1 + Math.sin(state.time * 18) * 0.06;
    ctx.save(); ctx.translate(W / 2, H - 100); ctx.scale(pulse, pulse);
    text('COMBO x' + Math.min(state.combo, 8), 0, 0, 22 + Math.min(state.combo, 8) * 1.5, state.combo >= 5 ? '#ffd93d' : '#ffffff');
    ctx.restore();
  }
  // effect timers
  let ey = H - 14;
  const list = [['wide', 'WIDE', '#4dd0ff', 12], ['slow', 'SLOW', '#5ee07a', 8], ['fire', 'FIRE', '#ff9f43', 7]];
  for (const [key, label, color, total] of list) {
    const rem = effects[key] - state.time;
    if (rem <= 0) continue;
    text(label, 14, ey, 11, color, 'left');
    ctx.fillStyle = 'rgba(255,255,255,0.12)'; rr(54, ey - 4, 80, 8, 4); ctx.fill();
    ctx.fillStyle = color; rr(54, ey - 4, 80 * clamp(rem / total, 0, 1), 8, 4); ctx.fill();
    ey -= 16;
  }
}

function drawOverlayBox(alpha) {
  ctx.fillStyle = 'rgba(7,9,20,' + alpha + ')'; ctx.fillRect(-20, -20, W + 40, H + 40);
}

function drawTitle(now) {
  drawOverlayBox(0.35);
  const hue = (now * 40) % 360;
  ctx.save();
  ctx.shadowColor = 'hsl(' + hue + ',90%,65%)'; ctx.shadowBlur = 40;
  text('BREAKOUT', W / 2, 170 + Math.sin(now * 2) * 6, 76, '#ffffff');
  ctx.restore();
  text('seven levels · combos · power-ups', W / 2, 230, 16, '#8fa3ff', 'center', 'normal');
  const lines = [
    ['MOVE', 'mouse  or  ← →  /  A D'],
    ['LAUNCH', 'click  or  SPACE'],
    ['PAUSE', 'P  or  ESC'],
  ];
  lines.forEach((l, i) => {
    text(l[0], W / 2 - 40, 300 + i * 30, 14, '#8a93b5', 'right');
    text(l[1], W / 2 - 20, 300 + i * 30, 14, '#ffffff', 'left', 'normal');
  });
  // power-up legend
  DROP_TYPES.forEach((d, i) => {
    const x = W / 2 - 220 + i * 110;
    ctx.fillStyle = d.color; rr(x - 11, 405, 22, 22, 5); ctx.fill();
    text(d.t, x, 417, 13, '#101428');
    text(d.label, x, 442, 10, '#8a93b5', 'center', 'normal');
  });
  if (state.hi > 0) text('HIGH SCORE  ' + pad(state.hi, 6), W / 2, 490, 14, '#ffd93d');
  const blink = Math.sin(now * 5) > -0.2;
  if (blink) text('CLICK OR PRESS SPACE TO PLAY', W / 2, 540, 18, '#ffffff');
}

function drawLevelBanner() {
  const t = state.time - levelStart;
  if (t > 1.8) return;
  const a = t < 0.3 ? t / 0.3 : t > 1.3 ? clamp((1.8 - t) / 0.5, 0, 1) : 1;
  ctx.globalAlpha = a;
  ctx.fillStyle = 'rgba(7,9,20,0.55)'; ctx.fillRect(0, H / 2 - 60, W, 120);
  text('LEVEL ' + (state.level + 1), W / 2, H / 2 - 18, 40, '#ffffff');
  text(LEVELS[state.level].name, W / 2, H / 2 + 24, 22, '#8fa3ff');
  ctx.globalAlpha = 1;
}

function drawReadyHint() {
  if (state.time - levelStart < 1.8) return;
  if (!balls.some(b => b.stuck)) return;
  const a = 0.6 + Math.sin(state.time * 6) * 0.4;
  ctx.globalAlpha = a;
  text('CLICK OR SPACE TO LAUNCH', W / 2, H - 120, 16, '#c9d4ff');
  ctx.globalAlpha = 1;
}

function drawClear() {
  const t = state.time - clearAt;
  const sc = Math.min(1, easeOutBack(clamp(t / 0.5, 0, 1)));
  ctx.save(); ctx.translate(W / 2, H / 2); ctx.scale(sc, sc);
  ctx.shadowColor = '#ffd93d'; ctx.shadowBlur = 30;
  text('LEVEL CLEAR!', 0, -20, 52, '#ffffff');
  ctx.shadowBlur = 0;
  text('BONUS  +' + clearBonus, 0, 30, 22, '#ffd93d');
  ctx.restore();
}

function drawEnd(won, now) {
  drawOverlayBox(0.6);
  ctx.save();
  ctx.shadowColor = won ? '#5ee07a' : '#ff4d6d'; ctx.shadowBlur = 36;
  text(won ? 'YOU WIN!' : 'GAME OVER', W / 2, 200, 64, '#ffffff');
  ctx.restore();
  text('FINAL SCORE', W / 2, 280, 14, '#8fa3ff');
  text(pad(state.score, 6), W / 2, 316, 40, '#ffffff');
  if (state.score >= state.hi && state.score > 0) text('NEW HIGH SCORE!', W / 2, 360, 20, '#ffd93d');
  else text('HIGH SCORE  ' + pad(state.hi, 6), W / 2, 360, 14, '#8a93b5');
  if (won) text('You cleared all ' + LEVELS.length + ' levels.', W / 2, 400, 16, '#c9d4ff', 'center', 'normal');
  if (Math.sin(now * 5) > -0.2) text('CLICK OR PRESS SPACE TO PLAY AGAIN', W / 2, 500, 18, '#ffffff');
}

function drawPaused() {
  drawOverlayBox(0.55);
  text('PAUSED', W / 2, H / 2 - 20, 48, '#ffffff');
  text('press P, ESC, SPACE or click to resume', W / 2, H / 2 + 30, 16, '#8fa3ff', 'center', 'normal');
}

function render(now) {
  ctx.save();
  if (shake > 0) ctx.translate((Math.random() - 0.5) * shake * 2, (Math.random() - 0.5) * shake * 2);
  drawBackground(now);
  const m = state.mode;
  if (m === 'title') {
    drawTitle(now);
  } else {
    drawBricks();
    drawDrops();
    drawPaddle();
    drawBalls();
    drawParts();
    drawTexts();
    drawHUD();
    if (m === 'playing') { drawLevelBanner(); drawReadyHint(); }
    if (m === 'clear') drawClear();
    if (m === 'paused') drawPaused();
    if (m === 'gameover') drawEnd(false, now);
    if (m === 'win') drawEnd(true, now);
  }
  ctx.restore();
  if (flash > 0) { ctx.globalAlpha = flash * 0.45; ctx.fillStyle = flashColor; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; }
  // vignette
  const v = ctx.createRadialGradient(W / 2, H / 2, H * 0.45, W / 2, H / 2, H * 0.9);
  v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0.45)');
  ctx.fillStyle = v; ctx.fillRect(0, 0, W, H);
  cv.style.cursor = (m === 'playing' || m === 'clear') ? 'none' : 'default';
}

/* ---------------- Input ---------------- */
function action() {
  const m = state.mode;
  if (m === 'title' || m === 'gameover' || m === 'win') startGame();
  else if (m === 'playing') launchBalls();
  else if (m === 'paused') state.mode = prevMode;
}
function togglePause() {
  if (state.mode === 'playing' || state.mode === 'clear') { prevMode = state.mode; state.mode = 'paused'; }
  else if (state.mode === 'paused') state.mode = prevMode;
}
addEventListener('keydown', e => {
  if (['Space', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.code)) e.preventDefault();
  keys.add(e.code);
  if (e.repeat) return;
  audio();
  if (e.code === 'Space' || e.code === 'Enter') action();
  else if (e.code === 'KeyP' || e.code === 'Escape') togglePause();
});
addEventListener('keyup', e => keys.delete(e.code));
addEventListener('blur', () => { keys.clear(); if (state.mode === 'playing') togglePause(); });

function toCanvas(e) {
  const r = cv.getBoundingClientRect();
  return { x: (e.clientX - r.left) * W / r.width, y: (e.clientY - r.top) * H / r.height };
}
cv.addEventListener('mousemove', e => { mouse.x = toCanvas(e).x; mouse.active = true; });
cv.addEventListener('mousedown', e => { e.preventDefault(); audio(); mouse.x = toCanvas(e).x; mouse.active = true; action(); });
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); mouse.x = toCanvas(e.touches[0]).x; mouse.active = true; action(); }, { passive: false });
cv.addEventListener('touchmove', e => { e.preventDefault(); mouse.x = toCanvas(e.touches[0]).x; mouse.active = true; }, { passive: false });

/* ---------------- Loop ---------------- */
let last = performance.now();
function frame(nowMs) {
  const dt = Math.min(0.033, Math.max(0, (nowMs - last) / 1000));
  last = nowMs;
  const now = nowMs / 1000;
  if (state.mode === 'playing' || state.mode === 'clear') update(dt);
  else updateFx(dt);
  render(now);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>

Compare with

Claude Fable 5.1: other attempts