DeepSeek V4.1 Flash 1017
by DeepSeekReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 15 s
- Cost
- $0.017
- Output tokens
- 33,778
- Spent thinking
- 23,689 tokens
- Speed
- 252.1 tok/s
- Console errors
- 0
- First token after
- 541 ms
- First visible text after
- 1 min 50 s
- Prompt tokens
- 534
- Code size
- 26.2 KB
- Finish reason
- stop
- Served by
- Cohere
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"high"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
Build a polished Breakout (brick breaker) game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A paddle controlled with the mouse and with the left/right arrow keys. - The bounce angle off the paddle depends on where the ball hits it (center = straight up, edges = sharp angle). - One level with at least 6 rows of bricks, mixing normal bricks and tougher bricks that need 2 hits and visibly crack after the first one. - Some bricks visibly carry a power-up icon; when broken they drop it, and catching it with the paddle gives either a wider paddle (10 seconds) or multi-ball (3 balls). - The ball must never pass through bricks or the paddle. - Particles when a brick breaks. - 3 lives, score displayed. Game over and victory screens with a restart option.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 975 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Breakout</title>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#04050a;}
body{
display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-tap-highlight-color:transparent;
background:radial-gradient(1200px 700px at 50% 18%, #0d1530 0%, #04050a 72%);
}
canvas{
display:block;border-radius:16px;
box-shadow:0 24px 80px rgba(0,0,0,.85), 0 0 0 1px rgba(120,170,255,.12), 0 0 70px rgba(40,90,220,.16);
touch-action:none;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
(function () {
'use strict';
/* ============================================================
SETUP
============================================================ */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', { alpha: false });
const W = 900, H = 640;
const FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
let vs = 1; // view scale (device px per logical px)
function resize() {
const pad = 14;
const aw = Math.max(300, window.innerWidth - pad * 2);
const ah = Math.max(220, window.innerHeight - pad * 2);
const s = Math.min(aw / W, ah / H);
const cw = Math.round(W * s), ch = Math.round(H * s);
canvas.style.width = cw + 'px';
canvas.style.height = ch + 'px';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
vs = canvas.width / W;
}
window.addEventListener('resize', resize);
/* ============================================================
HELPERS
============================================================ */
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const TAU = Math.PI * 2;
function rr(c, x, y, w, h, r) {
r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
c.beginPath();
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h - r);
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
c.lineTo(x + r, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
function shade(hex, amt) {
const n = parseInt(hex.slice(1), 16);
const r = clamp(Math.round(((n >> 16) & 255) * (1 + amt)), 0, 255);
const g = clamp(Math.round(((n >> 8) & 255) * (1 + amt)), 0, 255);
const b = clamp(Math.round((n & 255) * (1 + amt)), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
/* ============================================================
CONSTANTS
============================================================ */
const WALL_L = 14, WALL_R = W - 14, WALL_T = 70;
const BALL_R = 8;
const PADDLE_Y = 594, PADDLE_H = 14, PADDLE_W = 132;
const PADDLE_SPEED = 800;
const MAX_ANGLE = 62 * Math.PI / 180;
const COLS = 12, ROWS = 7;
const BRICK_GAP = 6;
const FIELD_L = WALL_L + 16, FIELD_R = WALL_R - 16;
const BRICK_W = (FIELD_R - FIELD_L - BRICK_GAP * (COLS - 1)) / COLS;
const BRICK_H = 26;
const BRICK_TOP = WALL_T + 18;
const ROW_COLORS = [
['#ff9db4', '#d90429'],
['#ffc078', '#e8590c'],
['#ffe066', '#e0a100'],
['#8ce99a', '#2b8a3e'],
['#66d9e8', '#0b7285'],
['#8bb8ff', '#1c4ed8'],
['#d0bfff', '#6741d9']
];
const POWER_POS = [
[1, 1, 'wide'], [1, 6, 'multi'],
[2, 3, 'multi'], [2, 9, 'wide'],
[4, 2, 'wide'], [4, 8, 'multi'],
[5, 5, 'multi'], [5, 10, 'wide']
];
/* ============================================================
STATE
============================================================ */
let state = 'demo'; // demo | play | over | win
let score = 0;
let lives = 3;
let time = 0;
let shake = 0;
let wideTimer = 0;
let serveTimer = 0;
let pendingVictory = false;
const paddle = { cx: W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H, baseW: PADDLE_W };
let balls = [];
let bricks = [];
let particles = [];
let powerups = [];
let popups = [];
const keys = { left: false, right: false };
let useMouse = false;
let mouseX = W / 2;
/* ============================================================
LEVEL
============================================================ */
function buildLevel() {
bricks = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = FIELD_L + c * (BRICK_W + BRICK_GAP);
const y = BRICK_TOP + r * (BRICK_H + BRICK_GAP);
const tough = (r === 0 && c % 2 === 1) || (r === 3 && c % 3 === 2) || (r === 6 && c % 2 === 0);
const hp = tough ? 2 : 1;
let power = null;
for (let i = 0; i < POWER_POS.length; i++) {
if (POWER_POS[i][0] === r && POWER_POS[i][1] === c) power = POWER_POS[i][2];
}
bricks.push({ x, y, w: BRICK_W, h: BRICK_H, hp, maxHp: hp, row: r, col: c, power, hit: 0 });
}
}
}
/* ============================================================
BALLS
============================================================ */
function makeBall(x, y, vx, vy) {
return { x, y, vx, vy, dead: false, stuck: false };
}
function serveNewBall() {
const b = makeBall(paddle.cx, paddle.y - BALL_R - 2, 0, 0);
b.stuck = true;
balls = [b];
serveTimer = state === 'demo' ? 0.45 : 1.0;
}
function launchBall(b) {
b.stuck = false;
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 0.7;
const sp = 400;
b.vx = Math.cos(ang) * sp;
b.vy = Math.sin(ang) * sp;
}
function multiBall() {
const src = balls.find(b => !b.stuck) || balls[0];
if (!src) return;
const sp = Math.max(340, Math.hypot(src.vx, src.vy));
const ang = Math.atan2(src.vy, src.vx);
const add = [];
for (const d of [0.55, -0.55]) {
if (balls.length + add.length >= 6) break;
add.push(makeBall(src.x, src.y, Math.cos(ang + d) * sp, Math.sin(ang + d) * sp));
}
balls.push.apply(balls, add);
addPopup(src.x, src.y - 24, 'MULTI-BALL!', '#fbbf24');
}
/* ============================================================
PARTICLES / POPUPS
============================================================ */
function spawnParticles(x, y, color, count, spread) {
if (particles.length > 520) return;
for (let i = 0; i < count; i++) {
const a = Math.random() * TAU;
const s = Math.random() * spread;
particles.push({
x, y,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s - 40,
r: 1.5 + Math.random() * 3,
life: 1,
decay: 1.1 + Math.random() * 1.1,
color
});
}
}
function addPopup(x, y, text, color) {
popups.push({ x, y, text, color, life: 1 });
}
/* ============================================================
DAMAGE / SCORING
============================================================ */
function damageBrick(br) {
br.hp--;
br.hit = 1;
if (br.hp > 0) {
spawnParticles(br.x + br.w / 2, br.y + br.h / 2, '#ffffff', 5, 110);
shake = Math.min(shake + 2.5, 7);
score += 25;
return;
}
const col = ROW_COLORS[br.row][0];
spawnParticles(br.x + br.w / 2, br.y + br.h / 2, col, 16, 300);
spawnParticles(br.x + br.w / 2, br.y + br.h / 2, '#ffffff', 6, 170);
score += br.maxHp === 2 ? 250 : 100;
shake = Math.min(shake + 3.5, 9);
if (br.power) dropPowerup(br);
if (bricks.every(b => b.hp <= 0)) pendingVictory = true;
}
function dropPowerup(br) {
powerups.push({
x: br.x + br.w / 2,
y: br.y + br.h / 2,
type: br.power,
vy: 170,
t: 0
});
}
function applyPowerup(type) {
if (type === 'wide') {
wideTimer = 10;
addPopup(paddle.cx, paddle.y - 30, 'WIDE PADDLE', '#4ade80');
} else {
multiBall();
}
}
/* ============================================================
COLLISIONS
============================================================ */
function hitPaddle(b) {
if (b.vy <= 0) return;
const p = paddle;
const px = p.cx - p.w / 2;
if (b.y + BALL_R < p.y) return;
if (b.y - BALL_R > p.y + p.h) return;
if (b.x + BALL_R < px || b.x - BALL_R > px + p.w) return;
b.y = p.y - BALL_R;
const rel = clamp((b.x - p.cx) / (p.w / 2), -1, 1);
const ang = rel * MAX_ANGLE;
let sp = Math.hypot(b.vx, b.vy);
sp = Math.min(sp * 1.012, 720);
sp = Math.max(sp, 360);
b.vx = Math.sin(ang) * sp;
b.vy = -Math.cos(ang) * sp;
spawnParticles(b.x, p.y, '#7dd3fc', 6, 140);
shake = Math.min(shake + 1.6, 6);
}
function hitBricks(b) {
for (let i = 0; i < bricks.length; i++) {
const br = bricks[i];
if (br.hp <= 0) continue;
const bcx = br.x + br.w / 2;
const bcy = br.y + br.h / 2;
const ox = (br.w / 2 + BALL_R) - Math.abs(b.x - bcx);
if (ox <= 0) continue;
const oy = (br.h / 2 + BALL_R) - Math.abs(b.y - bcy);
if (oy <= 0) continue;
if (ox < oy) {
if (b.x < bcx) { b.x -= ox; b.vx = -Math.abs(b.vx); }
else { b.x += ox; b.vx = Math.abs(b.vx); }
} else {
if (b.y < bcy) { b.y -= oy; b.vy = -Math.abs(b.vy); }
else { b.y += oy; b.vy = Math.abs(b.vy); }
}
damageBrick(br);
return;
}
}
function stepBall(b, dt) {
const sp = Math.hypot(b.vx, b.vy);
const steps = Math.max(1, Math.ceil((sp * dt) / 3));
const sdt = dt / steps;
for (let s = 0; s < steps; s++) {
b.x += b.vx * sdt;
b.y += b.vy * sdt;
// walls
if (b.x - BALL_R < WALL_L) { b.x = WALL_L + BALL_R; b.vx = Math.abs(b.vx); }
else if (b.x + BALL_R > WALL_R) { b.x = WALL_R - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y - BALL_R < WALL_T) { b.y = WALL_T + BALL_R; b.vy = Math.abs(b.vy); }
hitPaddle(b);
hitBricks(b);
if (b.y - BALL_R > H) { b.dead = true; return; }
}
}
/* ============================================================
AI (demo mode)
============================================================ */
function predictX(b, targetY) {
if (b.vy <= 0) return b.x;
const t = (targetY - b.y) / b.vy;
if (t <= 0) return b.x;
const x = b.x + b.vx * t;
const minX = WALL_L + BALL_R, maxX = WALL_R - BALL_R;
const span = maxX - minX;
let u = (x - minX) % (2 * span);
if (u < 0) u += 2 * span;
if (u > span) u = 2 * span - u;
return minX + u;
}
function aiUpdate(dt) {
const b = balls.find(x => !x.stuck) || balls[0];
let target = W / 2;
if (b) {
if (b.stuck) target = b.x;
else if (b.vy > 0) target = predictX(b, paddle.y - BALL_R);
else target = b.x;
}
target += Math.sin(time * 1.9) * 10;
const maxSpeed = 760;
const diff = target - paddle.cx;
paddle.cx += clamp(diff, -maxSpeed * dt, maxSpeed * dt);
}
/* ============================================================
GAME FLOW
============================================================ */
function resetCommon() {
score = 0;
lives = 3;
wideTimer = 0;
shake = 0;
particles = [];
powerups = [];
popups = [];
pendingVictory = false;
paddle.w = paddle.baseW;
paddle.cx = W / 2;
buildLevel();
}
function startDemo() {
state = 'demo';
resetCommon();
serveNewBall();
}
function startGame() {
state = 'play';
resetCommon();
serveNewBall();
}
function loseBall() {
shake = 10;
if (state === 'demo') { serveNewBall(); return; }
lives--;
if (lives <= 0) {
lives = 0;
state = 'over';
balls = [];
} else {
serveNewBall();
}
}
function handleVictory() {
if (state === 'demo') {
buildLevel();
serveNewBall();
return;
}
state = 'win';
balls = [];
}
/* ============================================================
UPDATE
============================================================ */
function update(dt) {
time += dt;
// paddle width (wide powerup)
const targetW = wideTimer > 0 ? paddle.baseW * 1.65 : paddle.baseW;
paddle.w = lerp(paddle.w, targetW, Math.min(1, dt * 11));
if (wideTimer > 0) {
wideTimer -= dt;
if (wideTimer < 0) wideTimer = 0;
}
// input
if (state === 'demo') {
aiUpdate(dt);
} else if (state === 'play') {
if (keys.left) paddle.cx -= PADDLE_SPEED * dt;
if (keys.right) paddle.cx += PADDLE_SPEED * dt;
if (useMouse && !keys.left && !keys.right) paddle.cx = mouseX;
}
paddle.cx = clamp(paddle.cx, WALL_L + paddle.w / 2, WALL_R - paddle.w / 2);
// balls
for (let i = balls.length - 1; i >= 0; i--) {
const b = balls[i];
if (b.stuck) {
b.x = paddle.cx;
b.y = paddle.y - BALL_R - 2;
serveTimer -= dt;
if (serveTimer <= 0) launchBall(b);
continue;
}
stepBall(b, dt);
if (b.dead) balls.splice(i, 1);
}
// powerups
for (let i = powerups.length - 1; i >= 0; i--) {
const p = powerups[i];
p.t += dt;
p.y += p.vy * dt;
if (p.y - 14 > H) { powerups.splice(i, 1); continue; }
const px = paddle.cx - paddle.w / 2;
if (p.y + 11 > paddle.y && p.y - 11 < paddle.y + paddle.h &&
p.x + 20 > px && p.x - 20 < px + paddle.w) {
applyPowerup(p.type);
spawnParticles(p.x, p.y, p.type === 'wide' ? '#4ade80' : '#fbbf24', 12, 200);
powerups.splice(i, 1);
}
}
// particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 1300 * dt;
p.vx *= 0.995;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= p.decay * dt;
if (p.life <= 0) particles.splice(i, 1);
}
// popups
for (let i = popups.length - 1; i >= 0; i--) {
const p = popups[i];
p.y -= 42 * dt;
p.life -= dt * 1.2;
if (p.life <= 0) popups.splice(i, 1);
}
// brick hit flash decay
for (let i = 0; i < bricks.length; i++) {
if (bricks[i].hit > 0) bricks[i].hit = Math.max(0, bricks[i].hit - dt * 4);
}
shake *= Math.pow(0.001, dt);
if (shake < 0.15) shake = 0;
if (pendingVictory) {
pendingVictory = false;
handleVictory();
}
if (balls.length === 0 && (state === 'play' || state === 'demo')) {
loseBall();
}
}
/* ============================================================
DRAWING
============================================================ */
function drawBackground() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0d1224');
g.addColorStop(0.55, '#080a14');
g.addColorStop(1, '#05060b');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const rg = ctx.createRadialGradient(W / 2, 250, 40, W / 2, 250, 560);
rg.addColorStop(0, 'rgba(64,120,255,0.13)');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
}
function drawWalls() {
const g = ctx.createLinearGradient(0, WALL_T, 0, H);
g.addColorStop(0, 'rgba(80,140,255,0.07)');
g.addColorStop(1, 'rgba(80,140,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(WALL_L, WALL_T, WALL_R - WALL_L, H - WALL_T);
ctx.strokeStyle = 'rgba(110,170,255,0.35)';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(WALL_L, H);
ctx.lineTo(WALL_L, WALL_T + 12);
ctx.quadraticCurveTo(WALL_L, WALL_T, WALL_L + 12, WALL_T);
ctx.lineTo(WALL_R - 12, WALL_T);
ctx.quadraticCurveTo(WALL_R, WALL_T, WALL_R, WALL_T + 12);
ctx.lineTo(WALL_R, H);
ctx.stroke();
}
function drawBrick(br) {
const x = br.x, y = br.y, w = br.w, h = br.h;
const c1 = ROW_COLORS[br.row][0];
const c2 = ROW_COLORS[br.row][1];
const g = ctx.createLinearGradient(0, y, 0, y + h);
if (br.maxHp === 2 && br.hp === 2) {
g.addColorStop(0, c1);
g.addColorStop(0.5, c2);
g.addColorStop(1, shade(c2, -0.3));
} else if (br.maxHp === 2 && br.hp === 1) {
g.addColorStop(0, shade(c1, -0.28));
g.addColorStop(1, shade(c2, -0.42));
} else {
g.addColorStop(0, c1);
g.addColorStop(1, c2);
}
rr(ctx, x, y, w, h, 6);
ctx.fillStyle = g;
ctx.fill();
// gloss
ctx.save();
rr(ctx, x, y, w, h, 6);
ctx.clip();
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(x, y, w, h * 0.44);
if (br.maxHp === 2) {
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.beginPath();
ctx.moveTo(x + w * 0.55, y);
ctx.lineTo(x + w * 0.8, y);
ctx.lineTo(x + w * 0.45, y + h);
ctx.lineTo(x + w * 0.2, y + h);
ctx.closePath();
ctx.fill();
}
ctx.restore();
// border
rr(ctx, x + 0.5, y + 0.5, w - 1, h - 1, 5.5);
if (br.maxHp === 2) {
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1.6;
} else {
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 1;
}
ctx.stroke();
// cracks on damaged tough bricks
if (br.maxHp === 2 && br.hp === 1) {
ctx.save();
rr(ctx, x, y, w, h, 6);
ctx.clip();
ctx.strokeStyle = 'rgba(6,8,16,0.7)';
ctx.lineWidth = 1.7;
ctx.lineCap = 'round';
const cx0 = x + w * 0.30;
ctx.beginPath();
ctx.moveTo(cx0, y + 1);
ctx.lineTo(cx0 + w * 0.10, y + h * 0.46);
ctx.lineTo(cx0 - w * 0.05, y + h - 1);
ctx.moveTo(cx0 + w * 0.10, y + h * 0.46);
ctx.lineTo(cx0 + w * 0.30, y + h * 0.74);
ctx.lineTo(cx0 + w * 0.44, y + h * 0.26);
ctx.moveTo(cx0 + w * 0.10, y + h * 0.46);
ctx.lineTo(cx0 - w * 0.14, y + h * 0.58);
ctx.stroke();
ctx.restore();
}
// hit flash
if (br.hit > 0) {
ctx.save();
rr(ctx, x, y, w, h, 6);
ctx.fillStyle = 'rgba(255,255,255,' + (br.hit * 0.55) + ')';
ctx.fill();
ctx.restore();
}
// powerup badge
if (br.power) {
const bx = x + w / 2, by = y + h / 2;
ctx.beginPath();
ctx.arc(bx, by, 8, 0, TAU);
ctx.fillStyle = 'rgba(6,9,20,0.62)';
ctx.fill();
ctx.strokeStyle = br.power === 'wide' ? '#7bf1a8' : '#ffd166';
ctx.lineWidth = 1.4;
ctx.stroke();
drawPUType(br.power, bx, by, br.power === 'wide' ? '#7bf1a8' : '#ffd166', 0.85);
}
}
function drawPUType(type, cx, cy, col, s) {
ctx.save();
ctx.fillStyle = col;
ctx.translate(cx, cy);
ctx.scale(s, s);
if (type === 'wide') {
ctx.beginPath();
ctx.moveTo(-7, 0); ctx.lineTo(-3, -3.6); ctx.lineTo(-3, 3.6);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(7, 0); ctx.lineTo(3, -3.6); ctx.lineTo(3, 3.6);
ctx.closePath(); ctx.fill();
ctx.fillRect(-3, -1.2, 6, 2.4);
} else {
for (let i = -1; i <= 1; i++) {
ctx.beginPath();
ctx.arc(i * 4.6, 0, 2.4, 0, TAU);
ctx.fill();
}
}
ctx.restore();
}
function drawBricks() {
for (let i = 0; i < bricks.length; i++) {
if (bricks[i].hp > 0) drawBrick(bricks[i]);
}
}
function drawPowerups() {
for (let i = 0; i < powerups.length; i++) {
const p = powerups[i];
const w = 38, h = 20;
const x = p.x - w / 2, y = p.y - h / 2;
const isWide = p.type === 'wide';
const pulse = 0.85 + 0.15 * Math.sin(p.t * 9);
ctx.save();
ctx.shadowColor = isWide ? 'rgba(74,222,128,0.9)' : 'rgba(251,191,36,0.9)';
ctx.shadowBlur = 16 * pulse;
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, isWide ? '#a7f3d0' : '#fde68a');
g.addColorStop(1, isWide ? '#16a34a' : '#d97706');
rr(ctx, x, y, w, h, 10);
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
rr(ctx, x + 0.5, y + 0.5, w - 1, h - 1, 9.5);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 1;
ctx.stroke();
drawPUType(p.type, p.x, p.y, 'rgba(10,20,10,0.85)', 1);
}
}
function drawParticles() {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (0.4 + p.life * 0.6), 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawPaddle() {
const x = paddle.cx - paddle.w / 2;
const y = paddle.y, w = paddle.w, h = paddle.h;
const wide = wideTimer > 0;
ctx.save();
ctx.shadowColor = wide ? 'rgba(74,222,128,0.95)' : 'rgba(96,165,250,0.95)';
ctx.shadowBlur = 20;
const g = ctx.createLinearGradient(0, y, 0, y + h);
if (wide) {
g.addColorStop(0, '#d1fae5');
g.addColorStop(0.5, '#4ade80');
g.addColorStop(1, '#15803d');
} else {
g.addColorStop(0, '#e0ecff');
g.addColorStop(0.5, '#60a5fa');
g.addColorStop(1, '#1d4ed8');
}
rr(ctx, x, y, w, h, h / 2);
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(ctx, x + 7, y + 2.5, w - 14, 2.6, 1.3);
ctx.fill();
}
function drawBalls() {
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
// trail
const sp = Math.hypot(b.vx, b.vy) || 1;
for (let k = 3; k >= 1; k--) {
ctx.globalAlpha = 0.09 * (4 - k);
ctx.fillStyle = '#bfe0ff';
ctx.beginPath();
ctx.arc(b.x - (b.vx / sp) * k * 7, b.y - (b.vy / sp) * k * 7, BALL_R * (1 - k * 0.16), 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.save();
ctx.shadowColor = 'rgba(150,200,255,0.95)';
ctx.shadowBlur = 18;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(b.x, b.y, BALL_R, 0, TAU);
ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(120,180,255,0.55)';
ctx.beginPath();
ctx.arc(b.x + 2.4, b.y + 2.4, BALL_R * 0.42, 0, TAU);
ctx.fill();
}
}
function drawPopups() {
ctx.textAlign = 'center';
for (let i = 0; i < popups.length; i++) {
const p = popups[i];
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.font = '800 16px ' + FONT;
ctx.fillStyle = p.color;
ctx.shadowColor = p.color;
ctx.shadowBlur = 12;
ctx.fillText(p.text, p.x, p.y);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
function drawHUD() {
const g = ctx.createLinearGradient(0, 0, 0, WALL_T + 14);
g.addColorStop(0, 'rgba(16,26,54,0.92)');
g.addColorStop(1, 'rgba(16,26,54,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, WALL_T + 14);
ctx.textBaseline = 'alphabetic';
// score
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(140,175,255,0.7)';
ctx.font = '700 11px ' + FONT;
ctx.fillText('SCORE', 26, 26);
ctx.fillStyle = '#eaf2ff';
ctx.font = '800 27px ' + FONT;
ctx.fillText(String(score).padStart(5, '0'), 26, 55);
// level
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(140,175,255,0.7)';
ctx.font = '700 11px ' + FONT;
ctx.fillText('LEVEL 1', W / 2, 26);
if (wideTimer > 0) {
const bw = 130, bx = W / 2 - bw / 2, by = 34;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
rr(ctx, bx, by, bw, 7, 3.5);
ctx.fill();
const p = clamp(wideTimer / 10, 0, 1);
ctx.fillStyle = '#4ade80';
rr(ctx, bx, by, bw * p, 7, 3.5);
ctx.fill();
ctx.fillStyle = 'rgba(160,255,200,0.85)';
ctx.font = '700 10px ' + FONT;
ctx.fillText('WIDE PADDLE', W / 2, by + 24);
}
// lives
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(140,175,255,0.7)';
ctx.font = '700 11px ' + FONT;
ctx.fillText('LIVES', W - 26, 26);
for (let i = 0; i < 3; i++) {
const x = W - 26 - 30 * (i + 1) + 8;
rr(ctx, x, 38, 22, 8, 4);
ctx.fillStyle = i < lives ? '#7dd3fc' : 'rgba(255,255,255,0.13)';
ctx.fill();
}
}
function drawOverlay() {
if (state === 'demo') {
const pulse = 0.5 + 0.5 * Math.sin(time * 3);
const pw = 560, ph = 92;
const px = W / 2 - pw / 2, py = 402;
ctx.save();
rr(ctx, px, py, pw, ph, 18);
ctx.fillStyle = 'rgba(6,10,22,0.80)';
ctx.fill();
ctx.strokeStyle = 'rgba(120,170,255,' + (0.28 + 0.4 * pulse) + ')';
ctx.lineWidth = 1.6;
ctx.stroke();
ctx.textAlign = 'center';
ctx.fillStyle = '#ffffff';
ctx.font = '800 22px ' + FONT;
ctx.fillText('CLICK OR PRESS A KEY TO START', W / 2, py + 40);
ctx.fillStyle = 'rgba(150,185,255,0.85)';
ctx.font = '600 13px ' + FONT;
ctx.fillText('Demo mode — the AI is playing · Mouse or ← → to move', W / 2, py + 68);
ctx.restore();
}
if (state === 'over' || state === 'win') {
const win = state === 'win';
ctx.fillStyle = 'rgba(4,6,14,0.80)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.save();
ctx.shadowColor = win ? 'rgba(74,222,128,0.8)' : 'rgba(255,80,130,0.8)';
ctx.shadowBlur = 30;
ctx.fillStyle = win ? '#4ade80' : '#ff6b9d';
ctx.font = '800 62px ' + FONT;
ctx.fillText(win ? 'VICTORY!' : 'GAME OVER', W / 2, H / 2 - 30);
ctx.restore();
ctx.fillStyle = '#ffffff';
ctx.font = '700 22px ' + FONT;
ctx.fillText('SCORE ' + score, W / 2, H / 2 + 30);
const pulse = 0.55 + 0.45 * Math.sin(time * 3.5);
ctx.fillStyle = 'rgba(150,185,255,' + pulse + ')';
ctx.font = '600 16px ' + FONT;
ctx.fillText('Click or press any key to play again', W / 2, H / 2 + 82);
}
}
function render() {
ctx.setTransform(vs, 0, 0, vs, 0, 0);
ctx.globalAlpha = 1;
drawBackground();
ctx.save();
if (shake > 0.15) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
}
drawWalls();
drawBricks();
drawPowerups();
drawParticles();
drawPaddle();
drawBalls();
drawPopups();
ctx.restore();
drawHUD();
drawOverlay();
}
/* ============================================================
INPUT
============================================================ */
function onKeyDown(e) {
const k = e.key;
if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
keys.left = true;
useMouse = false;
e.preventDefault();
} else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
keys.right = true;
useMouse = false;
e.preventDefault();
} else if (k === ' ' || k === 'Spacebar') {
e.preventDefault();
}
if (state !== 'play') startGame();
}
function onKeyUp(e) {
const k = e.key;
if (k === 'ArrowLeft' || k === 'a' || k === 'A') keys.left = false;
if (k === 'ArrowRight' || k === 'd' || k === 'D') keys.right = false;
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
canvas.addEventListener('pointermove', function (e) {
const r = canvas.getBoundingClientRect();
mouseX = ((e.clientX - r.left) / r.width) * W;
useMouse = true;
});
canvas.addEventListener('pointerdown', function (e) {
const r = canvas.getBoundingClientRect();
mouseX = ((e.clientX - r.left) / r.width) * W;
useMouse = true;
if (state !== 'play') startGame();
e.preventDefault();
});
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
/* ============================================================
MAIN LOOP
============================================================ */
let last = performance.now();
function loop(now) {
let dt = (now - last) / 1000;
last = now;
if (!isFinite(dt) || dt < 0) dt = 0;
dt = Math.min(dt, 0.05);
update(dt);
render();
requestAnimationFrame(loop);
}
/* ============================================================
BOOT
============================================================ */
resize();
startDemo();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (23,689 tokens)
Let me build a polished Breakout game in a single HTML file.
Key requirements:
- Paddle: mouse + arrow keys
- Bounce angle depends on hit position
- One level, ≥6 rows, normal + tough (2 hits, crack visual)
- Power-ups on bricks: wide paddle (10s), multi-ball (3 balls)
- No tunneling — use swept collision / sub-stepping
- Particles
- 3 lives, score, game over + victory screens with restart
- Self-playing demo mode behind "Click or press a key to start"
Let me design carefully.
Canvas: responsive, base resolution e.g. 900x640 logical, scaled with CSS to fit. Use devicePixelRatio.
Let me use a fixed logical size of 960x640 and scale to fit container while maintaining aspect ratio. Actually simpler: canvas internal size = displayed size * dpr, and compute game coordinates from that. But for consistent physics, use logical coordinate system with fixed height, and letterbox.
I'll do: logical width W = 900, height H = 640. Canvas CSS size fits within container preserving aspect ratio. Internal pixel size = CSS size * dpr. Then ctx.setTransform(scale...) so drawing in logical coords.
Layout:
- Playfield: full canvas. Bricks area at top.
- HUD drawn on canvas top: score, lives, level.
Bricks: 6+ rows. Let's do 8 rows? Requirement says at least 6. Let's do 7 rows x 12 columns.
Brick grid: cols = 12, rows = 7. Padding, offset top ~70 (below HUD).
Field width 900. left margin 40, right margin 40 → 820 usable. brick width = (820 - 11*gap)/12. gap = 6. 820 - 66 = 754/12 = 62.83. Height 22, gap 6 vertically.
Rows: 7 rows → 7*28 = 196. Top offset 80 → bricks end at 276. Paddle at y = 600. Ball starts around y = 480.
Hmm, that leaves a lot of empty space. Let's make bricks taller: height 26, gap 6 → 32 per row, 7 rows = 224, top 80 → ends at 304. Paddle at 600. OK fine, decent space for play.
Tough bricks: rows 0 and 1 maybe all tough? Let's mix: define pattern per row/col.
Let's define brick types:
- type 0: normal, 1 hit, color varies by row
- type 1: tough, 2 hits, needs crack rendering after first hit
- some bricks carry powerup: 'wide' or 'multi'
Power-up carrying bricks: mark with a small icon drawn on the brick (circle with "W" or arrows). When broken (fully destroyed), drop the powerup capsule that falls down; paddle catches it.
Requirement: "Some bricks visibly carry a power-up icon; when broken they drop it". For tough bricks, "broken" = fully destroyed (2nd hit). I'll drop on final destruction.
Powerups fall at ~140 px/s.
Wide paddle: 10 seconds, paddle width 1.6x. Timer displayed maybe.
Multi-ball: spawn 3 balls total (from each existing ball? simpler: from the ball that... ) — Let's split: for each existing ball, add 2 more? Requirement: "multi-ball (3 balls)". I'll take the first ball and create 3 balls total from it spreading at different angles, keeping other balls as is. Simpler: for each ball currently in play, spawn 2 clones at ±20° — but cap at some max. Let's do: spawn 2 extra balls from the ball that hit... Hmm, the powerup is caught by the paddle, not by a ball. So: take all current balls; create 2 additional balls for each? That could explode. Let's just: ensure at least 3 balls — if 1 ball, create 3 (spread); if 2, create 3rd. Actually simplest: clone each existing ball with ±25° offset up to a max of 5 balls. Hmm.
I'll do: for the primary set, take current balls, and for each of the first up to... Let's just do: newBalls = []; for each ball b in balls: push b, push clone with angle +0.4 rad, push clone with angle -0.4 rad — no, that triples.
Cleanest: if balls.length === 1 → spawn 2 more from that ball (total 3). If more, add 1 more ball. Cap at 6. That satisfies "3 balls".
Ball physics:
- Speed ~ 380 px/s initial, increase slightly over time / per hit.
- Sub-stepping: move in small steps to prevent tunneling. Use steps based on speed: step size ≤ min(brick height, paddle height)/2. With dt capped, do fixed sub-steps.
Better approach: use swept AABB collision with the bricks, but sub-stepping is simpler and reliable. Ball radius 8, speed up to 600. dt max 1/60 → 10px per frame. Brick height 26. So one step is fine actually. But to be safe, sub-step with max step 4px.
Collision with bricks: for each substep, check ball circle vs brick rect. Use circle-rect overlap test, then resolve by determining penetration axis and reflect. To be robust, I'll do: compute closest point on rect to ball center; if distance < radius → collision. Determine normal from the closest point direction; if ball center inside rect, use the minimum penetration axis.
Then reflect velocity about the normal, and push the ball out of the rect.
Careful with multiple bricks at once: after resolving one, break out of loop for that substep (or handle all). I'll handle one collision per substep and continue substepping.
Paddle collision: rect. Ball moving down and ball bottom >= paddle top and ball center y < paddle top + something and x within paddle bounds + radius. Then set ball position above paddle, compute angle:
rel = (ball.x - paddleCenterX) / (paddleWidth/2), clamp to [-1,1].
angle = rel * MAX_ANGLE (e.g. 60° = 1.047 rad) from vertical.
vx = speed * sin(angle), vy = -speed * cos(angle).
That's the classic. Center = straight up.
Walls: left/right/top reflect. Bottom: ball lost.
Also paddle must not be passable — the ball approaches from above; using a check with substepping and requiring vy>0 handles it. Also handle side hits? Just do the top check with a sweep: if prevY + radius <= paddleTop and newY + radius > paddleTop and x overlap → collide. Since we substep by ≤4px it's fine.
Actually, careful: ball radius 8, substep 4px. Ball bottom crossing paddle top → detect. Use continuous check within substep: check if ball.y + r >= paddle.y && ball.y - r <= paddle.y + paddle.h && ball.x + r >= paddle.x && ball.x - r <= paddle.x + paddle.w && ball.vy > 0. Then reposition ball.y = paddle.y - r, and set velocity. Good enough. But if the ball comes from the side at paddle level it could get stuck; rare. Add: only if ball.y < paddle.y (center above paddle top). Fine.
Particles: on brick break, spawn ~10 particles with brick color, gravity, fade.
Also add screen shake maybe on loss.
Rendering: nice gradient background, glowing bricks, rounded rects, subtle shadows. Ball with glow trail.
Let me write clean code.
Structure:
```js
const W=900,H=640;
```
Canvas sizing: I'll create a wrapper div, canvas with CSS width/height set by JS on resize to fit the viewport keeping aspect ratio (max 100% both dims). Actually easier: use CSS `width:100%; height:100%; object-fit: contain`? Canvas doesn't do object-fit for drawing buffer. Use: canvas style width/height computed.
Let me do:
```html
<div id="wrap"><canvas id="c"></canvas></div>
```
CSS: body flex center, #wrap position relative, canvas display block, border radius, box-shadow.
JS resize:
```js
function resize(){
const availW = window.innerWidth - 24;
const availH = window.innerHeight - 24;
const scale = Math.min(availW/W, availH/H);
const cssW = Math.floor(W*scale), cssH = Math.floor(H*scale);
canvas.style.width = cssW+'px';
canvas.style.height = cssH+'px';
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = Math.round(cssW*dpr);
canvas.height = Math.round(cssH*dpr);
ctx.setTransform(canvas.width/W, 0,0, canvas.height/H, 0,0);
}
```
Good.
Mouse input: listen on canvas mousemove → convert clientX to logical: (e.clientX - rect.left) / rect.width * W.
Also touch support: touchmove.
Keyboard: ArrowLeft/Right, and also A/D. Keys tracked in object.
Game states: 'demo' (self-playing AI behind message), 'playing', 'over', 'win', 'ready'?
Flow: start in demo mode. AI plays automatically. Overlay "CLICK OR PRESS A KEY TO START" centered. On click/keydown → start real game (reset).
During demo, AI controls paddle to track the ball, and it plays. If the AI loses... in demo, infinite lives maybe? Or just reset. Let's make demo mode have infinite lives and when it wins (clears all bricks) it resets the level. That keeps the demo lively.
Actually simpler: demo mode: lives = ∞, and on win, restart level automatically.
For the screenshot at 3s, the demo will be in action — good.
Overlay: draw on canvas or use HTML? Draw on canvas is simpler and scales.
HUD: top-left score, top-right lives, center maybe level. Draw on canvas.
Let's define layout:
- HUD area: y 0..56.
- Bricks: top offset 76.
- Paddle y = 596, height 14. Play area bottom 640.
- Ball spawn: on paddle center, y = paddle.y - 20, moving up-right at some angle.
Wall bounds: left 0, right W, top 0? The ball bounces off top at y=0. But HUD is at top... bricks start at 76, ball goes up to top region. Fine — the ball can travel up to y=0 above the bricks? No, bricks are below the top. The ball bounces at top y=0. That's fine visually since HUD is drawn over with transparency. Hmm, might look odd. Let's put a top wall at y=52 (below HUD). Actually the playfield boundary: draw a border rect from y=0? Let's just make top wall at y=56 with a visible line, and HUD above it in 0..56.
Hmm, but then bricks at 76 are just 20px below the wall. Fine.
Let's restructure:
- Top wall at TOP = 64. HUD drawn in 0..64 region.
- Bricks: offsetTop = 84.
- Brick rows 7, height 26, gapY 6 → 7*32 = 224 → ends at 308.
- Paddle y = 592, h = 14.
- Bottom = 640 (ball lost below 640).
- Left/right walls at 0 and W.
Ball radius 8.
Let me draw a subtle border/frame around the playfield.
Colors per row: nice palette.
Row colors:
0: '#ff4d6d' (red/pink)
1: '#ff9f1c' (orange)
2: '#ffd166' (yellow)
3: '#7bed9f' (green)
4: '#4cc9f0' (cyan)
5: '#a06cd5' (purple)
6: '#f72585' (magenta)
Tough bricks: white-ish overlay / metallic look. Let's make tough bricks a lighter/desaturated version with a "steel" look and a border. Cracked after first hit: draw crack lines.
Brick layout: 12 cols, 7 rows.
Field width for bricks: from x=30 to x=870 → 840. gap 6 → 11*6=66 → 774/12 = 64.5. Good.
Let's define brickW = 64.5? Better to compute exactly:
```js
const COLS=12, ROWS=7, GAP=6, SIDE=30;
const brickW = (W - SIDE*2 - GAP*(COLS-1))/COLS;
const brickH = 26;
const TOP = 84;
```
W=900, SIDE=30 → 840 - 66 = 774 / 12 = 64.5. OK.
Brick hp pattern:
Let rows 0..6.
Tough bricks: let's make row 1 and row 4 partially tough, plus maybe a full row.
Design pattern:
- Row 0 (top): tough bricks at columns where (c%3===0)? Let's just do: rows 0 and 1 are "hard" rows where all bricks are hp2. Hmm, that's 24 tough bricks, might be slow.
Let's do: hp2 if (r===0 && c%2===0) || (r===2 && (c%4===1||c%4===2)) || (r===4 && c%3===0). Something varied.
Simpler: define a pattern array per row.
I'll write:
```js
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
let hp = 1;
if (r===0 && c%2===1) hp=2;
else if (r===2 && (c%3===1)) hp=2;
else if (r===4 && (c<3 || c>8)) hp=2;
...
}
}
```
Hmm, let's just make it look deliberate:
Actually a nicer design: outer ring tough? Let's keep simple and readable.
hp2 set:
- row 0: columns 1,3,5,7,9,11 → 6 tough
- row 1: none
- row 2: columns 2,5,8,11 → 4 tough (hmm)
- row 4: columns 0,4,8 → hmm
Let me just do:
```js
const tough = (r===0 && c%2===1) || (r===3 && c%3===2) || (r===6 && c%2===0);
```
Rows 0,3,6 tough in a pattern. Total tough = 6 + 4 + 6 = 16. Total bricks 84. Good.
Power-ups: place on specific bricks. Say ~8 powerups.
Choose: (r,c) pairs: (1,1) wide, (1,6) multi, (2,3) wide, (2,9) multi, (4,2) multi, (4,10) wide, (5,5) wide, (5,8) multi... Let's use a deterministic list and assign alternating types.
Actually, place powerups only on non-tough bricks so they're easier to reach? Or any. I'll place them on specific bricks and if that brick is tough, they drop after both hits.
Powerup icons drawn on brick: small rounded badge with "↔" for wide and "●●●" for multi. Use simple shapes/text.
Let's use text: "W" for wide? Better: draw arrows "↔" and three dots for multi. I'll draw:
- wide: a small horizontal double arrow.
- multi: three small circles.
I'll render a badge circle in the center of the brick with the icon.
Hmm, brick height 26, so badge radius 8. Draw white circle with alpha, then icon inside.
Let's do it.
Powerup falling capsule: rounded rect 30x16 with icon and color, glowing, rotating slightly? Keep simple: rounded rect with gradient and icon.
Catching: rect overlap with paddle.
Effects:
- wide: paddle.w = baseW * 1.6, timer 10s. Reset on catch. When timer expires, revert (with a visual pulse).
- multi: add balls.
Ball speed: start 360. Increase by 8 on each paddle hit up to 620? Let's do speed increases slowly: base speed = 380 + level progress. Actually per-ball speed. Let's give each ball a speed property, and on paddle bounce, speed = min(speed * 1.015, 640).
Also, to keep the game playable, cap total.
Physics constants:
- MAX_BOUNCE_ANGLE = 62° in radians ≈ 1.082.
Ball update with substeps:
```js
function updateBall(b, dt){
const dist = Math.hypot(b.vx, b.vy) * dt;
const steps = Math.max(1, Math.ceil(dist / 4));
const sdt = dt/steps;
for (let i=0;i<steps;i++){
b.x += b.vx*sdt;
b.y += b.vy*sdt;
collideWalls(b);
collidePaddle(b);
collideBricks(b);
if (b.dead) return;
}
}
```
collideWalls:
```js
if (b.x - R < 0) { b.x = R; b.vx = Math.abs(b.vx); }
if (b.x + R > W) { b.x = W - R; b.vx = -Math.abs(b.vx); }
if (b.y - R < TOP) { b.y = TOP + R; b.vy = Math.abs(b.vy); }
if (b.y - R > H) { b.dead = true; }
```
Bottom: b.y > H + R.
collidePaddle: only if b.vy > 0 and ball above paddle top region.
```js
const p = paddle;
if (b.vy > 0 && b.y + R >= p.y && b.y - R <= p.y + p.h + 4 && b.x + R >= p.x && b.x - R <= p.x + p.w){
b.y = p.y - R;
const rel = clamp((b.x - (p.x + p.w/2)) / (p.w/2), -1, 1);
const ang = rel * MAX_ANGLE;
const sp = Math.min(Math.hypot(b.vx,b.vy) * 1.01, 700);
b.vx = Math.sin(ang) * sp;
b.vy = -Math.cos(ang) * sp;
...effects
}
```
Hmm, but if the ball is exactly at the edge with rel=±1, angle = ±62°, still going up. Good.
Also add a little influence from paddle velocity? Not needed. Maybe add slight spin from paddle movement for polish. Skip.
collideBricks: iterate bricks; for each alive brick, circle-rect test.
```js
function collideBricks(b){
for (const br of bricks){
if (br.hp <= 0) continue;
// closest point
const cx = clamp(b.x, br.x, br.x+br.w);
const cy = clamp(b.y, br.y, br.y+br.h);
const dx = b.x - cx, dy = b.y - cy;
if (dx*dx + dy*dy > R*R) continue;
// resolve
...
}
}
```
Wait: if the ball center is inside the rect, dx=dy=0, distance 0 < R → collision, but normal is undefined. Handle: compute penetration on each axis.
Let's do a robust resolution:
```js
const overlapX = (br.w/2 + R) - Math.abs(b.x - (br.x+br.w/2));
const overlapY = (br.h/2 + R) - Math.abs(b.y - (br.y+br.h/2));
if (overlapX <= 0 || overlapY <= 0) continue; // no collision (AABB expanded)
if (overlapX < overlapY) { reflect X, push out }
else { reflect Y, push out }
```
This is the expanded-AABB approach which is standard for breakout and works well. But it uses AABB vs circle as a box — slightly generous at corners, but fine and prevents tunneling well.
Hmm, at corners it can cause weird bounces but acceptable. Actually it's the classic approach; fine.
Push out:
```js
if (overlapX < overlapY){
if (b.x < br.x + br.w/2) { b.x -= overlapX; b.vx = -Math.abs(b.vx); }
else { b.x += overlapX; b.vx = Math.abs(b.vx); }
} else {
if (b.y < br.y + br.h/2) { b.y -= overlapY; b.vy = -Math.abs(b.vy); }
else { b.y += overlapY; b.vy = Math.abs(b.vy); }
}
```
Then damage brick, and break out of loop (one brick per substep).
Hmm, but with overlapX/overlapY, if the ball hits exactly at a corner both are equal → picks Y. Fine.
Damage:
```js
br.hp--;
if (br.hp <= 0){ destroy: particles, score, maybe drop powerup }
else { crack particles small, shake }
```
Score: normal 100? Let's do 50 for hit, 100 for destroy... Simpler: on destroy, score += 10 * (hp initial?) Let's do: normal brick 100, tough brick 250 on destroy. Plus 25 per hit on tough. Keep simple: score += br.maxHp === 2 ? 250 : 100 on destroy.
Hmm, keep it simple: destroy → score += 100 * (brick.maxHp) → 100 or 200. Fine.
Particles:
```js
for (let i=0;i<12;i++){ ... }
```
Particle: x,y,vx,vy,life,maxLife,size,color.
Gravity 900. Fade out.
Also add small "spark" particles on paddle hit.
Demo AI: paddle target = ball x. Move paddle toward the ball's x with some speed limit, with a slight offset to make it look natural. Also aim to create angle. Simple:
```js
const target = ball.x - paddle.w/2 + Math.sin(t*2)*10;
paddle.x += clamp(target - paddle.x, -maxSpeed*dt, maxSpeed*dt);
```
Actually a good AI: predict where the ball will land. Simple version: if ball.vy > 0, target = ball.x; else target = W/2 (center) — that looks decent and human-like. Let's use the ball.x tracking with limited speed (700 px/s). That will basically never miss since the ball moves at ~380. Hmm, it might miss on sharp angles. Fine — with infinite lives, no big deal.
Actually, for a nicer demo, use prediction: simulate ball position until it reaches paddle y, reflecting off walls. That's more code but nicer. Let's do a simple prediction with wall reflection:
```js
function predictX(b, targetY){
let x=b.x, y=b.y, vx=b.vx, vy=b.vy;
if (vy <= 0) return x; // not heading down
let t = (targetY - y)/vy;
x += vx*t;
// reflect
const span = W - 2*R;
let xx = x - R;
xx = ((xx % (2*span)) + 2*span) % (2*span);
if (xx > span) xx = 2*span - xx;
return xx + R;
}
```
That's neat. Use it in demo mode. Also handle brick collisions ignored — fine.
Paddle moves toward predicted x with max speed 620.
Demo mode should still be interesting. Good.
Also in demo mode, the AI should catch powerups too — it will naturally since it tracks the ball. Fine.
Now, states.
```js
let state = 'demo'; // 'demo' | 'play' | 'over' | 'win'
```
HUD shows "DEMO" badge in demo mode maybe.
Start: on click/keydown anywhere → resetGame(); state='play'.
In 'over'/'win', overlay shows "GAME OVER" / "VICTORY" + "Click or press SPACE to play again". Click/keydown → reset and play.
Also add a pause on visibility change? Not needed.
Lives: 3, drawn as small paddle icons or hearts in HUD.
When ball lost: if all balls dead → lose life; if lives > 0, spawn new ball on paddle (sticky until click? no — just launch automatically after short delay). Let's do: reset ball on paddle, state 'serve' with a brief 0.8s delay then launch. Simpler: immediately spawn ball attached to paddle and launch after 0.9s. During that time the paddle can move and the ball follows the paddle.
Let's implement: if no balls and lives > 0 → lives--, spawn ball attached (ball.stuck = true, follows paddle center), serveTimer = 1.0. When timer ≤ 0 → launch.
Hmm, if lives become 0 → game over.
Let me implement with a `serveTimer`.
Actually simpler: on ball loss, if lives > 0: lives--; if lives === 0 → game over. else spawn stuck ball with serveTimer=1.2.
Wait, standard: start with 3 lives. Lose ball → lives 2 → still playing. Lose again → 1. Lose again → 0 → game over. So decrement on each loss, and game over when lives hits 0.
Hmm, but "3 lives" typically means you get 3 balls total. Let's do: lives = 3 displayed; on losing a ball, lives--; if lives <= 0 → game over; else serve new ball.
Display lives as 3 hearts initially, then 2, then 1. Good.
Victory: all bricks destroyed → 'win'.
Restart option: click / press key.
Let me also add a subtle "Level 1" label.
Now the visuals.
Background: dark gradient with subtle grid or stars. Let's do radial gradient + faint vignette. Draw once per frame (cheap enough at 900x640).
Actually creating gradients every frame is fine.
Playfield: draw a rounded rect border with glow around the play area (0,TOP) to (W,H).
Hmm, walls at x=0 and x=W means the border is at the canvas edge. Let's inset the playfield: LEFT=12, RIGHT=W-12, TOP=64, BOTTOM=H-8. Then bricks from LEFT+18.
Let me redefine with a proper frame.
Let WALL_L = 14, WALL_R = W-14 = 886, WALL_T = 70, WALL_B = H (bottom open).
Bricks: side margin 14+16=30, so from 30 to 870 = 840 width. Same as before.
Ball bounces off WALL_L, WALL_R, WALL_T. Bottom at H.
Paddle: y = 588, h = 14, w = 130. Paddle x range [WALL_L, WALL_R - w].
Let me now write the drawing functions.
drawBrick(br):
```js
const grad = ctx.createLinearGradient(br.x, br.y, br.x, br.y+br.h);
grad.addColorStop(0, lighten(color));
grad.addColorStop(1, color);
```
I'll precompute colors as objects with base and light.
Let's define a helper to convert hex to rgba or just define palette entries as {c1, c2}.
Palette (gradient top → bottom):
row0: ['#ff8fa3', '#e5383b']
row1: ['#ffb703', '#f77f00']
row2: ['#ffe066', '#f2b705'] hmm too similar to row1.
Let me use distinct hues:
0: pink '#ff6b9d' → '#c9184a'
1: orange '#ffb26b' → '#e8590c'
2: yellow '#ffe066' → '#f0a500'
3: green '#8ce99a' → '#2f9e44'
4: cyan '#66d9e8' → '#0b7285'
5: blue '#8bb8ff' → '#1c4ed8'
6: purple '#c9a3ff' → '#6741d9'
Good.
Tough bricks: same color but with a metallic sheen — add a lighter top highlight and a darker inner border, plus a small "lock" or double-line. When cracked, draw crack lines in dark.
Rounded rect: use ctx.roundRect if available (Chrome supports it). I'll write my own for safety... Chrome latest supports roundRect. I'll use a helper.
Let me write helper `rr(ctx,x,y,w,h,r)`.
Drawing bricks:
```js
ctx.save();
rr(ctx, br.x, br.y, br.w, br.h, 5);
const g = ctx.createLinearGradient(0, br.y, 0, br.y+br.h);
g.addColorStop(0, col.a); g.addColorStop(1, col.b);
ctx.fillStyle = g; ctx.fill();
// inner highlight
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1;
...
```
For tough: add `ctx.strokeStyle='rgba(255,255,255,0.55)'; lineWidth=1.5; stroke` and maybe a diagonal sheen.
Cracks: if br.hp < br.maxHp, draw jagged lines:
```js
ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.lineWidth=1.5;
ctx.beginPath();
ctx.moveTo(br.x+br.w*0.3, br.y+2); ctx.lineTo(br.x+br.w*0.42, br.y+br.h*0.5); ctx.lineTo(br.x+br.w*0.32, br.y+br.h-2);
ctx.moveTo(br.x+br.w*0.42, br.y+br.h*0.5); ctx.lineTo(br.x+br.w*0.62, br.y+br.h*0.62);
ctx.stroke();
```
Good — visible cracks.
Powerup badge on brick: small circle with icon.
Now the ball: white with a glow (shadowBlur). Trail: keep a short history of positions? Might be nice but adds complexity. Let's add a simple glow with shadowBlur — cheap.
Paddle: rounded rect with gradient, glow, and small side caps.
Particles: circles with alpha.
Screen shake: apply ctx.translate with random offset when shake > 0.
Let's write the code.
```html
<!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:#07070d;overflow:hidden;}
body{display:flex;align-items:center;justify-content:center;font-family:...;}
#wrap{position:relative;}
canvas{display:block;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.06);cursor:none;}
</style>
```
Hmm, cursor:none on canvas would hide the cursor — for a mouse-controlled game that's fine (the paddle follows the cursor). But it might confuse. Let's keep default cursor... Actually hiding the cursor over the canvas is nice. But then users may not know. I'll keep `cursor: none` only when playing? Simpler: keep `crosshair`. Let's use `cursor: default` — hmm. I'll use `cursor: none` since the paddle is the cursor. Actually, the paddle is at the bottom and the mouse could be anywhere; hiding the cursor makes it feel odd. Let's keep the default arrow. Fine.
Let me write JS now.
```js
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 900, H = 640;
```
Resize handling as above.
Constants:
```js
const WALL_L = 14, WALL_R = W - 14, WALL_T = 70;
const BALL_R = 8;
const PADDLE_Y = 584, PADDLE_H = 14, PADDLE_W = 130;
const MAX_ANGLE = 62 * Math.PI/180;
```
Paddle object:
```js
const paddle = { x: W/2 - PADDLE_W/2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H, baseW: PADDLE_W, targetX: null, vx: 0 };
```
Wide timer:
```js
let wideTimer = 0;
```
When wideTimer > 0, target width = baseW*1.6. Animate paddle.w toward target width smoothly (lerp) — nice polish. Keep paddle centered when width changes.
Let me store paddle.cx (center) and compute x = cx - w/2. Actually for mouse control the paddle center follows the mouse. Let's store paddle.cx as the primary. Clamp cx to [WALL_L + w/2, WALL_R - w/2].
Yes: paddle.cx.
```js
paddle.w = lerp(paddle.w, targetW, 0.15)
paddle.cx clamp
```
When width changes, the center stays; ok.
Ball:
```js
function makeBall(x,y,vx,vy){ return {x,y,vx,vy,r:BALL_R,stuck:false}; }
```
Level generation:
```js
const COLS=12, ROWS=7;
const BRICK_GAP=6;
const FIELD_L = WALL_L + 16, FIELD_R = WALL_R - 16;
const brickW = (FIELD_R - FIELD_L - BRICK_GAP*(COLS-1))/COLS;
const brickH = 26;
const BRICK_TOP = WALL_T + 18;
```
WALL_T=70 → BRICK_TOP=88. 7 rows * (26+6) = 224 → ends at 88+224-6 = 306. Good.
Paddle at 584, ball spawn at ~560. Plenty of space.
Hmm, that's a lot of empty space (306 → 584 = 278px). It's fine — that's the play area.
Actually maybe make bricks a bit taller or start lower. It's fine.
Powerup placement list:
```js
const POWER_MAP = {
'1,1':'wide', '1,6':'multi', '2,4':'wide', '2,9':'multi',
'3,2':'multi', '3,10':'wide', '5,0':'wide', '5,7':'multi'
};
```
Hmm, row 3 has tough bricks at c%3===2 → c=2,5,8,11. So '3,2' is tough. Fine.
Let's finalize tough rule:
```js
const isTough = (r===0 && c%2===1) || (r===3 && c%3===2) || (r===6 && c%2===0);
```
Powerups: choose positions on rows 1,2,4,5 (non-tough rows) plus maybe row 3.
```js
const POWER_POS = [
[1,1,'wide'], [1,6,'multi'],
[2,3,'multi'], [2,9,'wide'],
[4,2,'wide'], [4,8,'multi'],
[5,5,'multi'], [5,10,'wide'],
];
```
That's 8 powerups out of 84 bricks. Reasonable.
Colors by row index.
Now, powerup object: {x, y, w:34, h:18, type, vy:150, t:0}.
Drawing falling powerup: rounded rect with color (wide=cyan/green, multi=orange), icon, glow, and slight bobbing rotation.
Catch: rect intersect with paddle rect.
Apply:
- wide: wideTimer = 10;
- multi: spawnBalls.
spawnBalls():
```js
const src = balls[0];
if (!src) return;
const n = Math.min(2, 6 - balls.length); // ensure ~3
```
Let's do: create 2 extra balls from the first ball, angles ±25°, total balls min(6). Actually requirement says 3 balls. Let's just: take the first ball, add 2 clones at ±0.45 rad rotated. If balls.length is already 3+, add 1 clone.
I'll do:
```js
function multiBall(){
const sources = balls.slice(0, 3);
const added = [];
for (const b of sources){
if (balls.length + added.length >= 6) break;
const sp = Math.hypot(b.vx,b.vy);
const ang = Math.atan2(b.vy,b.vx);
for (const d of [0.5, -0.5]){
if (balls.length + added.length >= 6) break;
added.push(makeBall(b.x, b.y, Math.cos(ang+d)*sp, Math.sin(ang+d)*sp));
}
break; // only from the first ball
}
balls.push(...added);
}
```
Simpler: from the first ball, add 2 clones with ±0.5 rad. Cap at 6. Good — "3 balls".
But wait: if the ball is stuck to the paddle when caught, angles... rare. Fine.
Also need to make sure the added balls don't spawn inside a brick — they spawn at the paddle area, so fine.
Now the main loop.
```js
let last = performance.now();
function loop(now){
let dt = (now - last)/1000; last = now;
dt = Math.min(dt, 0.05);
update(dt);
render();
requestAnimationFrame(loop);
}
```
update(dt):
- if state==='demo' → AI paddle control.
- else if state==='play' → keyboard/mouse control already applied via events? Better to apply input each frame:
For mouse: track mouseX; each frame set paddle.cx toward mouseX (instant). For keyboard: if left pressed, cx -= 640*dt.
Let's do:
```js
if (state==='play' || state==='demo'){
if (state==='demo'){ aiControl(dt); }
else {
if (keys.left) paddle.cx -= PADDLE_SPEED*dt;
if (keys.right) paddle.cx += PADDLE_SPEED*dt;
if (mouseActive) paddle.cx = lerp(paddle.cx, mouseX, 0.35); // smooth follow
}
}
```
Hmm — if both mouse and keyboard are used, mouse would override. Use: keyboard sets a flag that disables mouse until the mouse moves again. Let's do: on mousemove, set `useMouse = true`; on arrow keydown, `useMouse = false`.
Good.
Mouse smoothing: instant is more responsive. Let's do instant follow (paddle.cx = mouseX) but that can feel jerky; instant is standard for breakout. I'll do instant.
Hmm, but with mouse, if the mouse is outside the canvas vertically it still tracks x. Fine.
Actually a slight lerp (0.5) feels smoother. Let's use direct assignment for precision.
Ball update as described.
Serve: if ball.stuck, ball.x = paddle.cx, ball.y = paddle.y - BALL_R - 2; serveTimer -= dt; if serveTimer <= 0 → launch.
Launch angle: random-ish upward, e.g. -60° from horizontal... Let's use angle = -Math.PI/2 + (Math.random()-0.5)*0.6.
Now, the AI in demo mode: also needs to handle stuck ball (just launch immediately).
Demo mode: infinite lives. On ball loss in demo: respawn ball without losing lives.
Let me write a function `loseBall()`:
```js
if (state==='demo'){ serveNewBall(); return; }
lives--;
if (lives <= 0){ state='over'; ... } else serveNewBall();
```
serveNewBall(): balls = [makeBall(paddle.cx, paddle.y - BALL_R - 2, 0,0)] with stuck=true, serveTimer = state==='demo'?0.4:1.0.
Victory check: bricks.every(b=>b.hp<=0) → in demo, reset level; in play, state='win'.
Now render.
Order:
1. clear / background gradient
2. shake translate
3. playfield frame glow
4. bricks
5. powerups
6. particles
7. paddle
8. balls
9. HUD
10. overlays (demo message, game over, win)
Let me write the background:
```js
const bg = ctx.createLinearGradient(0,0,0,H);
bg.addColorStop(0,'#0b0f1e'); bg.addColorStop(1,'#05060c');
```
Plus radial glow near top center.
I'll cache the background in an offscreen canvas for performance. Actually just draw each frame — it's fine.
HUD:
- Left: "SCORE" label + value.
- Center: "LEVEL 1".
- Right: lives as small paddle icons.
- Also show active powerup timer bar for wide paddle.
Let me draw HUD text with a monospace-ish font stack: `'Segoe UI', system-ui, sans-serif`. Use bold.
Font sizes in logical px.
Let's write:
```js
ctx.font = '700 13px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign='left'; ctx.fillText('SCORE', 24, 28);
ctx.font = '800 24px ...'; ctx.fillStyle='#fff'; ctx.fillText(String(score).padStart(5,'0'), 24, 52);
```
Lives: draw small rounded rects (paddles) at right.
Wide timer: a small bar under the score area, or next to it. Let's draw near center-right: "WIDE 8.2s" with a bar.
Maybe just draw a small pill under the HUD center when active.
Let's keep it clean:
- x=24: SCORE + value
- center (W/2): "LEVEL 1" small, and below it a wide-paddle timer bar if active.
- right: lives icons at x from W-24 going left.
OK.
Overlay for demo: a semi-transparent dark panel across the middle with "CLICK OR PRESS A KEY TO START" and a subtitle "Watch the demo — the AI is playing". Pulsing alpha.
Should the overlay block the view of the game? Make it a centered pill with backdrop blur-ish. Let's draw a rounded rect with rgba(0,0,0,0.55) and white text, pulsing.
For game over: full-screen dark overlay with "GAME OVER", score, "Click or press any key to restart".
For win: "VICTORY!" with score and restart prompt.
Now let's write the code carefully.
```js
// ---------- utils ----------
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
const lerp = (a,b,t)=>a+(b-a)*t;
function rr(c,x,y,w,h,r){
r = Math.min(r, w/2, h/2);
c.beginPath();
c.moveTo(x+r,y);
c.lineTo(x+w-r,y); c.quadraticCurveTo(x+w,y,x+w,y+r);
c.lineTo(x+w,y+h-r); c.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
c.lineTo(x+r,y+h); c.quadraticCurveTo(x,y+h,x,y+h-r);
c.lineTo(x,y+r); c.quadraticCurveTo(x,y,x+r,y);
c.closePath();
}
```
Colors:
```js
const ROW_COLORS = [
['#ff8fa8','#d90429'],
['#ffc078','#e8590c'],
['#ffe066','#e6a700'],
['#8ce99a','#2b8a3e'],
['#66d9e8','#0b7285'],
['#8bb8ff','#1c4ed8'],
['#d0bfff','#6741d9'],
];
```
Brick object: {x,y,w,h,hp,maxHp,row,col,color,power}
Build level:
```js
let bricks = [];
function buildLevel(){
bricks = [];
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
const x = FIELD_L + c*(brickW+BRICK_GAP);
const y = BRICK_TOP + r*(brickH+BRICK_GAP);
const tough = (r===0 && c%2===1) || (r===3 && c%3===2) || (r===6 && c%2===0);
const hp = tough?2:1;
let power = null;
for (const [pr,pc,pt] of POWER_POS) if (pr===r && pc===c) power = pt;
bricks.push({x,y,w:brickW,h:brickH,hp,maxHp:hp,row:r,col:c,power,hit:0});
}
}
}
```
brick.hit used for a flash animation.
Rendering bricks with `hit` flash: hit decays over time, used to brighten.
OK. Now particles.
```js
let particles = [];
function spawnParticles(x,y,color,count,spread){
for(let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const s = Math.random()*spread;
particles.push({x,y,vx:Math.cos(a)*s, vy:Math.sin(a)*s - 40, life:1, max:1, r: 2+Math.random()*3, color});
}
}
```
Update: vy += 1200*dt; x+=vx*dt; y+=vy*dt; life -= dt*1.6.
Cap particles at ~400.
Rendering particles: `ctx.globalAlpha = life; fillRect or arc`.
OK, now let's handle the render of the playfield frame. Draw a rounded rect stroke around (WALL_L, WALL_T) to (WALL_R, H) — but bottom is open. Let's just draw left/right/top walls as glowing lines.
```js
ctx.strokeStyle='rgba(120,180,255,0.25)'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(WALL_L, H); ctx.lineTo(WALL_L, WALL_T+6); ctx.quadraticCurveTo(WALL_L, WALL_T, WALL_L+6, WALL_T);
ctx.lineTo(WALL_R-6, WALL_T); ctx.quadraticCurveTo(WALL_R, WALL_T, WALL_R, WALL_T+6);
ctx.lineTo(WALL_R, H);
ctx.stroke();
```
Nice.
Now the ball trail: I'll add a simple motion trail by drawing 3 ghost circles at reduced alpha along the velocity direction. Cheap.
```js
for (let i=1;i<=3;i++){
ctx.globalAlpha = 0.12/i;
ctx.beginPath(); ctx.arc(b.x - b.vx*0.008*i, b.y - b.vy*0.008*i, b.r, 0, 7); ctx.fillStyle='#fff'; ctx.fill();
}
```
OK.
Let's write the whole update logic.
```js
function update(dt){
time += dt;
// paddle width
const targetW = wideTimer > 0 ? paddle.baseW*1.65 : paddle.baseW;
paddle.w = lerp(paddle.w, targetW, Math.min(1, dt*12));
if (wideTimer > 0){ wideTimer -= dt; if (wideTimer <= 0){ wideTimer = 0; } }
// input
if (state === 'demo'){
aiUpdate(dt);
} else if (state === 'play'){
if (keys.left) paddle.cx -= PADDLE_SPEED*dt;
if (keys.right) paddle.cx += PADDLE_SPEED*dt;
if (useMouse) paddle.cx = mouseX;
}
paddle.cx = clamp(paddle.cx, WALL_L + paddle.w/2, WALL_R - paddle.w/2);
// balls
...
}
```
Hmm, in demo mode paddle.w also changes when the AI catches wide powerups. Fine.
Note: clamp uses paddle.w which is changing; fine.
Ball update loop:
```js
for (let i = balls.length-1; i>=0; i--){
const b = balls[i];
if (b.stuck){
b.x = paddle.cx; b.y = paddle.y - BALL_R - 2;
serveTimer -= dt;
if (serveTimer <= 0){ launchBall(b); }
continue;
}
stepBall(b, dt);
if (b.dead) balls.splice(i,1);
}
```
Careful with serveTimer decrementing per stuck ball — there's only one.
launchBall(b): angle random.
stepBall with substeps as described.
After the loop: if balls.length === 0 && state !== 'over' && state !== 'win' → loseBall().
But careful: loseBall creates a new ball. So do it after the loop.
Also victory check after brick collisions.
Powerups update:
```js
for (i...){ p.y += p.vy*dt; if (p.y > H+30) remove; if (catch) apply; }
```
Particles update.
Shake decay.
Now `stepBall`:
```js
function stepBall(b, dt){
const sp = Math.hypot(b.vx, b.vy);
const steps = Math.max(1, Math.ceil(sp*dt/3));
const sdt = dt/steps;
for (let s=0;s<steps;s++){
b.x += b.vx*sdt;
b.y += b.vy*sdt;
// walls
if (b.x - BALL_R < WALL_L){ b.x = WALL_L + BALL_R; b.vx = Math.abs(b.vx); }
else if (b.x + BALL_R > WALL_R){ b.x = WALL_R - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y - BALL_R < WALL_T){ b.y = WALL_T + BALL_R; b.vy = Math.abs(b.vy); }
// paddle
hitPaddle(b);
// bricks
hitBricks(b);
if (b.y - BALL_R > H) { b.dead = true; return; }
}
}
```
hitPaddle:
```js
function hitPaddle(b){
if (b.vy <= 0) return;
const p = paddle;
if (b.y + BALL_R < p.y) return;
if (b.y - BALL_R > p.y + p.h) return;
if (b.x + BALL_R < p.cx - p.w/2 || b.x - BALL_R > p.cx + p.w/2) return;
b.y = p.y - BALL_R;
const rel = clamp((b.x - p.cx)/(p.w/2), -1, 1);
const ang = rel * MAX_ANGLE;
let sp = Math.hypot(b.vx, b.vy);
sp = Math.min(sp*1.012, 720);
sp = Math.max(sp, 320);
b.vx = Math.sin(ang)*sp;
b.vy = -Math.cos(ang)*sp;
// fx
spawnParticles(b.x, p.y, '#7dd3fc', 5, 120);
shake = Math.min(shake + 2, 6);
playSound? // no audio, keep silent (or use WebAudio? allowed but risky). Skip.
}
```
Wait — `b.y + BALL_R < p.y` returns early, meaning the ball hasn't reached the paddle. And `b.y - BALL_R > p.y + p.h` means it's below. Combined check: ball overlaps paddle vertically. Good.
But there's an issue: if the ball is below the paddle top and moving down, it would get "teleported" to p.y - BALL_R, which could be a big jump. With substepping (3px), that won't happen. But if the ball somehow gets below... let's add: if (b.y > p.y + p.h) return; — handled.
Actually a subtlety: b.y + BALL_R >= p.y means the ball's bottom is at/below paddle top. If the ball is at paddle level moving down and the paddle moves into it from the side... rare. Fine.
hitBricks:
```js
function hitBricks(b){
for (const br of bricks){
if (br.hp <= 0) continue;
const bcx = br.x + br.w/2, bcy = br.y + br.h/2;
const ox = (br.w/2 + BALL_R) - Math.abs(b.x - bcx);
const oy = (br.h/2 + BALL_R) - Math.abs(b.y - bcy);
if (ox <= 0 || oy <= 0) continue;
if (ox < oy){
if (b.x < bcx){ b.x -= ox; b.vx = -Math.abs(b.vx); }
else { b.x += ox; b.vx = Math.abs(b.vx); }
} else {
if (b.y < bcy){ b.y -= oy; b.vy = -Math.abs(b.vy); }
else { b.y += oy; b.vy = Math.abs(b.vy); }
}
damageBrick(br);
return; // one brick per substep
}
}
```
Hmm, `return` after the first collision means the ball might be inside another brick. Next substep handles it. Fine.
damageBrick:
```js
function damageBrick(br){
br.hp--;
br.hit = 1;
if (br.hp > 0){
spawnParticles(br.x+br.w/2, br.y+br.h/2, '#ffffff', 4, 90);
shake = Math.min(shake+2, 7);
score += 25; // hmm, maybe not
return;
}
// destroyed
const col = ROW_COLORS[br.row][0];
spawnParticles(br.x+br.w/2, br.y+br.h/2, col, 16, 260);
spawnParticles(br.x+br.w/2, br.y+br.h/2, '#ffffff', 6, 160);
score += br.maxHp === 2 ? 250 : 100;
shake = Math.min(shake+3, 9);
if (br.power) dropPowerup(br);
checkVictory();
}
```
Careful: checkVictory during the ball loop — setting state to 'win' mid-update. That's fine as long as we handle it.
Also, don't award points for hitting tough bricks twice? Let's give 25 for the first hit. Fine.
dropPowerup(br): push {x: br.x+br.w/2, y: br.y+br.h/2, type: br.power, w:36, h:18, vy:150, t:0}.
Powerup catch:
```js
const p = paddle;
if (pu.y + pu.h/2 > p.y && pu.y - pu.h/2 < p.y+p.h && pu.x + pu.w/2 > p.cx - p.w/2 && pu.x - pu.w/2 < p.cx + p.w/2)
```
Since the powerup falls fast (150 px/s → 2.5px/frame) and the paddle is 14 tall, it won't tunnel. Fine.
applyPowerup(type).
For wide: wideTimer = 10.
For multi: multiBall();
Add a floating text popup for feedback: "+WIDE!" etc. Let's add `popups` array with text, position, life. Nice polish.
OK.
checkVictory: if bricks.every(b=>b.hp<=0) → if state==='demo' → reset level (buildLevel, balls reset). else state='win'.
Hmm, in demo mode after clearing, reset and continue.
Now the render.
Let me write render():
```js
function render(){
ctx.setTransform(scaleX,0,0,scaleY,0,0); // set in resize; but shake needs translate
```
Careful: resize sets the transform. In render, I should reset with ctx.setTransform(pxScale,0,0,pxScale,0,0) then apply shake translate. Let me store `viewScale = canvas.width / W` (which equals canvas.height/H since aspect preserved).
Actually canvas.width = cssW*dpr, and cssW/W = cssH/H = scale. So canvas.width/W = scale*dpr = uniform. Good, store `const vs = canvas.width / W;`
In render:
```js
ctx.setTransform(vs,0,0,vs,0,0);
ctx.clearRect(0,0,W,H);
```
Then apply shake: ctx.save(); ctx.translate(sx, sy); ... draw world ... ctx.restore(); then draw HUD/overlays without shake.
Good.
Let's write the drawing code.
Background:
```js
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0d1224');
g.addColorStop(0.55,'#080a14');
g.addColorStop(1,'#05060b');
ctx.fillStyle = g; ctx.fillRect(0,0,W,H);
// radial glow
const rg = ctx.createRadialGradient(W/2, 260, 40, W/2, 260, 520);
rg.addColorStop(0,'rgba(60,120,255,0.13)');
rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle = rg; ctx.fillRect(0,0,W,H);
```
Maybe add subtle dot grid. Optional, skip for perf... it's fine, draw a faint grid of lines? Let's add a subtle vertical/horizontal grid with very low alpha:
Actually skip; keep it clean.
Bricks drawing:
```js
for (const br of bricks){
if (br.hp <= 0) continue;
drawBrick(br);
}
```
drawBrick:
```js
function drawBrick(br){
const [c1,c2] = ROW_COLORS[br.row];
ctx.save();
// shadow
ctx.shadowColor = c2; ctx.shadowBlur = 10; ctx.shadowOffsetY = 2;
rr(ctx, br.x, br.y, br.w, br.h, 6);
const g = ctx.createLinearGradient(0, br.y, 0, br.y+br.h);
g.addColorStop(0, c1);
g.addColorStop(1, c2);
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
// top highlight
ctx.save();
rr(ctx, br.x, br.y, br.w, br.h, 6);
ctx.clip();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.fillRect(br.x, br.y, br.w, br.h*0.42);
ctx.restore();
// tough indicator
if (br.maxHp === 2 && br.hp === 2){
ctx.save();
rr(ctx, br.x+1.5, br.y+1.5, br.w-3, br.h-3, 5);
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// metallic diagonal sheen
...
}
...
}
```
Hmm, the glow shadow per brick with 84 bricks might be slow-ish but ok at 60fps? shadowBlur is expensive. 84 bricks * shadowBlur... could be heavy. Let's skip shadowBlur on bricks and instead use a subtle inner glow via gradient. Actually let's test mentally: shadowBlur on 84 rounded rects per frame in Chrome — probably fine but risky on low-end. I'll skip the shadow for bricks and add a subtle border instead. Better perf.
For tough bricks, draw a lighter border + inner darker line + a small "double" indicator. Actually let's render tough bricks with a distinct look: a lighter inner frame and a subtle metallic gradient overlay.
Cracked (hp=1 of 2): draw dark crack lines + reduce brightness.
Let's write:
```js
function drawBrick(br){
const [c1,c2] = ROW_COLORS[br.row];
const x=br.x,y=br.y,w=br.w,h=br.h;
const g = ctx.createLinearGradient(0,y,0,y+h);
if (br.maxHp===2 && br.hp===2){
g.addColorStop(0, c1); g.addColorStop(0.5, c2); g.addColorStop(1, shade(c2,-0.25));
} else if (br.maxHp===2 && br.hp===1){
// damaged: darker
g.addColorStop(0, shade(c1,-0.25)); g.addColorStop(1, shade(c2,-0.35));
} else {
g.addColorStop(0,c1); g.addColorStop(1,c2);
}
rr(ctx,x,y,w,h,6); ctx.fillStyle=g; ctx.fill();
// gloss
ctx.save(); rr(ctx,x,y,w,h,6); ctx.clip();
ctx.fillStyle='rgba(255,255,255,0.18)';
ctx.fillRect(x, y, w, h*0.45);
ctx.restore();
// border
rr(ctx,x+0.5,y+0.5,w-1,h-1,5.5);
ctx.strokeStyle = br.maxHp===2 ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.18)';
ctx.lineWidth = br.maxHp===2 ? 1.6 : 1;
ctx.stroke();
...
}
```
`shade(hex, amt)` helper: parse hex, multiply.
Cracks when damaged:
```js
if (br.maxHp===2 && br.hp===1){
ctx.strokeStyle='rgba(10,10,20,0.65)'; ctx.lineWidth=1.6; ctx.lineCap='round';
ctx.beginPath();
const cx0 = x + w*0.30, cy0 = y+2;
ctx.moveTo(cx0, cy0);
ctx.lineTo(cx0 + w*0.10, y+h*0.45);
ctx.lineTo(cx0 - w*0.04, y+h-2);
ctx.moveTo(cx0 + w*0.10, y+h*0.45);
ctx.lineTo(cx0 + w*0.30, y+h*0.72);
ctx.moveTo(cx0 + w*0.30, y+h*0.72);
ctx.lineTo(cx0 + w*0.42, y+h*0.30);
ctx.stroke();
}
```
Good.
Powerup icon on brick:
```js
if (br.power){
const bx = x+w/2, by = y+h/2;
ctx.save();
ctx.beginPath(); ctx.arc(bx,by,7.5,0,7);
ctx.fillStyle='rgba(8,10,20,0.55)'; ctx.fill();
ctx.strokeStyle = br.power==='wide' ? '#7bf1a8' : '#ffd166';
ctx.lineWidth=1.4; ctx.stroke();
// icon
...
ctx.restore();
}
```
Icon wide: two triangles + line (horizontal double arrow).
```js
ctx.fillStyle='#7bf1a8';
ctx.beginPath();
ctx.moveTo(bx-6, by); ctx.lineTo(bx-2.5, by-3.2); ctx.lineTo(bx-2.5, by+3.2); ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(bx+6, by); ctx.lineTo(bx+2.5, by-3.2); ctx.lineTo(bx+2.5, by+3.2); ctx.closePath(); ctx.fill();
```
Icon multi: three small circles.
```js
ctx.fillStyle='#ffd166';
for (const dx of [-4.5,0,4.5]){ ctx.beginPath(); ctx.arc(bx+dx, by, 2.2, 0, 7); ctx.fill(); }
```
Nice and readable.
Falling powerup: rounded rect 36x18, gradient, glow, icon inside, plus a little pulsing.
Now the paddle:
```js
function drawPaddle(){
const x = paddle.cx - paddle.w/2, y = paddle.y, w = paddle.w, h = paddle.h;
ctx.save();
ctx.shadowColor = wideTimer>0 ? 'rgba(123,241,168,0.9)' : 'rgba(90,170,255,0.9)';
ctx.shadowBlur = 18;
const g = ctx.createLinearGradient(0,y,0,y+h);
g.addColorStop(0, wideTimer>0 ? '#c9ffd9' : '#dbeafe');
g.addColorStop(0.5, wideTimer>0 ? '#4ade80' : '#60a5fa');
g.addColorStop(1, wideTimer>0 ? '#16a34a' : '#2563eb');
rr(ctx,x,y,w,h,h/2); ctx.fillStyle=g; ctx.fill();
ctx.restore();
// highlight line
ctx.fillStyle='rgba(255,255,255,0.5)';
rr(ctx, x+6, y+2.5, w-12, 2.5, 1.5); ctx.fill();
}
```
Balls: white glow.
Now HUD.
```js
function drawHUD(){
// top bar background
const g = ctx.createLinearGradient(0,0,0,WALL_T);
g.addColorStop(0,'rgba(255,255,255,0.06)');
g.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=g; ctx.fillRect(0,0,W,WALL_T);
ctx.textAlign='left'; ctx.textBaseline='alphabetic';
ctx.fillStyle='rgba(160,190,255,0.65)';
ctx.font='700 12px ui-sans-serif, system-ui, sans-serif';
ctx.fillText('SCORE', 26, 28);
ctx.fillStyle='#fff';
ctx.font='800 26px ui-sans-serif, system-ui, sans-serif';
ctx.fillText(String(score).padStart(5,'0'), 26, 54);
...
}
```
Hmm, WALL_T=70, so the score baseline at 54 with font 26 fits.
Lives on the right:
```js
ctx.textAlign='right';
ctx.fillStyle='rgba(160,190,255,0.65)';
ctx.font='700 12px ...';
ctx.fillText('LIVES', W-26, 28);
// draw hearts/paddles
for (let i=0;i<3;i++){
const x = W-26 - (i+1)*30 + 8;
...
}
```
Let's draw small paddle rects at y=38..50, width 22, height 8, rounded, filled if i < lives else dim.
Level in the center:
```js
ctx.textAlign='center';
ctx.fillStyle='rgba(160,190,255,0.65)'; ctx.font='700 12px ...';
ctx.fillText('LEVEL 1', W/2, 28);
```
And under it, a wide timer bar:
```js
if (wideTimer>0){
const bw=110, bx=W/2-bw/2, by=38;
ctx.fillStyle='rgba(255,255,255,0.12)'; rr(ctx,bx,by,bw,7,3.5); ctx.fill();
ctx.fillStyle='#4ade80'; rr(ctx,bx,by,bw*(wideTimer/10),7,3.5); ctx.fill();
}
```
Hmm, the level text and bar overlap in y? Level at baseline 28 (font 12 → top ~18). Bar at y=38..45. OK.
Maybe put "WIDE" label. Fine as is.
Overlay:
```js
function drawOverlay(){
if (state==='demo'){
const a = 0.55 + 0.25*Math.sin(time*3);
ctx.save();
ctx.textAlign='center';
// panel
const pw=460, ph=110, px=W/2-pw/2, py=H/2-70;
rr(ctx,px,py,pw,ph,18);
ctx.fillStyle='rgba(6,10,22,0.78)'; ctx.fill();
ctx.strokeStyle='rgba(120,170,255,'+(0.35+0.3*Math.sin(time*3))+')';
ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='#fff'; ctx.font='800 26px ...';
ctx.fillText('CLICK OR PRESS A KEY TO START', W/2, py+50);
ctx.fillStyle='rgba(160,190,255,0.8)'; ctx.font='600 14px ...';
ctx.fillText('Demo mode — the AI is playing', W/2, py+82);
ctx.restore();
}
}
```
Hmm the panel might be too narrow for the text at 26px. "CLICK OR PRESS A KEY TO START" at 26px bold ≈ 30 chars * ~15px = 450px. Tight. Let's make pw=560 and font 24px. And measure? Just use pw=600, font 22.
Actually let's compute with ctx.measureText and set panel width accordingly. Simpler: fixed 600 wide, font 22px. 30 chars * ~13 = 390. Fits.
Position: py = H/2 - 60. But the paddle is at y=584 and the ball area... The panel at H/2=320 is in the play area, over the bricks maybe (bricks end at 306). Panel from 260 to 370 — overlaps the last brick row slightly. That's fine with a dark panel.
Let's place it at py = 400 (below the bricks, above the paddle) — the play area from 306 to 584 is mostly empty. Panel at 400-510. Ball flies through there but the panel is semi-transparent dark; it would obscure the ball. Hmm.
Better: put the panel at the top-center over the bricks? Also obscures.
Alternative: put the message near the bottom, above the paddle: y = 500. Panel 480-590 overlaps the paddle at 584.
Let me just put it in the middle at y=330 (right below bricks) with a semi-transparent dark rounded panel. It's the classic look. The ball will pass behind/in front. Draw the overlay last so it's on top. It's fine — it's an instruction banner.
Actually, let's make the panel narrower and place it at the vertical center of the empty zone: cy = 440. Panel height 90: 395..485. Paddle at 584. Ball travels through it sometimes but it's translucent. OK, good.
Hmm, I want the screenshot to show the game clearly. The panel will be in the middle. That's acceptable and standard.
Let me make it more compact: width 520, height 84, at y = 400.
Text: "CLICK OR PRESS A KEY TO START" (22px bold) at y=432, subtitle at y=460.
OK.
Game over overlay: full dark veil + text.
```js
ctx.fillStyle='rgba(4,6,14,0.78)'; ctx.fillRect(0,0,W,H);
ctx.textAlign='center';
ctx.fillStyle='#ff6b9d'; ctx.font='800 64px ...';
ctx.fillText('GAME OVER', W/2, H/2-30);
ctx.fillStyle='#fff'; ctx.font='700 22px ...';
ctx.fillText('SCORE ' + score, W/2, H/2+30);
ctx.fillStyle='rgba(160,190,255,0.9)'; ctx.font='600 16px ...';
ctx.fillText('Click or press any key to play again', W/2, H/2+80);
```
With pulsing alpha.
Win: 'VICTORY!' in green/gold.
Now, the input handlers.
```js
const keys = {left:false, right:false};
let useMouse = false, mouseX = W/2;
window.addEventListener('keydown', e=>{
if (e.key==='ArrowLeft'||e.key==='a'||e.key==='A'){ keys.left=true; useMouse=false; e.preventDefault(); }
if (e.key==='ArrowRight'||e.key==='d'||e.key==='D'){ keys.right=true; useMouse=false; e.preventDefault(); }
if (state!=='play'){ startGame(); }
});
```
Careful: pressing a key during 'over' starts a new game — good. During 'demo', starts the game — good. During 'play', nothing (except arrow keys).
But there's a subtlety: keydown for arrow keys during 'over' would also trigger startGame — that's fine and desirable.
Hmm, but if state==='demo' and the user presses ArrowLeft, we call startGame. Good.
keyup clears keys.
Mouse:
```js
canvas.addEventListener('mousemove', e=>{
const r = canvas.getBoundingClientRect();
mouseX = (e.clientX - r.left)/r.width * W;
useMouse = true;
});
canvas.addEventListener('mousedown', ()=>{ if (state!=='play') startGame(); });
```
Also click on window? The iframe gets focus on click. Let's attach click to the canvas and also to window.
Touch:
```js
canvas.addEventListener('touchstart', e=>{ ... startGame ... }, {passive:false});
canvas.addEventListener('touchmove', e=>{ e.preventDefault(); const t=e.touches[0]; ... }, {passive:false});
```
Also `window.addEventListener('pointerdown', ...)`.
Let's use pointermove on the canvas for both mouse and touch.
```js
canvas.addEventListener('pointermove', e=>{
const r = canvas.getBoundingClientRect();
mouseX = (e.clientX - r.left)/r.width * W;
useMouse = true;
});
canvas.addEventListener('pointerdown', e=>{
if (state!=='play') startGame();
});
```
pointerdown on canvas gives focus to the iframe. Good.
Also `window.addEventListener('keydown', ...)` — the iframe needs focus for key events, which the click provides.
startGame():
```js
function startGame(){
score = 0; lives = 3; wideTimer = 0;
buildLevel();
balls = []; particles = []; powerups = []; popups = [];
paddle.w = paddle.baseW; paddle.cx = W/2;
state = 'play';
serveNewBall();
time = 0; // maybe keep
}
```
serveNewBall sets serveTimer.
Wait, in demo mode we also need serveNewBall but without losing lives.
Let me restructure:
```js
function serveNewBall(){
const b = makeBall(paddle.cx, paddle.y - BALL_R - 2, 0, 0);
b.stuck = true;
balls = [b];
serveTimer = state === 'demo' ? 0.5 : 1.1;
}
```
Hmm, `balls = [b]` clears other balls. That's fine since serve happens when all balls are gone.
Demo start:
```js
function startDemo(){
state='demo'; score=0; lives=3; wideTimer=0;
buildLevel(); particles=[]; powerups=[]; popups=[];
paddle.w = paddle.baseW; paddle.cx = W/2;
serveNewBall();
}
```
loseBall():
```js
function loseBall(){
if (state==='demo'){ serveNewBall(); return; }
lives--;
shake = 10;
if (lives <= 0){ lives = 0; state='over'; }
else serveNewBall();
}
```
Hmm, "3 lives" — with this, you lose the ball 3 times before game over? lives goes 3→2→1→0 → game over on the 3rd loss. Yes, 3 balls.
But the HUD shows lives=3 at start, and after the first loss shows 2. That's standard.
Hmm, but at game over lives=0 and the display shows 0 hearts. Fine.
Actually many games show lives as remaining balls including the current one. Whatever.
Victory:
```js
function checkVictory(){
if (bricks.some(b=>b.hp>0)) return;
if (state==='demo'){ buildLevel(); serveNewBall(); return; }
state='win';
balls = [];
}
```
Hmm, in demo, clearing the level → rebuild. But serveNewBall would reset balls to 1, which is fine.
Wait, but checkVictory is called during the ball's substep loop. If we reset balls array while iterating... In stepBall we're iterating over balls with index i in the update loop. Setting balls=[] mid-iteration could break. Let's use a flag instead.
Better: use a `victoryPending` flag checked after the ball loop.
Actually in update(), the ball loop is `for (let i = balls.length-1; i>=0; i--)`. If during stepBall we set `balls = []`, the loop continues with the old array reference? No — `balls` is reassigned, but the loop uses `balls.length` each iteration... Actually `for (let i = balls.length-1; ...)` evaluates `balls.length` once at init, but `const b = balls[i]` accesses the new array → undefined → crash.
So use a flag. Let me set `pendingVictory = true` in checkVictory, and handle it after the ball loop in update().
Similarly for demo level reset.
Let me restructure update():
```js
function update(dt){
time += dt;
// ... input, paddle width
// balls
for (let i = balls.length-1; i>=0; i--){ ... }
// powerups
// particles
// shake decay
if (pendingVictory){ pendingVictory=false; handleVictory(); }
if (balls.length === 0 && !pendingVictory && (state==='play'||state==='demo')) loseBall();
}
```
Hmm, careful ordering: if pendingVictory is true and we handle it, in demo it rebuilds and serves. In play it sets state='win' and clears balls. Then the `balls.length===0` check would call loseBall if state is still 'play'... but after handleVictory in play mode, state='win', so the condition (state==='play'||'demo') is false. Good. In demo mode, handleVictory rebuilds and serves a ball, so balls.length is 1. Good.
But also `loseBall` when state==='win' shouldn't happen. The condition guards it.
Also need to guard against calling loseBall repeatedly: loseBall → serveNewBall creates a stuck ball, so balls.length becomes 1. Good.
OK.
Another thing: when the ball is stuck and the state changes... fine.
Now `handleVictory`:
```js
function handleVictory(){
if (state==='demo'){ buildLevel(); serveNewBall(); return; }
state='win'; balls=[];
}
```
Now the AI:
```js
function aiUpdate(dt){
const b = balls.find(x=>!x.stuck) || balls[0];
let targetX;
if (!b) targetX = W/2;
else if (b.stuck) targetX = b.x;
else if (b.vy > 0) targetX = predictX(b, paddle.y - BALL_R);
else targetX = b.x; // follow in the air?
...
}
```
Hmm, when the ball is going up, the paddle should reposition. Following b.x directly is fine and looks natural.
Actually let's do: if ball is descending, predict the landing x; otherwise track the ball's x with some lag. And add a small error so it's not perfect: `targetX += Math.sin(time*1.7)*8`.
Paddle speed limit: 620 px/s.
```js
const maxSpeed = 700;
const diff = targetX - paddle.cx;
const move = clamp(diff, -maxSpeed*dt, maxSpeed*dt);
paddle.cx += move;
```
predictX:
```js
function predictX(b, targetY){
if (b.vy <= 0) return b.x;
const t = (targetY - b.y)/b.vy;
if (t <= 0) return b.x;
let x = b.x + b.vx*t;
const minX = WALL_L + BALL_R, maxX = WALL_R - BALL_R;
const span = maxX - minX;
let u = (x - minX) % (2*span);
if (u < 0) u += 2*span;
if (u > span) u = 2*span - u;
return minX + u;
}
```
Good.
Now, one concern: in demo mode with infinite lives, if the AI keeps missing, the demo resets constantly — fine.
Let's also make the demo not too perfect — it'll be fine.
Now popups (floating text):
```js
function addPopup(x,y,text,color){ popups.push({x,y,text,color,life:1}); }
```
Render: rising, fading.
Now let's write everything.
One more: score display padding. `String(score).padStart(5,'0')`.
Let me now write the final code.
Also need `shade()` helper:
```js
function shade(hex, amt){
// hex like '#ff8fa8'
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
r = clamp(Math.round(r + 255*amt),0,255);
...
}
```
With amt negative → darker. Let's do multiplicative-ish: `r = clamp(Math.round(r*(1+amt)),0,255)`.
Fine.
Now writing render for particles: use `ctx.globalAlpha`.
Let's also handle the ball glow:
```js
ctx.save();
ctx.shadowColor='#9ecbff'; ctx.shadowBlur=16;
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(b.x,b.y,BALL_R,0,7); ctx.fill();
ctx.restore();
```
With up to 6 balls, fine.
Alright, let me also double check the paddle collision when the ball has just been launched from a stuck state — it starts above the paddle moving up, so no immediate collision.
Edge case: after hitting the paddle, we set b.y = p.y - BALL_R and vy negative. Then the next substep moves it up. Good.
Edge case: ball moving down at the paddle edge (rel=±1) → angle ±62°, so vx is large. If the ball is at the very edge of the paddle, it will fly off at 62°. That's the requirement.
One more concern: the ball could get stuck in a horizontal loop bouncing between walls with vy≈0. With max angle 62°, vy is at least cos(62°)*sp = 0.47*sp. So it always has decent vertical speed. Good.
Now the multi-ball spawn: the new balls get angles from the source ball. If the source ball is moving at angle atan2(vy,vx), adding ±0.5 rad gives a spread. Good.
Let's write `launchBall`:
```js
function launchBall(b){
b.stuck = false;
const ang = -Math.PI/2 + (Math.random()-0.5)*0.7;
const sp = 400;
b.vx = Math.cos(ang)*sp;
b.vy = Math.sin(ang)*sp;
}
```
ang = -90° ± 20°, so it goes up. cos(-90°)=0 → vx small. Good.
Wait, `-Math.PI/2 + delta`: cos gives ±sin(delta) → small vx. sin(-90°+d) = -cos(d) ≈ -1 → vy negative (up). Good.
Initial ball speed 400. Paddle hit multiplies by 1.012, capped at 720.
Hmm, at 400 px/s the game is reasonably paced.
Now, the ball radius 8 and paddle 130 wide. Good.
Let me write the code for real now.
Also: `ctx.textBaseline` — set to 'alphabetic' globally and use explicit values.
Let me handle the canvas font string. I'll define `const FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';` and use template strings.
Let's write it.
```js
// ============ setup ============
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 900, H = 640;
let vs = 1;
function resize(){
const pad = 16;
const aw = Math.max(320, window.innerWidth - pad*2);
const ah = Math.max(240, window.innerHeight - pad*2);
const s = Math.min(aw/W, ah/H);
const cw = Math.round(W*s), ch = Math.round(H*s);
canvas.style.width = cw+'px';
canvas.style.height = ch+'px';
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = Math.round(cw*dpr);
canvas.height = Math.round(ch*dpr);
vs = canvas.width / W;
}
window.addEventListener('resize', resize);
resize();
```
Note: canvas.height/W vs canvas.width/W — canvas.width = round(cw*dpr), canvas.height = round(ch*dpr). Since cw/W = ch/H = s, they should be equal up to rounding. Minor sub-pixel difference; acceptable. To be safe, use `vs = canvas.width / W` for both.
Hmm, if there's rounding, the vertical might be off by a pixel. Negligible.
OK. Now let me write out the full file.
One more detail: the requirement "Something meaningful must be visible on first paint". Demo mode starts immediately, so yes.
Also I should ensure the demo doesn't take forever to look interesting. The AI plays and hits bricks. Good.
Let me write out all the code now, carefully.
```js
const WALL_L = 14, WALL_R = W - 14, WALL_T = 70;
const BALL_R = 8;
const PADDLE_Y = 586, PADDLE_H = 14, PADDLE_W = 132;
const PADDLE_SPEED = 780;
const MAX_ANGLE = 62 * Math.PI / 180;
const COLS = 12, ROWS = 7;
const BRICK_GAP = 6;
const FIELD_L = WALL_L + 16, FIELD_R = WALL_R - 16;
const BRICK_W = (FIELD_R - FIELD_L - BRICK_GAP*(COLS-1))/COLS;
const BRICK_H = 26;
const BRICK_TOP = WALL_T + 18;
```
FIELD_R - FIELD_L = (900-14-16) - (14+16) = 870 - 30 = 840. minus 66 = 774 / 12 = 64.5. Good.
BRICK_TOP = 88. Rows end at 88 + 7*32 - 6 = 88+224-6 = 306.
Paddle at 586, bottom 600. Canvas H=640. So there's 40px below the paddle. Hmm, the ball is lost when y - R > H = 640. So there's a gap where the ball falls below the paddle before dying. That's fine and standard.
Actually, let's set the paddle at y=600 and the bottom dead zone at H=640. 40px is fine.
Let me use PADDLE_Y = 596.
Alright.
Now writing everything out.
For the powerup falling: start at the brick center, vy=170.
Let me write the code.
```js
let state = 'demo';
let score = 0, lives = 3;
let time = 0, shake = 0;
let wideTimer = 0;
let serveTimer = 0;
let pendingVictory = false;
const paddle = { cx: W/2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H, baseW: PADDLE_W };
let balls = [], bricks = [], particles = [], powerups = [], popups = [];
let keys = { left:false, right:false };
let useMouse = false, mouseX = W/2;
```
Now buildLevel etc.
Let me write the render of the powerup capsule:
```js
function drawPowerup(p){
const w = 38, h = 20;
const x = p.x - w/2, y = p.y - h/2;
const isWide = p.type === 'wide';
const c1 = isWide ? '#86efac' : '#fde68a';
const c2 = isWide ? '#16a34a' : '#d97706';
ctx.save();
ctx.shadowColor = isWide ? 'rgba(74,222,128,0.9)' : 'rgba(251,191,36,0.9)';
ctx.shadowBlur = 14;
const g = ctx.createLinearGradient(0,y,0,y+h);
g.addColorStop(0,c1); g.addColorStop(1,c2);
rr(ctx,x,y,w,h,10); ctx.fillStyle=g; ctx.fill();
ctx.restore();
// icon
ctx.save();
ctx.translate(p.x, p.y);
drawIcon(p.type, 0, 0, 1);
ctx.restore();
}
```
Hmm, let me make a shared `drawIcon(type, cx, cy, scale)` that draws the wide arrows or multi dots in dark color when on a light capsule.
Actually on the brick badge, the icon is on a dark circle with a colored icon. On the falling capsule, the background is colored so the icon should be dark. Let's just pass a color.
```js
function drawPUType(type, cx, cy, col){
ctx.save();
ctx.fillStyle = col;
if (type === 'wide'){
ctx.beginPath(); ctx.moveTo(cx-7,cy); ctx.lineTo(cx-3,cy-3.5); ctx.lineTo(cx-3,cy+3.5); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(cx+7,cy); ctx.lineTo(cx+3,cy-3.5); ctx.lineTo(cx+3,cy+3.5); ctx.closePath(); ctx.fill();
ctx.fillRect(cx-3, cy-1.2, 6, 2.4);
} else {
for (const dx of [-5,0,5]){ ctx.beginPath(); ctx.arc(cx+dx, cy, 2.4, 0, 7); ctx.fill(); }
}
ctx.restore();
}
```
Good.
Now let's assemble everything. I'll write the final HTML.
For the demo overlay text, I want it visible and clear. Also maybe a hint about controls at the bottom? Let's add a subtle line under the demo panel: "Mouse / ← → to move". Actually put that in the panel subtitle: "Demo mode — the AI is playing". And the main line "CLICK OR PRESS A KEY TO START".
OK, writing now.
Also I need to double-check the `hitBricks` loop — iterating over all 84 bricks for each substep. With up to 6 balls and ~10 substeps per frame, that's 6*10*84 = 5040 checks/frame. Fine.
Actually, let me optimize slightly: skip bricks whose bounding box is far from the ball. The ox/oy check does that cheaply enough.
Let me write.
One more consideration for `stepBall`: the substep count uses `sp*dt/3`. At sp=720 and dt=0.016, that's 11.5/3 ≈ 4 substeps. Good.
Now writing the final code.
Let me be careful about the paddle collision check using `paddle.cx`:
```js
function hitPaddle(b){
if (b.vy <= 0) return;
const p = paddle;
const px = p.cx - p.w/2;
if (b.y + BALL_R < p.y) return;
if (b.y - BALL_R > p.y + p.h) return;
if (b.x + BALL_R < px || b.x - BALL_R > px + p.w) return;
...
}
```
Wait, `b.y - BALL_R > p.y + p.h` means the ball is entirely below the paddle → return. But then the ball would keep falling. That's correct (it missed).
But there's the case where the ball is inside the paddle vertically but came from below — we return early if vy<=0. Good.
Also I should handle: if the ball's center is below the paddle top (b.y > p.y), we teleport it to p.y - BALL_R, which is an upward jump. With substepping this only happens when the ball is genuinely overlapping. Fine.
Let's finalize.
Also, add a `hitFlash` on the paddle when it's hit? Minor. Skip.
Writing the final code now.
For the render of particles, cap at 500.
Let me write out the complete file.
Also, don't forget `ctx.lineJoin`/`lineCap` resets.
Final check on the demo AI and wide paddle: paddle.w changes, and paddle.cx is clamped. Good.
Writing.
One more: the ball's `stuck` state — in demo mode, the AI should launch quickly (serveTimer 0.5). Fine.
Also, when the ball is stuck and the state is 'demo', the paddle tracks the ball x → the ball follows the paddle → feedback loop. But we set targetX = b.x which equals paddle.cx, so no movement. Fine.
Let me write out the code.
Actually one more thought about the `loseBall` in demo: shake=10 is only for play mode. Let's put shake in loseBall before the state check.
Now writing for real.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Breakout</title>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#04050a;}
body{
display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-tap-highlight-color:transparent;
background:radial-gradient(1200px 700px at 50% 20%, #0d1530 0%, #04050a 70%);
}
canvas{
display:block;border-radius:16px;
box-shadow:0 24px 80px rgba(0,0,0,.85), 0 0 0 1px rgba(120,170,255,.12), 0 0 60px rgba(40,90,220,.18);
touch-action:none;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
...
</script>
</body>
</html>
```
Now the script. Let me write it all out.
I'll write it carefully and completely.
```js
(function(){
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', { alpha: false });
```
Hmm, alpha:false with a background fill is fine and faster. But then the rounded corners... the CSS border-radius clips the canvas element, so it's fine.
Actually with alpha:false, the canvas is opaque black by default. Fine since we fill the background.
Wait, but the body background gradient behind the canvas would be hidden. That's fine.
Let me use alpha: false for perf.
Now the constants and everything.
Let me write it.
Final code:
I'll write the whole thing now in one go, being careful.
Note on `ctx.setTransform(vs,0,0,vs,0,0)` each frame in render — good.
Let me write the render function:
```js
function render(){
ctx.setTransform(vs,0,0,vs,0,0);
ctx.globalAlpha = 1;
drawBackground();
ctx.save();
if (shake > 0.1){
ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
}
drawWalls();
drawBricks();
drawPowerups();
drawParticles();
drawPaddle();
drawBalls();
ctx.restore();
drawHUD();
drawPopups(); // hmm, popups should shake? put inside
drawOverlay();
}
```
Popups inside the shake group. Fine.
drawBackground fills the whole canvas with the gradient — but with shake, the edges might show. Since the background is drawn before the shake translate, no problem.
Alright. Writing now.
For the walls, I'll draw them inside the shake too.
Let me write drawWalls:
```js
function drawWalls(){
// subtle inner field glow
const g = ctx.createLinearGradient(0, WALL_T, 0, H);
g.addColorStop(0,'rgba(80,140,255,0.06)');
g.addColorStop(1,'rgba(80,140,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(WALL_L, WALL_T, WALL_R-WALL_L, H-WALL_T);
ctx.strokeStyle = 'rgba(110,170,255,0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(WALL_L, H);
ctx.lineTo(WALL_L, WALL_T+10);
ctx.quadraticCurveTo(WALL_L, WALL_T, WALL_L+10, WALL_T);
ctx.lineTo(WALL_R-10, WALL_T);
ctx.quadraticCurveTo(WALL_R, WALL_T, WALL_R, WALL_T+10);
ctx.lineTo(WALL_R, H);
ctx.stroke();
}
```
Good.
Now writing everything for real. Let me produce the final answer.
One last thing: `score` display and `lives`. Let me write drawHUD.
```js
function drawHUD(){
ctx.save();
const g = ctx.createLinearGradient(0,0,0,WALL_T+10);
g.addColorStop(0,'rgba(20,30,60,0.9)');
g.addColorStop(1,'rgba(20,30,60,0)');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,WALL_T+10);
ctx.textBaseline = 'alphabetic';
// score
ctx.textAlign='left';
ctx.fillStyle='rgba(140,175,255,0.7)';
ctx.font = '700 11px ' + FONT;
ctx.fillText('SCORE', 26, 26);
ctx.fillStyle='#eaf2ff';
ctx.font = '800 27px ' + FONT;
ctx.fillText(String(score).padStart(5,'0'), 26, 54);
// level
ctx.textAlign='center';
ctx.fillStyle='rgba(140,175,255,0.7)';
ctx.font = '700 11px ' + FONT;
ctx.fillText('LEVEL 1', W/2, 26);
if (wideTimer > 0){
const bw = 120, bx = W/2 - bw/2, by = 34;
ctx.fillStyle='rgba(255,255,255,0.12)';
rr(ctx,bx,by,bw,7,3.5); ctx.fill();
const p = clamp(wideTimer/10,0,1);
ctx.fillStyle='#4ade80';
rr(ctx,bx,by,bw*p,7,3.5); ctx.fill();
ctx.fillStyle='rgba(160,255,200,0.9)';
ctx.font='700 10px '+FONT;
ctx.fillText('WIDE PADDLE', W/2, by+22);
}
// lives
ctx.textAlign='right';
ctx.fillStyle='rgba(140,175,255,0.7)';
ctx.font='700 11px '+FONT;
ctx.fillText('LIVES', W-26, 26);
for (let i=0;i<3;i++){
const x = W-26 - 30*(i+1) + 8;
const y = 40;
const on = i < lives;
rr(ctx, x, y, 22, 8, 4);
ctx.fillStyle = on ? '#7dd3fc' : 'rgba(255,255,255,0.13)';
ctx.fill();
}
ctx.restore();
}
```
Hmm, `x = W-26-30*(i+1)+8`: for i=0: 900-26-30+8 = 852; the rect spans 852..874. For i=2: 900-26-90+8=792..814. Good, right-aligned.
Wait, the rightmost should end at W-26 = 874. Yes for i=0 it's 852..874.
Now `drawOverlay`. Let me finalize.
Writing the final answer now.
Also I want to make sure the ball doesn't get stuck bouncing horizontally forever between the walls above the bricks — impossible since bricks occupy that region.
One more: the ball could get stuck in a corner. Rare.
Let me also add a small safety: if a ball's |vy| is very small (< 30) after a brick collision, nudge it. Let's add in stepBall after collisions:
```js
if (Math.abs(b.vy) < 60) { b.vy = (b.vy >= 0 ? 1 : -1) * 60; }
```
Hmm, that changes direction. Actually after a brick hit, vy could become tiny if the collision was nearly horizontal. Let's just ensure |vy| >= 40:
```js
const minVy = 60;
if (Math.abs(b.vy) < minVy) b.vy = Math.sign(b.vy || -1) * minVy;
```
But this changes the speed. It's a safety net; acceptable. Let me instead renormalize after: compute speed and adjust. Simpler: just clamp vy magnitude with a small nudge, which slightly changes the speed. It's fine.
Actually, with the paddle bounce giving at most 62° from vertical, |vy| >= cos(62°)*sp = 0.47*sp >= 150. Brick bounces preserve the speed. So |vy| can only get small if a brick collision flips vy when it was already small... which can't happen since it starts large. Hmm, a vertical reflection keeps |vy| the same. A horizontal reflection keeps |vy| the same. So |vy| never decreases. Only the paddle sets vy = -cos(ang)*sp with |vy| >= 0.47*sp.
So no stuck-horizontal issue. But there's the paddle case where rel=±1 exactly → ang=62° → vy = -0.47*sp. Fine.
OK, no safety needed. But I'll add a tiny one anyway for robustness? No, skip.
Let me write it.
Actually, wait. There's a subtle issue with the paddle bounce: I set `sp = Math.hypot(b.vx,b.vy)` then `sp = min(sp*1.012, 720)`. If the ball is very slow... it starts at 400. Fine.
Now writing.
Final code below.