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
- 1 min 36 s
- Cost
- $0.040
- Output tokens
- 33,125
- Spent thinking
- 22,695 tokens
- Speed
- 347.9 tok/s
- Console errors
- 0
- First token after
- 469 ms
- First visible text after
- 1 min 15 s
- Prompt tokens
- 534
- Code size
- 28.3 KB
- Finish reason
- stop
- Served by
- CoreWeave
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"low"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 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 · 1019 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Breakout</title>
<style>
*{box-sizing:border-box}
html,body{height:100%;margin:0;background:#05060d;overflow:hidden;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;}
#wrap{position:fixed;inset:0;display:grid;place-items:center;
background:radial-gradient(circle at 50% 28%,#131a33 0%,#06070f 62%,#03040a 100%);}
canvas{display:block;width:min(94vw,124vh);height:auto;border-radius:16px;
box-shadow:0 24px 70px rgba(0,0,0,.75),0 0 0 1px rgba(120,160,255,.13),
0 0 70px rgba(60,100,255,.10);
touch-action:none;cursor:crosshair;}
</style>
</head>
<body>
<div id="wrap"><canvas id="game"></canvas></div>
<script>
(function(){
'use strict';
/* ------------------------------------------------------------------ */
/* Canvas setup */
/* ------------------------------------------------------------------ */
const W = 800, H = 600;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function fitCanvas(){
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
window.addEventListener('resize', fitCanvas);
/* ------------------------------------------------------------------ */
/* Utils */
/* ------------------------------------------------------------------ */
const clamp = (v,a,b) => v < a ? a : (v > b ? b : v);
const rand = (a,b) => a + Math.random()*(b-a);
function rr(x,y,w,h,r){
if (w < 2*r) r = w/2;
if (h < 2*r) r = h/2;
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function shade(hex, amt){
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
if (amt < 0){ const f = 1+amt; r*=f; g*=f; b*=f; }
else { r += (255-r)*amt; g += (255-g)*amt; b += (255-b)*amt; }
return 'rgb(' + (r|0) + ',' + (g|0) + ',' + (b|0) + ')';
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const BALL_SPEED = 430;
const COLS = 12, ROWS = 7;
const GAP = 5, ROW_GAP = 6, BRICK_H = 24, BRICK_TOP = 78;
const FIELD_L = 36, FIELD_W = W - 72;
const BRICK_W = (FIELD_W - GAP*(COLS-1)) / COLS;
const ROW_COLORS = ['#ff4d6d','#ff8a3d','#ffd23f','#5ee07a','#3ec9f0','#7c8cff','#c86bff'];
const PATTERN = [
'..========..',
'.==========.',
'############',
'############',
'.##########.',
'..########..',
'...######...'
];
/* ------------------------------------------------------------------ */
/* Game state */
/* ------------------------------------------------------------------ */
const G = {
mode: 'demo', // demo | play | victory | gameover
score: 0,
lives: 3,
time: 0,
shake: 0
};
const paddle = { x: W/2, y: H-48, w: 112, baseW: 112, h: 14, wide: 0 };
let balls = [];
let bricks = [];
let powerups = [];
let particles = [];
let floaters = [];
let mouseX = null;
const keys = { left:false, right:false };
/* ------------------------------------------------------------------ */
/* Level building */
/* ------------------------------------------------------------------ */
function makeCracks(w,h){
const lines = [];
for (let i=0;i<3;i++){
let x = w*rand(0.25,0.75), y = h*rand(0.25,0.75);
let ang = rand(0, Math.PI*2);
const pts = [[x,y]];
for (let k=0;k<3;k++){
ang += rand(-0.7,0.7);
const len = rand(4,8);
x = clamp(x + Math.cos(ang)*len, 2, w-2);
y = clamp(y + Math.sin(ang)*len, 2, h-2);
pts.push([x,y]);
}
lines.push(pts);
}
return lines;
}
function buildLevel(){
bricks = [];
for (let r=0;r<ROWS;r++){
const pat = PATTERN[r];
for (let c=0;c<COLS;c++){
const ch = pat[c];
if (ch === '.') continue;
const tough = ch === '=';
bricks.push({
x: FIELD_L + c*(BRICK_W+GAP),
y: BRICK_TOP + r*(BRICK_H+ROW_GAP),
w: BRICK_W, h: BRICK_H,
row: r, col: c,
hp: tough ? 2 : 1,
maxHp: tough ? 2 : 1,
color: ROW_COLORS[r],
power: null,
alive: true,
flash: 0,
cracks: tough ? makeCracks(BRICK_W, BRICK_H) : null
});
}
}
// spread power-up bricks over the lower (normal) rows
const cands = bricks.filter(b => b.row >= 2);
const N = 9;
for (let k=0;k<N;k++){
const idx = Math.floor((k+0.5) * cands.length / N);
if (cands[idx]) cands[idx].power = (k % 2 === 0) ? 'wide' : 'multi';
}
}
/* ------------------------------------------------------------------ */
/* Game flow */
/* ------------------------------------------------------------------ */
function makeBall(x,y,speed,angle){
return {
x, y,
vx: Math.cos(angle)*speed,
vy: Math.sin(angle)*speed,
r: 8,
stuck: false,
stuckT: 0,
dead: false
};
}
function launchBall(b){
b.stuck = false;
const ang = -Math.PI/2 + rand(-0.28, 0.28);
b.vx = Math.cos(ang) * BALL_SPEED;
b.vy = Math.sin(ang) * BALL_SPEED;
}
function resetBall(){
balls = [];
const b = makeBall(paddle.x, paddle.y - 12, BALL_SPEED, -Math.PI/2);
b.stuck = true;
b.stuckT = (G.mode === 'demo') ? 0.45 : 0.95;
balls.push(b);
}
function startGame(demo){
G.mode = demo ? 'demo' : 'play';
G.score = 0;
G.lives = 3;
G.time = 0;
G.shake = 0;
paddle.w = paddle.baseW;
paddle.wide = 0;
paddle.x = W/2;
powerups = [];
particles = [];
floaters = [];
buildLevel();
resetBall();
}
function addScore(n){ G.score += n; }
function spawnParticles(x,y,color,n,power){
for (let i=0;i<n;i++){
const a = rand(0, Math.PI*2);
const sp = rand(50, 240) * (power||1);
particles.push({
x, y,
vx: Math.cos(a)*sp,
vy: Math.sin(a)*sp - 50,
life: rand(0.4, 0.95),
maxLife: 1,
size: rand(1.6, 3.8),
color
});
particles[particles.length-1].maxLife = particles[particles.length-1].life;
}
}
function dropPower(br){
powerups.push({
x: br.x + br.w/2,
y: br.y + br.h/2,
w: 46, h: 22,
vy: 155,
type: br.power
});
}
function damageBrick(br){
br.hp--;
br.flash = 1;
const cx = br.x + br.w/2, cy = br.y + br.h/2;
if (br.hp > 0){
addScore(5);
spawnParticles(cx, cy, br.color, 5, 0.6);
return;
}
br.alive = false;
const pts = br.maxHp > 1 ? 25 : 10;
addScore(pts);
floaters.push({
x: cx, y: cy, vy: -42,
life: 0.9, maxLife: 0.9,
text: '+' + pts,
color: '#ffffff'
});
spawnParticles(cx, cy, br.color, 15, 1.1);
if (br.power) dropPower(br);
checkWin();
}
function checkWin(){
if (bricks.some(b => b.alive)) return;
if (G.mode === 'demo'){
G.score += 200;
floaters.push({ x: W/2, y: H/2, vy:-30, life:1.2, maxLife:1.2,
text:'LEVEL CLEAR +200', color:'#9ef7c0' });
buildLevel();
return;
}
G.mode = 'victory';
}
function loseLife(){
if (G.mode === 'demo'){
resetBall();
return;
}
G.lives--;
G.shake = 1;
if (G.lives <= 0){
G.lives = 0;
G.mode = 'gameover';
return;
}
resetBall();
}
/* ------------------------------------------------------------------ */
/* Power-ups */
/* ------------------------------------------------------------------ */
function applyPower(type){
if (type === 'wide'){
paddle.w = Math.min(paddle.baseW * 1.8, 260);
paddle.wide = 10;
floaters.push({ x: paddle.x, y: paddle.y - 26, vy:-46, life:1.1, maxLife:1.1,
text:'WIDE PADDLE', color:'#6ee7a0' });
} else {
const src = balls.slice();
let need = Math.max(0, 3 - balls.length);
if (need === 0) need = 1;
for (let k=0;k<need;k++){
if (balls.length >= 8) break;
const base = src[k % src.length];
if (!base) break;
let ang, sp;
if (base.stuck){
sp = BALL_SPEED;
ang = -Math.PI/2 + (k % 2 === 0 ? 0.55 : -0.55);
} else {
sp = Math.hypot(base.vx, base.vy) || BALL_SPEED;
ang = Math.atan2(base.vy, base.vx) + (k % 2 === 0 ? 0.5 : -0.5);
}
const nb = makeBall(base.x, base.y, sp, ang);
balls.push(nb);
}
floaters.push({ x: paddle.x, y: paddle.y - 26, vy:-46, life:1.1, maxLife:1.1,
text:'MULTI-BALL', color:'#38bdf8' });
}
}
function updatePowerups(dt){
const p = paddle;
for (let i=powerups.length-1;i>=0;i--){
const pu = powerups[i];
pu.y += pu.vy * dt;
const inX = pu.x + pu.w/2 > p.x - p.w/2 && pu.x - pu.w/2 < p.x + p.w/2;
const inY = pu.y + pu.h/2 > p.y && pu.y - pu.h/2 < p.y + p.h;
if (inX && inY){
applyPower(pu.type);
spawnParticles(pu.x, pu.y, pu.type === 'wide' ? '#6ee7a0' : '#38bdf8', 14, 0.8);
powerups.splice(i,1);
continue;
}
if (pu.y - pu.h/2 > H) powerups.splice(i,1);
}
}
/* ------------------------------------------------------------------ */
/* Physics */
/* ------------------------------------------------------------------ */
function fixAngle(b){
const sp = Math.hypot(b.vx, b.vy);
if (sp < 1) return;
if (Math.abs(b.vy) < sp*0.22){
const sgnY = b.vy >= 0 ? 1 : -1;
const sgnX = b.vx < 0 ? -1 : 1;
b.vy = sgnY * sp * 0.22;
b.vx = sgnX * Math.sqrt(Math.max(0, sp*sp - b.vy*b.vy));
}
}
function wallCollide(b){
if (b.x - b.r < 0){ b.x = b.r; b.vx = Math.abs(b.vx); }
else if (b.x + b.r > W){ b.x = W - b.r; b.vx = -Math.abs(b.vx); }
if (b.y - b.r < 0){ b.y = b.r; b.vy = Math.abs(b.vy); }
}
function brickCollide(b){
for (let i=0;i<bricks.length;i++){
const br = bricks[i];
if (!br.alive) continue;
if (b.x + b.r < br.x || b.x - b.r > br.x + br.w ||
b.y + b.r < br.y || b.y - b.r > br.y + br.h) continue;
const cx = clamp(b.x, br.x, br.x + br.w);
const cy = clamp(b.y, br.y, br.y + br.h);
let dx = b.x - cx, dy = b.y - cy;
let d2 = dx*dx + dy*dy;
if (d2 >= b.r*b.r) continue;
let nx, ny;
if (d2 > 0.0001){
const d = Math.sqrt(d2);
nx = dx/d; ny = dy/d;
b.x = cx + nx*(b.r + 0.02);
b.y = cy + ny*(b.r + 0.02);
} else {
const pL = b.x - br.x, pR = br.x + br.w - b.x;
const pT = b.y - br.y, pB = br.y + br.h - b.y;
const m = Math.min(pL, pR, pT, pB);
if (m === pL){ nx = -1; ny = 0; b.x = br.x - b.r - 0.02; }
else if (m === pR){ nx = 1; ny = 0; b.x = br.x + br.w + b.r + 0.02; }
else if (m === pT){ nx = 0; ny = -1; b.y = br.y - b.r - 0.02; }
else { nx = 0; ny = 1; b.y = br.y + br.h + b.r + 0.02; }
}
const vn = b.vx*nx + b.vy*ny;
if (vn < 0){ b.vx -= 2*vn*nx; b.vy -= 2*vn*ny; }
damageBrick(br);
fixAngle(b);
break;
}
}
function paddleCollide(b){
const p = paddle;
const L = p.x - p.w/2, R = p.x + p.w/2, T = p.y, B = p.y + p.h;
const cx = clamp(b.x, L, R);
const cy = clamp(b.y, T, B);
const dx = b.x - cx, dy = b.y - cy;
if (dx*dx + dy*dy > b.r*b.r) return;
if (dy < 0){ // hit from above
b.y = T - b.r;
const off = clamp((b.x - p.x)/(p.w/2), -1, 1);
const ang = off * (Math.PI/3); // ±60°
const sp = Math.hypot(b.vx, b.vy) || BALL_SPEED;
b.vx = Math.sin(ang) * sp;
b.vy = -Math.cos(ang) * sp;
spawnParticles(b.x, T, '#9ec5ff', 4, 0.4);
} else if (dy > 0){ // hit from below
b.y = B + b.r;
b.vy = Math.abs(b.vy);
} else { // side
if (b.x < p.x){ b.x = L - b.r; b.vx = -Math.abs(b.vx); }
else { b.x = R + b.r; b.vx = Math.abs(b.vx); }
}
}
function updateBalls(dt){
for (let i=balls.length-1;i>=0;i--){
const b = balls[i];
if (b.stuck){
b.x = paddle.x;
b.y = paddle.y - b.r - 2;
b.stuckT -= dt;
if (b.stuckT <= 0) launchBall(b);
continue;
}
const sp = Math.hypot(b.vx, b.vy);
let steps = Math.ceil((sp * dt) / (b.r * 0.7));
steps = clamp(steps, 1, 14);
const sdt = dt / steps;
for (let s=0;s<steps;s++){
b.x += b.vx * sdt;
b.y += b.vy * sdt;
wallCollide(b);
brickCollide(b);
paddleCollide(b);
if (b.y - b.r > H){ b.dead = true; break; }
}
}
for (let i=balls.length-1;i>=0;i--){
if (balls[i].dead) balls.splice(i,1);
}
if (balls.length === 0) loseLife();
}
/* ------------------------------------------------------------------ */
/* Paddle */
/* ------------------------------------------------------------------ */
function updatePaddle(dt){
const p = paddle;
const half = p.w/2;
if (G.mode === 'demo'){
let target = p.x;
let best = null;
for (const b of balls){
if (!b.stuck && b.vy > 40 && (!best || b.y > best.y)) best = b;
}
if (best){
const t = Math.max(0, (p.y - best.y) / best.vy);
let px = best.x + best.vx * t;
const P = 2 * W;
px = ((px % P) + P) % P;
if (px > W) px = P - px;
target = px + Math.sin(G.time * 2.2) * 16;
} else if (balls[0]){
target = balls[0].x;
}
target = clamp(target, half, W - half);
const diff = target - p.x;
const maxStep = 900 * dt;
p.x += Math.abs(diff) <= maxStep ? diff : Math.sign(diff) * maxStep;
} else {
let dir = 0;
if (keys.left) dir -= 1;
if (keys.right) dir += 1;
if (dir !== 0){
p.x += dir * 780 * dt;
mouseX = null;
} else if (mouseX !== null){
p.x += (mouseX - p.x) * Math.min(1, dt * 30);
}
}
p.x = clamp(p.x, half, W - half);
if (p.wide > 0){
p.wide -= dt;
if (p.wide <= 0){ p.wide = 0; p.w = p.baseW; }
}
}
/* ------------------------------------------------------------------ */
/* Particles / floaters */
/* ------------------------------------------------------------------ */
function updateParticles(dt){
for (let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.vy += 950 * dt;
p.vx *= (1 - 1.2*dt);
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i,1);
}
}
function updateFloaters(dt){
for (let i=floaters.length-1;i>=0;i--){
const f = floaters[i];
f.y += f.vy * dt;
f.vy *= (1 - 1.5*dt);
f.life -= dt;
if (f.life <= 0) floaters.splice(i,1);
}
}
/* ------------------------------------------------------------------ */
/* Main update */
/* ------------------------------------------------------------------ */
function update(dt){
G.time += dt;
if (G.shake > 0) G.shake = Math.max(0, G.shake - dt*3.2);
for (const br of bricks){
if (br.flash > 0) br.flash = Math.max(0, br.flash - dt*5);
}
updateParticles(dt);
updateFloaters(dt);
if (G.mode === 'demo' || G.mode === 'play'){
updatePaddle(dt);
updateBalls(dt);
updatePowerups(dt);
}
}
/* ------------------------------------------------------------------ */
/* Rendering */
/* ------------------------------------------------------------------ */
const bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0, '#0a0c1a');
bgGrad.addColorStop(0.5, '#0d1024');
bgGrad.addColorStop(1, '#15112a');
const topGlow = ctx.createRadialGradient(W/2, -140, 0, W/2, -140, 720);
topGlow.addColorStop(0, 'rgba(90,130,255,0.30)');
topGlow.addColorStop(1, 'rgba(90,130,255,0)');
function drawBackground(){
ctx.fillStyle = bgGrad;
ctx.fillRect(0,0,W,H);
ctx.fillStyle = topGlow;
ctx.fillRect(0,0,W,H);
ctx.strokeStyle = 'rgba(130,160,255,0.035)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x=20; x<W; x+=40){ ctx.moveTo(x+0.5, 0); ctx.lineTo(x+0.5, H); }
for (let y=20; y<H; y+=40){ ctx.moveTo(0, y+0.5); ctx.lineTo(W, y+0.5); }
ctx.stroke();
// glowing rails
const rail = ctx.createLinearGradient(0,0,0,H);
rail.addColorStop(0, 'rgba(120,170,255,0.30)');
rail.addColorStop(1, 'rgba(120,170,255,0.03)');
ctx.fillStyle = rail;
ctx.fillRect(0,0,2.5,H);
ctx.fillRect(W-2.5,0,2.5,H);
}
function drawPowerGlyph(cx, cy, type, color){
ctx.fillStyle = color;
if (type === 'wide'){
rr(cx-7, cy-2.5, 14, 5, 2.5);
ctx.fill();
} else {
ctx.beginPath();
ctx.arc(cx-5, cy+2.5, 2.1, 0, 7);
ctx.arc(cx, cy-3, 2.1, 0, 7);
ctx.arc(cx+5, cy+2.5, 2.1, 0, 7);
ctx.fill();
}
}
function drawBricks(){
for (let i=0;i<bricks.length;i++){
const br = bricks[i];
if (!br.alive) continue;
const x = br.x, y = br.y, w = br.w, h = br.h, r = 6;
if (br.maxHp > 1 && br.hp > 1){
// intact tough brick
ctx.fillStyle = shade(br.color, -0.42);
rr(x, y, w, h, r); ctx.fill();
ctx.strokeStyle = br.color;
ctx.lineWidth = 2;
rr(x+1, y+1, w-2, h-2, r-1); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.12)';
rr(x+4, y+3.5, w-8, 6, 3); ctx.fill();
// rivets
ctx.fillStyle = 'rgba(255,255,255,0.42)';
const rv = 1.8;
ctx.beginPath();
ctx.arc(x+6, y+h/2, rv, 0, 7);
ctx.arc(x+w-6, y+h/2, rv, 0, 7);
ctx.fill();
} else if (br.maxHp > 1){
// cracked tough brick
ctx.fillStyle = shade(br.color, -0.70);
rr(x+1.5, y+1.5, w-3, h-3, r-1); ctx.fill();
ctx.strokeStyle = shade(br.color, -0.35);
ctx.lineWidth = 1.5;
rr(x+1.5, y+1.5, w-3, h-3, r-1); ctx.stroke();
ctx.strokeStyle = 'rgba(0,0,0,0.72)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
for (let k=0;k<br.cracks.length;k++){
const line = br.cracks[k];
ctx.beginPath();
for (let j=0;j<line.length;j++){
const p = line[j];
if (j === 0) ctx.moveTo(x + p[0], y + p[1]);
else ctx.lineTo(x + p[0], y + p[1]);
}
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
for (let k=0;k<br.cracks.length;k++){
const line = br.cracks[k];
ctx.beginPath();
for (let j=0;j<line.length;j++){
const p = line[j];
if (j === 0) ctx.moveTo(x + p[0] - 1, y + p[1] - 1);
else ctx.lineTo(x + p[0] - 1, y + p[1] - 1);
}
ctx.stroke();
}
ctx.lineCap = 'butt';
} else {
// normal brick
ctx.fillStyle = br.color;
rr(x, y, w, h, r); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.26)';
rr(x+3, y+2.5, w-6, h*0.34, 3); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.16)';
rr(x+3, y+h-5, w-6, 3.5, 2); ctx.fill();
}
// power icon
if (br.power){
const cx = x + w/2, cy = y + h/2;
const pulse = 0.5 + 0.5*Math.sin(G.time*4 + br.col*0.6 + br.row);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, 8.5 + pulse*1.6, 0, 7);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.22 + pulse*0.35) + ')';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, 8, 0, 7);
ctx.fillStyle = 'rgba(255,255,255,0.93)';
ctx.fill();
drawPowerGlyph(cx, cy, br.power, shade(br.color, -0.55));
ctx.restore();
}
// hit flash
if (br.flash > 0){
ctx.fillStyle = 'rgba(255,255,255,' + (br.flash*0.65) + ')';
rr(x, y, w, h, r); ctx.fill();
}
}
}
function drawPowerups(){
for (const pu of powerups){
const col = pu.type === 'wide' ? '#4ade80' : '#38bdf8';
ctx.save();
ctx.shadowColor = col;
ctx.shadowBlur = 18;
const g = ctx.createLinearGradient(0, pu.y-pu.h/2, 0, pu.y+pu.h/2);
g.addColorStop(0, shade(col, 0.35));
g.addColorStop(1, shade(col, -0.35));
ctx.fillStyle = g;
rr(pu.x-pu.w/2, pu.y-pu.h/2, pu.w, pu.h, pu.h/2);
ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
rr(pu.x-pu.w/2+5, pu.y-pu.h/2+3, pu.w-10, 5, 2.5);
ctx.fill();
drawPowerGlyph(pu.x, pu.y, pu.type, '#ffffff');
}
}
function drawPaddle(){
const p = paddle;
const x = p.x - p.w/2, y = p.y;
ctx.save();
ctx.shadowColor = p.wide > 0 ? 'rgba(74,222,128,0.95)' : 'rgba(120,180,255,0.85)';
ctx.shadowBlur = 24;
const g = ctx.createLinearGradient(0, y, 0, y + p.h);
g.addColorStop(0, '#f2f8ff');
g.addColorStop(0.45, p.wide > 0 ? '#7df0ab' : '#a8ccff');
g.addColorStop(1, p.wide > 0 ? '#1f9c58' : '#3363cc');
ctx.fillStyle = g;
rr(x, y, p.w, p.h, 7);
ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(x + 6, y + 2.5, Math.max(4, p.w - 12), 3, 1.5);
ctx.fill();
}
function drawBalls(){
for (const b of balls){
ctx.save();
ctx.shadowColor = 'rgba(150,205,255,0.95)';
ctx.shadowBlur = 18;
const g = ctx.createRadialGradient(b.x-2.5, b.y-3, 0.5, b.x, b.y, b.r);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.55, '#c6e2ff');
g.addColorStop(1, '#5aa4ff');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
}
function drawParticles(){
for (const p of particles){
const a = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(0.4, p.size * a), 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawFloaters(){
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 14px system-ui,-apple-system,sans-serif';
for (const f of floaters){
ctx.globalAlpha = Math.max(0, f.life / f.maxLife);
ctx.fillStyle = f.color;
ctx.fillText(f.text, f.x, f.y);
}
ctx.globalAlpha = 1;
}
function drawHUD(){
ctx.textBaseline = 'middle';
// score
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(150,168,210,0.72)';
ctx.font = '700 12px system-ui,-apple-system,sans-serif';
ctx.fillText('S C O R E', 36, 26);
ctx.fillStyle = '#ffffff';
ctx.font = '800 26px system-ui,-apple-system,sans-serif';
ctx.fillText(String(G.score).padStart(4,'0'), 36, 50);
// level label
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(140,158,200,0.5)';
ctx.font = '700 12px system-ui,-apple-system,sans-serif';
ctx.fillText('LEVEL 1', W/2, 30);
// lives
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(150,168,210,0.72)';
ctx.font = '700 12px system-ui,-apple-system,sans-serif';
ctx.fillText('L I V E S', W-36, 26);
for (let i=0;i<3;i++){
const bx = W - 36 - i*30;
ctx.beginPath();
if (i < G.lives){
ctx.fillStyle = i === 0 ? '#a8ccff' : '#7fa8e8';
rr(bx-22, 44, 22, 9, 4.5);
ctx.fill();
} else {
ctx.fillStyle = 'rgba(255,255,255,0.10)';
rr(bx-22, 44, 22, 9, 4.5);
ctx.fill();
}
}
// wide paddle timer
if (paddle.wide > 0){
const w = 120, x = W/2 - w/2, y = 44;
ctx.fillStyle = 'rgba(255,255,255,0.10)';
rr(x, y, w, 6, 3); ctx.fill();
ctx.fillStyle = '#4ade80';
rr(x, y, w * clamp(paddle.wide/10, 0, 1), 6, 3); ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(110,231,160,0.9)';
ctx.font = '700 10px system-ui,sans-serif';
ctx.fillText('WIDE', W/2, y + 18);
}
// divider
ctx.strokeStyle = 'rgba(120,160,255,0.10)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(20, 70.5); ctx.lineTo(W-20, 70.5);
ctx.stroke();
}
function drawDemoCard(){
const w = 460, h = 134;
const x = W/2 - w/2, y = 318;
ctx.save();
ctx.shadowColor = 'rgba(60,110,255,0.45)';
ctx.shadowBlur = 34;
ctx.fillStyle = 'rgba(10,13,28,0.80)';
rr(x, y, w, h, 18); ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(120,165,255,0.38)';
ctx.lineWidth = 1.5;
rr(x+0.75, y+0.75, w-1.5, h-1.5, 18);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const grad = ctx.createLinearGradient(W/2-110, 0, W/2+110, 0);
grad.addColorStop(0, '#7dd3fc');
grad.addColorStop(0.5, '#c4b5fd');
grad.addColorStop(1, '#fb7185');
ctx.fillStyle = grad;
ctx.font = '800 32px system-ui,-apple-system,sans-serif';
ctx.fillText('BREAKOUT', W/2, y + 40);
const pulse = 0.5 + 0.5*Math.sin(G.time * 3.6);
ctx.fillStyle = 'rgba(180,210,255,' + (0.55 + pulse*0.45) + ')';
ctx.font = '700 17px system-ui,-apple-system,sans-serif';
ctx.fillText('Click or press a key to start', W/2, y + 83);
ctx.fillStyle = 'rgba(140,158,196,0.72)';
ctx.font = '700 11px system-ui,-apple-system,sans-serif';
ctx.fillText('DEMO · AI IS PLAYING', W/2, y + 112);
}
function drawEndCard(){
ctx.fillStyle = 'rgba(5,7,16,0.76)';
ctx.fillRect(0,0,W,H);
const w = 470, h = 264;
const x = W/2 - w/2, y = H/2 - h/2;
const win = G.mode === 'victory';
ctx.save();
ctx.shadowColor = win ? 'rgba(74,222,128,0.5)' : 'rgba(255,77,109,0.5)';
ctx.shadowBlur = 40;
ctx.fillStyle = 'rgba(12,15,32,0.95)';
rr(x, y, w, h, 22); ctx.fill();
ctx.restore();
ctx.strokeStyle = win ? 'rgba(110,231,160,0.55)' : 'rgba(255,110,140,0.55)';
ctx.lineWidth = 1.5;
rr(x+0.75, y+0.75, w-1.5, h-1.5, 22);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const grad = ctx.createLinearGradient(W/2-140, 0, W/2+140, 0);
if (win){
grad.addColorStop(0, '#a7f3d0');
grad.addColorStop(1, '#34d399');
} else {
grad.addColorStop(0, '#fda4af');
grad.addColorStop(1, '#ef4444');
}
ctx.fillStyle = grad;
ctx.font = '800 44px system-ui,-apple-system,sans-serif';
ctx.fillText(win ? 'VICTORY!' : 'GAME OVER', W/2, y + 68);
ctx.fillStyle = 'rgba(150,168,210,0.8)';
ctx.font = '700 13px system-ui,-apple-system,sans-serif';
ctx.fillText('FINAL SCORE', W/2, y + 120);
ctx.fillStyle = '#ffffff';
ctx.font = '800 42px system-ui,-apple-system,sans-serif';
ctx.fillText(String(G.score), W/2, y + 156);
const pulse = 0.5 + 0.5*Math.sin(G.time * 3.6);
ctx.fillStyle = 'rgba(190,215,255,' + (0.55 + pulse*0.45) + ')';
ctx.font = '700 16px system-ui,-apple-system,sans-serif';
ctx.fillText('Click or press a key to play again', W/2, y + 214);
}
function render(){
ctx.save();
if (G.shake > 0){
const s = G.shake * 9;
ctx.translate(rand(-s,s), rand(-s,s));
}
drawBackground();
drawBricks();
drawPowerups();
drawParticles();
drawPaddle();
drawBalls();
drawFloaters();
drawHUD();
if (G.mode === 'demo') drawDemoCard();
else if (G.mode === 'victory' || G.mode === 'gameover') drawEndCard();
ctx.restore();
}
/* ------------------------------------------------------------------ */
/* Input */
/* ------------------------------------------------------------------ */
function updateMouse(e){
const r = canvas.getBoundingClientRect();
if (r.width === 0) return;
mouseX = (e.clientX - r.left) / r.width * W;
}
canvas.addEventListener('pointermove', updateMouse);
canvas.addEventListener('pointerdown', function(e){
e.preventDefault();
updateMouse(e);
if (G.mode === 'play'){
for (const b of balls) if (b.stuck) launchBall(b);
} else {
startGame(false);
}
});
canvas.addEventListener('pointerleave', function(){ mouseX = null; });
window.addEventListener('keydown', function(e){
const k = e.key;
if (k === 'ArrowLeft' || k === 'a' || k === 'A') keys.left = true;
if (k === 'ArrowRight' || k === 'd' || k === 'D') keys.right = true;
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' ||
k === 'ArrowDown' || k === ' '){
e.preventDefault();
}
if (G.mode !== 'play'){
startGame(false);
} else if (k === ' '){
for (const b of balls) if (b.stuck) launchBall(b);
}
}, { passive:false });
window.addEventListener('keyup', function(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('blur', function(){
keys.left = false;
keys.right = false;
});
/* ------------------------------------------------------------------ */
/* Loop */
/* ------------------------------------------------------------------ */
let lastT = performance.now();
function frame(now){
let dt = (now - lastT) / 1000;
lastT = now;
if (dt > 0.05) dt = 0.05;
if (dt < 0) dt = 0;
update(dt);
render();
requestAnimationFrame(frame);
}
startGame(true);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (22,695 tokens)
Let me build a polished Breakout game in a single HTML file.
Key features:
- Canvas rendering, responsive
- Paddle: mouse + arrow keys
- Ball bounce angle depends on hit position
- One level with 6+ rows, normal and tough (2 hit, cracked) bricks
- Power-ups: wide paddle (10s), multi-ball (3 balls)
- No tunneling: use swept collision / substeps
- Particles on brick break
- 3 lives, score
- Game over / victory screens with restart
- Demo mode: AI plays behind "Click or press a key to start"
Let me design carefully.
Canvas logical size: let's use a fixed internal resolution scaled to fit, e.g. 900x640 logical, and CSS scale. Actually to handle responsiveness, I'll set canvas width/height based on container size with devicePixelRatio, and compute layout in CSS pixels. That's a bit more work but fine. Let's use a fixed logical world of 800x600 and scale it via CSS to fit the container while preserving aspect ratio. That's simplest and consistent.
World: W=800, H=600.
Bricks: 6 rows... let's do 7 rows, cols=11? Let's compute: padding 40 left/right => 720 width. cols = 12 => 60 each with small gap. Let's do cols=12, brickW = (720 - 11*gap)/12. gap=3. brickW = (720-33)/12 = 57.25. Fine.
Rows: 7 rows. rowH = 26, gap 4 => total 7*30 = 210. Start at y=70.
Tough bricks: rows 0-1 tough? Let's make patterns. Tough bricks have hp 2, drawn with a darker/hatched overlay; when hit once they crack (draw crack lines) and change color.
Power-up bricks: some bricks carry a power-up, drawn with an icon (a small letter/icon inside). When broken, drop a power-up capsule.
Ball physics: radius 8, speed ~ 380 px/s initially, increases slightly.
Collision: substep the ball movement. Move in small steps (e.g., max 4px per substep) and check collisions each substep. That prevents tunneling through bricks and paddle.
Ball vs brick: AABB vs circle. Simple approach: for each substep, check brick overlap; resolve by determining minimal penetration axis.
Simpler robust approach: use swept-ish with substepping: dt divided into steps such that movement per step < ballRadius/2. Then on each step, check paddle collision and brick collision with circle-AABB.
Circle-AABB collision resolution: find closest point on rect to circle center, if dist < r, compute normal = (center - closest).normalize(). If center inside rect, use axis of minimal penetration.
Push ball out along normal and reflect velocity: v = v - 2*(v·n)*n, but only if v·n < 0.
For brick, also determine top/bottom vs left/right for the normal. Using closest-point normal works well.
Paddle: rect. Collision: if ball bottom > paddle top and ball overlapping paddle rect... Use circle-rect too but with special bounce angle logic: when ball hits paddle top (ball moving down and its center y < paddleTop + something), set angle based on offset.
Let's do: check if ball overlaps paddle rect (circle-rect). If so and vy > 0 (moving down), place ball at paddleTop - r, compute offset = (ball.x - paddleCenter)/(paddleW/2), clamp to [-1,1], angle = offset * 60 degrees from vertical, speed preserved, vx = speed*sin(angle), vy = -speed*cos(angle). Also add small influence of paddle velocity for feel? Keep simple.
Also handle side hits: if ball hits paddle side, just reflect horizontally. Let's just do: if circle-rect overlap, determine if the ball center is above paddle top - some threshold => top hit. Otherwise reflect on x.
Given substeps small, the ball will rarely hit side. Let's handle: if ball.y < paddle.y (center above top) => top bounce; else => push out horizontally and vx = -vx.
Ball never passes through paddle due to substepping.
Walls: left, right, top. Bottom = lose ball.
Multi-ball: when power-up caught, split each existing ball into... spec says 3 balls. Let's take current balls and add 2 more with angled velocities (if 1 ball, create 2 extra). Ensure total at least 3.
Wide paddle: paddle width *= 1.6 for 10s, with timer; visual glow.
Particles: on brick break, spawn ~10 particles with brick color, gravity, fading.
Score: normal brick 10, tough brick 20 on break (and 5 on first hit?). Let's: normal 10, tough 25.
Lives: 3. When all balls lost, lose a life; reset ball on paddle (stuck) until launched — or auto-launch after short delay. For demo mode, auto-launch.
Demo mode (AI): game plays itself. AI paddle follows the ball with some prediction and imperfection. Display overlay "Click or press a key to start". On click/keypress, start real game.
But the demo needs to show the game. So demo mode runs the same game loop with AI controlling the paddle. When user starts, reset to fresh game with 3 lives and human control.
Also the screenshot at 3 seconds: demo mode should show bricks, ball moving, particles maybe. Good.
Let's structure:
State:
- mode: 'demo' | 'playing' | 'gameover' | 'victory' | 'ready'(ball stuck)
- Actually keep it simple: `state.running`, `state.demo`, `state.over`.
Let me write:
```js
const W = 800, H = 600;
```
Canvas element with width/height set to W*dpr, H*dpr, style width/height scaled by CSS.
Container: center canvas, scale to fit viewport using CSS:
```css
#wrap { position: fixed; inset: 0; display:flex; align-items:center; justify-content:center; }
canvas { max-width: 100%; max-height: 100%; aspect-ratio: 800/600; }
```
Hmm, with aspect-ratio and max-width/max-height, need width:auto. Let's do canvas { display:block; width: min(100vw, calc(100vh * 4/3)); height:auto; } Actually 800/600 = 4/3. Use:
```css
canvas { width: min(100vw, 133.333vh); height: auto; }
```
Wait height auto with width set: canvas intrinsic aspect from width/height attributes (800*dpr, 600*dpr) preserves 4:3. Yes. But we set canvas.width = 800*dpr and height = 600*dpr, so intrinsic ratio 4:3, width CSS set, height auto → good.
But on a 1280x800 viewport, width = min(1280, 133.33*8=1066.6) = 1066.6px. Height = 800. That's full height. Maybe leave some margin. Let's use min(96vw, 128vh) to leave margins... Actually let's compute with a wrapper of padding. Simpler:
```css
canvas{ width: min(94vw, 125vh); height: auto; }
```
125vh at 800px height = 1000px width → height 750. Good. On 375x800 mobile: 94vw = 352.5, height = 264. Fits.
Background: dark gradient, drawn on canvas so it scales.
Now rendering to canvas with dpr: ctx.setTransform(dpr,0,0,dpr,0,0) each frame, then draw in world coords (800x600).
Mouse: convert clientX to world x: (e.clientX - rect.left) / rect.width * W.
Keyboard: arrows.
Let's write the game.
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 800, H = 600;
let dpr = 1;
function resize(){
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
}
resize();
```
Mouse/touch on canvas.
Game objects:
```js
const paddle = { x: W/2, y: H-40, w: 110, baseW: 110, h: 14, vx: 0, targetX: W/2 };
```
Paddle movement: keyboard sets vx; mouse sets targetX directly. For mouse control, set paddle.x smoothly toward target.
Let's use: paddle.x is center. Keyboard: paddle.x += dir * 620 * dt. Mouse: if mouseActive, paddle.x = lerp(paddle.x, mouseX, 0.55) or set directly. Set directly feels responsive; but slight smoothing avoids jitter. I'll set directly with clamp.
Actually mixing: track `paddle.targetX` from mouse; if keyboard pressed, use keyboard. Let's keep: keys override mouse.
Ball:
```js
{x,y,vx,vy,r}
```
speed base 400. Let's use 420.
Ball launch: from paddle center, vy = -speed, vx small random.
Bricks:
```js
const COLS = 12, ROWS = 7;
const FIELD = { left: 40, right: W-40, top: 70 };
```
brickW = (720 - (COLS-1)*gap)/COLS with gap 4 → (720-44)/12 = 56.33. brickH = 24, vgap = 6.
Let's define grid positions.
Brick object: {x,y,w,h,hp,maxHp,color,power: null|'wide'|'multi',alive}
Pattern: I'll create a nice pattern — maybe rows colored by rainbow and tough bricks in a pattern.
Rows (0 top to 6 bottom):
- row 0: tough (hp2) across but with gaps?
Let's make it interesting:
- rows 0,1: hp2 tough bricks, with some holes.
- rows 2-6: hp1.
Actually spec: at least 6 rows, mixing normal and tougher. Let's do 7 rows.
Layout pattern: For each row, skip bricks in a pattern to make it look designed. E.g., row 0: skip every 5th. Let's just do:
```
const toughRows = [0,1];
```
And holes: for row 0, remove bricks where col%4===3? Hmm.
Let's define a pattern string array of 7 rows × 12 chars: '#' = normal, '=' = tough, '.' = empty, and mark power-ups separately.
Row patterns:
```
"==..==..==.." hmm
```
Let me instead do a symmetric pyramid-ish pattern. 12 cols:
row0: "############" tough all? That's 12 tough bricks—maybe too many.
Let's do:
row0: "..########.." tough
row1: ".##########." tough
row2: "############" normal
row3: "############" normal
row4: ".##########." normal
row5: "..########.." normal
row6: "...######..." normal
Total bricks: 8+10+12+12+10+8+6 = 66. Reasonable.
Hmm but tough all in top 2 rows means you must hit twice. Fine, 18 tough bricks.
Maybe better to scatter tough bricks. Let's do row0 tough, row1 tough, and a few tough in middle rows. Let's keep simple: rows 0,1 tough.
Actually let me make it more visually varied: tough bricks in a checkerboard in rows 0-1.
I'll just do: rows 0 and 1 are tough ('='), rest normal, with the pyramid shape. Good.
Colors: rainbow by row.
row0: '#ff3b6b' (pink/red)
row1: '#ff9f1c' (orange)
row2: '#ffd93d' (yellow)
row3: '#4ade80' (green)
row4: '#38bdf8' (cyan)
row5: '#818cf8' (indigo)
row6: '#c084fc' (purple)
Tough bricks: draw with a metallic overlay and hp indicator (cracks after first hit).
Power-up bricks: choose specific bricks deterministically. Let's place power-ups on a handful: e.g., pick some bricks and assign 'wide' or 'multi'. About 8 power-up bricks. Put them in the middle rows so they're reachable.
I'll assign after building: iterate list, for indices where (i % 7 === 3) assign alternating power types. Let's do more controlled: gather alive bricks, and pick random-ish with a seeded pattern.
Let's do: for each brick, if (row*COLS+col) % 9 === 4 → power 'wide' or 'multi' based on parity. Hmm need enough in view.
Actually let's just pick 10 bricks spread out: use index into the bricks array with a fixed pseudo-random (seeded LCG) so it's stable.
Simplify: after building the grid, iterate and assign power when `bricks.length % 8 === 5`... hmm that's order dependent but deterministic. Fine but distribution might clump.
I'll do: candidates = all bricks; for k in 0..9: pick index = Math.floor((k+0.5)*candidates.length/10) roughly, alternate types. That spreads them evenly. Good.
But power bricks in tough rows: they need 2 hits. When broken (hp reaches 0) they drop power. Fine.
Power-up capsule: {x, y, vy: 130, type, w:34, h:18}. Falls, caught by paddle → apply.
Paddle catch: AABB overlap with paddle rect.
Wide paddle: paddle.w = baseW*1.7, timer 10s. On expiry revert.
Multiball: for each existing ball, add two clones with rotated velocity ±25°. If 1 ball → 3 balls. Cap at 6.
Score display in HUD (draw on canvas) — better to draw HUD on canvas for cohesion. Let's draw HUD at top: score left, lives right (as small paddle icons or hearts), and a small "power" indicator.
Actually the playfield top is 70, HUD area 0-70. Good.
Let's draw HUD: "SCORE 0000" left, lives as 3 small circles/paddles right.
Particles: array of {x,y,vx,vy,life,maxLife,color,size}.
Also add screen shake on losing a life? Maybe subtle. Optional.
Let's add a little "ball trail" for polish? Could be nice but keep perf fine.
Now the game 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:
```js
function update(dt){
if (state.paused) return;
updatePaddle(dt);
updateBalls(dt);
updatePowerups(dt);
updateParticles(dt);
}
```
Ball movement with substeps:
```js
function updateBalls(dt){
for each ball:
const speed = Math.hypot(b.vx,b.vy);
let dist = speed*dt;
let steps = Math.max(1, Math.ceil(dist / (b.r*0.6)));
let sdt = dt/steps;
for(let s=0;s<steps;s++){
b.x += b.vx*sdt;
b.y += b.vy*sdt;
collideWalls(b);
collideBricks(b);
collidePaddle(b);
if(b.dead) break;
}
}
```
Hmm, collideBricks inside the substep loop with break after collision? After a brick collision the ball direction changes; continue substeps is fine.
Ball radius 8, speed 420 → dist per frame at 60fps = 7px. steps = ceil(7/4.8)=2. OK.
Actually if the ball speeds up let's keep it capped.
Let's set ball speed 430 constant (no acceleration) for predictability. Maybe slight increase per level—only one level, skip.
collideWalls:
```js
if(b.x - b.r < 0){ b.x = b.r; b.vx = Math.abs(b.vx); }
if(b.x + b.r > W){ b.x = W - b.r; b.vx = -Math.abs(b.vx); }
if(b.y - b.r < 0){ b.y = b.r; b.vy = Math.abs(b.vy); }
if(b.y - b.r > H){ b.dead = true; }
```
Also playfield walls: should the ball bounce off at x=0 and x=W? Yes, full width. Or add side walls at x=0/W. Fine.
collideBricks: iterate bricks, circle-rect check.
```js
function circleRect(cx,cy,r,rx,ry,rw,rh){
const nx = Math.max(rx, Math.min(cx, rx+rw));
const ny = Math.max(ry, Math.min(cy, ry+rh));
const dx = cx-nx, dy = cy-ny;
return dx*dx+dy*dy < r*r ? {dx,dy,nx,ny} : null;
}
```
Hmm for the normal, if the ball center is inside the rect, dx=dy=0 → need fallback.
For brick collision resolution, better approach: compute overlap on each axis.
```js
function resolveBrick(b, br){
const bx = br.x, by = br.y, bw = br.w, bh = br.h;
// closest point
let cx = Math.max(bx, Math.min(b.x, bx+bw));
let cy = Math.max(by, Math.min(b.y, by+bh));
let dx = b.x - cx, dy = b.y - cy;
let d2 = dx*dx + dy*dy;
if(d2 > b.r*b.r) return false;
...
}
```
If d2 > 0: normal = (dx,dy)/d. Push out: b.x = cx + nx*b.r, etc. Reflect if v·n < 0.
If d2 === 0 (center inside): find min penetration axis:
penX = min(b.x - bx, bx+bw - b.x); penY likewise. Choose axis with smaller penetration; normal accordingly.
Since the substeps are small, center-inside is rare. Fine.
Reflect:
```js
const vn = b.vx*nx + b.vy*ny;
if(vn < 0){ b.vx -= 2*vn*nx; b.vy -= 2*vn*ny; }
```
For axis-aligned normal (1,0) this just flips vx. Good.
Also nudge so it doesn't re-collide.
For the brick: hit it, hp--, if hp<=0 remove & particles & score & drop power.
Also need to prevent ball from destroying multiple bricks in one step—that's fine and natural.
Paddle collision:
```js
function collidePaddle(b){
const p = paddle;
const px = p.x - p.w/2, py = p.y;
const cx = Math.max(px, Math.min(b.x, px+p.w));
const cy = Math.max(py, Math.min(b.y, py+p.h));
const dx = b.x-cx, dy = b.y-cy;
if(dx*dx+dy*dy > b.r*b.r) return;
// if hitting from top
if(b.y < py + p.h*0.5){
// top bounce
b.y = py - b.r;
const off = Math.max(-1, Math.min(1, (b.x - p.x)/(p.w/2)));
const ang = off * (Math.PI/3); // max 60deg
const sp = Math.hypot(b.vx,b.vy);
b.vx = Math.sin(ang)*sp;
b.vy = -Math.cos(ang)*sp;
// ensure minimum upward angle
} else {
// side
if(b.x < p.x){ b.x = px - b.r; b.vx = -Math.abs(b.vx); }
else { b.x = px+p.w+b.r; b.vx = Math.abs(b.vx); }
}
}
```
Hmm if ball is below paddle center it's a side/underside hit. If b.y > py + p.h (fully below), the ball is lost anyway... Actually if it goes below the paddle it's lost. Let's mark lost when b.y - b.r > H.
Actually a ball can hit the paddle bottom when coming from the side. Just push out.
Careful: after top bounce, adding a tiny bit of paddle influence: b.vx += p.vx*0.1? Not necessary.
Also if the ball hits the paddle edge with off = ±1, angle = 60°, giving a sharp horizontal-ish shot. Good. But then vy = -cos(60)*sp = -0.5*sp, vx = ±0.866sp. Still upward. Good.
Let's cap so that the ball doesn't get too horizontal: max 65° maybe. Use 60. Fine.
Also avoid the ball getting stuck in a purely horizontal loop: if |vy| < 0.15*speed, nudge. Add in wall collisions or after brick bounces. Let's add: after brick collision, if Math.abs(b.vy) < 0.2*speed then adjust. Actually could cause weirdness. Let's add a gentle check in updateBalls: ensure |vy| >= 0.18*speed.
Hmm, that could fight physics. I'll add it only after brick collisions.
Paddle update:
```js
function updatePaddle(dt){
const p = paddle;
let dir = 0;
if(keys.left) dir -= 1;
if(keys.right) dir += 1;
if(dir !== 0){ p.x += dir * 700 * dt; mouseActive=false; }
else if(mouseX !== null){ p.x += (mouseX - p.x) * Math.min(1, dt*22); }
// demo AI
...
p.x = clamp(p.x, p.w/2, W-p.w/2);
}
```
Hmm, mixing smoothing with keyboard: fine.
Actually for the mouse let's set p.x = mouseX directly but clamp — instant is best for gameplay. But the lerp is nicer. Use lerp factor dt*30 (fast). Actually with dt=1/60, factor 0.5 → reaches in a few frames. Good enough and smooth.
Hmm, but lerping means the paddle lags behind the mouse, which can feel off. Use factor min(1, dt*30) ≈ 0.5. It's fine.
For the demo AI: predict where the ball will be at paddle y, move paddle toward it with a max speed and some noise.
```js
if(state.demo){
// find lowest ball
let target = W/2;
let best = null;
for(const b of balls){ if(b.vy>0 && (!best || b.y > best.y)) best = b; }
if(best){
const t = (paddle.y - best.y)/best.vy;
let px = best.x + best.vx*t;
// reflect off walls
...
}
}
```
Wall reflection prediction: field width 0..W. Use a simple loop: while px < 0 or px > W, reflect.
```js
const period = W*2; // wait—ball bounces between 0 and W with radius r
```
Simplify: use modulo reflection:
```js
let p = ((px % (2*W)) + 2*W) % (2*W);
if(p > W) p = 2*W - p;
```
That's reflection for a 0..W track. Good enough (ignoring radius).
Then target = p + noise. Move paddle toward target at speed 520, with an error margin so it's not perfect: target += Math.sin(now*0.003)*30.
Also for demo it should not be perfect—but actually for a demo we want it to look good (not lose all lives quickly). Perfect-ish is fine.
Demo paddle should also launch the ball automatically.
Ball stuck state: when a ball is "attached" to the paddle. Let's keep it simple: after losing a life, spawn a new ball attached; auto-launch after 1s (or immediately in demo). Actually let's show "READY" and launch on click/press... simpler: auto-launch after 0.9s with a short countdown glow. In demo, launch immediately after 0.6s.
Let's implement attached ball: b.attached = true, positioned at paddle top; on launch set velocity.
Simplify: keep `stuckBall` boolean. When balls.length === 0 after a loss and lives > 0: create ball at paddle, attached=true, timer 0.8s. Update: if attached, b.x = paddle.x, b.y = paddle.y - r - 1; timer -= dt; if timer <=0 → launch.
For the human, allow clicking/space to launch early? Nice but optional. I'll auto-launch.
Now the overlay messages:
- Demo: "CLICK OR PRESS A KEY TO START" — big, centered, with a subtle pulse. Plus maybe "DEMO" label.
- Game over: "GAME OVER" + score + "Click or press SPACE to restart".
- Victory: "VICTORY!" + score + restart.
Since the demo runs with AI, on the user's first click/key it transitions to a fresh human game.
Handling: `state.phase` ∈ 'demo' | 'play' | 'over' | 'win'.
Click handler: if phase === 'demo' → startGame(); else if phase === 'over' or 'win' → startGame(). During play, click does nothing (or launches ball).
Key handler: same, plus arrows.
Wait: "The iframe only receives keyboard events after the user clicks it". We handle both.
Let's also make sure the demo restarts itself if it wins or loses during demo (infinite demo). Yes: in demo, on game over → restart demo automatically after a delay; on victory → restart.
Now, victory: all bricks destroyed. Then show VICTORY. In demo, restart.
Rendering:
1. Background: dark gradient with subtle grid.
2. Bricks with rounded rects, gradient fill, highlight.
3. Tough brick: darker outline, and after 1 hit, crack lines drawn.
4. Power-up icon: small circle with a letter/arrow drawn in white; a subtle pulsing glow.
5. Paddle: rounded rect with gradient, glow.
6. Balls: radial gradient white.
7. Power-ups falling: rounded rect capsule with icon.
8. Particles.
9. HUD: score, lives, active powerup timer bar.
10. Overlays.
Let me use roundRect (supported in Chrome 99+). Fine.
Let's write the draw functions.
Colors for bricks with hp 2: base color darker + metallic highlight.
Crack drawing: for a cracked brick, draw a few line segments in dark color with a jagged shape. Deterministic per brick — store a seed or precompute crack path.
Let's precompute crack lines when the brick is created (relative coords).
```js
function makeCracks(w,h){
const lines = [];
const cx = w/2, cy = h/2;
// 3 cracks radiating
...
}
```
Simpler: generate 3 polylines from center-ish to edges with random jitter.
```js
for(let i=0;i<3;i++){
const a = Math.random()*Math.PI*2;
const pts = [[cx + Math.cos(a)*2, cy + Math.sin(a)*2]];
let x = cx, y = cy, ang = a;
for(let k=0;k<3;k++){
ang += (Math.random()-0.5)*0.9;
const len = Math.min(w,h)/2 * (0.5 + Math.random()*0.5);
x += Math.cos(ang)*len/2; y += Math.sin(ang)*len/2;
pts.push([x,y]);
}
lines.push(pts);
}
```
Clamp within bounds. Fine — it's decorative.
Now 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{height:100%;margin:0;background:#07070f;overflow:hidden;
font-family: ui-sans-serif, system-ui, ...;}
#wrap{position:fixed;inset:0;display:grid;place-items:center;}
canvas{display:block;width:min(94vw,124vh);height:auto;border-radius:14px;
box-shadow:0 30px 80px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.08);
cursor:none; touch-action:none;}
</style>
```
Hmm cursor:none over the canvas — for mouse control that's fine but maybe hide is odd. Let's keep default cursor crosshair? I'll use `cursor: crosshair` ... Actually the paddle follows the mouse so hiding cursor is fine but the overlay prompt needs clicking. Use default.
Body background: radial gradient dark.
Now, careful with `height:auto` and canvas width/height attributes set to W*dpr — yes ratio preserved.
But on very tall narrow screens, width:94vw gives small height. That's fine.
Let's make sure the canvas fits in the viewport height: width = min(94vw, 124vh) → height = width*0.75 = min(70.5vh, 93vh)... wait: if width = 94vw and vw < 1.24*vh then height = 0.75*94vw = 70.5vw which could exceed vh if vw is large. Let's check: width = min(94vw, 124vh). Height = 0.75 * width = min(70.5vw, 93vh). If 94vw > 124vh, i.e., vw > 1.319vh, then width = 124vh, height = 93vh < 100vh ✓. If vw ≤ 1.319vh, width = 94vw, height = 70.5vw ≤ 93vh ✓. Good, always fits.
Now the JS.
```js
(function(){
'use strict';
const W = 800, H = 600;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function fitCanvas(){
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = Math.round(W*dpr);
canvas.height = Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
}
fitCanvas();
window.addEventListener('resize', fitCanvas);
```
Careful: setTransform resets each resize. And when drawing we don't reset. Fine. But if the canvas is resized, the transform is reset by setting width. OK.
Note: with dpr up to 2, at width 1066 CSS px the canvas backing is 1600x1200 — fine.
Now game state.
```js
const state = {
phase: 'demo', // demo | play | over | win
score: 0,
lives: 3,
time: 0,
};
```
Paddle:
```js
const paddle = { x: W/2, y: H-46, w: 112, h: 14, baseW: 112, speed: 720 };
```
Wide powerup: paddle.w = baseW * 1.7; wideTimer.
Balls array, bricks array, powerups array, particles array.
Let's write `startGame(isDemo)`:
```js
function newGame(isDemo){
state.demo = isDemo;
state.phase = isDemo ? 'demo' : 'play';
state.score = 0;
state.lives = 3;
paddle.baseW = 112;
paddle.w = 112;
paddle.x = W/2;
wideTimer = 0;
balls.length = 0;
powerups.length = 0;
particles.length = 0;
buildBricks();
spawnBall(true);
...
}
```
Hmm, state.demo vs phase. Let's just have `state.demo` boolean and `state.over` (null | 'over' | 'win').
Simplify:
- state.mode: 'demo' | 'play' | 'gameover' | 'victory'
And `isDemo = state.mode === 'demo'`.
Restart logic:
- Click/key when mode is 'demo' → newGame('play')
- Click/key when mode is 'gameover' or 'victory' → newGame(state.demoWasDemo? no) → 'play'
- In demo, when game ends → after 1.2s restart demo.
Let's add `state.restartTimer`.
Let me structure the update:
```js
function update(dt){
state.t += dt;
if(state.mode === 'gameover' || state.mode === 'victory'){
state.restartTimer -= dt;
updateParticles(dt);
if(state.mode === 'demoEnd')...
}
}
```
Hmm, demo end. Let's handle: when in demo and game ends, set mode to 'demo' again immediately but with a small pause. Actually simpler: when demo ends (win or lose all lives), immediately call newGame('demo') after a 1.5s delay using a timer; during the delay, keep the game frozen but draw the overlay. Let's add state.freeze = true.
Simplify further: in demo mode, don't allow game over — give infinite lives. If the AI loses a ball, just respawn (lives stay 3). If it wins, immediately rebuild the bricks and continue. That keeps the demo running forever with no state complexity.
So: in demo, when lives would drop below 1, just reset to 3 ... actually let's make demo lives = Infinity effectively: on ball loss in demo, respawn without decrementing (but still show 3 lives). And on victory in demo, restart the level immediately (with a tiny delay for a "LEVEL CLEAR" flash? no, just restart).
Hmm, but then the demo never shows the victory screen. That's fine — the human player will see it.
Actually, a nice touch: in demo, after clearing all bricks, rebuild bricks and keep going. Let's do that.
OK.
Now write brick building.
```js
const COLS = 12, ROWS = 7;
const BRICK_TOP = 78;
const FIELD_L = 36, FIELD_R = W - 36;
const GAP = 5;
const BRICK_W = (FIELD_R - FIELD_L - GAP*(COLS-1))/COLS; // (728 - 55)/12 = 56.08
const BRICK_H = 24;
const ROW_GAP = 6;
```
728-55 = 673; /12 = 56.08. OK.
Pattern rows as strings of length 12:
```js
const PATTERN = [
'..########..',
'.##########.',
'############',
'############',
'.##########.',
'..########..',
'...######...',
];
```
Row count 7 ✓. Tough rows: indices 0 and 1 → char '='.
Let's define pattern with '=' for tough:
```js
const PATTERN = [
'..========..',
'.==========.',
'############',
'############',
'.##########.',
'..########..',
'...######...',
];
```
Total tough = 8+10 = 18. Normal = 12+12+10+8+6 = 48. Total 66.
Row colors:
```js
const ROW_COLORS = ['#ff4d6d','#ff8a3d','#ffd23f','#5ee07a','#3ec9f0','#7c8cff','#c86bff'];
```
Now bricky: y = BRICK_TOP + row*(BRICK_H+ROW_GAP).
Power-up assignment: gather indices, spread.
```js
const powerBricks = [];
const all = bricks;
const count = 9;
for(let k=0;k<count;k++){
const idx = Math.floor((k+0.5)*all.length/count);
...
}
```
Alternate types by k parity. But we want them on lower rows probably. Whatever, spread across all is fine — but if they're on tough bricks in the top rows, they're hard to get. That's OK; some will be on lower rows.
Actually, let's bias toward rows 2-6 (normal bricks). Let's filter: candidates = bricks.filter(b => b.row >= 2). Then pick 8 spread over those.
Total normal bricks 48, picking 8 → fine.
Types alternate: even k → 'wide', odd → 'multi'.
Now, drawing a brick with power icon: draw a small rounded square in the center with a symbol: for 'wide' draw a horizontal wide bar; for 'multi' draw three small circles. Let's draw with alpha 0.85 white.
Actually simpler: draw a circle with the icon inside. Let's do:
- 'wide': a small rounded rect (representing wide paddle) inside a circle outline.
- 'multi': three dots.
Given brick height 24 and width 56, there's room for a ~14px circle.
Let's do: circle radius 8 at center, filled rgba(255,255,255,0.9), then icon in brick color.
Nice.
Falling power-up: capsule 36x18 with same icon in white on colored background, with a glow.
Now particles.
```js
function spawnParticles(x,y,color,n){
for(let i=0;i<n;i++){
const a = Math.random()*Math.PI*2;
const sp = 60 + Math.random()*220;
particles.push({x,y,vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 40, life:0.5+Math.random()*0.5, maxLife:..., size:2+Math.random()*3, color});
}
}
```
Update: vy += 900*dt; x += vx*dt; life -= dt; remove when <=0.
Render with alpha = life/maxLife.
Now HUD.
Draw at top: score on the left "SCORE" label small, value big. Lives on the right as small paddle icons.
Also show active power-up timer as a small bar under the HUD.
Let's design HUD:
- top-left: "SCORE" in small letter-spaced gray, then the number in white bold 26px.
- top-right: "LIVES" then 3 rounded rects.
- center: maybe level name "LEVEL 1" small.
Let's also animate score popups? Maybe skip. Actually small floating "+10" text would be nice polish. Let's add floating score texts — cheap to implement.
OK, let's include `floaters` array {x,y,text,life,color}.
Now write the render function.
```js
function render(){
// background
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0b0d1a');
g.addColorStop(1,'#141024');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
// subtle vignette / grid
...
}
```
Maybe add a radial glow behind the brick area.
Let's do a soft radial gradient in the center top.
Then a border rect around the playfield.
Let's draw the playfield border: strokeRect(2,2,W-4,H-4) with rgba(255,255,255,0.06), lineWidth 4.
Draw bricks, powerups, paddle, balls, particles, HUD, overlays.
Order: background, bricks, powerups, particles(below balls?), paddle, balls, HUD, overlay.
Now overlay text rendering:
```js
function drawOverlay(){
if(state.mode === 'demo'){
// dim
ctx.fillStyle = 'rgba(6,8,18,0.55)';
ctx.fillRect(0,0,W,H); // hmm, this dims the game too much for the screenshot
}
}
```
Hmm — for the screenshot at 3s, the demo mode with a dark overlay might look dull. Let's use a subtle overlay only in the center band, not the whole screen. Like a rounded panel with the message.
Yes: a centered card with "BREAKOUT", "CLICK OR PRESS A KEY TO START", "DEMO MODE — AI PLAYING" small. With a semi-transparent dark background and a glow border. That looks polished and keeps the game visible.
But the card covers the middle of the playfield... It's OK — it's only for the first 3 seconds. Hmm, it will be in the screenshot. That's fine and expected for these demos. Let's make the card semi-transparent (alpha 0.72) and positioned center. Bricks are above and below it partially. Actually let's position the card a bit lower, around y=300..430 (center-ish), so bricks rows 0-2 are visible above.
Bricks occupy y=78 to 78+7*30=288. So a card from y=320 to y=450 sits below the bricks, in the empty play area.
But the ball and paddle are around y=460-560. The card at 320-450 is in the empty zone. Good.
Let's do that.
Card content:
- "BREAKOUT" big gradient text
- "Click or press any key to play"
- small: "AI demo running"
Pulsing opacity on the prompt.
For game over/victory: full-ish overlay with a card.
Let's write:
```js
function panel(x,y,w,h,r){
ctx.beginPath();
ctx.roundRect(x,y,w,h,r);
...
}
```
roundRect is available in Chrome 99+. Should be fine for "latest Chrome".
Now let's be careful with performance: 66 bricks drawn per frame with gradients — creating gradients each frame for each brick is a bit heavy but OK at 60fps for ~66 bricks. To be safe, precompute per-row gradient colors as solid colors and just use fills + a lighter top highlight rect. Let's avoid per-brick gradient creation. I'll use solid fill + a translucent white top strip.
Actually creating 66 linear gradients per frame is maybe 1-2ms. Acceptable but let's cache: create gradient per row once (they're the same for all bricks in a row since width is same... but gradient is in absolute canvas coords). Use a gradient created once per row with y offsets — but all bricks in a row share the same y, so one gradient per row per frame = 7 gradients. Let's precompute at build time per row using the row's y position (constant). Yes! Bricks don't move. So precompute gradient objects per row at build time.
Good.
Let's do that:
```js
const rowGradients = ROW_COLORS.map((c,i)=> {
const y = BRICK_TOP + i*(BRICK_H+ROW_GAP);
return ctx.createLinearGradient(0, y, 0, y+BRICK_H);
});
```
But gradients depend on the canvas transform? No — gradients are in user-space coordinates, and the transform is applied at draw time. So creating them with user coords is fine.
Wait, but ctx.createLinearGradient stores coordinates in the current user space at creation? No — gradient coordinates are interpreted in the user space at the time of *painting*, not creation. Actually, per spec, the gradient coordinates are in the coordinate space of the context when the gradient is used for painting. So it's fine.
OK but they need color stops. Let's define with light/dark variants.
Simpler: just use solid colors with a white overlay strip. That looks clean and modern. Let's do:
```js
ctx.fillStyle = color;
roundRect(...); fill();
// highlight
ctx.fillStyle = 'rgba(255,255,255,0.18)';
roundRect(x, y, w, h*0.45, [r,r,0,0]); fill();
```
Hmm, roundRect with per-corner radii array. Supported.
Let's keep it simple: draw the base rounded rect, then a smaller rounded rect at the top with white alpha.
For tough bricks, add a diagonal hatch pattern or a darker inner border + a small "hp" pip. And after first hit, draw cracks and reduce brightness.
Let's define:
- normal brick: color = rowColor
- tough brick (hp 2): color = rowColor but with a metallic overlay: fill dark, then stroke with color, plus inner inset rect with the color at lower alpha. Let's do: base fill = darker version of rowColor, border = rowColor (2px), and a small inner highlight. After the first hit, add cracks and desaturate.
Need a helper to darken a hex color.
```js
function shade(hex, amt){ // amt -1..1
const n = parseInt(hex.slice(1),16);
let r = (n>>16)&255, g=(n>>8)&255, b=n&255;
if(amt<0){ r*=1+amt; g*=1+amt; b*=1+amt; }
else { r += (255-r)*amt; ... }
return `rgb(${r|0},${g|0},${b|0})`;
}
```
OK.
Now let's write everything out.
Ball rendering: radial gradient white core → color. Cache a ball gradient? It moves, so create per ball per frame — only up to 6 balls. Fine.
Let me now write the code in full.
```js
/* ---------- Setup ---------- */
const W=800,H=600;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
let dpr=1;
function fit(){
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(W*dpr);
canvas.height=Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
}
fit();
addEventListener('resize',fit);
```
Careful: on resize the transform resets, and drawing continues. Fine.
Pointer input:
```js
let mouseX = null;
canvas.addEventListener('pointermove', e=>{
const r = canvas.getBoundingClientRect();
mouseX = (e.clientX - r.left) / r.width * W;
});
canvas.addEventListener('pointerleave', ()=>{ mouseX = null; });
canvas.addEventListener('pointerdown', e=>{
e.preventDefault();
if(state.mode!=='play') startHuman();
else if(ballAttached) launch();
});
```
Hmm pointerleave sets mouseX to null, then the paddle stops following. Good.
But for touch, pointermove only fires while touching. On touchstart it fires too (pointerdown then pointermove). Let's handle pointerdown to also set mouseX.
Keyboard:
```js
const keys = {left:false,right:false};
addEventListener('keydown', e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A'){ keys.left=true; e.preventDefault(); }
...
if(state.mode!=='play'){ startHuman(); }
});
```
Careful: pressing space triggers start. Any key starts.
But we should ignore key repeats for starting. Use `e.repeat` check? It's fine.
Let's write:
```js
window.addEventListener('keydown', (e)=>{
const k = e.key;
if(k==='ArrowLeft'||k==='a'||k==='A'){ keys.left = true; }
if(k==='ArrowRight'||k==='d'||k==='D'){ keys.right = true; }
if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(k)) e.preventDefault();
if(state.mode !== 'play'){ startHuman(); }
}, {passive:false});
```
Hmm preventDefault needs non-passive; keydown is non-passive by default. OK.
Now the main game object.
```js
const G = {
mode:'demo', // 'demo' | 'play' | 'gameover' | 'victory'
score:0,
lives:3,
time:0,
shake:0,
};
```
Wait, `state` name conflicts? I'll call it `G`.
Paddle:
```js
const paddle = { x:W/2, y:H-48, w:112, h:14, baseW:112, wide:0 };
```
`wide` = remaining seconds.
Balls:
```js
let balls = [];
let bricks = [];
let powerups = [];
let particles = [];
let floaters = [];
```
Ball factory:
```js
function makeBall(x,y,speed,angle){
return { x, y, vx:Math.cos(angle)*speed, vy:Math.sin(angle)*speed, r:8, stuck:false, stuckT:0 };
}
```
Actually with angle measured from +x axis. Launch upward: angle = -Math.PI/2 + offset.
Base speed: 430.
Let me define BALL_SPEED = 430.
Level build:
```js
function buildLevel(){
bricks = [];
const left = 36;
const totalW = W - 72;
const bw = (totalW - (COLS-1)*GAP)/COLS;
for(let r=0;r<ROWS;r++){
const pat = PATTERN[r];
for(let c=0;c<COLS;c++){
const ch = pat[c];
if(ch==='.') continue;
const tough = ch==='=';
bricks.push({
x: left + c*(bw+GAP),
y: BRICK_TOP + r*(BRICK_H+ROW_GAP),
w: bw, h: BRICK_H,
row: r, col: c,
hp: tough?2:1,
maxHp: tough?2:1,
color: ROW_COLORS[r],
power: null,
alive: true,
flash: 0,
cracks: tough ? makeCracks(bw, BRICK_H) : null,
});
}
}
// assign power-ups
const cands = bricks.filter(b=>b.row>=2);
const N = 9;
for(let k=0;k<N;k++){
const idx = Math.floor((k+0.5)*cands.length/N);
cands[idx].power = (k%2===0)?'wide':'multi';
}
}
```
Wait, some indices could repeat? (k+0.5)/N * len, with len=48, N=9: 0.5/9*48=2.67→2, 1.5/9*48=8, 2.5→13.3→13, 3.5→18.67→18, 4.5→24, 5.5→29.3→29, 6.5→34.67→34, 7.5→40, 8.5→45.3→45. All distinct ✓.
Hmm, but `cands` order is row-major from row 2 downward. So k=0 → row 2, etc. Fine, they're spread.
Actually I'd like some power-ups in the middle rows visible near the top. Rows 2-6 is where they are. Good.
Now `makeCracks`:
```js
function makeCracks(w,h){
const lines=[];
for(let i=0;i<3;i++){
let x = w*(0.3+Math.random()*0.4), y = h*(0.3+Math.random()*0.4);
let ang = Math.random()*Math.PI*2;
const pts=[[x,y]];
for(let k=0;k<3;k++){
ang += (Math.random()-0.5)*1.2;
const len = 4+Math.random()*7;
x = Math.max(2,Math.min(w-2, x+Math.cos(ang)*len));
y = Math.max(2,Math.min(h-2, y+Math.sin(ang)*len));
pts.push([x,y]);
}
lines.push(pts);
}
return lines;
}
```
OK.
Now start:
```js
function startGame(demo){
G.mode = demo ? 'demo' : 'play';
G.score = 0;
G.lives = 3;
G.time = 0;
paddle.w = paddle.baseW;
paddle.wide = 0;
paddle.x = W/2;
balls = [];
powerups = [];
particles = [];
floaters = [];
buildLevel();
resetBall();
}
function resetBall(){
balls = [];
const b = makeBall(paddle.x, paddle.y - 10, BALL_SPEED, -Math.PI/2);
b.stuck = true;
b.stuckT = G.mode==='demo' ? 0.5 : 1.0;
balls.push(b);
}
```
While stuck, the ball follows the paddle, and after stuckT seconds it launches.
Actually for the human, launching automatically after 1s is good (keeps things moving). And they can also click to launch early.
Hmm — but clicking during play when the ball is stuck: I said pointerdown starts the game if mode !== 'play'. If mode === 'play', launch. Good.
Launch:
```js
function launchBall(b){
b.stuck = false;
const ang = -Math.PI/2 + (Math.random()-0.5)*0.5;
b.vx = Math.cos(ang)*BALL_SPEED;
b.vy = Math.sin(ang)*BALL_SPEED;
}
```
Now the update loop.
```js
let lastT = performance.now();
function frame(now){
let dt = (now-lastT)/1000;
lastT = now;
if(dt > 0.05) dt = 0.05;
update(dt);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```
update:
```js
function update(dt){
G.time += dt;
updateParticles(dt);
updateFloaters(dt);
if(G.mode==='demo' || G.mode==='play'){
updatePaddle(dt);
updateBall(dt);
updatePowerups(dt);
}
if(G.mode==='gameover' || G.mode==='victory'){
// nothing
}
}
```
Wait, particles should still animate in gameover. Yes, handled.
updatePaddle:
```js
function updatePaddle(dt){
const p = paddle;
const half = p.w/2;
let moved = false;
if(G.mode==='demo'){
// AI
let target = W/2;
let best=null;
for(const b of balls){ if(!b.stuck && b.vy>0){ if(!best || b.y>best.y) best=b; } }
if(best){
const t = (p.y - best.y)/best.vy;
let px = best.x + best.vx*Math.max(0,t);
const period = 2*(W - 2*best.r);
let m = ((px - best.r) % period + period) % period;
if(m > period/2) m = period - m;
target = m + best.r;
target += Math.sin(G.time*2.1)*14;
} else {
// follow x of nearest ball
const b = balls[0];
if(b) target = b.x;
}
target = clamp(target, half, W-half);
const diff = target - p.x;
const spd = 760;
const step = Math.sign(diff)*Math.min(Math.abs(diff), spd*dt);
p.x += step;
} else {
let dir = 0;
if(keys.left) dir--;
if(keys.right) dir++;
if(dir!==0){ p.x += dir*760*dt; mouseX = null; }
else if(mouseX!=null){ p.x += (mouseX - p.x)*Math.min(1, dt*28); }
}
p.x = clamp(p.x, half, W-half);
}
```
Hmm the mouse: if the user moves the mouse we set mouseX. But keyboard sets it to null, so it takes over. Once the user moves the mouse again, it takes over. Good.
Wait, `mouseX = null` inside the update — but if the key is released, the paddle will keep following the last mouseX. But we set it to null on key press, so it stops. Then pointermove sets it again. Good.
The AI prediction reflection math: ball travels in a field of width W (walls at 0 and W, with radius r). Using triangle wave with period 2W is standard. I wrote `period = 2*(W - 2*r)` and m relative to r. Let's simplify: ignore radius, use W:
```js
let px = best.x + best.vx*t;
const P = 2*W;
px = ((px % P) + P) % P;
if(px > W) px = P - px;
target = px;
```
That's clean. Use that.
Also the AI should aim to hit the ball with the paddle center-ish for control: target = px + small offset. Fine.
updateBall(dt): For each ball, if stuck, position it at the paddle. Else substep.
```js
function updateBall(dt){
for(let i=balls.length-1;i>=0;i--){
const b = balls[i];
if(b.stuck){
b.x = paddle.x;
b.y = paddle.y - b.r - 2;
b.stuckT -= dt;
if(b.stuckT <= 0) launchBall(b);
continue;
}
const sp = Math.hypot(b.vx,b.vy);
let steps = Math.ceil(sp*dt/(b.r*0.7));
steps = Math.min(steps, 12);
const sdt = dt/steps;
for(let s=0;s<steps;s++){
b.x += b.vx*sdt;
b.y += b.vy*sdt;
walls(b);
bricksCollide(b);
paddleCollide(b);
if(b.y - b.r > H){ b.dead = true; break; }
}
}
// remove dead
for(let i=balls.length-1;i>=0;i--) if(balls[i].dead) balls.splice(i,1);
if(balls.length===0) loseLife();
}
```
Careful: `loseLife` gets called if all balls are gone. But resetBall pushes a new ball. Fine — but only if mode is play/demo.
Wait, but the check `if(balls.length===0)` runs every frame while... resetBall creates a ball, so it won't loop. But if lives reach 0 → game over → mode changes → update stops. Good.
Each ball should have a trail... skip. Let's add a subtle motion trail via storing previous positions? Optional; skip for perf/simplicity, but a glow is nice.
Speed cap: after paddle bounce speed stays constant. After brick bounce it stays constant too (reflection preserves magnitude). Good.
But the ball could get into a nearly-horizontal state, bouncing forever between side walls. Add a check in `walls` or after brick collisions:
```js
function fixAngle(b){
const sp = Math.hypot(b.vx,b.vy);
if(Math.abs(b.vy) < sp*0.22){
const sign = b.vy >= 0 ? 1 : -1;
b.vy = sign * sp * 0.22;
const nx = Math.sqrt(Math.max(0, sp*sp - b.vy*b.vy)) * Math.sign(b.vx || 1);
b.vx = nx;
}
}
```
Call after brick collisions. Hmm, but this changes the magnitude? No: vy set to 0.22*sp, vx recomputed as sqrt(sp²-vy²). Magnitude preserved ✓.
Careful with sign when b.vx is 0 → sign(0)=0 → vx=0, that's bad. Use `b.vx >= 0 ? 1 : -1`.
Actually let's use `const s = b.vx < 0 ? -1 : 1; b.vx = s*Math.sqrt(...)`.
Now walls:
```js
function walls(b){
if(b.x - b.r < 0){ b.x = b.r; b.vx = Math.abs(b.vx); }
else if(b.x + b.r > W){ b.x = W - b.r; b.vx = -Math.abs(b.vx); }
if(b.y - b.r < 0){ b.y = b.r; b.vy = Math.abs(b.vy); }
}
```
Hmm, the ball can also go above into the HUD area. Top wall at y=0 covers it. Bricks start at y=78. So the top region 0..78 is free. That's fine.
Actually, should the playfield have a top border at y=0? Yes.
Now brick collision:
```js
function bricksCollide(b){
for(let i=0;i<bricks.length;i++){
const br = bricks[i];
if(!br.alive) continue;
// broad phase
if(b.x + b.r < br.x || b.x - b.r > br.x+br.w || b.y + b.r < br.y || b.y - b.r > br.y+br.h) continue;
// narrow
const cx = clamp(b.x, br.x, br.x+br.w);
const cy = clamp(b.y, br.y, br.y+br.h);
let dx = b.x - cx, dy = b.y - cy;
let d2 = dx*dx + dy*dy;
if(d2 >= b.r*b.r) continue;
let nx, ny;
if(d2 > 0.0001){
const d = Math.sqrt(d2);
nx = dx/d; ny = dy/d;
b.x = cx + nx*(b.r+0.01);
b.y = cy + ny*(b.r+0.01);
} else {
// center inside: pick min penetration
const pL = b.x - br.x, pR = br.x+br.w - b.x;
const pT = b.y - br.y, pB = br.y+br.h - b.y;
const m = Math.min(pL,pR,pT,pB);
if(m===pL){ nx=-1; ny=0; b.x = br.x - b.r - 0.01; }
else if(m===pR){ nx=1; ny=0; b.x = br.x+br.w + b.r + 0.01; }
else if(m===pT){ nx=0; ny=-1; b.y = br.y - b.r - 0.01; }
else { nx=0; ny=1; b.y = br.y+br.h + b.r + 0.01; }
}
const vn = b.vx*nx + b.vy*ny;
if(vn < 0){ b.vx -= 2*vn*nx; b.vy -= 2*vn*ny; }
damageBrick(br, b);
fixAngle(b);
break; // one brick per substep
}
}
```
Wait — after repositioning the ball (b.x/b.y changed), the loop `break`s, which is fine.
Hmm, but the reposition uses the closest point which for a corner hit gives a diagonal normal. Good.
`damageBrick(br, b)`:
```js
function damageBrick(br, ball){
br.hp--;
br.flash = 1;
if(br.hp > 0){
// crack
addScore(5);
spawnParticles(br.x+br.w/2, br.y+br.h/2, br.color, 5, 0.6);
// small sound? no
return;
}
br.alive = false;
const pts = br.maxHp>1 ? 25 : 10;
addScore(pts);
floaters.push({x:br.x+br.w/2, y:br.y+br.h/2, vy:-38, life:0.9, maxLife:0.9, text:'+'+pts, color:'#fff'});
spawnParticles(br.x+br.w/2, br.y+br.h/2, br.color, 14, 1);
if(br.power) dropPower(br);
checkWin();
}
```
Wait, if hp>0 we don't spawn particles... we do a small burst. Good.
`checkWin()` — if no bricks alive → victory.
```js
function checkWin(){
if(bricks.some(b=>b.alive)) return;
if(G.mode==='demo'){
// rebuild level for endless demo
G.score += 200;
buildLevel();
// re-spawn? keep balls
return;
}
G.mode='victory';
G.winScore = G.score;
}
```
Hmm, careful: checkWin is called from damageBrick, which is called from inside the ball's substep loop while iterating bricks. Rebuilding bricks mid-iteration could cause issues but we `break` after. And buildLevel reassigns `bricks`. The outer loop `for(let i=0;i<bricks.length;i++)` — after break we exit. Fine.
But there's also `for(const br of bricks)`? I used index loop with break. OK.
Power drop:
```js
function dropPower(br){
powerups.push({
x: br.x+br.w/2, y: br.y+br.h/2,
w: 44, h: 22, vy: 150, type: br.power, t: 0
});
}
```
updatePowerups:
```js
function updatePowerups(dt){
for(let i=powerups.length-1;i>=0;i--){
const p = powerups[i];
p.y += p.vy*dt;
p.t += dt;
// catch
if(p.y + p.h/2 > paddle.y && p.y - p.h/2 < paddle.y + paddle.h &&
p.x + p.w/2 > paddle.x - paddle.w/2 && p.x - p.w/2 < paddle.x + paddle.w/2){
applyPower(p.type);
particles...
powerups.splice(i,1);
continue;
}
if(p.y - p.h/2 > H) powerups.splice(i,1);
}
}
```
applyPower:
```js
function applyPower(type){
if(type==='wide'){
paddle.w = Math.min(paddle.baseW*1.75, 260);
paddle.wide = 10;
floaters.push({...text:'WIDE PADDLE'})
} else {
// multiball
const src = balls.slice();
const target = 3;
let need = Math.max(0, 3 - balls.length);
if(need === 0) need = 1; // still add one
for(let k=0;k<need;k++){
const base = src[k % src.length];
const sp = Math.hypot(base.vx,base.vy) || BALL_SPEED;
const ang = Math.atan2(base.vy, base.vx) + (k%2===0?0.5:-0.5);
const nb = makeBall(base.x, base.y, sp, ang);
nb.stuck = false;
balls.push(nb);
}
}
}
```
Hmm, makeBall with angle uses cos/sin — fine.
But careful: if the source ball is stuck, its velocity is 0. Then sp=0. Handle: if stuck, launch it first, or use BALL_SPEED and -PI/2 ± 0.5.
Let's handle: if base.stuck → sp = BALL_SPEED, ang = -Math.PI/2 + 0.5 or so.
Also cap total balls at 8.
Also `floaters` text for feedback: "MULTI-BALL!".
Wide timer:
```js
if(paddle.wide > 0){
paddle.wide -= dt;
if(paddle.wide <= 0){ paddle.wide = 0; paddle.w = paddle.baseW; }
}
```
Careful about paddle.w shrinking while the ball is near — could cause the ball to be inside the paddle. Minor; the paddle collision will push it out next frame.
Actually the paddle collision: I should handle the ball coming from above with the top-bounce always. Let's write paddleCollide carefully:
```js
function paddleCollide(b){
const p = paddle;
const L = p.x - p.w/2, R = p.x + p.w/2, T = p.y, B = p.y + p.h;
const cx = clamp(b.x, L, R);
const cy = clamp(b.y, T, B);
const dx = b.x-cx, dy = b.y-cy;
if(dx*dx+dy*dy > b.r*b.r) return;
if(b.y < T){
// coming from above
b.y = T - b.r;
const off = clamp((b.x - p.x)/(p.w/2), -1, 1);
const ang = off * (Math.PI/3); // ±60°
const sp = Math.hypot(b.vx,b.vy) || BALL_SPEED;
b.vx = Math.sin(ang)*sp;
b.vy = -Math.cos(ang)*sp;
// small paddle-velocity transfer
b.vx += (p.vx||0)*0.15; // skip
} else {
// side or bottom
if(b.x < p.x){ b.x = L - b.r; b.vx = -Math.abs(b.vx); }
else { b.x = R + b.r; b.vx = Math.abs(b.vx); }
b.vy = -Math.abs(b.vy)*0.8 ... hmm
}
}
```
Hmm the "coming from above" test with `b.y < T` — if the ball center is above the paddle top, the bounce is a top bounce. But what if the ball is deeply overlapping and b.y > T but it came from above? With small substeps this is unlikely.
Actually a better test: check whether the closest point is on the top face: i.e., dy < 0 and b.y < T. Since dy = b.y - cy, and cy = T when b.y < T. So dy < 0. Let's just use `if(b.y <= T + 2)` — meaning anywhere near/above the top → top bounce. Hmm, if the ball is beside the paddle at the same height, that's a side hit.
Let me use: top bounce if `b.y < T + p.h*0.5` AND the ball is moving downward or is above. Actually if the ball center y < T + h/2 (i.e., in the upper half of the paddle vertically), treat as top bounce. That's the standard approach. But the ball would be pushed to T - r, which is a big jump if it was beside the paddle. Hmm.
Given the substeps are small (≤ ~5px) and the paddle is only 14px tall, the ball can't move more than ~5px per substep, so it can't jump from beside the paddle to inside it. The horizontal movement per substep is also ≤5px, so the ball enters the paddle region from the top or side, not from within.
So at the first overlap, if the ball is above the paddle top, it's a top hit → top bounce is safe. If it's overlapping horizontally from the side, then b.y is between T and B → side hit.
Let's use `const fromTop = (b.y - b.r) < T - 0.5 || b.vy > 0 && b.y < T + p.h*0.5;` Hmm.
Simply: `if (dy < 0)` → the closest point is the top surface → top bounce. dy<0 means b.y < T. Good. Else if dy > 0 and b.y > B → bottom hit → push down and reflect vy (ball is lost anyway). Else — the ball center y is within [T,B] → side hit.
But with a corner overlap, dy<0 too. Fine.
Wait, if b.y < T, then cy = T, dy = b.y - T < 0 ✓.
So:
```js
if(dy < 0){ top bounce }
else if(dy > 0){ // below paddle
b.y = B + b.r; b.vy = Math.abs(b.vy);
}
else { // side
if(dx < 0){ b.x = L - b.r; b.vx = -Math.abs(b.vx); }
else { b.x = R + b.r; b.vx = Math.abs(b.vx); }
}
```
Hmm but with the corner case where dx<0 and dy<0, we take the top bounce which repositions x? No, we only change y and velocities. Fine.
Also, when a top bounce happens, ensure vy is negative (upward). Yes by construction.
Edge case: `off` computed from b.x could be slightly outside due to the corner; clamp handles it.
Good.
Now `loseLife`:
```js
function loseLife(){
if(G.mode==='demo'){
resetBall();
return;
}
G.lives--;
G.shake = 1;
if(G.lives <= 0){
G.mode='gameover';
return;
}
resetBall();
}
```
Wait, but in demo mode, `loseLife` is called when balls.length === 0. resetBall adds a new ball. Fine.
Hmm, but for the human, when the ball is lost, we should keep the existing bricks (which we do) and reset the ball. ✓.
Also `paddle.wide` should probably reset on life loss? Nah, keep it.
Now score floaters and particles update.
```js
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.vy += 900*dt;
p.x += p.vx*dt;
p.y += p.vy*dt;
p.life -= dt;
if(p.life<=0) particles.splice(i,1);
}
}
```
Floaters: y += vy*dt; life -= dt.
Now render.
```js
function render(){
ctx.save();
if(G.shake>0){
const s = G.shake*8;
ctx.translate((Math.random()-0.5)*s, (Math.random()-0.5)*s);
G.shake = Math.max(0, G.shake - 0.06);
}
// background
...
ctx.restore();
}
```
Hmm, shake decaying in render is not great but acceptable. Let's decay in update instead.
Background:
```js
const bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#0a0b18');
bgGrad.addColorStop(0.5,'#0e1026');
bgGrad.addColorStop(1,'#16112b');
```
Create once at module level (transform-independent-ish). Gradient coords in user space; the transform is constant (dpr scale). Fine to create once.
Then a radial glow:
```js
const glow = ctx.createRadialGradient(W/2, -100, 0, W/2, -100, 700);
glow.addColorStop(0,'rgba(90,120,255,0.22)');
glow.addColorStop(1,'rgba(90,120,255,0)');
```
Then subtle grid lines: for x from 0 to W step 40: strokeStyle rgba(255,255,255,0.02).
Playfield border: rounded rect stroke.
Let's draw a rounded rect border around the whole canvas: inset 0. Hmm, canvas already has a CSS border-radius 14px and box-shadow. Let's not draw an inner border; instead draw subtle side walls:
```js
ctx.strokeStyle='rgba(140,170,255,0.12)';
ctx.lineWidth=3;
ctx.strokeRect(1.5,1.5,W-3,H-3);
```
That draws inside. OK, but with rounded CSS corners this might look odd. Let's skip the rect and instead draw glowing side "rails": vertical gradient lines at x=2 and x=W-2. Meh.
I'll just draw a soft top line under the HUD.
Let's keep it clean: background gradient + radial glow + faint grid + bricks + etc.
HUD:
```js
function drawHUD(){
ctx.save();
ctx.textBaseline='middle';
// score
ctx.fillStyle='rgba(160,175,215,0.7)';
ctx.font='600 13px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign='left';
ctx.letterSpacing = '2px'; // supported in Chrome 99+
ctx.fillText('SCORE', 36, 32);
ctx.fillStyle='#fff';
ctx.font='700 26px ui-sans-serif, system-ui, sans-serif';
ctx.fillText(String(G.score).padStart(4,'0'), 36, 56);
...
}
```
Hmm, `ctx.letterSpacing` is supported in Chrome 99+. It's fine but might be flaky; let's just use it sparingly or skip.
Careful: font strings must be valid. Use `'700 26px system-ui, sans-serif'`.
Lives: draw small paddle icons at the right.
```js
for(let i=0;i<3;i++){
const x = W-36-i*30, y = 44;
ctx.fillStyle = i < G.lives ? '#7cf' : 'rgba(255,255,255,0.12)';
roundRect(x-20, y-5, 20, 10, 5); ctx.fill();
}
```
Hmm, that draws from the right edge inward. x = W-36 - i*30 gives positions. The rect x-20..x is to the left. Fine.
Actually let's do lives as small heart/circle icons. Paddle icons are thematic. OK.
Wide-paddle timer bar: if paddle.wide > 0, draw a small bar under the score.
Center title: "LEVEL 1" small in the center of the HUD.
Now the bricks.
```js
function drawBricks(){
for(const br of bricks){
if(!br.alive) continue;
const x=br.x, y=br.y, w=br.w, h=br.h, r=6;
if(br.maxHp > 1 && br.hp === 1){
// cracked
ctx.fillStyle = shade(br.color, -0.55);
rr(x,y,w,h,r); ctx.fill();
ctx.strokeStyle = shade(br.color, -0.15);
ctx.lineWidth = 1.5;
rr(x+1,y+1,w-2,h-2,r-1); ctx.stroke();
// cracks
ctx.strokeStyle='rgba(0,0,0,0.75)';
ctx.lineWidth=1.4;
for(const line of br.cracks){ ... }
} else if(br.maxHp > 1) {
// tough, intact
ctx.fillStyle = shade(br.color, -0.35);
rr(x,y,w,h,r); ctx.fill();
ctx.strokeStyle = br.color;
ctx.lineWidth = 2;
rr(x+1,y+1,w-2,h-2,r-1); ctx.stroke();
// inner sheen
ctx.fillStyle='rgba(255,255,255,0.14)';
rr(x+4, y+3, w-8, h*0.32, 3); ctx.fill();
} else {
ctx.fillStyle = br.color;
rr(x,y,w,h,r); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.25)';
rr(x+3, y+2.5, w-6, h*0.35, 3); ctx.fill();
}
// flash
if(br.flash > 0){
ctx.fillStyle = `rgba(255,255,255,${br.flash*0.7})`;
rr(x,y,w,h,r); ctx.fill();
}
// power icon
if(br.power) drawPowerIcon(br.x+w/2, br.y+h/2, 8, br.power, br.color);
}
}
```
Hmm, `rr` = roundRect helper that begins a path.
For the damaged tough brick, I want it to *visibly crack*. Good.
Actually maybe simpler: tough bricks of hp 2 drawn with a distinct look (metallic/hatched), and when hp 1, drawn darker with cracks and a slight size reduction (a 1px inset). Let's add scale-down: draw at x+1.5, y+1.5, w-3, h-3.
Let me refine:
hp2 (intact tough): base fill = shade(color,-0.25), plus a diagonal hatch of lighter lines, plus a bright border.
hp1 (cracked): base fill = shade(color,-0.6), border dashed-ish darker, cracks in near-black.
Also add a tiny "2" ... no, let's use the visual style alone.
Hmm, actually let me make the tough bricks visually distinct with a "rivet" look: 4 small dots in the corners. Cute. And when cracked, the rivets are gone and there are cracks.
I'll do: intact tough = darker fill + bright 2px border + small corner rivets (white alpha). Cracked = darker fill + dark border + crack lines + no rivets.
Good.
Power icon drawing:
```js
function drawPowerIcon(cx, cy, r, type, brickColor){
ctx.save();
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.fillStyle='rgba(255,255,255,0.92)';
ctx.fill();
ctx.fillStyle = dark; // brickColor darkened
if(type==='wide'){
// wide paddle bar
ctx.fillRect(cx-r*0.6, cy-2, r*1.2, 4); // rounded
} else {
// three dots
ctx.beginPath();
ctx.arc(cx-4,cy+2,1.8,0,7); ctx.arc(cx,cy-3,1.8,0,7); ctx.arc(cx+4,cy+2,1.8,0,7);
ctx.fill();
}
ctx.restore();
}
```
Good enough.
Also add a pulsing ring around the power icon for visibility:
```js
ctx.strokeStyle = `rgba(255,255,255,${0.3+0.3*Math.sin(t*4)})`;
```
Now the falling power-up capsule:
```js
function drawPowerups(){
for(const p of powerups){
ctx.save();
ctx.shadowColor = p.type==='wide' ? '#4ade80' : '#38bdf8';
ctx.shadowBlur = 16;
ctx.fillStyle = p.type==='wide' ? '#4ade80' : '#38bdf8';
rr(p.x-p.w/2, p.y-p.h/2, p.w, p.h, p.h/2); ctx.fill();
ctx.shadowBlur=0;
drawPowerIcon(p.x, p.y, 8, p.type, '#000');
ctx.restore();
}
}
```
Hmm, a capsule with an icon inside — maybe better: capsule with white icon shapes drawn directly. Let's draw the capsule in a color + a white circle + colored glyph. Same as the brick version but with a distinct capsule. Simpler: capsule + white glyph.
Let me just do: capsule filled with a gradient, then white glyph directly (not a circle).
For 'wide': a wide white rounded bar in the middle.
For 'multi': three white circles.
Good.
Paddle drawing:
```js
function drawPaddle(){
const p = paddle;
const x = p.x-p.w/2, y = p.y;
ctx.save();
ctx.shadowColor = p.wide>0 ? 'rgba(74,222,128,0.9)' : 'rgba(120,180,255,0.8)';
ctx.shadowBlur = 22;
const g = ctx.createLinearGradient(0,y,0,y+p.h);
g.addColorStop(0, '#eaf4ff');
g.addColorStop(0.5, p.wide>0 ? '#6ee7a0' : '#9ec5ff');
g.addColorStop(1, p.wide>0 ? '#22a05a' : '#3b6fd4');
ctx.fillStyle = g;
rr(x,y,p.w,p.h,7); ctx.fill();
ctx.restore();
}
```
Balls:
```js
for(const b of balls){
ctx.save();
ctx.shadowColor='rgba(160,210,255,0.9)';
ctx.shadowBlur=16;
const g = ctx.createRadialGradient(b.x-2,b.y-3,1, b.x,b.y,b.r);
g.addColorStop(0,'#ffffff');
g.addColorStop(0.6,'#bfe0ff');
g.addColorStop(1,'#5fa8ff');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
ctx.restore();
}
```
Careful: shadows on many objects can be slow. There are at most ~6 balls. Fine.
Particles: small squares/circles with globalAlpha.
Now overlays.
Demo card:
```js
function drawDemoCard(){
const w=440, h=132, x=W/2-w/2, y=326;
ctx.save();
ctx.fillStyle='rgba(10,12,26,0.78)';
rr(x,y,w,h,18); ctx.fill();
ctx.strokeStyle='rgba(120,160,255,0.35)';
ctx.lineWidth=1.5;
rr(x+.75,y+.75,w-1.5,h-1.5,18); ctx.stroke();
// text
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle='#fff';
ctx.font='800 30px system-ui, sans-serif';
ctx.fillText('BREAKOUT', W/2, y+42);
const a = 0.55 + 0.45*Math.sin(G.time*3.4);
ctx.fillStyle = `rgba(150,190,255,${a})`;
ctx.font='700 17px system-ui, sans-serif';
ctx.fillText('Click or press a key to start', W/2, y+84);
ctx.fillStyle='rgba(140,155,190,0.75)';
ctx.font='600 12px system-ui, sans-serif';
ctx.fillText('DEMO — AI IS PLAYING', W/2, y+110);
ctx.restore();
}
```
y=326, h=132 → bottom at 458. Paddle at y=552. Ball around there. OK.
Hmm, the demo card overlaps where the ball travels. That's fine, it's semi-transparent.
Wait — but the ball will be drawn *behind* the card (card drawn after). That's fine.
Actually, it might be nicer to have the card at the very center and the game dimmed slightly. Let's keep as is.
Game over / victory card:
```js
function drawEndCard(){
ctx.fillStyle='rgba(6,8,18,0.72)';
ctx.fillRect(0,0,W,H);
const w=460,h=250, x=W/2-w/2, y=H/2-h/2;
...
}
```
With title "GAME OVER" (red) or "VICTORY!" (green), score, and "Click or press SPACE to play again".
OK.
Now the `rr` helper:
```js
function rr(x,y,w,h,r){
ctx.beginPath();
if(ctx.roundRect){ ctx.roundRect(x,y,w,h,r); }
else { /* fallback */ ctx.rect(x,y,w,h); }
}
```
roundRect exists in Chrome 99+. I'll include a fallback for safety.
Now let's put it all together, and double-check variable declarations order (function declarations hoist; const/let at module top used inside functions is fine).
One issue: `buildLevel` uses `ctx`? No. Good.
Let's also handle `mouseX` clamping and the paddle width for clamping.
Another detail: `resetBall` sets the ball at paddle.y - 10 but the actual position is set in update when stuck. Fine.
Now let's write `startHuman()`:
```js
function startHuman(){ startGame(false); }
```
And ensure it's called only when mode !== 'play'.
Let's write the input:
```js
function onStartInput(){
if(G.mode === 'play') return;
startGame(false);
}
```
For pointerdown: if mode is 'play' and a ball is stuck, launch it.
Let's write:
```js
canvas.addEventListener('pointerdown', (e)=>{
e.preventDefault();
updateMouse(e);
if(G.mode === 'play'){
for(const b of balls) if(b.stuck) { b.stuckT = 0.001; }
} else {
startGame(false);
}
});
```
Hmm, setting stuckT to 0.001 makes it launch next frame. But we want it to launch with the angle... launchBall is called when stuckT <= 0. Fine. Actually simpler: directly call launchBall(b) and set stuck=false.
Let me do `if(b.stuck) launchBall(b);`
But careful: launchBall uses a random angle. Fine.
For keys: any keydown starts the game if not playing. Also arrow keys control.
Now the initial state: `startGame(true)` at load.
Let's double check `updateBall` when `G.mode==='over'` — we don't update. Good.
One more: in `update`, the paddle update includes the wide timer decrement. Let's put that in updatePaddle.
Now, the `checkWin` in demo rebuilds the level — but we also need to reset the ball? No, keep going.
Hmm, but if the demo rebuilds the level while the ball is in the brick zone... fine.
Also `floaters` for +200.
Alright, let's also add sound? No, can't rely on autoplay and it's extra. Skip.
Let me now also double check the brick field width: left=36, totalW = 800-72 = 728. GAP=5, COLS=12 → bw = (728 - 55)/12 = 673/12 = 56.0833. Right edge = 36 + 12*56.0833 + 11*5 = 36 + 673 + 55 = 764 = W - 36 ✓.
BRICK_TOP = 78, BRICK_H=24, ROW_GAP=6 → row 6 y = 78 + 6*30 = 258, bottom = 282. Good, leaves the play area from 282 to 552 free.
Paddle y = 552, height 14, bottom 566. H=600, so 34px below. Good.
Now the HUD is at y 0..70 and the bricks start at 78. Good.
Let me write the final code.
Also: `ctx.textAlign` etc. must be reset appropriately — I'll set them each time.
Potential issue: `ctx.letterSpacing` — skip.
One more polish: ball trail. Let's add a simple trail by storing the last few positions. With substeps, the ball moves a lot. Let's store a trail array of the last ~8 positions updated once per frame, and draw a fading line. Might look nice. But with multiple balls it's more work. I'll skip trails and instead draw a glow.
Let's write the code now.
Additional check on `fixAngle` being called after paddle collision — the paddle bounce already guarantees a good angle. But after brick collisions the angle could become near-horizontal. fixAngle after brick collision handles it.
Also after a wall bounce, vy could be tiny? No.
Let me also make sure the ball doesn't get stuck bouncing exactly horizontally along the top wall. fixAngle prevents |vy| < 0.22*speed.
Hmm, but if the ball is moving mostly horizontally and hits a side wall, it keeps vy small. fixAngle is only called after brick collisions. Let's also call it after wall collisions... but that would look odd if the ball is going straight up (vx=0, vy=-sp). fixAngle only triggers when |vy| < 0.22sp, i.e., near-horizontal. So it's safe to call everywhere. Let's call it in `walls` after reflecting too.
Actually the ball starting straight up has vy=-sp, |vy|=sp > 0.22sp. Fine.
Now writing.
```js
/* ===== Utils ===== */
const clamp = (v,a,b)=> v<a?a:v>b?b:v;
function rr(x,y,w,h,r){
ctx.beginPath();
if(w<2*r) r = w/2;
if(h<2*r) r = h/2;
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
```
This manual path avoids relying on roundRect.
`shade`:
```js
function shade(hex, amt){
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
if(amt<0){ const f=1+amt; r*=f; g*=f; b*=f; }
else { r+=(255-r)*amt; g+=(255-g)*amt; b+=(255-b)*amt; }
return `rgb(${r|0},${g|0},${b|0})`;
}
```
Now write it all.
For the demo AI, I realize `paddle.y` is 552 and balls come down; the prediction t = (p.y - b.y)/b.vy is positive when the ball is above the paddle moving down. Good.
If the ball is moving up (vy<0), we skip it. If no ball is moving down, we just track the first ball's x. Let's default target = balls[0].x.
Also the AI should be near-perfect so the demo looks good. With a max speed of 760 px/s and a field of 800, it can cover. Good.
Let me set AI speed to 900.
Now writing the render for particles:
```js
for(const p of particles){
const a = Math.max(0, p.life/p.maxLife);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x,p.y,p.size*a + 0.5,0,Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
```
Floaters:
```js
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.font='700 14px system-ui,sans-serif';
for(const f of floaters){
ctx.globalAlpha = Math.max(0, f.life/f.maxLife);
ctx.fillStyle = f.color;
ctx.fillText(f.text, f.x, f.y);
}
ctx.globalAlpha = 1;
```
Alright. Also the flash on bricks: decrement `br.flash` in update. Let's add a loop over bricks in update: `if(br.flash>0) br.flash = Math.max(0, br.flash - dt*5)`.
Now final code assembly. Let me be careful about ordering of const declarations used by functions called at load.
Order:
1. Canvas setup
2. Utils
3. Constants & state
4. Build functions
5. Update functions
6. Render functions
7. Input handlers
8. Init: startGame(true); requestAnimationFrame(frame);
Since `startGame` is a function declaration, hoisting works.
Let's write it.
One thing: `G.time` used for pulsing; I increment it in update. During demo/play. Fine.
Also for the "flash" of the ball hitting the paddle — skip.
Final check on `updatePowerups` catch condition: the paddle is at y=552, h=14. A power-up falling with h=22 at y between 552 and 566 → caught. Good.
Also, power-ups should perhaps be caught slightly above. Fine.
Let me write out the whole thing now.
Also need `state.shake` decay in update.
Writing the final 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>
*{box-sizing:border-box}
html,body{height:100%;margin:0;background:#05060d;overflow:hidden;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;}
#wrap{position:fixed;inset:0;display:grid;place-items:center;background:
radial-gradient(circle at 50% 30%, #131a33 0%, #06070f 60%, #03040a 100%);}
canvas{display:block;width:min(94vw,124vh);height:auto;border-radius:16px;
box-shadow:0 24px 70px rgba(0,0,0,.75), 0 0 0 1px rgba(120,160,255,.12),
0 0 60px rgba(60,100,255,.08);
touch-action:none;cursor:crosshair;}
</style>
</head>
<body>
<div id="wrap"><canvas id="game"></canvas></div>
<script>
...
</script>
</body>
</html>
```
Now the JS. Let me write it fully and carefully.
I'll write it now in final form.
Regarding `ctx.setTransform` on resize and the gradient objects created with `ctx.createLinearGradient` at module init — since painting uses the current transform, and it's just a scale, it's fine.
Actually, I create the bg gradient at module load before/after fit()? Doesn't matter for correctness.
Let me write.
```js
(function(){
'use strict';
const W=800,H=600;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d',{alpha:false});
```
Hmm alpha:false could speed things up but the CSS border-radius would show black corners... Actually with alpha:false the canvas is opaque; the CSS border-radius clips it visually anyway. It's fine. But let's just use default.
Let me write everything.
For the demo, at load, the ball is stuck for 0.5s then launches. At 3 seconds, the screenshot shows action. Good.
Let me finalize the code text.
One more consideration on `paddleCollide`: when the ball is stuck to the paddle we skip collisions (handled in updateBall by `continue`). Good.
Also I must guard: after a top bounce the ball's y = T - r; but if the paddle moves up... the paddle doesn't move vertically. Fine.
Writing now.
For `checkWin` in play mode, set G.mode='victory' and also maybe stop balls. We just stop updating.
Let me write the final.
Note on `floaters` in demo — fine.
Let me write out the code.
Also for `applyPower`, cap balls at 8.
Writing final answer.
One more: the ball's `dead` flag is set when it falls below; I splice after the loop. But `loseLife` is called if the array is empty. If some balls survive, no life lost. Correct.
Now writing.
Regarding key handling with `e.key === 'ArrowLeft'` etc. and also preventing scroll — the body has overflow hidden. Fine.
Final code below.
Let me double check `damageBrick` `addScore` → just G.score += pts.
Alright, also floaters color for score.
Writing it out.
Also: on victory I should keep particles animating — yes, updateParticles runs unconditionally.
Let me write out.
Concern: `for(const line of br.cracks)` drawing polylines:
```js
ctx.beginPath();
for(let i=0;i<line.length;i++){
const p = line[i];
if(i===0) ctx.moveTo(x+p[0], y+p[1]);
else ctx.lineTo(x+p[0], y+p[1]);
}
ctx.stroke();
```
Good.
Now write everything.