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)
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>

