Mistral Large 4
by MistralReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 11 min 35 s
- Cost
- $0.121
- Output tokens
- 57,409
- Spent thinking
- 45,276 tokens
- Speed
- 82.8 tok/s
- Console errors
- 0
- First token after
- 1.7 s
- First visible text after
- 9 min 43 s
- Prompt tokens
- 528
- Code size
- 32.6 KB
- Finish reason
- stop
- Served by
- Mistral
- Model id
mistralai/mistral-large-4-0- Reasoning sent
model default- Max output
- 262,144 tokens
- Generated
- Nov 12, 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 · 759 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="color-scheme" content="dark">
<title>Breakout — Brick Breaker</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#04050c}
body{font-family:ui-sans-serif,system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#e5e7eb;
-webkit-user-select:none;user-select:none;touch-action:none}
#wrap{position:fixed;inset:0;overflow:hidden;cursor:none}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
/* ---------- HUD ---------- */
#hud{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
padding:12px 16px;pointer-events:none;z-index:5;gap:8px}
.box{background:rgba(8,12,26,.55);border:1px solid rgba(148,163,184,.16);border-radius:12px;
padding:8px 14px;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
box-shadow:0 6px 24px rgba(0,0,0,.35);display:flex;flex-direction:column;gap:2px;min-width:96px}
.box.center{align-items:center;text-align:center}
.box.right{flex-direction:row;align-items:center;gap:10px;min-width:0}
.lbl{font-size:9px;letter-spacing:2.5px;color:#7dd3fc;font-weight:700;white-space:nowrap}
.val{font-size:clamp(16px,2.6vw,22px);font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;display:inline-block}
.val.pop{animation:pop .28s ease}
@keyframes pop{40%{transform:scale(1.35);color:#67e8f9}}
.best{font-size:9px;color:#64748b;letter-spacing:2px}
.best b{color:#94a3b8;font-size:10px}
#lives{display:flex;gap:3px}
.heart{color:#fb7185;text-shadow:0 0 10px rgba(251,113,133,.9);font-size:clamp(14px,2.4vw,19px);line-height:1;transition:all .2s}
.heart.off{color:rgba(148,163,184,.25);text-shadow:none}
#muteBtn{pointer-events:auto;background:rgba(148,163,184,.12);border:1px solid rgba(148,163,184,.25);color:#e2e8f0;
border-radius:9px;padding:4px 9px;font-size:14px;cursor:pointer}
#muteBtn:hover{background:rgba(148,163,184,.25)}
/* ---------- buff timer ---------- */
#buff{position:absolute;top:88px;left:50%;transform:translateX(-50%);display:none;flex-direction:column;
align-items:center;gap:4px;z-index:5;pointer-events:none}
#buffLabel{font-size:9px;letter-spacing:2px;font-weight:800;color:#fbbf24;text-shadow:0 0 8px rgba(251,191,36,.8);white-space:nowrap}
#buff .bar{width:170px;max-width:44vw;height:8px;border-radius:5px;background:rgba(148,163,184,.18);
border:1px solid rgba(148,163,184,.25);overflow:hidden}
#buffFill{height:100%;width:0%;border-radius:5px;background:linear-gradient(90deg,#fde68a,#f59e0b);box-shadow:0 0 10px rgba(251,191,36,.9)}
/* ---------- toast ---------- */
#toast{position:absolute;left:50%;bottom:15%;transform:translateX(-50%);background:rgba(8,12,26,.72);
border:1px solid rgba(125,211,252,.25);color:#e2e8f0;padding:8px 18px;border-radius:999px;
font-size:13px;font-weight:700;letter-spacing:1px;opacity:0;transition:opacity .25s;pointer-events:none;
z-index:6;white-space:nowrap;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);max-width:92vw;
overflow:hidden;text-overflow:ellipsis}
#toast.show{opacity:1}
/* ---------- overlay screens ---------- */
#overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;
background:radial-gradient(ellipse at center,rgba(4,6,15,.35),rgba(3,4,10,.72));
backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);opacity:1;transition:opacity .3s;
cursor:pointer;padding:16px}
#overlay.hidden{opacity:0;pointer-events:none}
.card{text-align:center;background:linear-gradient(165deg,rgba(17,24,48,.9),rgba(7,9,20,.92));
border:1px solid rgba(129,140,248,.22);border-radius:22px;
padding:clamp(20px,4vw,40px) clamp(22px,5vw,52px);
box-shadow:0 0 80px rgba(59,90,246,.18),0 24px 60px rgba(0,0,0,.5);max-width:min(560px,94vw)}
.pill{display:inline-flex;align-items:center;gap:8px;font-size:10px;font-weight:800;letter-spacing:2.5px;
color:#67e8f9;border:1px solid rgba(103,232,249,.35);background:rgba(34,211,238,.08);
padding:5px 12px;border-radius:999px;margin-bottom:14px}
.pill .dot{width:7px;height:7px;border-radius:50%;background:#22d3ee;box-shadow:0 0 10px #22d3ee;animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.3}}
h1.title{margin:0 0 8px;font-size:clamp(34px,7vw,58px);font-weight:900;letter-spacing:8px;line-height:1;
background:linear-gradient(90deg,#22d3ee,#818cf8,#f472b6);
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 4px 24px rgba(56,130,246,.35))}
h1.title.win{background:linear-gradient(90deg,#34d399,#22d3ee);-webkit-background-clip:text;background-clip:text}
h1.title.lose{background:linear-gradient(90deg,#f87171,#f472b6);-webkit-background-clip:text;background-clip:text}
.sub{color:#94a3b8;font-size:14px;margin:0 0 14px}
.hint{font-size:14px;color:#e2e8f0;margin:6px 0 16px}
.hint b{color:#67e8f9}
.controls{display:flex;flex-direction:column;gap:7px;font-size:12px;color:#94a3b8;margin-bottom:4px}
kbd{background:rgba(148,163,184,.14);border:1px solid rgba(148,163,184,.3);border-bottom-width:2px;
border-radius:6px;padding:2px 7px;font-size:11px;font-family:inherit;color:#e2e8f0}
.btn{margin-top:14px;padding:13px 34px;font-size:14px;font-weight:800;letter-spacing:2px;color:#050b14;
background:linear-gradient(90deg,#22d3ee,#818cf8);border:none;border-radius:999px;cursor:pointer;
box-shadow:0 10px 34px rgba(34,211,238,.35);transition:transform .15s,box-shadow .15s;font-family:inherit}
.btn:hover{transform:translateY(-2px) scale(1.04);box-shadow:0 14px 40px rgba(34,211,238,.5)}
.stats{display:flex;gap:14px;justify-content:center;margin:10px 0 4px;flex-wrap:wrap}
.stats div{background:rgba(148,163,184,.08);border:1px solid rgba(148,163,184,.15);border-radius:12px;
padding:10px 18px;min-width:86px}
.stats span{display:block;font-size:9px;letter-spacing:2px;color:#7dd3fc;font-weight:700}
.stats b{font-size:20px;font-weight:800;font-variant-numeric:tabular-nums}
@media (max-width:480px){
#hud{padding:8px 10px}
.box{padding:6px 10px;min-width:0}
#buff{top:78px}
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<div id="hud">
<div class="box">
<span class="lbl">SCORE</span>
<span class="val" id="scoreVal">0</span>
<span class="best">BEST <b id="bestVal">0</b></span>
</div>
<div class="box center">
<span class="lbl">LEVEL 1 · BRICKS</span>
<span class="val" id="brickVal" style="font-size:clamp(13px,2vw,17px)">0</span>
</div>
<div class="box right">
<span id="lives"></span>
<button id="muteBtn" type="button" title="Toggle sound (M)">🔊</button>
</div>
</div>
<div id="buff">
<span id="buffLabel">WIDE PADDLE</span>
<div class="bar"><div id="buffFill"></div></div>
</div>
<div id="toast"></div>
<div id="overlay"></div>
</div>
<script>
(function(){
'use strict';
/* ================= DOM ================= */
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var scoreVal = document.getElementById('scoreVal');
var bestVal = document.getElementById('bestVal');
var brickVal = document.getElementById('brickVal');
var livesEl = document.getElementById('lives');
var muteBtn = document.getElementById('muteBtn');
var buffEl = document.getElementById('buff');
var buffLabel= document.getElementById('buffLabel');
var buffFill = document.getElementById('buffFill');
var toastEl = document.getElementById('toast');
var overlayEl= document.getElementById('overlay');
/* ================= helpers ================= */
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }
function rand(a,b){ return a+Math.random()*(b-a); }
function now(){ return performance.now()/1000; }
function rr(c,x,y,w,h,r){ r=Math.min(r,w/2,h/2); c.beginPath(); c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r); c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath(); }
function circ(c,x,y,r){ c.beginPath(); c.arc(x,y,r,0,6.2832); c.fill(); }
function fmtTime(s){ s=Math.max(0,Math.floor(s)); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
/* ================= state ================= */
var W=0,H=0,DPR=1,bgGrad=null,vig=null,stars=[];
var L={margin:0,gap:6,top:112,bh:24,bw:80,cols:12,rows:8};
var state='demo'; /* demo | playing | over | win */
var paused=false;
var score=0,best=0,lives=3,elapsed=0,lastScore=-1,lastBest=-1,lastBricks=-1;
var bricks=[],balls=[],parts=[],pops=[],caps=[];
var paddle={x:0,y:0,w:120,h:14,vx:0};
var basePW=120,ballR=7,keySpeed=800,baseSpeed=420,maxSpeed=800;
var mouseX=null;
var keys={l:false,r:false};
var wideUntil=-1,shake=0,flash=0,serveTimer=0;
var muted=false,AC=null,toastT=0;
var ROW_COLORS=['#f43f5e','#fb923c','#facc15','#4ade80','#2dd4bf','#60a5fa','#a78bfa','#f472b6'];
/* ================= layout / level ================= */
function layoutMetrics(){
L.margin=clamp(W*0.055,20,64);
L.gap=clamp(W*0.007,4,8);
L.top=Math.max(112,H*0.15);
L.bh=clamp(H*0.03,18,26);
L.cols=clamp(Math.round((W-2*L.margin)/92),8,12);
L.rows=8;
}
function layoutBricks(){
layoutMetrics();
if(!bricks.length) return;
var areaW=W-2*L.margin;
L.bw=(areaW-L.gap*(L.cols-1))/L.cols;
for(var i=0;i<bricks.length;i++){ var br=bricks[i];
br.w=L.bw; br.h=L.bh;
br.x=L.margin+br.c*(L.bw+L.gap);
br.y=L.top+br.r*(L.bh+L.gap);
br.cx=br.x+br.w/2; br.cy=br.y+br.h/2;
}
}
function kindFor(r,c){
if(r<2) return 'tough';
if(r===2) return (c%3===1)?'tough':'normal';
if(r===3) return (c%4===2)?'tough':'normal';
return 'normal';
}
function buildLevel(){
layoutMetrics();
bricks=[];
for(var r=0;r<L.rows;r++) for(var c=0;c<L.cols;c++){
var kind=kindFor(r,c);
bricks.push({r:r,c:c,kind:kind,hp:(kind==='tough'?2:1),alive:true,cracked:false,flash:0,cracks:null,
pow:null,color:ROW_COLORS[r%ROW_COLORS.length],x:0,y:0,w:0,h:0,cx:0,cy:0});
}
var defs=[[1,2,'wide'],[1,7,'multi'],[2,5,'wide'],[3,9,'multi'],[4,1,'multi'],[5,6,'wide'],[6,3,'wide'],[3,4,'multi']];
var used={};
for(var i=0;i<defs.length;i++){
var pr=Math.min(defs[i][0],L.rows-1), pc=Math.min(defs[i][1],L.cols-1), key=pr+'_'+pc;
if(used[key]) continue; used[key]=1;
for(var j=0;j<bricks.length;j++){ if(bricks[j].r===pr&&bricks[j].c===pc){ bricks[j].pow=defs[i][2]; break; } }
}
layoutBricks();
}
function resize(){
DPR=clamp(window.devicePixelRatio||1,1,2);
W=window.innerWidth; H=window.innerHeight;
canvas.width=Math.max(1,Math.round(W*DPR));
canvas.height=Math.max(1,Math.round(H*DPR));
ctx.setTransform(DPR,0,0,DPR,0,0);
basePW=clamp(W*0.13,84,170);
ballR=clamp(Math.min(W,H)*0.011,6,9);
keySpeed=clamp(W*1.15,650,1100);
baseSpeed=clamp(H*0.6,330,520);
maxSpeed=baseSpeed*1.9;
paddle.y=H-clamp(H*0.075,46,70);
paddle.h=clamp(H*0.02,12,17);
if(!paddle.x) paddle.x=W/2;
paddle.x=clamp(paddle.x,paddle.w/2+6,W-paddle.w/2-6);
for(var i=0;i<balls.length;i++){ balls[i].x=clamp(balls[i].x,balls[i].r,W-balls[i].r); balls[i].y=clamp(balls[i].y,balls[i].r,H+50); }
layoutBricks();
stars=[];
var n=clamp(Math.round(W*H/15000),40,140);
for(var k=0;k<n;k++) stars.push({x:rand(0,W),y:rand(0,H),r:rand(0.6,1.7),p:rand(0,6.28),s:rand(0.6,2.2)});
bgGrad=ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#0a0f22'); bgGrad.addColorStop(0.55,'#070a18'); bgGrad.addColorStop(1,'#04050c');
vig=ctx.createRadialGradient(W/2,H*0.42,Math.min(W,H)*0.3,W/2,H*0.55,Math.max(W,H)*0.78);
vig.addColorStop(0,'rgba(0,0,0,0)'); vig.addColorStop(1,'rgba(0,0,0,0.5)');
}
/* ================= entities ================= */
function attachBall(){
balls=[{x:paddle.x,y:paddle.y-ballR-1,vx:0,vy:0,r:ballR,speed:baseSpeed,attached:true,trail:[]}];
}
function serveNow(){
if(state!=='playing'&&state!=='demo') return;
for(var i=0;i<balls.length;i++){ var b=balls[i]; if(!b.attached) continue;
b.attached=false;
var a=rand(-0.42,0.42);
b.vx=Math.sin(a)*baseSpeed; b.vy=-Math.cos(a)*baseSpeed; b.speed=baseSpeed;
b.trail.length=0; sfx('serve'); return;
}
}
function multiBall(){
var src=balls.filter(function(b){return !b.attached;});
if(!src.length) return;
var clones=[];
for(var i=0;i<src.length;i++){
var b=src[i];
for(var k=0;k<2;k++){
if(balls.length+clones.length>=7) break;
var sp=Math.hypot(b.vx,b.vy)||b.speed;
var a=Math.atan2(b.vy,b.vx)+(k===0?1:-1)*rand(0.35,0.6);
var vx=Math.cos(a)*sp, vy=Math.sin(a)*sp;
if(vy>0) vy=-vy;
clones.push({x:b.x,y:b.y,vx:vx,vy:vy,r:b.r,speed:sp,attached:false,trail:[]});
}
}
for(var j=0;j<clones.length;j++) balls.push(clones[j]);
}
function applyPower(type){
if(type==='wide'){ wideUntil=now()+10; toast('WIDE PADDLE \u00B7 10s'); burst(paddle.x,paddle.y-12,'#fbbf24',14); }
else{ multiBall(); toast('MULTI-BALL \u00D73!'); burst(paddle.x,paddle.y-12,'#f472b6',20); }
sfx('power');
}
function burst(x,y,color,n){
for(var i=0;i<n;i++){
var a=rand(0,6.28), sp=rand(50,300), lf=rand(0.35,0.9);
parts.push({x:x,y:y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-60,life:lf,max:lf,color:color,size:rand(1.5,3.6),grav:430,sq:Math.random()<0.4});
}
if(parts.length>500) parts.splice(0,parts.length-500);
}
function confetti(){
for(var i=0;i<160;i++){
var lf=rand(1.8,3.4);
parts.push({x:rand(0,W),y:rand(-H*0.25,-8),vx:rand(-50,50),vy:rand(60,230),life:lf,max:lf,
color:ROW_COLORS[i%ROW_COLORS.length],size:rand(3,6),grav:110,sq:true});
}
}
function addPop(x,y,txt,color){ pops.push({x:x,y:y,txt:txt,color:color,life:1}); if(pops.length>24) pops.shift(); }
function genCracks(br){
br.cracks=[];
var n=3+(Math.random()*2|0);
for(var i=0;i<n;i++){
var px=br.cx+rand(-br.w*0.3,br.w*0.3), py=br.y+rand(4,br.h-4), ang=rand(0,6.28), pts=[[px,py]];
for(var s=0;s<5;s++){
ang+=rand(-1.3,1.3);
var len=rand(3,br.w*0.18);
px=clamp(px+Math.cos(ang)*len,br.x+3,br.x+br.w-3);
py=clamp(py+Math.sin(ang)*len,br.y+3,br.y+br.h-3);
pts.push([px,py]);
}
br.cracks.push(pts);
}
}
/* ================= collisions ================= */
function circleRect(b,r){
var cx=clamp(b.x,r.x,r.x+r.w), cy=clamp(b.y,r.y,r.y+r.h);
var dx=b.x-cx, dy=b.y-cy;
return dx*dx+dy*dy < b.r*b.r;
}
function hitBrick(b,br){
var cx=clamp(b.x,br.x,br.x+br.w), cy=clamp(b.y,br.y,br.y+br.h);
var dx=b.x-cx, dy=b.y-cy, nx,ny, d2=dx*dx+dy*dy;
if(d2>1e-6){ var 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{
var pl=b.x-br.x, pr=br.x+br.w-b.x, pt=b.y-br.y, pb=br.y+br.h-b.y, 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;}
}
var dot=b.vx*nx+b.vy*ny;
b.vx-=2*dot*nx; b.vy-=2*dot*ny;
var sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
if(Math.abs(b.vy)<0.2*sp){ b.vy=(b.vy<0?-1:1)*0.22*sp; var m2=Math.hypot(b.vx,b.vy)||1; b.vx*=sp/m2; b.vy*=sp/m2; }
br.flash=1;
br.hp--;
if(br.hp<=0){ destroyBrick(br); }
else{
br.cracked=true; genCracks(br); score+=30;
addPop(br.cx,br.cy-8,'+30','#cbd5e1'); sfx('crack'); burst(br.cx,br.cy,'#94a3b8',6);
}
}
function destroyBrick(br){
br.alive=false;
var pts=(br.kind==='tough')?100:50;
score+=pts;
addPop(br.cx,br.cy,'+'+pts,br.color);
sfx('break');
burst(br.cx,br.cy,br.color,16);
burst(br.cx,br.cy,'#ffffff',5);
for(var i=0;i<balls.length;i++){ var b=balls[i]; if(b.attached) continue;
var old=Math.hypot(b.vx,b.vy); if(old>0){ var ns=Math.min(b.speed+4,maxSpeed); b.speed=ns; b.vx*=ns/old; b.vy*=ns/old; } }
if(br.pow){ caps.push({x:br.cx,baseX:br.cx,y:br.cy,vy:rand(140,185),type:br.pow,w:38,h:20,taken:false}); sfx('drop'); }
if(!bricks.some(function(x){return x.alive;})) winGame();
}
function collideWorld(b,dt){
var sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
var steps=clamp(Math.ceil(sp*dt/(b.r*0.55)),1,12);
var sdt=dt/steps;
for(var s=0;s<steps;s++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
if(b.x<b.r){ b.x=b.r; b.vx=Math.abs(b.vx); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
else if(b.x>W-b.r){ b.x=W-b.r; b.vx=-Math.abs(b.vx); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
if(b.y<b.r){ b.y=b.r; b.vy=Math.abs(b.vy); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
/* paddle — angle depends on hit position (center = straight up, edges = sharp) */
var pw=paddle.w;
if(b.vy>0 && b.y+b.r>=paddle.y && b.y-b.r<=paddle.y+paddle.h &&
b.x>=paddle.x-pw/2-b.r && b.x<=paddle.x+pw/2+b.r){
var rel=clamp((b.x-paddle.x)/(pw/2),-1,1);
var ang=rel*1.15; /* up to ~66 deg */
var ns=Math.min(b.speed*1.015+2,maxSpeed); b.speed=ns;
var vx=Math.sin(ang)*ns+paddle.vx*0.15, vy=-Math.cos(ang)*ns;
var m=Math.hypot(vx,vy)||1; vx*=ns/m; vy*=ns/m;
b.vx=vx; b.vy=vy;
b.x=clamp(b.x,paddle.x-pw/2,paddle.x+pw/2);
b.y=paddle.y-b.r-0.5;
sfx('paddle'); burst(b.x,paddle.y,'#67e8f9',6);
}
/* bricks */
for(var i=0;i<bricks.length;i++){
var br=bricks[i];
if(!br.alive) continue;
if(circleRect(b,br)){ hitBrick(b,br); break; }
}
if(b.y-b.r>H){ b.dead=true; return; }
}
}
/* ================= AI (demo mode) ================= */
function aiDir(){
var target=W/2, bb=null, by=-1e9;
for(var i=0;i<balls.length;i++){ var b=balls[i];
if(b.attached) continue;
if(b.vy>0 && b.y>by){ by=b.y; bb=b; }
}
if(bb){
var tt=Math.max(0,(paddle.y-bb.y)/bb.vy);
target=clamp(bb.x+bb.vx*tt, paddle.w/2+10, W-paddle.w/2-10);
} else {
target=W/2+Math.sin(now()*0.6)*W*0.22;
}
target+=Math.sin(now()*2.1)*7;
var d=target-paddle.x;
return Math.abs(d)<7?0:(d>0?1:-1);
}
/* ================= update ================= */
function update(dt){
var t=now();
if(state==='demo'||state==='playing') elapsed+=dt;
shake=Math.max(0,shake-dt*26);
flash=Math.max(0,flash-dt*1.6);
for(var i=0;i<bricks.length;i++) if(bricks[i].flash>0) bricks[i].flash=Math.max(0,bricks[i].flash-dt*5);
var wide=(t<wideUntil);
paddle.w+=(basePW*(wide?1.7:1)-paddle.w)*Math.min(1,12*dt);
var dir=0;
if(state==='playing'){ if(keys.l)dir-=1; if(keys.r)dir+=1; }
else if(state==='demo'){ dir=aiDir(); }
var spd=keySpeed*(state==='demo'?0.92:1);
if(dir!==0){ paddle.vx=dir*spd; paddle.x+=dir*spd*dt; }
else if(state==='playing'&&mouseX!=null){
var d=mouseX-paddle.x;
paddle.vx=d*16; paddle.x+=d*Math.min(1,16*dt);
} else {
paddle.vx*=Math.pow(0.0001,dt);
}
paddle.x=clamp(paddle.x,paddle.w/2+6,W-paddle.w/2-6);
serveTimer-=dt;
if(serveTimer<=0){ if(balls.some(function(b){return b.attached;})) serveNow(); else serveTimer=0.3; }
if(state==='demo'||state==='playing'){
for(var j=0;j<balls.length;j++){
var b=balls[j];
if(b.attached){ b.x=paddle.x; b.y=paddle.y-b.r-1; continue; }
collideWorld(b,dt);
b.trail.push({x:b.x,y:b.y}); if(b.trail.length>10) b.trail.shift();
}
balls=balls.filter(function(b){return !b.dead;});
if(!balls.length && state!=='win' && state!=='over') loseLife();
for(var k=0;k<caps.length;k++){
var c=caps[k];
c.y+=c.vy*dt; c.x=c.baseX+Math.sin(c.y*0.06)*10;
if(!c.taken && c.y+c.h/2>=paddle.y && c.y-c.h/2<=paddle.y+paddle.h+16 &&
Math.abs(c.x-paddle.x)<=paddle.w/2+c.w/2){
c.taken=true; applyPower(c.type);
}
}
caps=caps.filter(function(c){return !c.taken && c.y-c.h/2<H+30;});
}
for(var p=0;p<parts.length;p++){ var pt=parts[p]; pt.life-=dt; pt.vy+=pt.grav*dt; pt.x+=pt.vx*dt; pt.y+=pt.vy*dt; }
parts=parts.filter(function(p){return p.life>0 && p.y<H+40;});
for(var q=0;q<pops.length;q++){ pops[q].life-=dt*0.85; pops[q].y-=36*dt; }
pops=pops.filter(function(p){return p.life>0;});
/* HUD */
setScore(score);
var bc=0; for(var z=0;z<bricks.length;z++) if(bricks[z].alive) bc++;
if(bc!==lastBricks){ lastBricks=bc; brickVal.textContent=bc; }
if(best!==lastBest){ lastBest=best; bestVal.textContent=best; }
if(wide){
buffEl.style.display='flex';
var rem=Math.max(0,wideUntil-t);
buffFill.style.width=(rem/10*100)+'%';
buffLabel.textContent='WIDE PADDLE \u00B7 '+rem.toFixed(1)+'s';
} else buffEl.style.display='none';
}
function setScore(v){
if(v!==lastScore){ lastScore=v; scoreVal.textContent=v;
scoreVal.classList.remove('pop'); void scoreVal.offsetWidth; scoreVal.classList.add('pop'); }
}
/* ================= render ================= */
function drawGlyph(type,cx,cy,s,color){
ctx.save(); ctx.fillStyle=color; ctx.shadowColor=color; ctx.shadowBlur=7;
if(type==='wide'){ rr(ctx,cx-s*0.85,cy-s*0.24,s*1.7,s*0.48,Math.min(s*0.24,3)); ctx.fill(); }
else{ var r=s*0.27; circ(ctx,cx,cy-s*0.32,r); circ(ctx,cx-s*0.36,cy+s*0.3,r); circ(ctx,cx+s*0.36,cy+s*0.3,r); }
ctx.restore();
}
function drawBrick(br){
var x=br.x,y=br.y,w=br.w,h=br.h,r=Math.min(5,w/4,h/3);
rr(ctx,x,y,w,h,r);
ctx.fillStyle=br.kind==='tough'?(br.cracked?'#414c5e':'#5b6b80'):br.color;
ctx.fill();
ctx.save(); rr(ctx,x,y,w,h,r); ctx.clip();
ctx.fillStyle='rgba(255,255,255,0.20)'; ctx.fillRect(x,y,w,h*0.42);
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(x,y+h*0.62,w,h*0.38);
if(br.kind==='tough'){
ctx.fillStyle='rgba(255,255,255,0.35)';
circ(ctx,x+5,y+5,1.6); circ(ctx,x+w-5,y+5,1.6); circ(ctx,x+5,y+h-5,1.6); circ(ctx,x+w-5,y+h-5,1.6);
}
if(br.pow){
var rad=Math.min(h*0.46,w*0.3);
ctx.fillStyle='rgba(255,255,255,0.16)'; circ(ctx,br.cx,br.cy,rad);
ctx.strokeStyle='rgba(255,255,255,0.65)'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.arc(br.cx,br.cy,rad,0,6.2832); ctx.stroke();
drawGlyph(br.pow,br.cx,br.cy,Math.min(7,h*0.3),'#ffffff');
}
ctx.restore();
if(br.cracked&&br.cracks){
ctx.strokeStyle='rgba(8,11,18,0.9)'; ctx.lineWidth=1.4; ctx.lineJoin='round';
for(var i=0;i<br.cracks.length;i++){ var cr=br.cracks[i];
ctx.beginPath(); ctx.moveTo(cr[0][0],cr[0][1]);
for(var j=1;j<cr.length;j++) ctx.lineTo(cr[j][0],cr[j][1]);
ctx.stroke();
}
}
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1; rr(ctx,x,y,w,h,r); ctx.stroke();
if(br.flash>0){ rr(ctx,x,y,w,h,r); ctx.fillStyle='rgba(255,255,255,'+(br.flash*0.55).toFixed(3)+')'; ctx.fill(); }
}
function drawCapsule(c,t){
var y=c.y+Math.sin(t*5+c.baseX)*2;
var col=c.type==='wide'?'#22d3ee':'#f472b6';
ctx.save();
ctx.shadowColor=col; ctx.shadowBlur=14;
var g=ctx.createLinearGradient(0,y-c.h/2,0,y+c.h/2);
g.addColorStop(0,'#ffffff'); g.addColorStop(0.45,col); g.addColorStop(1,col);
rr(ctx,c.x-c.w/2,y-c.h/2,c.w,c.h,c.h/2);
ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.2; ctx.stroke();
drawGlyph(c.type,c.x,y,7,'#0b1220');
ctx.restore();
}
function drawPaddle(){
var wide=now()<wideUntil;
var x=paddle.x-paddle.w/2, y=paddle.y, w=paddle.w, h=paddle.h;
ctx.save();
ctx.shadowColor=wide?'#fbbf24':'#22d3ee'; ctx.shadowBlur=wide?24:16;
var g=ctx.createLinearGradient(0,y,0,y+h);
if(wide){ g.addColorStop(0,'#fde68a'); g.addColorStop(1,'#f59e0b'); }
else{ g.addColorStop(0,'#a5f3fc'); g.addColorStop(0.55,'#38bdf8'); g.addColorStop(1,'#2563eb'); }
rr(ctx,x,y,w,h,h/2); ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='rgba(255,255,255,0.4)'; rr(ctx,x+3,y+2,Math.max(0,w-6),h*0.34,h*0.17); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.lineWidth=1; rr(ctx,x,y,w,h,h/2); ctx.stroke();
ctx.restore();
}
function drawBall(b){
for(var i=0;i<b.trail.length;i++){
var p=b.trail[i], f=i/b.trail.length;
ctx.fillStyle='rgba(165,243,252,'+(f*0.2).toFixed(3)+')';
circ(ctx,p.x,p.y,b.r*(0.35+0.65*f));
}
var g=ctx.createRadialGradient(b.x-b.r*0.35,b.y-b.r*0.35,b.r*0.15,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff'); g.addColorStop(1,'#bae6fd');
ctx.save(); ctx.shadowColor='#e0f2fe'; ctx.shadowBlur=14; ctx.fillStyle=g; circ(ctx,b.x,b.y,b.r); ctx.restore();
}
function drawPart(p){
ctx.globalAlpha=Math.max(0,p.life/p.max);
ctx.fillStyle=p.color;
if(p.sq) ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
else circ(ctx,p.x,p.y,p.size/2);
ctx.globalAlpha=1;
}
function drawPop(p){
ctx.save();
ctx.globalAlpha=Math.max(0,p.life);
ctx.font='800 13px system-ui, sans-serif'; ctx.textAlign='center';
ctx.fillStyle=p.color; ctx.shadowColor=p.color; ctx.shadowBlur=6;
ctx.fillText(p.txt,p.x,p.y);
ctx.restore();
}
function centerText(txt){
ctx.fillStyle='rgba(2,6,16,0.55)'; ctx.fillRect(0,0,W,H);
ctx.fillStyle='#e2e8f0'; ctx.font='900 34px system-ui, sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.shadowColor='#22d3ee'; ctx.shadowBlur=24;
ctx.fillText(txt,W/2,H/2);
ctx.shadowBlur=0; ctx.textBaseline='alphabetic';
}
function render(){
var t=now();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle=bgGrad; ctx.fillRect(0,0,W,H);
for(var i=0;i<stars.length;i++){
var s=stars[i];
var a=(0.25+0.55*Math.abs(Math.sin(t*s.s+s.p)))*0.6;
ctx.fillStyle='rgba(148,163,184,'+a.toFixed(3)+')';
ctx.fillRect(s.x,s.y,s.r,s.r);
}
ctx.save();
if(shake>0) ctx.translate(rand(-1,1)*shake*0.4, rand(-1,1)*shake*0.4);
for(var j=0;j<bricks.length;j++) if(bricks[j].alive) drawBrick(bricks[j]);
for(var k=0;k<caps.length;k++) drawCapsule(caps[k],t);
drawPaddle();
for(var m=0;m<balls.length;m++) drawBall(balls[m]);
ctx.globalCompositeOperation='lighter';
for(var p=0;p<parts.length;p++) drawPart(parts[p]);
ctx.globalCompositeOperation='source-over';
for(var q=0;q<pops.length;q++) drawPop(pops[q]);
ctx.restore();
ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
if(flash>0){ ctx.fillStyle='rgba(248,113,113,'+(flash*0.45).toFixed(3)+')'; ctx.fillRect(0,0,W,H); }
if(paused&&state==='playing'){ centerText('PAUSED'); }
else if(state==='playing'&&balls.some(function(b){return b.attached;})){
ctx.save();
ctx.globalAlpha=0.55+0.45*Math.sin(t*5);
ctx.fillStyle='#cbd5e1'; ctx.font='700 12px system-ui, sans-serif'; ctx.textAlign='center';
ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,paddle.y-30);
ctx.restore();
}
}
/* ================= game flow ================= */
function loseLife(){
lives--; renderLives(); flash=0.5; shake=14; sfx('lose'); caps.length=0;
if(lives<=0){ gameOver(); }
else{ attachBall(); serveTimer=1.2; toast('Ball lost \u2014 '+(lives===1?'1 life':lives+' lives')+' left'); }
}
function gameOver(){ state='over'; if(score>best)best=score; sfx('over'); showEnd(false); }
function winGame(){ state='win'; if(score>best)best=score; balls.length=0; confetti(); sfx('win'); showEnd(true); }
function restart(toPlaying){
buildLevel();
score=0; lives=3; elapsed=0;
parts.length=0; pops.length=0; caps.length=0;
wideUntil=-1; shake=0; flash=0; paused=false;
paddle.x=W/2; paddle.w=basePW; paddle.vx=0;
renderLives();
attachBall(); serveTimer=toPlaying?1.3:0.8;
state=toPlaying?'playing':'demo';
if(toPlaying){ hideOverlay(); toast('Get ready!'); } else showDemo();
}
function takeControl(){
if(state!=='demo') return;
state='playing'; hideOverlay(); toast('Your paddle! Mouse or \u2190 \u2192');
}
/* ================= HUD / overlay ================= */
function renderLives(){
livesEl.innerHTML='';
for(var i=0;i<3;i++){
var s=document.createElement('span');
s.className='heart'+(i<lives?'':' off');
s.textContent='\u2665';
livesEl.appendChild(s);
}
}
function toast(msg){
toastEl.textContent=msg; toastEl.classList.add('show');
clearTimeout(toastT); toastT=setTimeout(function(){ toastEl.classList.remove('show'); },1800);
}
function showDemo(){
overlayEl.innerHTML=
'<div class="card">'+
'<div class="pill"><span class="dot"></span>DEMO MODE · AI IS PLAYING</div>'+
'<h1 class="title">BREAKOUT</h1>'+
'<p class="sub">A self-playing demo is running behind this card.</p>'+
'<p class="hint"><b>Click or press a key to start</b> — you take control of the paddle</p>'+
'<div class="controls">'+
'<span><kbd>Mouse</kbd> or <kbd>←</kbd> <kbd>→</kbd> — move paddle</span>'+
'<span><kbd>Click</kbd> / <kbd>Space</kbd> — launch ball</span>'+
'<span><kbd>P</kbd> pause · <kbd>M</kbd> sound · <kbd>R</kbd> restart</span>'+
'</div>'+
'</div>';
overlayEl.classList.remove('hidden');
}
function showEnd(win){
overlayEl.innerHTML=
'<div class="card">'+
'<h1 class="title '+(win?'win':'lose')+'">'+(win?'YOU WIN!':'GAME OVER')+'</h1>'+
'<p class="sub">'+(win?'Every brick destroyed. Legendary!':'Out of lives. The bricks remain\u2026')+'</p>'+
'<div class="stats">'+
'<div><span>SCORE</span><b>'+score+'</b></div>'+
'<div><span>BEST</span><b>'+best+'</b></div>'+
'<div><span>TIME</span><b>'+fmtTime(elapsed)+'</b></div>'+
'</div>'+
'<button class="btn" type="button">▶ PLAY AGAIN</button>'+
'<p class="hint" style="margin:12px 0 0">or click anywhere / press <kbd>R</kbd></p>'+
'</div>';
overlayEl.classList.remove('hidden');
}
function hideOverlay(){ overlayEl.classList.add('hidden'); }
/* ================= audio (WebAudio, no files) ================= */
function ensureAudio(){
if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ AC=null; } }
if(AC&&AC.state==='suspended'){ AC.resume().catch(function(){}); }
}
function tone(f0,f1,dur,type,vol,delay){
if(!AC||muted) return;
var t0=AC.currentTime+(delay||0);
var o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.setValueAtTime(f0,t0);
if(f1&&f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t0+dur);
g.gain.setValueAtTime(vol,t0);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0+dur+0.02);
}
function sfx(n){
if(!AC||muted) return;
switch(n){
case 'paddle': tone(200,320,0.07,'square',0.15); break;
case 'wall': tone(140,120,0.05,'sine',0.10); break;
case 'crack': tone(170,120,0.09,'square',0.14); break;
case 'break': tone(520,180,0.12,'sawtooth',0.12); tone(760,300,0.10,'triangle',0.08,0.02); break;
case 'serve': tone(280,420,0.08,'sine',0.12); break;
case 'drop': tone(440,660,0.08,'sine',0.08); break;
case 'power': tone(620,620,0.07,'square',0.12); tone(930,930,0.09,'square',0.12,0.08); break;
case 'lose': tone(300,90,0.35,'sawtooth',0.16); break;
case 'over': tone(392,392,0.15,'triangle',0.14); tone(311,311,0.15,'triangle',0.14,0.16); tone(233,233,0.32,'triangle',0.14,0.32); break;
case 'win': [523,659,784,1047].forEach(function(f,i){ tone(f,f,0.14,'triangle',0.14,i*0.11); }); break;
}
}
function toggleMute(){ muted=!muted; muteBtn.innerHTML=muted?'🔇':'🔊'; if(!muted) ensureAudio(); }
/* ================= input ================= */
window.addEventListener('resize',resize);
window.addEventListener('pointermove',function(e){ mouseX=e.clientX; },{passive:true});
window.addEventListener('pointerdown',function(e){
mouseX=e.clientX;
if(e.target&&e.target.closest&&e.target.closest('#muteBtn')) return;
ensureAudio();
if(state==='demo') takeControl();
else if(state==='over'||state==='win') restart(true);
else if(state==='playing') serveNow();
});
window.addEventListener('keydown',function(e){
var k=e.key;
if(k===' '||k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown') e.preventDefault();
ensureAudio();
if(state==='demo') takeControl();
if(k==='ArrowLeft'||k==='a'||k==='A') keys.l=true;
else if(k==='ArrowRight'||k==='d'||k==='D') keys.r=true;
if(k===' '){
if(state==='over'||state==='win') restart(true); else serveNow();
}
else if(k==='r'||k==='R') restart(true);
else if(k==='m'||k==='M') toggleMute();
else if(k==='p'||k==='P'||k==='Escape'){
if(state==='playing'){ paused=!paused; if(paused) toast('Paused \u2014 press P to resume'); }
}
});
window.addEventListener('keyup',function(e){
var k=e.key;
if(k==='ArrowLeft'||k==='a'||k==='A') keys.l=false;
if(k==='ArrowRight'||k==='d'||k==='D') keys.r=false;
});
window.addEventListener('blur',function(){ keys.l=keys.r=false; });
document.addEventListener('visibilitychange',function(){
if(document.hidden&&state==='playing'&&!paused) paused=true;
});
muteBtn.addEventListener('click',function(e){ e.stopPropagation(); toggleMute(); });
/* ================= main loop ================= */
var last=performance.now();
function loop(t){
requestAnimationFrame(loop);
var dt=(t-last)/1000; last=t;
if(!(dt>0)) dt=0.0001;
if(dt>0.033) dt=0.033;
if(!paused) update(dt);
render();
}
/* ================= boot ================= */
resize();
paddle.w=basePW; paddle.x=W/2;
buildLevel();
renderLives();
attachBall();
serveTimer=0.9;
state='demo';
showDemo();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (45,276 tokens)
The user wants a Breakout game in a single HTML file. Let me build a polished, complete game with all requirements:
1. Mouse + arrow key paddle control
2. Bounce angle based on hit position
3. One level, ≥6 rows of bricks, mixing normal and 2-hit bricks that crack visibly
4. Power-up bricks with visible icons, dropping power-ups (wider paddle 10s, multi-ball 3 balls)
5. Ball never passes through bricks or paddle (swept collision or sub-stepping)
6. Particles on brick break
7. 3 lives, score display, game over and victory screens with restart
Self-playing demo mode: AI plays behind a "Click or press a key to start" message. So on load, show an overlay "Click or press a key to start", and behind it the AI plays the game automatically. When the user clicks or presses a key, the overlay disappears and the user takes control (or restarts?). Let me think: "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." So the game runs with AI from load, with an overlay message. When clicked/key pressed, the overlay hides and player controls take over. That works well.
Design considerations:
- Canvas 1280x800 viewport — the game should fit nicely. Let's make the game canvas fill the viewport, with a nice dark background, glow effects. Or a fixed aspect game area centered. Let me make the canvas responsive: fit within window while maintaining aspect ratio (e.g., 960x720 game area scaled). Actually simpler: canvas fills the whole window, and game coordinates adapt. But for polish, maybe a fixed logical resolution (e.g., 900x700) scaled to fit, letterboxed. Hmm, "must look right in a 280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar." A canvas that resizes to window with game logic scaled via a scale factor is doable. Let me use a logical size of W=960, H=720 and scale canvas to fit window (contain), centered with dark background. Mouse coordinates need to map back through the scale.
Actually simpler and robust: canvas width/height set to window inner size, and game uses dynamic layout based on canvas size: paddle width relative to canvas width, bricks computed from width. That adapts to any size. But bricks layout recompute on resize is fine. Let me do that — dynamic layout, min sizes. Ball speed in px/sec relative to canvas height for consistency.
Let me design:
- Logical coordinate system = canvas CSS pixels * devicePixelRatio for crispness. I'll handle DPR: canvas.width = cssW * dpr, ctx.setTransform(dpr,0,0,dpr,0,0), and use cssW/cssH as logical dims.
- Background: dark gradient with subtle stars/grid, drawn on canvas.
- HUD: score top-left, lives top-right (hearts), level maybe. Draw in DOM overlay or canvas? DOM overlay is easier for crisp text and buttons. I'll use a HUD div positioned over canvas, plus overlay screens as DOM elements (start/demo message, game over, victory) with restart buttons. That's polished.
Game mechanics:
- Paddle: y near bottom, width base = 12% of width clamped [70, 160], height ~14. Moves with mouse (x follows mouse x smoothly) and arrow keys (velocity). Wide power-up: width * 1.6 for 10 seconds, with a timer bar shown.
- Ball: radius ~7. Speed starts ~ 420 px/s scaled by canvas height/720 maybe. Increase slightly on each paddle hit (up to max).
- Paddle bounce: relative = (ball.x - paddle.x) / (paddle.w/2), clamp [-1,1]; angle = relative * maxAngle (e.g., 60° from vertical... typical: maxBounce = 65°). new velocity dir = (sin(angle), -cos(angle)) with speed preserved/increased. Ensure ball moving up after paddle hit.
- Walls: left/right/top bounce. Bottom: lose life.
- Bricks: rows >= 6, say 8 rows. Layout: margin top ~ 90px (below HUD), brick area width = W - 2*margin, gap 6. Columns maybe 10. Each brick: width = (areaW - gap*(cols-1))/cols, height ~ 22.
- Brick types: normal (1 hit, colors by row), tough (2 hits, metallic look with rivets; after first hit shows crack lines — draw cracks). Some bricks marked as power-up carriers with an icon (drawn: star for wide, or "×3" for multiball). Requirement: "Some bricks visibly carry a power-up icon; when broken they drop it". So mark a handful of bricks (e.g., 6-8 scattered) with icon; on destruction (final hit), spawn falling power-up capsule with the icon. If a tough brick carries a power-up, it drops when destroyed (second hit). Icons: draw a small glyph — e.g., "W" for wide? Better: draw paddle icon (wide) and three dots (multiball). I'll draw: wide = small paddle shape in capsule; multi = three small circles. Capsule falls down at ~140 px/s; if paddle intersects, apply effect. If it falls off bottom, it's lost.
- Collision ball-brick: to guarantee "never pass through", do sub-stepping: move ball in small steps (e.g., step = ball radius/2 or fixed dt substeps) each frame; for each substep, check collision with bricks via AABB-circle resolution: find closest point on brick rect to ball center, if dist < r, resolve: compute overlap normal, reflect velocity across normal, push ball out. Handle multiple bricks: iterate all bricks, resolve against each collided one (or the first found then break out of loop but still check others next substep — safer: resolve all overlapping bricks in the substep, but reflect only once? Standard approach: for each substep, check bricks; on first hit, reflect and reposition, then continue checking remaining bricks with new position — could double-hit. Simpler robust: substep so movement per substep < ball radius (e.g., 4px), then ball can't tunnel through a brick thicker than ~8px. Brick height 22 > 2*radius? radius 7, so 2r=14 < 22, substep of 6px means max move 6px per check, ball diameter 14, brick height 22 — can't tunnel since 6+14=20 < 22? Actually tunneling through requires the ball to move more than brick thickness + diameter between checks... With substep 6 and brick height 22, ball center moves ≤6px between checks; overlap detection uses circle vs rect, which catches overlap when within r of the rect. To pass through completely in one substep, center would need to travel from above (y < top - r) to below (y > bottom + r): distance > 22 + 2r = 36 > 6. So no tunneling. Good. Also paddle: thickness 14, similar check each frame with substeps too — paddle collision check each substep as well. Or check paddle with swept AABB. I'll include paddle in substep checks: if ball moving down and overlaps paddle rect, reflect based on hit position.
Also handle ball-brick corner cases: compute closest point, normal from closest point to center; if center inside rect (rare), use min-axis push. Reflect velocity: v' = v - 2(v·n)n. Then nudge ball out along n by (r - dist).
- Particles: on brick destroy, spawn ~14 particles with brick color, gravity slight, fade. Also small particle burst on paddle hit and wall hit maybe. Keep particle count bounded.
- Score: normal brick 50, tough 100 (first hit 50? let's do tough: 30 on crack, 70 on destroy → total 100). Power-up brick bonus +50. Combo? Keep simple but polished: score popup floating text.
- Lives: 3, shown as hearts (♥) in HUD. On bottom loss: brief slow-mo/flash, ball resets on paddle, "Life lost" message; if lives 0 → game over screen with final score + restart button. Victory when all bricks destroyed → victory screen with score, time, restart.
- Demo AI mode: AI moves paddle toward ball x (with slight prediction: target = ball.x + ball.vx * small factor when ball moving down, else center). When overlay visible (demo), AI controls; after user clicks/presses, control switches to user (mouse/keys). But also requirement says "Click or press a key to start" — pressing a key starts player control. Also space could launch ball. Let me define flow:
States: "demo" (AI plays, overlay "Click or press any key to play"), "playing" (user), "over", "win". In demo, ball is launched automatically (AI serves). When user clicks or presses key during demo → switch to playing: hide overlay, player controls; ball continues (or relaunch if stuck). Also allow click to launch ball when ball is stuck on paddle. In playing state, if ball is attached to paddle (after life loss), show "Click or press Space to launch"; mouse move also launches? Let's do: click or Space launches. Arrow keys move paddle anytime.
Restart: buttons on game over/victory screens and an "R" key / restart button in HUD. Restart resets everything, goes to playing state (or demo? go to playing with overlay? Simpler: restart → state playing, ball attached, "click to launch" hint). Actually for judging, maybe restart should go straight into demo AI again? The screens say restart option; the judge clicks restart and then... if it goes to "click to launch" waiting state, screenshot after 3s would show waiting screen. Hmm. "Something meaningful must be visible on first paint" — first paint is demo mode with AI playing, good. After restart click, to keep action visible, make restart go into demo AI mode with the "Click or press a key to start" overlay again? That seems consistent: the game always starts in demo; user takes over by clicking. But user clicked restart expecting to play... they can click again. Alternatively restart → playing state with ball auto-launching after 1s. Let me make restart start in demo mode again (AI plays, overlay shown) — the overlay says "Click or press any key to take control". That keeps action always visible. And initial load = demo. Good, consistent.
Hmm, but "Click or press a key to start" message — I'll word it: "▶ Demo mode — click or press any key to play". Good.
Also keyboard: arrow keys shouldn't scroll page — preventDefault on arrows/space. Since iframe receives keyboard only after click, the message covers that.
Power-ups:
- wide: paddle width ×1.6 for 10s, show timer bar under HUD or on paddle. Implement as paddle.wideUntil timestamp; effective width = base * (wide ? 1.6 : 1). Smooth width transition (lerp) for polish. Timer displayed as small bar.
- multiball: spawn 2 extra balls (total 3) at current ball position with angles spread (e.g., ±25° from current velocity direction), each with same speed. Cap total balls at, say, 6 to avoid chaos; requirement says 3 balls — spawn until 3 balls from that brick (i.e., duplicate current balls so that there are 3? "multi-ball (3 balls)": when caught, if 1 ball → becomes 3. If multiple balls exist, add up to make total... simplest: for each existing ball, spawn 2 clones → could exceed. I'll do: clones = min(2, 3 - balls.length) per ball? Let's just: on catch, for each ball, add clones so total becomes up to 3× current? Keep simple: if balls.length < 3, add (3 - balls.length) clones of the first ball with varied angles. That gives exactly 3 balls from 1. If already multi, adds up to 3 total. Fine. Actually nicer: duplicate each existing ball twice, cap at 8. Requirement just says 3 balls; I'll do: spawn 2 clones per existing ball, cap 6 total. Hmm, that could chain. Cap total balls at 6. OK.
Falling capsules: {x, y, vy, type}. Draw rounded rect capsule with glow and icon. Catch: if capsule bottom overlaps paddle top region and |x - paddle.x| < paddle.w/2 + capW/2 → apply.
Ball-paddle collision detail: only when ball.vy > 0. Compute relative = clamp((ball.x - paddle.x)/(paddle.effW/2), -1, 1). angle = relative * MAXANGLE (MAXANGLE = 65° ≈ 1.134 rad). speed = min(ball.speed * 1.02, maxSpeed). ball.vx = sin(angle)*speed; ball.vy = -cos(angle)*speed. Position ball.y = paddle.y - r - 0.5. Also add slight paddle velocity influence: add paddle.vx * 0.15 to vx then renormalize. Nice touch.
Brick layout: rows = 8, cols = 10 (or adapt: cols = clamp(floor(W/90), 8, 12)). Tough bricks: place in a pattern — e.g., rows 3-4 mostly tough, plus scattered. Let's define per-row config: row 0 (top): tough? Classic: top rows tougher. Let's do: rows 0-1: tough (2 hits), rows 2-5: normal, rows 6-7: normal... Requirement: mixing normal and tougher. I'll make rows 0,1 tough; rows 2,3 mix (some tough); rows 4-7 normal. Or random with seed. Deterministic pattern nicer for "one level". Let me create pattern via function: tough if (row < 2) or (row===2 && col%3===1) etc. Also assign power-ups: choose ~7 bricks scattered: e.g., indices spread: pick bricks at (row, col) pairs [(1,2,'wide'),(2,7,'multi'),(3,4,'wide'),(4,8,'multi'),(5,1,'wide'),(6,6,'multi'),(2,0? )...]. Ensure they're on normal bricks (drop on destroy) — can be on tough too but then only after 2 hits; fine, but ensure visible icon on tough brick as well. I'll assign icons to a fixed set of positions, mixing rows 1-6.
Colors by row: gradient palette (e.g., row0 #f43f5e, row1 #fb7185... or a nice palette: ['#ff5d8f','#ff8a5c','#ffc94d','#7ee081','#4dd0e1','#7aa2ff','#b388ff','#f06292']). Normal bricks: fill with row color, top highlight, subtle inner gradient. Tough: metallic gray-blue with rivets; crack state: draw jagged crack lines (precomputed per brick random crack segments) in dark color + darker overlay.
Brick draw: rounded rect radius 4, gradient vertical lighter top, border darker, small shine line. Tough: base #6b7280→#374151 gradient, rivets circles at corners, when hit once (hp=1) draw cracks (2-3 jagged polylines) and slight desaturation/flash.
Also brick hit flash: white overlay fading.
Score popups: floating "+50" texts rising and fading.
HUD (DOM): top bar with Score left, center maybe "BREAKOUT" title small, lives right as hearts. Use CSS. Also power-up timer bar. Also a small "restart" icon button? Optional; add a small button top-right? Keep HUD clean: Score: 0 | ♥♥♥. Maybe also show high score in memory (not localStorage — allowed? localStorage blocked. Use in-memory variable only; it resets on reload, fine, or skip). I'll show "Best" in-memory session best.
Overlays (DOM, absolutely positioned over canvas):
- #demoOverlay: centered card: "BREAKOUT" big title, "Demo mode — AI is playing", "Click or press any key to take control". Semi-transparent backdrop blur. Visible during demo state.
- #endOverlay: hidden; contains "GAME OVER" or "YOU WIN!", final score, best, time, and "▶ Play again" button. Button click → restart (to demo? or to playing?). Let me decide: restart goes to demo mode with overlay (consistent, action visible). But the button says "Play again" — clicking it and then getting demo overlay with "click to take control" is acceptable and keeps motion. Hmm, but a human tester clicks "Play again" then sees demo again and must click again to play — slightly annoying but per instructions demo mode is required at start. The instruction: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". "start" = initial load. After restart, I can go directly to player mode with ball attached and auto-launch after a short countdown? To be safe for automated judging (which screenshots at ~3s after load only), restart behavior matters less. But a human evaluator might click restart and expect to play. I'll make restart go to player-controlled mode with the ball served automatically after 0.8s and a small "Get ready" toast. That's friendlier. And initial load = demo with overlay. Also pressing R anytime restarts.
Wait — but if overlay hidden and user hasn't clicked, keyboard won't reach iframe. That's fine because demo AI plays; message tells them to click.
Edge: When user clicks during demo → take control: state='playing', hide overlay. If ball was attached (shouldn't be in demo since AI auto-launches), launch.
Ball attach: after life lost, ball attaches to paddle, follows paddle; click/space/auto (in demo, AI auto-launches after 0.6s) launches.
Let me also add pause when tab hidden (rAF handles), and 'P' pause maybe. Add Esc/P pause overlay? Keep simple: 'P' toggles pause with small overlay text drawn on canvas or DOM. I'll add a simple paused indicator in HUD. Optional — include lightweight.
Sound? Could use WebAudio for beeps (no external files). Nice polish, no network. Add simple oscillator beeps on hits, with mute toggle (M). AudioContext requires user gesture — in demo mode no gesture yet, so guard: create context lazily on first user gesture; in demo, sounds skipped until ctx exists. Fine. Keep volume low. I'll include it — polish. Must not error if AudioContext unavailable; wrap in try.
Canvas rendering polish:
- Background: radial gradient dark navy, subtle moving starfield (precomputed stars with parallax drift), faint grid or glow at top. Also a subtle vignette.
- Paddle: rounded rect with gradient (cyan→blue), glow shadow, when wide show golden tint + timer arc/bar.
- Ball: white with radial gradient + glow trail (store last positions, draw fading circles).
- Bricks as described.
- Particles with additive blending (globalCompositeOperation 'lighter' for particles/glow).
- Screen shake on life loss (small) — polish.
- Flash overlay on brick row clear? Not needed.
Performance: fine.
Resize handling: recompute layout (brick positions) on resize — but bricks may be destroyed; store bricks with row/col and compute x,y from layout each frame or on resize update positions preserving hp/alive. I'll compute brick rects in a function layoutBricks() that sets x,y,w,h for each brick based on current canvas size; call on init and resize. Bricks array persists.
Ball speed scaling: base speed = clamp(H * 0.62, 340, 560) px/s. maxSpeed = base*1.9.
Substeps: const steps = Math.ceil(speed*dt / (ball.r*0.5)) clamp [1, 8]; per substep move ball by v*dt/steps and collide.
Collision with bricks per substep: loop bricks alive; circle-rect test; if hit: determine normal; reflect; reposition; mark brick hit (hp--, if 0 destroy → particles, score, maybe power-up drop); break inner loop? After reflecting, continue loop could hit adjacent brick — actually that's fine/desirable (ball between two bricks). But reflecting twice in one substep could cause weirdness; with substeps small it's OK. Common approach: handle one brick per substep (break after first collision). With substeps ≤ r/2, ball can't tunnel; handling one per substep is fine and stable. I'll handle first collision per substep then break.
Circle-rect: closestX = clamp(ball.x, rx, rx+rw), closestY = clamp(ball.y, ry, ry+rh); dx = ball.x-closestX, dy = ball.y-closestY; dist2 = dx*dx+dy*dy; if dist2 < r*r → collided. Normal: if dist2 > 0: n = (dx,dy)/dist. else center inside: compute penetration to each side, pick min axis. Reflect v about n: v -= 2*(v·n)*n. Then push ball to surface: ball.x = closestX + n.x*(r+0.01)... For inside case, push along min axis.
Also ensure after reflection ball moves away: if v·n > 0 after reflection? Reflection guarantees v'·n = -(v·n); if approaching (v·n<0) then leaving. Fine.
Paddle collision per substep: if ball.vy > 0 and ball overlaps paddle rect (expanded by r): compute as circle-rect too but with special angle logic: use overlap with rect top primarily. Simpler: if ball.y + r >= paddle.y && ball.y - r <= paddle.y + ph && ball.x >= paddle.x - pw/2 - r && ball.x <= paddle.x + pw/2 + r && ball.vy > 0: then do the angle bounce, set ball.y = paddle.y - r - 0.1. This is a "broad" check; with substeps it can't tunnel (paddle height 14 + ball diameter 14 = 28 > substep move ≤ r/2*... substep move = speed*dt/steps ≤ r/2 ≈ 3.5px, so no tunneling). Good.
Walls per substep or per frame: do within substep: if x - r < 0 → x = r, vx = |vx|; etc. Top: y - r < topWall(0? HUD area is DOM overlay; playfield starts at 0; but HUD overlays top — keep bricks starting at y=84 and walls at 0; HUD is translucent so fine. Maybe add a top "ceiling" line at y=0.) Bottom: y - r > H → life lost.
Now DOM structure:
```
<body>
<div id="wrap">
<canvas id="game"></canvas>
<div id="hud">
<div class="hud-left">SCORE <span id="score">0</span></div>
<div class="hud-center">BREAKOUT</div>? maybe skip
<div class="hud-right"><span id="lives"></span> <button id="muteBtn">🔊</button></div>
</div>
<div id="wideTimer"><div id="wideFill"></div></div> (positioned under hud center)
<div id="toast"></div>
<div id="overlay"> ... dynamic content ... </div>
</div>
</body>
```
I'll build overlay content dynamically via JS (showDemo(), showEnd(win), hide()).
Styling: dark theme, glassmorphism card, neon accents (cyan/magenta), Orbitron-like font? No external fonts allowed. Use system fonts with letter-spacing for a techy look: font-family: "Segoe UI", system-ui... Use CSS with 'Trebuchet MS'? I'll use system-ui stack with heavy weights and letter-spacing; add text-shadow glows. Fine.
Layout: body margin 0, overflow hidden, background #05060f; #wrap fills viewport; canvas absolute inset 0 w/h 100%. HUD absolute top with padding, pointer-events none except buttons.
Overlay: absolute inset 0, display flex center, background rgba(4,6,16,0.55) + backdrop-filter blur(6px). Card: padding, border 1px rgba(255,255,255,.12), border-radius 16px, background rgba(10,14,30,.7), box-shadow glow. Title with gradient text. Button: gradient cyan→violet, hover scale. Also "controls" hints: 🖱 move mouse · ← → keys · Space/click launch.
Touch support? Not required; mouse + keys required. Maybe add basic touchmove for mobile grace — cheap to add: on touchmove set paddle target. I'll add pointer events: pointermove covers mouse+touch (pointer events unify). Use pointermove on canvas/window: set mouseX. Also pointerdown → click action (take control / launch / restart button). Use 'pointerdown' for click. Keyboard: keydown.
Careful: clicking the restart button inside overlay — overlay has pointer-events auto; clicking anywhere on overlay in demo state takes control. But button click should restart. Handle: overlay click handler: if state demo → start playing; if state over/win → do nothing (button handles restart) but clicking backdrop could also restart? Let's make overlay click (not on button) in end states also restart. Button stopPropagation not needed if same action.
Also clicking during play when ball attached → launch.
Prevent context menu etc. not needed.
Key handling: ArrowLeft/ArrowRight set key flags; Space → action (take control in demo, launch ball, restart in end states? Space on end screen → restart). 'r' → restart. 'm' → mute. 'p' → pause. preventDefault for arrows/space to avoid scroll.
Game loop with requestAnimationFrame, dt clamp 0.033.
AI: in demo state (and also when state==='over'? no), paddle target: if ball attached → move to center; else if any ball moving down, target = predicted x of the lowest incoming ball: x + vx * ( (paddleY - ball.y)/vy ) clamp within [margin, W-margin], plus small error wobble (sin(time)*8) to look human and occasionally miss? If AI never misses, demo never ends — that's fine actually (it plays well). But demo should maybe show game progressing: bricks break, score rises. AI with prediction rarely misses; add slight reaction delay: AI moves paddle toward target with max speed (paddle keyboard speed), so it may miss fast balls — dynamic. I'll implement AI as: compute desired x, then move paddle at up to aiSpeed toward it (like key control). Add small noise. Good enough.
When user takes control mid-demo: state='playing', overlay hidden; paddle control = mouse/keys.
Also during demo, if all bricks cleared → victory screen shows even in demo (fine, shows victory screen; then restart). If lives run out in demo → game over screen. That's OK — but for the 3s screenshot, initial state is fresh demo with full bricks, good.
Auto-launch in demo: when ball attached, after 0.8s launch automatically with slight random angle.
Score popup: array of {x,y,text,life}.
Particles: {x,y,vx,vy,life,maxLife,color,size}. On brick destroy: 16 particles. On paddle hit: 6 small. On wall: 3.
Trail: ball.trail array of positions, push each frame, cap 12, draw with decreasing alpha.
Screen shake: shake magnitude decays; apply ctx.translate(rand) each frame when >0. On life loss shake=8.
Flash: full-canvas white flash alpha decays on life loss.
Wide timer: DOM bar width % updated each frame, visible when active. Position: top center under HUD. Or draw on canvas above paddle. DOM simpler: a small bar centered at top: "WIDE PADDLE 7.3s". I'll do DOM: #buffBar with label and fill.
Lives display: hearts via spans, update on change: '♥'.repeat(lives) colored, lost ones dim. Use ♥ (U+2665). Color #ff4d6d with glow.
Score: animate count? Just set text; add pop animation via CSS class re-trigger. Keep simple: set textContent; add a quick scale animation using CSS transition by toggling class. Simple: scoreEl.classList.remove('pop'); void offsetWidth; add 'pop'.
Best score: session variable best, shown on end screens and maybe HUD small "BEST 1234". I'll include in HUD under score? Keep HUD: left: SCORE value + small BEST. Right: lives + mute btn.
Timer for level: track elapsed time in playing/demo; show on end screen as mm:ss.
Now brick pattern details:
cols: choose based on width: cols = 11 for wide screens? Let's compute cols = clamp(Math.round((W - 2*margin) / 86), 8, 12). margin = clamp(W*0.06, 24, 70). rows = 8. Brick h = clamp(H*0.032, 18, 26). gap = 6 (scale with W? gap = clamp(W*0.006,4,8)). top = clamp(H*0.13, 76, 110).
Type assignment: function brickType(row, col, rows, cols):
- rows 0 and 1 → tough.
- rows 2 and 3 → tough if (col % 4 === 1) or (row===3 && col%5===2)? Let me craft a pattern: tough at row 2: cols where col%3===1; row 3: col%4===2. Rows 4-7 normal.
Actually let's make it look intentional: top two rows tough (silver), then alternating accents. Fine.
Power-ups: assign to specific (row,col): pick list: [{r:1,c:2,t:'wide'},{r:1,c:8,t:'multi'},{r:2,c:5,t:'wide'},{r:3,c:1,t:'multi'},{r:4,c:9,t:'wide'},{r:5,c:3,t:'multi'},{r:6,c:6,t:'wide'}]. Need cols ≥ 10 to include c:9. If cols < 10, scale col = Math.floor(c * cols / 10). Ensure distinct bricks; if collision, skip. Icons drawn on brick: for normal brick, draw icon in contrasting color (white with glow) at center: wide → paddle glyph (rounded rect wide short) ; multi → three circles. Also maybe tint brick slightly (e.g., add golden border) to make "visibly carry a power-up icon" clear. I'll draw a small badge: circle behind icon with semi-transparent white, plus icon. And capsule drop uses same glyph.
Tough brick with power-up: icon drawn too (white). OK.
Crack rendering: when tough hp goes 2→1: brick.cracked = true; generate crack lines: 3 polylines from a random point, jagged. Draw dark strokes with slight white edge. Also change appearance: darker, maybe slight red tint flash.
Brick hit (any): brick.flash = 1 (decays), draw white overlay alpha flash*0.6.
Destroy: alive=false, particles, score popup, if powerUp → spawn capsule at brick center with type; sound.
Ball-brick scoring: normal destroy 50; tough first hit 30, destroy 100; plus power-up brick bonus 25? fine: +50 bonus if it had power-up? Keep: destroy tough 100, normal 50, crack 30.
Also small speed-up per brick destroyed: ball.speed += 2 up to max (applies to all balls) — classic. Keep mild.
Multiball clones: angle offsets ±0.45 rad rotated velocity; speed same. Cap balls length ≤ 6? If already 3, catching multi adds clones to reach... spec: multi-ball gives 3 balls. Implementation: on catch: const current = balls.slice(); for each ball in current, if balls.length < 6 spawn up to 2 clones? That yields 3 from 1, 6 from 2... cap 6. Hmm "3 balls" — from 1 ball → 3. I'll do: target = Math.min(balls.length * 2 + 1? ...). Simplest matching spec: if balls.length===1 → spawn 2 clones (total 3). If >1 → spawn clones of each until cap 6? To keep spec-clean: always ensure at least 3 balls: needed = Math.max(0, 3 - balls.length); spawn `needed` clones of the fastest ball with angle offsets cycling [0.5, -0.5, 0.9, -0.9...]. If balls.length >= 3 already, spawn 1 clone anyway? No — if already ≥3, maybe still duplicate one for fun but cap 8. I'll do: clones needed = Math.min(2, Math.max(0, 3 - balls.length))... if balls.length is 2 → need 1 → total 3. If 1 → 2 → total 3. If ≥3 → 0? Then catching multi at 3 balls does nothing — meh. Let's do: if balls.length < 3: add until 3. else: add 1 clone of a random ball (cap 6). OK fine, minor.
Actually simpler & fun: duplicate all current balls once (each ball spawns 1 clone with ±angle), cap 8. From 1 → 2 balls though, not 3. Spec says 3 balls. So from 1 ball spawn 2 clones. I'll go: for each existing ball, spawn 2 clones but cap total at 6? From 1 → 3. From 2 → 6. From 3 → 6 (capped). That's "multi-ball (3 balls)" satisfied at base case and cap prevents chaos. Clones angles: rotate velocity by ±(0.35..0.6) random. Good.
Wide paddle: wideUntil = now + 10s. Effective width target = base*1.7. Smooth: paddle.w lerps toward target each frame (rate 8/s). Timer bar shows remaining.
Paddle base width: clamp(W*0.13, 80, 170).
Paddle control:
- keys: if left, vx = -keySpeed; right +; keySpeed = clamp(W*1.1, 600, 1000) px/s. Both → 0. Smooth: paddle.vx approach? Direct set is fine; add slight lerp for smoothness: paddle.x += (target approach). I'll do velocity with acceleration for smooth: ax approach. Keep simple: direct velocity, but mouse control uses smooth lerp: if mouse active recently (within 3s?) hmm mixing inputs. Approach: paddle has targetX. Mouse move sets targetX = mouseX (and mode='mouse'). Keys set mode='keys' and move directly by velocity; when keys released, if mouse present, lerp to mouseX. Implementation: each frame: if keys pressed → x += dir*speed*dt; else if mouseX != null → x += (mouseX - x) * min(1, 18*dt) (smooth follow). AI sets x directly toward target with speed limit. Good.
Clamp paddle x within [w/2 + 4, W - w/2 - 4] using current effective width.
Ball serve: ball attached: x = paddle.x, y = paddle.y - r - 1; on launch: angle = -90° + rand(-25°,25°) → vx = sin(a)*speed, vy = -|cos|... use angle from vertical: vx = Math.sin(a)*speed, vy = -Math.cos(a)*speed with a ∈ [-0.45, 0.45].
Lives lost: lives--; update HUD; if 0 → gameOver() else: reset balls to one attached; capsules cleared; flash+shake; toast "Ball lost — click to serve"; in demo auto-serve timer.
Victory: when bricks alive count === 0 → state='win', overlay victory with score/time, best update, confetti particles maybe (spawn celebration particles).
Game over: overlay with score, best, restart button.
Restart(): reset score, lives=3, bricks rebuild, balls=[attached], capsules=[], particles cleared, state = (from user action ? 'playing' : 'demo')... Decide: restart via button/key → state='playing', overlay hidden, ball attached, auto-serve countdown 1s? If user pressed key R, they're engaged; auto-serve after 0.9s with toast "Get ready!". If initial load → demo. Also after game over in demo (AI died), overlay end screen; clicking restart → playing mode. Good.
Hmm wait: on initial load demo plays; if AI clears all bricks within 3s? Impossible. Fine.
Also ensure first paint shows demo overlay + game running behind (canvas animating). Overlay backdrop semi-transparent so game visible behind (blur). Good — "self-playing demo behind message".
Pause: when overlay end screens shown, stop updating? Keep rendering but freeze gameplay (state 'over'/'win' → skip update, still draw + particles maybe). I'll keep particles updating for victory confetti.
Sound design (WebAudio): function beep(freq, dur, type, vol). Sounds: paddle hit 220→ maybe 180 square 0.05; wall 140; brick 320 + pitch by combo; tough hit 200; destroy 520 sawtooth quick + noise? Keep simple oscillators. Mute toggle button 🔊/🔇. Create AudioContext on first pointerdown/keydown (gesture). In demo before gesture, skip silently.
Also add subtle background music? Skip — beeps enough.
Now write the code carefully. Sizes: use CSS pixels logical W,H = innerWidth/innerHeight. DPR handling: canvas.width = W*dpr etc., ctx.setTransform(dpr,0,0,dpr,0,0). On resize re-setup.
Watch: HUD is DOM above canvas; bricks top at ~13% H so below HUD (~60px). HUD height ~56px; top = max(76, H*0.12).
Edge: very small width 375: cols = clamp(round((375-48)/86)= round(327/86)=4 → clamp min 8 → 8 cols, brick width = (375-48 - 7*5)/8 ≈ (327-35)/8 ≈ 36.5 — small but OK. gap = clamp(375*0.006=2.25→4). margin = clamp(375*0.06=22.5→24). area = 375-48=327; brick w = (327 - 7*4)/8 = (327-28)/8=37.4. OK playable. Paddle base = clamp(375*0.13=48.75→80) = 80. Ball r = clamp(W*0.009, 6, 9) → 6. Fine.
Font sizes responsive via clamp().
Let me now think about the "never pass through paddle" more: ball could hit paddle side while moving down — broad check handles when overlapping; angle bounce sets vy negative always when overlapping and vy>0. If ball hits side of paddle (x beyond edge but within r expansion), relative clamps to ±1 → sharp angle. OK.
One more: ball stuck inside brick after reflection due to deep penetration — reposition along normal by penetration + r. Handled.
Also prevent ball from getting infinite horizontal loops (vy near 0): after paddle bounce ensure min vertical component: if |vy| < 0.25*speed → adjust: recompute angle min 15°? The max angle 65° gives |vy| = cos(65°)=0.42*speed, fine. Wall bounces don't change angle. Brick reflection could produce shallow angles; clamp: after any reflection, if |vy| < 0.18*speed and ball not attached → rotate velocity slightly toward vertical: set vy sign keep, vy = ±max(|vy|, 0.25*speed), renormalize. I'll add a helper normalizeBall(b) ensuring min vertical ratio 0.22. Apply after brick collisions.
Substep count: steps = clamp(Math.ceil((speed*dt)/ (r*0.6)), 1, 10). At speed 800, dt 0.016 → 12.8/4.2 ≈ 4. OK.
Now particles update even in end states for confetti.
DOM overlay content: I'll have one #overlay div; JS sets innerHTML per state with buttons wired via addEventListener after set (or event delegation). Use delegation: overlay.addEventListener('click', e => { if demo → takeControl(); else if end → restart(true); }). Button click bubbles to overlay → same action. Good, no wiring needed. But careful: in demo state, clicking should take control, NOT restart. In end states, click → restart. Also mute button in HUD separate (stopPropagation not needed since HUD not inside overlay; but HUD has pointer-events none except button — set .hud {pointer-events:none} and button {pointer-events:auto}).
Keydown: if state==='demo' → takeControl() (any key? maybe any key except F12... just any). Then also handle that key's action (e.g., Space also launches). Implement: on keydown: ensureAudio(); if state demo → startPlaying(); then if key===' ' → serveOrNothing; arrows set flags; 'r' → restart; 'm' mute; 'p' pause toggle. preventDefault for Space/Arrows.
takeControl(): state='playing'; hideOverlay(); toast('You're in control!'); If ball attached → serve after short delay? Let user click/space to serve, or auto-serve 0.8s. I'll auto-serve after 0.8s for smoothness with toast "Click / Space to serve".
Actually during demo the ball is always in play (AI serves), so taking control mid-flight is fine.
pointerdown on wrap: ensureAudio(); if state demo → takeControl(); else if state playing → if ball attached serve(); else if end → restart(true). Also set mouseX from event. Also pointermove sets mouseX always (for when control taken later).
Also when state playing and ball attached, moving mouse shows aim? Draw a faint guide line from ball upward? Nice: draw dashed line showing launch angle? Launch angle random; skip.
Toast: DOM div, show text 1.6s fade.
Level complete confetti: spawn 120 particles across top with palette colors, gravity.
Timer: elapsed accumulates only in demo/playing states (not paused/end).
Pause: 'p' toggles paused when playing/demo? Only playing. Show toast "Paused". Skip updates when paused (but draw "PAUSED" text on canvas). Simple.
Mute: muted flag; button text update.
Now brick icon drawing functions:
drawIcon(ctx, type, x, y, s):
- wide: paddle glyph: rounded rect width s*1.6, height s*0.5 at center, plus small glow. Color white.
- multi: three circles radius s*0.28 arranged triangle (two bottom, one top) or horizontal row. Use triangle: positions (0,-s*0.25), (-s*0.35, s*0.3), (s*0.35, s*0.3).
On brick: draw badge circle radius s*0.62 fill rgba(255,255,255,0.16) stroke rgba(255,255,255,0.5), then icon white with shadowBlur.
Capsule: w=34,h=18 rounded, gradient by type (wide: cyan; multi: magenta), border white-ish, icon inside dark? Icon white with glow; capsule glow shadowColor type color. Falls vy=150. Bobbing slight rotation? Keep straight, maybe slight sine x wobble.
Catch check: capsule.y + h/2 >= paddle.y && capsule.y - h/2 <= paddle.y + ph && |capsule.x - paddle.x| <= pw/2 + w/2 → apply. Also only when capsule.y < paddle.y + ph + something. Fine.
Apply wide: wideUntil = performance.now()/1000 + 10; toast "WIDE PADDLE 10s"; sound up.
Apply multi: clone balls; toast "MULTI-BALL!"; sound.
HUD buff bar: element #buff with label "WIDE" and fill width = remaining/10*100%. Update in rAF when playing/demo. Hidden otherwise.
Also show active multiball? Not needed.
Score popups drawn on canvas.
Let me also add row-clear bonus? skip.
Let me now also handle "restart option" on screens: button "↻ PLAY AGAIN". Also hint "or press R / click anywhere".
Write CSS:
:root colors: --bg:#05060f; --cyan:#22d3ee; --violet:#8b5cf6; --pink:#f472b6.
body { margin:0; height:100vh; overflow:hidden; background:#04050c; font-family: ui-sans-serif, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color:#e5e7eb; user-select:none; }
#wrap {position:fixed; inset:0;}
canvas {position:absolute; inset:0; display:block; cursor:none? } Hmm cursor: hide over canvas when playing? Mouse control → hide cursor for immersion: canvas {cursor:none} but in demo show default? Set cursor:none always on wrap; overlay card shows cursor:default. But then clicking overlay... overlay covers wrap, cursor default there. OK: #wrap{cursor:none} #overlay{cursor:default}. But on touch devices no cursor anyway. Also when overlay end screen, cursor none over backdrop is odd; set #overlay{cursor:pointer}. Fine.
HUD: position absolute; top:0; left:0; right:0; display:flex; justify-content:space-between; align-items:center; padding:14px 22px; pointer-events:none; z-index:5;
.hud-box {background:rgba(10,14,28,.55); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:8px 14px; backdrop-filter: blur(8px); box-shadow: 0 4px 24px rgba(0,0,0,.35);}
label small caps letter-spacing 2px color #7dd3fc font-size 10px; value font-size 20px font-weight 800.
lives hearts font-size 18px color #fb7185 text-shadow glow; lost: color rgba(255,255,255,.15).
mute button pointer-events:auto; background rgba(255,255,255,.06); border...; border-radius 8px; color #e5e7eb; cursor pointer; padding 4px 8px.
Buff bar: position absolute; top:64px; left:50%; transform:translateX(-50%); width:180px; height:10px; background rgba(255,255,255,.08); border-radius:6px; overflow hidden; display none. Fill gradient cyan. Label above? Put label inside? Small text above bar: I'll include label span inside container positioned above. Simpler: container has text "WIDE PADDLE" tiny above bar. Make container flex column gap 3, align center.
Toast: absolute bottom 18% center, font-size 15px, letter-spacing 1px, opacity transition.
Overlay: absolute inset 0; display:flex; align-items:center; justify-content:center; z-index:10; background: radial-gradient(...) rgba(3,5,12,.45); backdrop-filter: blur(5px) — but backdrop-filter over canvas works. transition opacity .25s. .hidden {opacity:0; pointer-events:none}.
Card: text-align center; padding 34px 46px; border-radius 20px; background: linear-gradient(160deg, rgba(15,20,40,.85), rgba(8,10,24,.85)); border:1px solid rgba(120,160,255,.18); box-shadow: 0 0 60px rgba(56,130,246,.15), inset 0 0 30px rgba(255,255,255,.03); max-width min(520px, 86vw).
Title: font-size clamp(34px, 6vw, 56px); font-weight 900; letter-spacing 6px; background: linear-gradient(90deg,#22d3ee,#8b5cf6,#f472b6); -webkit-background-clip:text; color:transparent; text-shadow? can't with clip; use filter drop-shadow.
Sub: color #94a3b8.
Big message for demo: "DEMO MODE" pill: small uppercase pill with pulsing dot.
Button: margin-top 18px; padding 12px 30px; font-size 15px; font-weight 700; letter-spacing 2px; color #051018; background linear-gradient(90deg,#22d3ee,#818cf8); border none; border-radius 999px; cursor pointer; box-shadow 0 8px 30px rgba(34,211,238,.35); transition transform .15s; hover transform translateY(-2px) scale(1.03).
Controls hint list: small rows with kbd styling: kbd {background rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.15); border-radius:6px; padding:2px 7px; font-size:11px; font-family:inherit}.
End screens: h1 "GAME OVER" (red glow) / "YOU WIN!" (green/cyan glow) — implement via class .win/.lose with different gradient text colors: lose: #f87171→#f472b6; win: #34d399→#22d3ee.
Stats row: SCORE / BEST / TIME boxes.
"Click anywhere or press R to play again" hint.
Now JS structure:
```js
(() => {
'use strict';
const canvas = ..., ctx = ...
let W=0,H=0,DPR=1;
let state='demo'; // demo | playing | over | win
let paused=false;
let score=0, best=0, lives=3, elapsed=0;
let bricks=[], balls=[], parts=[], pops=[], caps=[];
let paddle, mouseX=null, keys={left:false,right:false};
let wideUntil=0, shake=0, flash=0, serveTimer=0, aiTimer=0;
let muted=false, audioCtx=null;
let stars=[];
...
})();
```
resize(): set W,H, canvas sizes, layoutBricks(), stars regenerate, clamp paddle.
layoutBricks(): compute metrics {margin, gap, top, bh, bw, cols, rows} stored in L; for each brick set x,y,w,h. Bricks created in buildLevel() with row,col,type,pow; layout assigns geometry.
buildLevel(): rows=8; cols computed; create bricks; assign types/powerups.
Type assignment function:
```
function brickKind(r,c,rows,cols){
if(r===0||r===1) return 'tough';
if(r===2) return (c%3===1)?'tough':'normal';
if(r===3) return (c%4===2)?'tough':'normal';
return 'normal';
}
```
Powerups: const powDefs=[[1,2,'wide'],[1,7,'multi'],[2,5,'wide'],[3,9,'multi'],[4,1,'multi'],[5,6,'wide'],[6,3,'multi'],[2,9? ...]] ensure within cols: map c → Math.min(c, cols-1); check not duplicate with others; assign if brick exists. Also maybe row 0? top row tough carrying icon ok but let's keep rows 1..6. Count ~7.
Actually with cols possibly 8 (mobile), c=9 → maps to 7 etc. Could collide with another def mapped to 7 — dedupe via Set of "r,c".
Ball object: {x,y,vx,vy,r,speed,trail:[]}.
newBall(x,y,angle,speed): r computed global ballR.
serve(): from paddle: angle random ±0.4; balls=[makeBall(paddle.x, paddle.y - r - 1, angle, baseSpeed)]; attached=false.
attachBall(): balls=[{...attached:true...}] — represent attached as ball.attached=true; update follows paddle.
update(dt):
- elapsed += dt (if state demo/playing and !paused)
- paddle update (control)
- wide timer: effW target
- if attached ball: follow paddle; serveTimer countdown → serve (demo) or in playing wait for input (serveTimer only in demo; in playing show hint; auto-serve? Let's auto-serve in playing too after 1.2s? The spec doesn't require click-to-serve; auto-serve after 1s is smoother. But "Click or press a key to start" refers to taking control. For serving after life loss, auto-serve 1s with toast. I'll auto-serve in both, and click/space serves immediately.)
- balls: substep movement & collisions
- capsules update
- particles, popups update
- shake/flash decay
- check win: bricks.some(alive) false → win()
- HUD updates (score text when changed, lives, buff bar)
Collision code per ball per substep:
```
function collideWorld(b, dt){
const steps = Math.max(1, Math.ceil(Math.hypot(b.vx,b.vy)*dt / (b.r*0.55)));
const sdt = dt/steps;
for(let s=0;s<steps;s++){
b.x += b.vx*sdt; b.y += b.vy*sdt;
// walls
if(b.x < b.r){ b.x=b.r; b.vx=Math.abs(b.vx); sfx('wall'); puff(b.x,b.y,...)}
else if(b.x > W-b.r){...}
if(b.y < b.r){ b.y=b.r; b.vy=Math.abs(b.vy); ... }
// paddle
const pw = effPaddleW();
if(b.vy>0 && b.y + b.r >= paddle.y && b.y - b.r <= paddle.y + paddle.h && b.x >= paddle.x - pw/2 - b.r && b.x <= paddle.x + pw/2 + b.r){
const rel = clamp((b.x - paddle.x)/(pw/2), -1, 1);
const maxA = 1.15; // ~66deg
const ang = rel*maxA;
let sp = Math.min(b.speed*1.015, maxSpeed);
b.speed = sp;
b.vx = Math.sin(ang)*sp + paddleVelX*0.12;
b.vy = -Math.cos(ang)*sp;
// renormalize to keep speed sp
const m = Math.hypot(b.vx,b.vy); b.vx*=sp/m; b.vy*=sp/m;
b.y = paddle.y - b.r - 0.5;
sfx('paddle'); spark particles;
}
// bricks
for(const br of bricks){ if(!br.alive) continue; if(circleRect(b, br)){ hitBrick(b, br); break; } }
// bottom
if(b.y - b.r > H){ b.dead=true; break; }
}
}
```
Wait paddle check should use circle-rect too but the simple AABB check is fine given vy>0 condition and substeps. However if ball approaches paddle from side while vy>0 and x beyond paddle edge by < r, rel clamps to 1 → sharp angle, fine.
hitBrick(b, br): compute normal from circle-rect closest point:
```
const cx = clamp(b.x, br.x, br.x+br.w), cy = clamp(b.y, br.y, br.y+br.h);
let nx, ny; const dx=b.x-cx, dy=b.y-cy; const d2=dx*dx+dy*dy;
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: push along min penetration axis
const pl = b.x-br.x, pr = br.x+br.w-b.x, 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;}
}
// reflect
const dot = b.vx*nx + b.vy*ny;
b.vx -= 2*dot*nx; b.vy -= 2*dot*ny;
// ensure not moving into brick anymore
ensureMinVertical(b);
br.hp--; br.flash=1;
if(br.hp<=0){ destroyBrick(br); } else { sfx('crack'); pop small }
```
ensureMinVertical: if |b.vy| < 0.22*sp → set vy = sign(vy|| -1)*0.22*sp... hmm sign should be away from brick; after reflection vy sign is correct mostly; just enforce magnitude:
```
const sp=Math.hypot(b.vx,b.vy)||1;
if(Math.abs(b.vy) < 0.2*sp){ b.vy = (b.vy>=0?1:-1)*0.2*sp; const m=Math.hypot(b.vx,b.vy); b.vx*=sp/m; b.vy*=sp/m; }
```
Wait that changes direction sign incorrectly if vy was 0? Use Math.sign(b.vy)|| (ny<0?-1:1)? Simpler: if |vy|<0.2*sp: b.vy = (b.vy<0?-1:1)*0.25*sp; renormalize. OK.
destroyBrick(br): br.alive=false; score += br.kind==='tough'?100:50; popup; particles 16 with br.color; sfx('break'); speed up balls slightly: for each ball b.speed=min(b.speed+3,maxSpeed) and renormalize velocity to new speed? Changing speed requires scaling vx,vy by new/old. Do: const old=hypot; b.vx*=ns/old etc. Fine.
if(br.pow) spawnCapsule(br.cx, br.cy, br.pow).
Crack (tough first hit): score+=30; br.cracked=true; generate cracks; sfx('crack'); small particles gray.
Ball death: remove from balls; if balls empty → loseLife().
loseLife(): lives--; updateLives(); flash=0.35; shake=10; sfx('lose'); if(lives<=0){ gameOver(); } else { attach new ball; serveTimer=1.0; caps.length=0; toast('Ball lost!'); }
gameOver(): state='over'; best=max; showEnd(false);
win(): state='win'; best; confetti; showEnd(true);
showEnd(win): overlay.innerHTML = ...; overlay.classList.remove('hidden'); state set. Also stop ball updates (state not in demo/playing → update skips ball sim but still draws particles).
restart(toPlaying): buildLevel(); score=0; lives=3; elapsed=0; caps=[]; parts=[]; pops=[]; balls=[]; attachBall(); wideUntil=0; paddle.x=W/2; state = toPlaying?'playing':'demo'; serveTimer = state==='demo'?0.7:1.0; if(state==='demo') showDemoOverlay(); else hideOverlay(); update HUD; toast.
Initial: resize(); buildLevel(); attachBall(); showDemo(); serveTimer=0.8; state='demo'; requestAnimationFrame(loop).
takeControl(): if(state!=='demo')return; state='playing'; hideOverlay(); ensureAudio(); toast('Your turn! Move mouse or ← →');
Loop:
```
let last=performance.now();
function loop(t){ requestAnimationFrame(loop); let dt=(t-last)/1000; last=t; dt=Math.min(dt,0.033); if(!paused){ update(dt); } render(); }
```
render always (even end states, with overlay above).
update(dt):
```
elapsed += dt (state demo/playing)
shake decay, flash decay
// paddle control
const targetW = basePW * (now<wideUntil?1.7:1); paddle.w += (targetW-paddle.w)*Math.min(1,10*dt);
let move=0;
if(state==='playing'){ if(keys.left)move=-1; if(keys.right)move=1; }
else if(state==='demo'){ move = aiControl(dt); }
if(move!==0){ paddle.x += move*keySpeed*dt; paddle.vx = move*keySpeed; } else { if(state==='playing' && mouseX!=null){ paddle.x += (mouseX-paddle.x)*Math.min(1,20*dt); paddle.vx=(mouseX-paddle.x)*20*dt approx } else paddle.vx*=0.8; }
clamp paddle.
// attached ball follows
for balls attached: x=paddle.x, y=paddle.y - r - 1
serveTimer -= dt; if(serveTimer<=0 && balls.some(attached)) serve();
// balls
for each ball if !attached collideWorld
remove dead
if balls.length===0 && state playing/demo → loseLife() (guard once)
// capsules
// particles, pops
// HUD refresh (score, buff)
```
AI control:
```
function aiControl(dt){
// find ball heading down closest to paddle
let target=W/2;
let bestBall=null, bestY=-1;
for(const b of balls){ if(b.attached) continue; if(b.vy>0 && b.y>bestY){bestY=b.y;bestBall=b;} }
if(bestBall){ const t=(paddle.y - bestBall.y)/bestBall.vy; target = clamp(bestBall.x + bestBall.vx*t, pw/2, W-pw/2); target += Math.sin(perf*0.7)*10; }
else target = W/2 + Math.sin(perf*0.5)*W*0.2;
const diff = target - paddle.x; const sp = keySpeed*0.9;
return Math.abs(diff) < 6 ? 0 : Math.sign(diff); // moves via keySpeed in update
}
```
Hmm update applies move*keySpeed*dt — AI returns -1/0/1. Add reaction limit: aiSpeed maybe lower: use move * keySpeed*0.95. It's fine; also AI imperfect due to discrete direction & speed — will miss occasionally → demo can end → end screen; acceptable (shows game over screen too). Actually nice: shows both screens possible. But within first 3s no.
Serve in demo: serveTimer set.
Mouse: pointermove on window: mouseX = e.clientX (canvas covers full window so client coords == logical). pointerdown: handle click logic + mouseX set.
Also prevent paddle following mouse when state==='demo'? In demo AI controls; mouse shouldn't fight. Handled: only playing uses mouseX.
But when user takes control, paddle should snap toward mouse smoothly — lerp handles.
Keys: keydown:
```
if(['ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
ensureAudio();
if(state==='demo'){ takeControl(); }
if(e.key==='ArrowLeft')keys.left=true; ...
if(e.key===' '){ if(state==='over'||state==='win') restart(true); else serveNow(); }
if(e.key==='r'||e.key==='R') restart(true);
if(e.key==='m'||e.key==='M') toggleMute();
if(e.key==='p'||e.key==='P'){ if(state==='playing'||state==='demo'){paused=!paused; toast(paused?'Paused':'') } }
keyup: clear flags.
```
serveNow(): if any attached ball → serve() (and serveTimer reset small). Also if state end? space restarts handled.
Note: after takeControl via keydown of e.g. ArrowLeft, keys.left set true — good.
pointerdown handler:
```
window.addEventListener('pointerdown', e=>{ ensureAudio(); mouseX=e.clientX; if(state==='demo'){ takeControl(); } else if(state==='over'||state==='win'){ restart(true); } else if(state==='playing'){ serveNow(); } });
```
But clicking mute button also triggers window pointerdown → would serve ball; harmless. But mute button inside HUD with pointer-events auto; the window listener fires anyway (bubbles) → serveNow when playing — minor; acceptable, or check e.target closest('#muteBtn') skip. I'll guard: if(e.target.closest('#muteBtn')) return;
Overlay clicks: overlay covers screen; pointerdown on overlay bubbles to window → demo takeControl or end restart — exactly desired. Good, no separate overlay handler needed. But button inside overlay also bubbles — same action.
However: when state==='playing' and user clicks canvas → serveNow; fine. When overlay hidden (pointer-events none) clicks go to canvas → window handler. Good.
Rendering:
render():
```
ctx.clearRect? draw bg gradient (cache gradient on resize).
stars: draw with twinkle (alpha = base + sin(t*speed+phase)).
vignette: radial gradient overlay cached.
apply shake translate.
draw bricks (alive): with flash.
draw capsules.
draw paddle (with glow, wide tint, rounded).
draw balls with trail.
draw particles (lighter composite).
draw popups.
reset transform (setTransform(dpr...) each frame start).
flash overlay: fillRect white alpha flash.
if paused && (playing) draw "PAUSED" center.
if attached ball && playing: draw hint text near ball? "click / space to serve" small — draw on canvas bottom? DOM toast enough; but after toast fades, hint gone. Draw small text under paddle when attached: ctx text 'CLICK OR SPACE TO LAUNCH' alpha pulsing. Good.
```
Background:
```
const bg = ctx.createLinearGradient(0,0,0,H) cached: '#070a18' → '#04050c'
stars: 90 stars {x,y,r,phase,spd} in [0,W]x[0,H], drift down slowly? static with twinkle; maybe slow parallax shift by elapsed*2 px wrap. Keep static twinkle.
Also draw a faint horizontal glow line at top? The playfield top edge: subtle.
```
Brick drawing:
```
function drawBrick(br){
const {x,y,w,h}=br; const r=5;
roundRect path.
if kind tough: base gradient gray: create per-frame gradient? Costly-ish but fine (few dozen). Cache gradients per brick on layout? Colors fixed per kind; gradient depends on y — cache on brick at layout: br.grad = ctx.createLinearGradient(0,y,0,y+h)... but ctx transform changes with DPR — gradients in user space fine since we setTransform(dpr) and draw in CSS px. Create in layoutBricks after setTransform? Gradients are in user-space coordinates at creation? Actually gradient coordinates are in the current user space at creation time... They're stored in user space; when transform changes, the gradient transforms with it? Per spec, gradient coordinates are in the user space in effect when the gradient is used? No — "The coordinates are in the user coordinate system at the time the gradient is used"? Let me recall: createLinearGradient coordinates are in the current user space at creation time, and later transforms do NOT affect already-created gradients? Actually gradients are affected by the transform at fill time? Hmm. Per spec, the gradient is transformed by the CTM at the time of filling? I believe createLinearGradient defines coordinates in user space, and the CTM at paint time applies... Testing knowledge: In canvas, gradients and patterns are in user space coordinates, and transformations applied after creating the gradient DO affect rendering (the gradient is in user space, transformed by CTM when drawing). Yes — the CTM at draw time applies to gradient. Since we setTransform(dpr,...) once per frame and draw in CSS px consistently, creating gradients with CSS-px coords each frame is consistent. To be safe, create gradients per frame inside draw (cheap enough, ~80 bricks) or cache keyed by y — per frame creation of ~80 gradients is fine perf-wise honestly (modern browsers handle). I'll create per draw but keep it light: use solid fills + overlay highlight rect instead of gradients for bricks to save perf: base fill color, then top 30% lighter overlay (white alpha .18), bottom darker (black .22), border stroke darker. Looks good and fast. Tough: base '#4b5563', overlay metallic: linear? use two-tone: fill '#556', top highlight white .25, rivets. Cracked: fill '#3f4753' + cracks.
border: strokeStyle darker shade — compute via helper shade(color,-30)? Precompute per brick: br.colTop etc at build. Simpler: fill br.color; then fillRect top strip rgba(255,255,255,.22) height h*0.35 rounded top — need rounded clip; use roundRect clip. Fine: ctx.save(); roundRect path; clip(); fill base; fill highlight strip; fill bottom shade; restore; then stroke path with rgba(0,0,0,.35).
powerup icon: badge + glyph.
flash: if br.flash>0: fill white alpha flash*0.5 over path.
}
```
roundRect helper (ctx.roundRect exists in modern Chrome — yes, Chrome 99+. Use native ctx.roundRect with fallback? Latest Chrome fine; but add tiny fallback function to be safe. I'll write helper rr(ctx,x,y,w,h,r) using path arcs manually — safe everywhere.)
Paddle draw:
```
const pw=effW, ph=paddle.h, x=paddle.x-pw/2, y=paddle.y;
glow: shadowColor cyan, shadowBlur 18 (when wide: gold).
gradient vertical: wide? '#fbbf24'→'#f59e0b' : '#67e8f9'→'#3b82f6';
rr rounded r=ph/2; fill; inner top highlight strip; stroke rgba(255,255,255,.35).
```
shadowBlur each frame OK.
Ball draw: trail: for i, alpha i/len*0.25 circle radius r*(0.4+0.6*i/len) fill white; main ball radial gradient white→#cbd5e1, shadowBlur 12 white.
Particles: 'lighter' composite; each: alpha=life/max; fillRect or arc size; color.
Pops: text alpha, y rises: font 700 14px; fillStyle white with color.
Capsule draw: bob = sin(t*6+x)*2; body rr(x-17,y-9+bob*0.3,34,18,9); gradient by type; stroke white .5; shadowBlur 10 color; icon glyph centered scaled.
Icons glyph draw function drawGlyph(type, cx, cy, s, color):
- wide: rr(cx-s*0.8, cy-s*0.22, s*1.6, s*0.44, s*0.2) fill color.
- multi: three arcs r=s*0.26 at (cx, cy-s*0.28),(cx-s*0.32,cy+s*0.3),(cx+s*0.32,cy+s*0.3) fill color.
On brick: s = min(br.h*0.34, br.w*0.2, 12)? Badge radius br.h*0.42. Icon s ~ 7.
Colors per row: palette array of 8: ['#f43f5e','#fb923c','#facc15','#4ade80','#2dd4bf','#60a5fa','#a78bfa','#f472b6'].
Tough color scheme: base '#64748b' with bluish tint; cracked: '#475569'.
HUD lives: hearts: for i<3: span class on/off.
Buff bar update: if now<wideUntil: show, width%, label 'WIDE PADDLE'; else hide.
Score HUD update on change with pop class.
Sounds:
```
function ensureAudio(){ if(!audioCtx){ try{ audioCtx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume(); }
function sfx(name){ if(muted||!audioCtx) return; const t=audioCtx.currentTime; ... oscillator per name with freq/decay:
paddle: 190 Hz square .06 vol .18 + quick up? use freq ramp 190→260.
wall: 130 sine .05
brick: 340+combo? simple: 300+Math.random()*60 triangle .07
tough/crack: 180 square .08
break: 520→? sawtooth .12 with freq drop 520→180? plus noise? keep osc.
power: two beeps 660,880.
lose: 220→110 saw .3
win: arpeggio 523,659,784,1046.
}
```
Implement generic: tone(f0,f1,dur,type,vol,delay).
Keep code compact but complete.
Toast function: set text, class show, timeout clear (store handle).
Now the demo overlay content:
```
<div class="card">
<div class="pill"><span class="dot"></span> DEMO MODE — AI PLAYING</div>
<h1 class="title">BREAKOUT</h1>
<p class="sub">Watch the AI, then jump in:</p>
<div class="hint"><b>Click or press any key</b> to take control</div>
<div class="controls">
<span><kbd>🖱</kbd> mouse or <kbd>←</kbd><kbd>→</kbd> move</span>
<span><kbd>Click</kbd>/<kbd>Space</kbd> launch ball</span>
<span><kbd>P</kbd> pause · <kbd>M</kbd> sound</span>
</div>
</div>
```
End overlay:
```
<div class="card">
<h1 class="title lose|win">GAME OVER|YOU WIN!</h1>
<p class="sub">score line</p>
<div class="stats"> SCORE x | BEST y | TIME t </div>
<button class="btn">▶ PLAY AGAIN</button>
<p class="hint">or press <kbd>R</kbd> / click anywhere</p>
</div>
```
Note: emoji 🖱 in kbd — fine.
One more requirement: "The ball must never pass through bricks or the paddle." — substepping covers. Also ensure dt clamp prevents huge jumps (0.033 → max move at maxSpeed ~ 1000*0.033=33px; substeps handle: steps=ceil(33/ (r*0.55≈3.85)) = 9 → 3.7px per step. Good.)
Also when tab inactive long, dt clamp handles.
Also "3 lives, score displayed" — HUD shows.
Edge: multiple balls and loseLife when all dead — handled.
Also when multiball active and one ball lost, others continue; only when all gone lose life. Good.
Wide paddle timer only one instance; re-catch refreshes.
Now, code size — sizable but fine. Let me write it carefully, ensuring no console errors.
Potential pitfalls:
- ctx.roundRect availability: I'll implement own rr() to be safe.
- backdrop-filter fine.
- gradient text: use background-clip:text with -webkit prefix; also set color:transparent. In Chrome fine.
- devicePixelRatio changes on zoom — listen resize only; fine.
- pointer events: use 'pointermove' on window; also 'mousemove' fallback? pointer events widely supported. Add both? pointermove covers mouse. OK.
- keydown repeat for arrows fine (flags). Space repeat → serveNow repeatedly; guard: if attached only. After serve, not attached → no-op. OK. preventDefault space to avoid page scroll (page not scrollable anyway) and button activation — button focus could trigger on space! If restart button focused and user presses space → click event → restart AND our handler. To avoid double, blur buttons after click: in restart via button, call btn.blur(). Or overlay click handler: since window pointerdown handles restart, a focused button + Enter/Space would fire click → we don't have click handlers on button (delegation via pointerdown only). Keyboard space on focused button fires 'click' but no listener → nothing. But our keydown space → restart(true) too. OK no conflict. However pointerdown on button bubbles to window → restart once. Good. Also prevent default on space keydown prevents button activation via keyboard anyway (space activates on keyup for buttons; preventDefault on keydown prevents scroll; click on keyup still fires? For buttons, Space keyup triggers click if keydown not prevented? Actually preventing default on keydown prevents the click activation. We preventDefault space → safe.)
- When state==='over'/'win', overlay visible; render continues drawing scene behind (frozen balls? balls may still be in array; update skipped since state not demo/playing → balls frozen mid-air. Better: keep updating particles & pops in end states, and maybe let balls drift? Freeze is fine; confetti on win animates. I'll update particles/pops always; ball physics only in demo/playing.
- On win, clear balls? Keep frozen behind overlay; overlay blurred shows scene. Nice: on win, remove balls (they vanish) + confetti. I'll set balls=[] on win for clean look.
- loseLife during demo → attachBall + serveTimer; AI serves automatically. Good.
- Guard: loseLife called when balls empty; ensure not multiple calls same frame: after loseLife, balls has attached ball so fine.
- restart during demo via 'r': restart(true) → playing mode. Or restart(false)? 'R' pressed by user → they want to play → restart(true). Overlay end click → restart(true). Initial → demo. So restart always to playing except initial. But then demo only at load. Hmm — the requirement: game starts in demo mode; after game over, restart → playing is fine.
Wait, but there's subtlety: the auto-screenshot at 3s happens at load → demo visible.
- takeControl when state==='demo' but game over screen showing? state would be 'over' not demo. fine.
Let me also add a subtle "aim" preview when ball attached in playing: draw dotted line from ball upward at serve angle? Serve angle random; skip.
Also paddle.h = clamp(H*0.02, 12, 18) = ~14-16. ballR = clamp(Math.min(W,H)*0.011, 6, 9).
keySpeed = clamp(W*1.15, 650, 1100).
basePW = clamp(W*0.13, 84, 170).
baseSpeed = clamp(H*0.6, 330, 520). maxSpeed = baseSpeed*1.85.
Brick metrics in layoutBricks():
```
L.margin = clamp(W*0.055, 20, 64);
L.cols = clamp(Math.round((W - 2*L.margin)/92), 8, 12);
L.rows = 8;
L.gap = clamp(W*0.007, 4, 8);
L.top = Math.max(84, H*0.14);
L.bh = clamp(H*0.03, 18, 26);
const areaW = W - 2*L.margin;
L.bw = (areaW - L.gap*(L.cols-1))/L.cols;
for each br: br.x = L.margin + br.c*(L.bw+L.gap); br.y = L.top + br.r*(L.bh+L.gap); br.w=L.bw; br.h=L.bh; br.cx=br.x+br.w/2; br.cy=br.y+br.h/2;
```
buildLevel creates bricks with r,c,kind,pow,hp,alive,cracked,flash,cracks.
cols must be ≥ 10 for powerup defs mapping; clamp min 8; mapping c=min(c,cols-1) handles.
Power defs (r,c,t): [[1,2,'wide'],[1,7,'multi'],[2,5,'wide'],[3,9,'multi'],[4,1,'multi'],[5,6,'wide'],[6,3,'wide'],[3,4,'multi']] — 8 powerups. Ensure not on same brick: dedupe by key.
Cracks generation: br.cracks = [] each with array of points: start at random point inside, 4-6 segments random walk. Generate on first crack.
Draw cracks: stroke dark '#0f172a' alpha .8 lineWidth 1.5, plus white alpha .15 offset? Just dark lines jagged.
Also tough brick draw rivets: 4 small circles at corners inset 4px, fill rgba(255,255,255,.25) / dark.
Power badge on brick: circle at center r=min(h*0.44, w*0.3); fill 'rgba(255,255,255,.14)'; stroke 'rgba(255,255,255,.55)' lw 1.5; glyph white.
For tough bricks maybe badge slightly bigger contrast — fine.
Capsule spawn: caps.push({x:cx, y:cy, vy: 150+rand*40, type, w:36, h:20, rot:0}).
Update caps: y+=vy*dt; x+=sin(y*0.05)*20*dt? small wobble: x = baseX + sin(y*0.08)*8. Store baseX. Remove if y>H+30.
Catch: as described; on catch apply + sfx + toast + particles burst at paddle.
applyPower(type): wide → wideUntil = now+10; multi → duplicate.
now = performance.now()/1000.
Multiball duplication:
```
function multiBall(){
const src = balls.filter(b=>!b.attached);
if(!src.length) return;
const base = src[0];
const clones=[];
for(const b of src){ for(let k=0;k<2;k++){ if(balls.length+clones.length>=7) break; const ang=(k===0?1:-1)*(0.4+Math.random()*0.25); clones.push(cloneBall(b,ang)); } }
balls.push(...clones);
}
function cloneBall(b, ang){ const sp=Math.hypot(b.vx,b.vy)||b.speed; const a=Math.atan2(b.vy,b.vx)+ang; return {x:b.x,y:b.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,r:b.r,speed:sp,trail:[]}; }
```
From 1 ball → 2 clones → 3 total.
HUD buff: also maybe show multiball active? skip.
Popups: pops.push({x,y,txt,life:1,color}); update life-=dt*1.2; draw alpha life, y -= 30*dt.
Particles: parts.push({x,y,vx,vy,life,max,color,size,grav}); update: vy+=grav*dt; x+=vx*dt; life-=dt; draw.
brickBurst(x,y,color): 16 parts: angle rand, speed 60-260, life .5-.9, size 2-4, grav 300.
paddleHit sparks: 8 parts cyan small.
wall puff: 4 parts gray.
Confetti: 140 parts across top: x rand W, y=-10, vy 60-200, vx rand ±60, grav 120, colors palette, life 2-3.5, size 3-5 rect? draw as small rects rotated? Keep circles/rects simple: fillRect with rotation? Just circles fine, or small squares: ctx.fillRect(x-s/2,y-s/2,s,s). Use squares for confetti. I'll add shape flag.
Screen shake: in render: if(shake>0){ ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake); shake*=Math.pow(0.001,dt)? simpler shake=max(0,shake-dt*30) }.
Flash: fillRect rgba(255,80,80,flash)? white flash: rgba(255,255,255,flash). On life loss flash=0.25 red-ish: use rgba(248,113,113,flash).
OK, HUD score pop CSS:
```
#scoreVal.pop{animation:pop .25s ease} @keyframes pop{50%{transform:scale(1.35); color:#67e8f9}}
```
scoreVal display:inline-block for transform.
Lives: build spans once, toggle class.
Toast: #toast with transition opacity .3s, .show class.
Buff: #buff {position:absolute; top:70px; left:50%; transform:translateX(-50%); display:none; text-align:center; z-index:5; pointer-events:none}
inner label + bar.
Also small "level" indicator? Show "LEVEL 1" center HUD? Add center hud-box with "LEVEL 1" — but buff bar at top 70 might overlap center hud. Put buff bar below center? Place buff at top: 74px centered — center HUD box is at top with padding 14 → height ~44 → ends ~58. Buff at 70 fine.
Actually let me restructure HUD: left box (SCORE + BEST), center box (LEVEL 1), right box (lives + mute). Buff bar under center.
Pause overlay: draw on canvas "PAUSED" text.
Also draw subtle top boundary? Not needed.
Fonts: use font-weight 800 system; canvas texts: '700 13px system-ui'.
Let me now also handle the case W small: HUD boxes shrink: font sizes clamp. Use CSS clamp for hud value font-size: clamp(14px, 2.4vw, 20px). Padding smaller on mobile via media query. Fine.
Now write final HTML. Also include <meta viewport>. Title "Breakout".
Let me write JS fully, mentally checking each function.
```js
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const scoreEl=..., bestEl, livesEl, buffEl, buffFill, buffLabel, toastEl, overlayEl, muteBtn;
let W=0,H=0,DPR=1;
let state='demo', paused=false;
let score=0,best=0,lives=3,elapsed=0;
let bricks=[],balls=[],parts=[],pops=[],caps=[];
let L={};
const paddle={x:0,y:0,w:120,h:14,vx:0};
let mouseX=null;
const keys={l:false,r:false};
let wideUntil=-1,shake=0,flash=0,serveTimer=0;
let muted=false,AC=null;
let stars=[];
const ROW_COLORS=[...];
const now=()=>performance.now()/1000;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
```
resize():
```
function resize(){
DPR=Math.min(window.devicePixelRatio||1,2);
W=window.innerWidth; H=window.innerHeight;
canvas.width=Math.round(W*DPR); canvas.height=Math.round(H*DPR);
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
paddle.y=H-clamp(H*0.075,46,70);
paddle.h=clamp(H*0.02,12,17);
basePW=clamp(W*0.13,84,170); // module-level lets
ballR=clamp(Math.min(W,H)*0.011,6,9);
keySpeed=clamp(W*1.15,650,1100);
baseSpeed=clamp(H*0.6,330,520); maxSpeed=baseSpeed*1.85;
paddle.w=basePW; if(paddle.x===0)paddle.x=W/2; else clamp...
layoutBricks();
makeStars();
bgGrad=ctx.createLinearGradient(0,0,0,H); ... (user space; created after setTransform with CSS coords — fine since we always draw with same transform)
vig=ctx.createRadialGradient(W/2,H*0.45,Math.min(W,H)*0.35, W/2,H*0.5, Math.max(W,H)*0.75);
}
```
Note gradients created in resize with CSS-px coords; render sets same transform each frame → consistent.
makeStars: stars=Array.from({length: Math.round(W*H/16000)}, ...{x:rand(0,W),y:rand(0,H),r:rand(0.5,1.6),p:rand(0,6.28),s:rand(0.5,2)})
layoutBricks(): as above; if bricks empty (before build) just compute L. I'll compute L always, then position bricks.
buildLevel():
```
bricks=[];
const rows=8; L.rows=rows; L.cols=clamp(Math.round((W-2*L.margin)/92),8,12);
for r,c: kind=...; hp=kind==='tough'?2:1; color=ROW_COLORS[r%8];
bricks.push({r,c,kind,hp,alive:true,cracked:false,flash:0,cracks:null,pow:null,x:0,y:0,w:0,h:0,cx:0,cy:0,color});
const powDefs=[[1,2,'wide'],[1,7,'multi'],[2,5,'wide'],[3,9,'multi'],[4,1,'multi'],[5,6,'wide'],[6,3,'wide'],[3,4,'multi']];
const used=new Set();
for(const [r,c,t] of powDefs){ const cc=Math.min(c,L.cols-1); const key=r+':'+cc; if(used.has(key))continue; used.add(key); const br=bricks.find(b=>b.r===r&&b.c===cc); if(br) br.pow=t; }
layoutBricks();
```
Note L.cols depends on W; buildLevel calls layoutBricks which uses L.cols — ensure L.margin set in layoutBricks before cols. Order: layoutBricks computes margin, gap, top, bh first, then cols? cols needed in buildLevel before layout... Let me compute cols inside a function computeCols(). Restructure:
```
function layoutMetrics(){ L.margin=clamp(W*0.055,20,64); L.gap=clamp(W*0.007,4,8); L.top=Math.max(84,H*0.14); L.bh=clamp(H*0.03,18,26); L.cols=clamp(Math.round((W-2*L.margin)/92),8,12); L.rows=8; }
function layoutBricks(){ layoutMetrics(); if(!bricks.length)return; const areaW=W-2*L.margin; L.bw=(areaW-L.gap*(L.cols-1))/L.cols; for(const br of bricks){ br.w=L.bw; br.h=L.bh; br.x=L.margin+br.c*(L.bw+L.gap); br.y=L.top+br.r*(L.bh+L.gap); br.cx=br.x+br.w/2; br.cy=br.y+br.h/2; } }
```
buildLevel: layoutMetrics() first to get cols, then create bricks, then layoutBricks() (which recomputes metrics — same values). OK.
resize calls layoutMetrics+layoutBricks (position existing bricks). buildLevel calls layoutMetrics, creates, layoutBricks.
Brick kind function:
```
function kindFor(r,c){ if(r<2)return 'tough'; if(r===2)return c%3===1?'tough':'normal'; if(r===3)return c%4===2?'tough':'normal'; return 'normal'; }
```
Ball creation:
```
function makeBall(x,y,ang,sp){ return {x,y,vx:Math.cos(ang)*sp,vy:Math.sin(ang)*sp,r:ballR,speed:sp,attached:false,trail:[]}; }
function attachBall(){ balls=[{x:paddle.x,y:paddle.y-ballR-1,vx:0,vy:0,r:ballR,speed:baseSpeed,attached:true,trail:[]}]; }
function serve(){ const b=balls.find(b=>b.attached); if(!b)return; const a=rand(-0.42,0.42); const sp=baseSpeed; b.vx=Math.sin(a)*sp; b.vy=-Math.cos(a)*sp; b.speed=sp; b.attached=false; sfx('serve'); }
```
Use angle from vertical: vx=sin(a)*sp, vy=-cos(a)*sp. (In makeBall I used cos/sin — unify: serve sets directly.)
collideWorld(b,dt) as planned. Let me finalize with bottom check: if(b.y-b.r>H){b.dead=true;}
After loop: balls=balls.filter(b=>!b.dead); if(!balls.length && (state==='demo'||state==='playing')) loseLife();
But careful: loseLife attaches new ball and sets serveTimer; ensure not called repeatedly same frame — after attach, balls.length=1 → fine.
Paddle collision inside substep loop — but note after reflecting off paddle we should `continue` to next substep (walls checks already done). Also after brick hit we break inner brick loop but continue substeps.
Order in substep: move → walls → paddle → bricks → bottom.
hitBrick(b,br):
```
function hitBrick(b,br){
const cx=clamp(b.x,br.x,br.x+br.w), 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; let nx,ny;
if(d2>1e-6){ 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, 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 dot=b.vx*nx+b.vy*ny; b.vx-=2*dot*nx; b.vy-=2*dot*ny;
// enforce min vertical
const sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
if(Math.abs(b.vy)<0.2*sp){ b.vy=(b.vy<0?-1:1)*0.22*sp; const m=Math.hypot(b.vx,b.vy); b.vx*=sp/m; b.vy*=sp/m; }
br.flash=1;
br.hp--;
if(br.hp<=0){ destroyBrick(br); } else { br.cracked=true; genCracks(br); score+=30; addPop(br.cx,br.cy-6,'+30','#cbd5e1'); sfx('crack'); burst(br.cx,br.cy,'#94a3b8',6); }
}
```
Wait: if ball hits side of brick, normal horizontal, reflection flips vx — min vertical enforcement may add vy. OK.
destroyBrick(br):
```
br.alive=false;
const pts = br.kind==='tough'?100:50;
score+=pts; addPop(br.cx,br.cy,'+'+pts, br.color); sfx('break');
burst(br.cx,br.cy,br.color,16); burst(br.cx,br.cy,'#ffffff',5);
bumpBallSpeed();
if(br.pow){ caps.push({x:br.cx,baseX:br.cx,y:br.cy,vy:rand(140,180),type:br.pow,w:38,h:20}); sfx('drop'); }
checkWin();
```
bumpBallSpeed: for each ball: const old=Math.hypot(b.vx,b.vy)||b.speed; const ns=Math.min(b.speed+4,maxSpeed); b.speed=ns; if(old>0){b.vx*=ns/old;b.vy*=ns/old;} — for attached ball skip.
checkWin: if(!bricks.some(b=>b.alive)) winGame();
winGame(): state='win'; best=Math.max(best,score); balls=[]; confetti(); sfx('win'); showEnd(true);
loseLife(): lives--; renderLives(); flash=0.4; shake=12; sfx('lose'); caps.length=0; if(lives<=0){ gameOver(); } else { attachBall(); serveTimer=1.1; toast('Ball lost — '+lives+' left'); }
gameOver(): state='over'; best=Math.max(best,score); sfx('over'); showEnd(false);
serveTimer handling in update: serveTimer-=dt; if(serveTimer<=0){ const b=balls.find(x=>x.attached); if(b) serve(); else serveTimer=0.5; } — if no attached, keep waiting; set serveTimer=0.2 to recheck. Simpler: if any attached and serveTimer<=0 → serve.
serveNow(): if(state playing/demo){ const b=balls.find(attached); if(b){serveTimer=0; serve();} }
update(dt):
```
const t=now();
if(state==='demo'||state==='playing') elapsed+=dt;
// decay
shake=Math.max(0,shake-dt*26); flash=Math.max(0,flash-dt*1.8);
// paddle width
const wide = t<wideUntil;
const tw = basePW*(wide?1.7:1);
paddle.w += (tw-paddle.w)*Math.min(1,12*dt);
// control
let dir=0;
if(state==='playing'){ if(keys.l)dir-=1; if(keys.r)dir+=1; }
else if(state==='demo'){ dir=aiDir(); }
if(dir!==0){ paddle.vx=dir*keySpeed; paddle.x+=dir*keySpeed*dt; }
else { if(state==='playing'&&mouseX!=null){ const d=mouseX-paddle.x; paddle.vx=d*18; paddle.x+=d*Math.min(1,18*dt); } else { paddle.vx*=Math.pow(0.001,dt); } }
paddle.x=clamp(paddle.x,paddle.w/2+6,W-paddle.w/2-6);
// serve timer
serveTimer-=dt;
if(serveTimer<=0){ const b=balls.find(x=>x.attached); if(b) serve(); else serveTimer=0.25; }
// balls
if(state==='demo'||state==='playing'){
for(const b of balls){ if(!b.attached){ collideWorld(b,dt); b.trail.push({x:b.x,y:b.y}); if(b.trail.length>10)b.trail.shift(); } else { b.x=paddle.x; b.y=paddle.y-b.r-1; } }
balls=balls.filter(b=>!b.dead);
if(!balls.length) loseLife();
// capsules
for(const c of caps){ c.y+=c.vy*dt; c.x=c.baseX+Math.sin(c.y*0.06)*10; if(c.y-c.h/2>paddle.y && c.y+c.h/2<paddle.y+paddle.h+18 && Math.abs(c.x-paddle.x)<paddle.w/2+c.w/2){ c.taken=true; applyPower(c.type); } }
caps=caps.filter(c=>!c.taken && c.y<H+40);
}
// particles & pops always
updateParts(dt); updatePops(dt);
// HUD
if(score!==lastScore){scoreEl.textContent=score; pop anim; lastScore=score;}
buff update: if(wide){ buffEl.style.display='block'; const rem=wideUntil-t; buffFill.style.width=clamp(rem/10*100,0,100)+'%'; buffLabel.textContent='WIDE PADDLE '+rem.toFixed(1)+'s'; } else buffEl.style.display='none';
```
Hmm buff label update each frame — fine.
aiDir():
```
function aiDir(){
let target=W/2; let bb=null,by=-1e9;
for(const b of balls){ if(b.attached)continue; if(b.vy>0&&b.y>by){by=b.y;bb=b;} }
if(bb){ const tt=(paddle.y-bb.y)/bb.vy; target=clamp(bb.x+bb.vx*tt, paddle.w/2+8, W-paddle.w/2-8); }
else { target=W/2+Math.sin(now()*0.6)*W*0.25; }
target+=Math.sin(now()*2.3)*6;
const d=target-paddle.x;
if(Math.abs(d)<8)return 0;
return d>0?1:-1;
}
```
AI moves at keySpeed — maybe too perfect; use keySpeed*0.92: return dir but in update use aiSpeed=keySpeed*0.92 when demo. I'll incorporate: in update, const spd = state==='demo'? keySpeed*0.92 : keySpeed.
applyPower(type):
```
if(type==='wide'){ wideUntil=now()+10; toast('WIDE PADDLE — 10s'); sfx('power'); burst(paddle.x,paddle.y-10,'#fbbf24',14); }
else { multiBall(); toast('MULTI-BALL ×3!'); sfx('power'); burst(paddle.x,paddle.y-10,'#f472b6',18); }
```
multiBall as above with cap 7? spec 3 balls; from 1 → 3. cap total 7 to be safe. Actually clones: for each src ball 2 clones → 1→3, 2→6, 3→9 cap 7. Hmm 3→9 is a lot; cap 6: balls.length<6. From 3 → +2 → 5? loop breaks at cap. Fine.
cloneBall: ensure vy can be negative? Rotating by ±~0.5 rad keeps upward if original upward. If ball moving down (about to be lost), clones also down — they'll die too. Acceptable. Maybe force clones upward if original vy>0: if clone.vy>0 → vy=-vy? That'd be nicer: clones always bounce up: if(c.vy>0)c.vy=-Math.abs(c.vy)*... renormalize. I'll do: after rotate, if vy>0, reflect vy (vy=-vy) keeping speed. Simple.
render():
```
function render(){
const t=now();
ctx.setTransform(DPR,0,0,DPR,0,0);
// bg
ctx.fillStyle=bgGrad; ctx.fillRect(0,0,W,H);
// stars
for(const s of stars){ const a=0.25+0.55*Math.abs(Math.sin(t*s.s+s.p)); ctx.fillStyle=`rgba(148,163,184,${a*0.5})`; ctx.fillRect(s.x,s.y,s.r,s.r); }
// shake
ctx.save();
if(shake>0) ctx.translate(rand(-shake,shake)*0.5, rand(-shake,shake)*0.5);
// bricks
for(const br of bricks) if(br.alive) drawBrick(br,t);
// capsules
for(const c of caps) drawCapsule(c,t);
// paddle
drawPaddle(t);
// balls
for(const b of balls) drawBall(b,t);
// particles
ctx.globalCompositeOperation='lighter';
for(const p of parts) drawPart(p);
ctx.globalCompositeOperation='source-over';
// pops
for(const p of pops) drawPop(p);
ctx.restore();
// vignette
ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
// flash
if(flash>0){ ctx.fillStyle=`rgba(248,113,113,${flash*0.5})`; ctx.fillRect(0,0,W,H); }
// paused
if(paused&&(state==='playing')){ ctx.fillStyle='rgba(2,6,16,0.55)'; ctx.fillRect(0,0,W,H); text 'PAUSED' center; }
// attached hint
if((state==='playing'||state==='demo')&&balls.some(b=>b.attached)){ pulsing text above paddle: 'CLICK / SPACE TO LAUNCH' }
}
```
Wait vignette after restore — vignette should not be shaken; fine. But flash after vignette fine.
Note: drawing text: ctx.font='800 13px system-ui, sans-serif'; textAlign center.
drawBrick(br,t):
```
const {x,y,w,h}=br; const r=Math.min(5,w/4,h/3);
rr(ctx,x,y,w,h,r);
if(br.kind==='tough'){ ctx.fillStyle=br.cracked?'#3f4a5c':'#5b6b80'; ctx.fill(); }
else { ctx.fillStyle=br.color; ctx.fill(); }
// highlight top
ctx.save(); rr(...); ctx.clip();
ctx.fillStyle='rgba(255,255,255,0.20)'; ctx.fillRect(x,y,w,h*0.42);
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(x,y+h*0.62,w,h*0.38);
// tough rivets
if(br.kind==='tough'){ ctx.fillStyle='rgba(255,255,255,0.35)'; for corners circle r=1.6 at (x+5,y+5),(x+w-5,y+5),(x+5,y+h-5),(x+w-5,y+h-5); }
if(br.pow){ badge circle at cx,cy r=Math.min(h*0.46,w*0.3); fill rgba(255,255,255,0.16); stroke rgba(255,255,255,0.6) lw1.5; drawGlyph(br.pow,cx,cy,Math.min(7,h*0.3),'#fff'); }
ctx.restore();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1; rr(...); ctx.stroke();
// cracks
if(br.cracked&&br.cracks){ ctx.strokeStyle='rgba(10,14,22,0.85)'; ctx.lineWidth=1.4; for each crack path stroke; also white thin offset? skip }
// flash
if(br.flash>0){ rr; ctx.fillStyle=`rgba(255,255,255,${br.flash*0.55})`; ctx.fill(); br.flash=Math.max(0,br.flash-0.08)? decay in update instead }
```
Decay flash in update loop for bricks: for br of bricks br.flash=max(0,flash-dt*6). Do in update.
Cracks drawn after restore (no clip) but they're within brick anyway.
drawGlyph(type,cx,cy,s,color):
```
ctx.fillStyle=color; ctx.shadowColor=color; ctx.shadowBlur=6;
if(type==='wide'){ rr(ctx,cx-s*0.85,cy-s*0.24,s*1.7,s*0.48,Math.min(s*0.24,s*0.3)); ctx.fill(); }
else { const r=s*0.26; circle(cx,cy-s*0.3,r); circle(cx-s*0.34,cy+s*0.3,r); circle(cx+s*0.34,cy+s*0.3,r); }
ctx.shadowBlur=0;
```
drawCapsule(c,t):
```
const bob=Math.sin(t*5+c.baseX)*2; const x=c.x, y=c.y+bob*0.4;
const col=c.type==='wide'?'#22d3ee':'#f472b6';
ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=14;
const g=ctx.createLinearGradient(0,y-c.h/2,0,y+c.h/2); g.addColorStop(0,'#ffffff'); g.addColorStop(0.25,col); g.addColorStop(1,shade?) — simpler: g: light→col→dark col. Use col and white mix: stops: 0 '#fff', .35 col, 1 darker. I'll just do: 0 '#e8faff', .5 col, 1 col with globalAlpha? Fine: createLinearGradient stops 0:'rgba(255,255,255,.95)', 0.45: col, 1: col; plus stroke 'rgba(255,255,255,.8)'.
rr(x-c.w/2,y-c.h/2,c.w,c.h,c.h/2); fill g; stroke.
drawGlyph(c.type,x,y,7,'#0b1020')? icon dark on bright capsule: color '#0f172a'. With shadowBlur 0. Use dark for contrast.
ctx.restore();
```
drawPaddle(t):
```
const pw=paddle.w, ph=paddle.h, x=paddle.x-pw/2, y=paddle.y;
const wide=now()<wideUntil;
ctx.save();
ctx.shadowColor=wide?'#fbbf24':'#22d3ee'; ctx.shadowBlur=wide?22:16;
const g=ctx.createLinearGradient(0,y,0,y+ph);
if(wide){g.addColorStop(0,'#fde68a');g.addColorStop(1,'#f59e0b');} else {g.addColorStop(0,'#a5f3fc');g.addColorStop(0.5,'#38bdf8');g.addColorStop(1,'#2563eb');}
rr(x,y,pw,ph,ph/2); ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='rgba(255,255,255,0.35)'; rr(x+3,y+2,pw-6,ph*0.35,ph*0.17); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.lineWidth=1; rr(x,y,pw,ph,ph/2); ctx.stroke();
ctx.restore();
```
drawBall(b,t):
```
// trail
for(let i=0;i<b.trail.length;i++){ const p=b.trail[i]; const a=(i/b.trail.length)*0.22; ctx.fillStyle=`rgba(165,243,252,${a})`; ctx.beginPath(); ctx.arc(p.x,p.y,b.r*(0.35+0.65*i/b.trail.length),0,7); ctx.fill(); }
const g=ctx.createRadialGradient(b.x-b.r*0.35,b.y-b.r*0.35,b.r*0.2,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff'); g.addColorStop(1,'#bae6fd');
ctx.save(); ctx.shadowColor='#e0f2fe'; ctx.shadowBlur=14;
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,7); ctx.fill(); ctx.restore();
```
arc end angle 7 > 2π fine.
drawPart(p): alpha=p.life/p.max; fillStyle color with globalAlpha; if square fillRect else arc.
updateParts: p.life-=dt; p.vy+=p.grav*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; filter life>0. Cap parts length: if(parts.length>400) parts.splice(0,parts.length-400).
updatePops: p.life-=dt*0.9; p.y-=34*dt; filter.
addPop(x,y,txt,color): pops.push({x,y,txt,color,life:1});
burst(x,y,color,n): for n: ang rand, sp rand(60,280), parts.push({x,y,vx:cos*sp,vy:sin*sp-40,life:rand(.4,.9),max:...,color,size:rand(1.5,3.5),grav:420,sq:Math.random()<0.5}).
confetti(): for 150: x rand(0,W), y rand(-H*0.3,-10), vx rand(-40,40), vy rand(60,220), life rand(2,3.6), color palette random, size rand(3,6), grav 90, sq true.
genCracks(br): br.cracks=[]; const n=3+((Math.random()*2)|0); for each: let px=br.cx+rand(-br.w*0.3,br.w*0.3), py=br.y+rand(3,br.h-3); pts=[[px,py]]; let ang=rand(0,6.28); for(let s=0;s<5;s++){ ang+=rand(-1.2,1.2); const len=rand(3,br.w*0.16); px+=Math.cos(ang)*len; py+=Math.sin(ang)*len; clamp inside brick; pts.push([px,py]); } cracks.push(pts).
Draw crack: moveTo first, lineTo rest, stroke.
HUD render functions:
```
function renderLives(){ livesEl.innerHTML=''; for(let i=0;i<3;i++){ const s=document.createElement('span'); s.className='heart'+(i<lives?'':' off'); s.textContent='♥'; livesEl.appendChild(s); } }
```
CSS .heart{color:#fb7185; text-shadow:0 0 8px rgba(251,113,133,.8); margin:0 2px; font-size:17px} .heart.off{color:rgba(255,255,255,.14); text-shadow:none}
Score pop:
```
function setScore(v){ if(v!==lastScore){ scoreEl.textContent=v; scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); lastScore=v; } }
```
Call in update when score changes — simpler: track displayed; or just call setScore(score) every frame (classList re-add guarded by change check inside). I'll call setScore(score) each frame.
best display: bestEl.textContent=best — update when best changes; just set each frame cheap: if text differs set. fine.
toast(msg): toastEl.textContent=msg; toastEl.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>toastEl.classList.remove('show'),1800);
Overlays:
```
function showDemo(){ overlayEl.innerHTML= `...demo card...`; overlayEl.classList.remove('hidden'); }
function showEnd(win){ const t=fmtTime(elapsed); overlayEl.innerHTML=`<div class="card"> <h1 class="title ${win?'win':'lose'}">${win?'YOU WIN!':'GAME OVER'}</h1> <p class="sub">${win?'All bricks destroyed!':'No lives left.'}</p> <div class="stats"> <div><span>SCORE</span><b>${score}</b></div> <div><span>BEST</span><b>${best}</b></div> <div><span>TIME</span><b>${t}</b></div> </div> <button class="btn" type="button">▶ PLAY AGAIN</button> <p class="hint">click anywhere or press <kbd>R</kbd></p> </div>`; overlayEl.classList.remove('hidden'); }
function hideOverlay(){ overlayEl.classList.add('hidden'); }
```
fmtTime: mm:ss.
restart(toPlaying):
```
function restart(toPlaying){
buildLevel(); score=0; setScore(0); lives=3; renderLives(); elapsed=0; parts.length=0; pops.length=0; caps.length=0; wideUntil=-1; shake=0; flash=0; paused=false;
paddle.x=W/2; paddle.w=basePW; mouseX=mouseX;
attachBall(); serveTimer= toPlaying? 1.2 : 0.7;
state= toPlaying?'playing':'demo';
if(toPlaying){ hideOverlay(); toast('Get ready!'); } else showDemo();
bestEl.textContent=best;
}
```
Initial boot: resize(); buildLevel(); renderLives(); attachBall(); serveTimer=0.9; state='demo'; showDemo(); requestAnimationFrame(loop);
Order: define functions then boot.
takeControl(): if(state!=='demo')return; state='playing'; hideOverlay(); toast('You control the paddle!'); sfx? small.
Key handler details:
```
window.addEventListener('keydown',e=>{
const k=e.key;
if(k===' '||k.startsWith('Arrow')) e.preventDefault();
ensureAudio();
if(state==='demo') takeControl();
if(k==='ArrowLeft'||k==='a'||k==='A')keys.l=true;
if(k==='ArrowRight'||k==='d'||k==='D')keys.r=true;
if(k===' '){ if(state==='over'||state==='win') restart(true); else serveNow(); }
if(k==='r'||k==='R'){ restart(true); }
if(k==='m'||k==='M'){ toggleMute(); }
if(k==='p'||k==='P'||k==='Escape'){ if(state==='playing'){ paused=!paused; toast(paused?'Paused — P to resume':''); } }
});
keyup: clear keys.l/r.
```
Note: pressing 'r' during demo → takeControl then restart(true) → playing. OK.
Wait: if state==='demo' and user presses key, takeControl sets playing; then 'r' check restarts to playing — fine.
Also WASD added as bonus.
pointer handlers:
```
window.addEventListener('pointermove',e=>{ mouseX=e.clientX; });
window.addEventListener('pointerdown',e=>{
ensureAudio(); mouseX=e.clientX;
if(e.target.closest('#muteBtn')) return;
if(state==='demo') takeControl();
else if(state==='over'||state==='win') restart(true);
else if(state==='playing') serveNow();
});
```
muteBtn click: toggleMute(); also stopPropagation not needed due to guard.
toggleMute(): muted=!muted; muteBtn.textContent=muted?'🔇':'🔊';
Audio:
```
function ensureAudio(){ if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(err){ AC=null; } } if(AC&&AC.state==='suspended'){ AC.resume().catch(()=>{}); } }
function tone(f0,f1,dur,type,vol,delay=0){ if(!AC||muted)return; const t0=AC.currentTime+delay; const o=AC.createOscillator(), g=AC.createGain(); o.type=type; o.frequency.setValueAtTime(f0,t0); if(f1&&f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t0+dur); g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(0.0001,t0+dur); o.connect(g).connect(AC.destination); o.start(t0); o.stop(t0+dur+0.02); }
function sfx(n){ if(!AC||muted)return; switch(n){
case 'paddle': tone(200,320,0.07,'square',0.15); break;
case 'wall': tone(140,120,0.05,'sine',0.10); break;
case 'brick': tone(300+Math.random()*80,240,0.07,'triangle',0.16); break;
case 'crack': tone(170,120,0.09,'square',0.14); break;
case 'break': tone(520,180,0.12,'sawtooth',0.12); tone(760,300,0.1,'triangle',0.08,0.02); break;
case 'serve': tone(280,420,0.08,'sine',0.12); break;
case 'power': tone(620,620,0.07,'square',0.12); tone(930,930,0.09,'square',0.12,0.08); break;
case 'drop': tone(440,660,0.08,'sine',0.08); break;
case 'lose': tone(300,90,0.35,'sawtooth',0.16); break;
case 'over': tone(392,392,0.15,'triangle',0.14); tone(311,311,0.15,'triangle',0.14,0.16); tone(233,233,0.3,'triangle',0.14,0.32); break;
case 'win': [523,659,784,1047].forEach((f,i)=>tone(f,f,0.14,'triangle',0.14,i*0.11)); break;
}}
```
Loop:
```
let last=performance.now();
function loop(t){ requestAnimationFrame(loop); let dt=(t-last)/1000; last=t; if(dt>0.033)dt=0.033; if(dt<=0)return; if(!paused) update(dt); render(); }
```
Note when paused we skip update but render — shake frozen, fine.
One issue: update uses `now()` for wide timer etc. fine.
Another: when state==='over'/'win', update still runs (not paused) — my update does elapsed only in demo/playing, balls physics only in demo/playing, but particles/pops update always, paddle control: state not playing/demo → dir=0 and mouse branch requires playing → paddle static. serveTimer decrements but no attached ball → keeps resetting 0.25, harmless. HUD buff uses wideUntil — fine. OK, but I gated ball physics with if(state demo/playing) — good; capsules also gated? Capsules frozen at end — fine, or let them fall; gate them too inside the same block. Yes I put caps inside the block.
Actually loseLife check `if(!balls.length)` inside that block — on win we set balls=[] and state='win' → block not entered → no loseLife. Good. But careful: winGame called during hitBrick inside collideWorld loop — state changes to 'win' mid-update; subsequent balls in loop still processed (state check was at block entry). Minor: after win, remaining balls keep moving this frame — fine.
gameOver similar: loseLife sets state='over' then attachBall not called (lives 0) — balls empty; block already entered; after loseLife returns, `if(!balls.length) loseLife()` was the trigger... wait flow: balls filtered → empty → loseLife() → lives 0 → gameOver() → state='over'. Then loop continues to caps etc. Next frame block skipped. OK but within same frame after loseLife, code continues to caps update (empty). Fine.
Edge: loseLife when lives>0 attaches ball; then `if(!balls.length)` already passed. Good.
collideWorld full:
```
function collideWorld(b,dt){
const sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
const steps=clamp(Math.ceil(sp*dt/(b.r*0.55)),1,12);
const sdt=dt/steps;
for(let s=0;s<steps;s++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
if(b.x<b.r){ b.x=b.r; b.vx=Math.abs(b.vx); sfx('wall'); puff(b.x,b.y,'#94a3b8',3); }
else if(b.x>W-b.r){ b.x=W-b.r; b.vx=-Math.abs(b.vx); sfx('wall'); puff(...); }
if(b.y<b.r){ b.y=b.r; b.vy=Math.abs(b.vy); sfx('wall'); }
// paddle
const pw=paddle.w;
if(b.vy>0 && b.y+b.r>=paddle.y && b.y-b.r<=paddle.y+paddle.h && b.x>=paddle.x-pw/2-b.r && b.x<=paddle.x+pw/2+b.r){
const rel=clamp((b.x-paddle.x)/(pw/2),-1,1);
const ang=rel*1.15;
const ns=Math.min(b.speed*1.015+2,maxSpeed); b.speed=ns;
let vx=Math.sin(ang)*ns + paddle.vx*0.15;
let vy=-Math.cos(ang)*ns;
const m=Math.hypot(vx,vy)||1; vx*=ns/m; vy*=ns/m;
b.vx=vx; b.vy=vy; b.x=clamp(b.x,paddle.x-pw/2,paddle.x+pw/2);
b.y=paddle.y-b.r-0.5;
sfx('paddle'); puff(b.x,paddle.y,'#67e8f9',7);
}
// bricks
for(const br of bricks){ if(!br.alive)continue; if(circleRect(b,br)){ hitBrick(b,br); break; } }
if(b.y-b.r>H){ b.dead=true; return; }
}
}
function circleRect(b,r){ const cx=clamp(b.x,r.x,r.x+r.w), cy=clamp(b.y,r.y,r.y+r.h); const dx=b.x-cx, dy=b.y-cy; return dx*dx+dy*dy < b.r*b.r; }
```
puff = burst small.
Note: paddle check uses b.vy>0 before position update? We update pos first then check — vy unchanged by walls (walls don't flip vy unless top). Fine.
Potential issue: ball resting on paddle repeatedly colliding — after bounce vy<0, so no re-trigger. Good.
Ball stuck between two bricks: handled by substeps.
Now the "attached" ball trail: skip.
drawBall for attached ball too (sitting on paddle) — fine.
Hint text when attached: in render after restore:
```
if((state==='playing'||state==='demo')&&balls.some(b=>b.attached)){
ctx.save(); ctx.globalAlpha=0.6+0.4*Math.sin(t*5); ctx.fillStyle='#e2e8f0'; ctx.font='700 12px system-ui,sans-serif'; ctx.textAlign='center';
ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH', W/2, paddle.y-34); ctx.restore();
}
```
In demo it auto-serves quickly; hint flashes briefly — fine, or only show in playing. Show only playing: state==='playing'.
Paused text similar.
Now HTML skeleton:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Breakout — Brick Breaker</title>
<style> ... </style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<div id="hud">
<div class="box" id="scoreBox"><span class="lbl">SCORE</span><span class="val" id="scoreVal">0</span><span class="lbl best">BEST <b id="bestVal">0</b></span></div>
<div class="box" id="levelBox"><span class="lbl">LEVEL</span><span class="val small">1</span></div>
<div class="box right"><span id="lives"></span><button id="muteBtn" title="Mute (M)">🔊</button></div>
</div>
<div id="buff"><span id="buffLabel">WIDE PADDLE</span><div class="bar"><div id="buffFill"></div></div></div>
<div id="toast"></div>
<div id="overlay"></div>
</div>
<script> ... </script>
</body>
</html>
```
Score box layout: label above value: make .box flex column. Left box: SCORE big, best small below. Let me structure:
```
<div class="box"><span class="lbl">SCORE</span><span class="val" id="scoreVal">0</span><span class="lbl" style="...">BEST <b id="bestVal">0</b></span></div>
```
Simpler: two stacked rows. CSS: .box{display:flex;flex-direction:column;gap:2px;...} .val{font-size:clamp(16px,2.6vw,22px);font-weight:800;line-height:1} .best line: font-size 10px color #64748b; b color #94a3b8.
Center box: LEVEL 1 — maybe show bricks remaining? "BRICKS 63" could be nice: show remaining count. I'll show LEVEL 1 and small "BRICKS <b id=brickCount>". Update per frame. Nice touch.
Right box: flex row align center gap 10: hearts + mute button.
CSS details:
```
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#04050c}
body{font-family:ui-sans-serif,system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#e5e7eb;-webkit-user-select:none;user-select:none;touch-action:none}
#wrap{position:fixed;inset:0;cursor:none;overflow:hidden}
canvas{position:absolute;inset:0;display:block}
#hud{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;padding:12px 16px;pointer-events:none;z-index:5;gap:8px}
.box{background:rgba(8,12,26,.55);border:1px solid rgba(148,163,184,.16);border-radius:12px;padding:8px 14px;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 6px 24px rgba(0,0,0,.35);display:flex;flex-direction:column;gap:2px;min-width:96px}
.box.center{align-items:center;text-align:center}
.box.right{flex-direction:row;align-items:center;gap:10px;min-width:0}
.lbl{font-size:9px;letter-spacing:2.5px;color:#7dd3fc;font-weight:700}
.val{font-size:clamp(16px,2.6vw,22px);font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;display:inline-block}
.val.pop{animation:pop .28s ease}
@keyframes pop{40%{transform:scale(1.35);color:#67e8f9}}
.best{font-size:9px;color:#64748b;letter-spacing:2px}
.best b{color:#94a3b8;font-size:10px}
#lives{display:flex;gap:3px}
.heart{color:#fb7185;text-shadow:0 0 10px rgba(251,113,133,.9);font-size:clamp(14px,2.4vw,19px);line-height:1;transition:all .2s}
.heart.off{color:rgba(148,163,184,.25);text-shadow:none}
#muteBtn{pointer-events:auto;background:rgba(148,163,184,.12);border:1px solid rgba(148,163,184,.25);color:#e2e8f0;border-radius:9px;padding:4px 9px;font-size:14px;cursor:pointer}
#muteBtn:hover{background:rgba(148,163,184,.25)}
#buff{position:absolute;top:86px;left:50%;transform:translateX(-50%);display:none;flex-direction:column;align-items:center;gap:4px;z-index:5;pointer-events:none}
#buffLabel{font-size:9px;letter-spacing:2px;font-weight:800;color:#fbbf24;text-shadow:0 0 8px rgba(251,191,36,.8)}
#buff .bar{width:170px;height:8px;border-radius:5px;background:rgba(148,163,184,.18);border:1px solid rgba(148,163,184,.25);overflow:hidden}
#buffFill{height:100%;width:0%;border-radius:5px;background:linear-gradient(90deg,#fde68a,#f59e0b);box-shadow:0 0 10px rgba(251,191,36,.9)}
#toast{position:absolute;left:50%;bottom:16%;transform:translateX(-50%);background:rgba(8,12,26,.7);border:1px solid rgba(125,211,252,.25);color:#e2e8f0;padding:8px 18px;border-radius:999px;font-size:13px;font-weight:700;letter-spacing:1px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:6;white-space:nowrap;backdrop-filter:blur(8px)}
#toast.show{opacity:1}
#overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;background:radial-gradient(ellipse at center,rgba(4,6,15,.35),rgba(3,4,10,.72));backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);opacity:1;transition:opacity .3s;cursor:pointer;padding:16px}
#overlay.hidden{opacity:0;pointer-events:none}
.card{text-align:center;background:linear-gradient(165deg,rgba(17,24,48,.88),rgba(7,9,20,.9));border:1px solid rgba(129,140,248,.22);border-radius:22px;padding:clamp(20px,4vw,40px) clamp(24px,5vw,52px);box-shadow:0 0 80px rgba(59,90,246,.18),0 24px 60px rgba(0,0,0,.5);max-width:min(540px,92vw)}
.pill{display:inline-flex;align-items:center;gap:8px;font-size:10px;font-weight:800;letter-spacing:2.5px;color:#67e8f9;border:1px solid rgba(103,232,249,.35);background:rgba(34,211,238,.08);padding:5px 12px;border-radius:999px;margin-bottom:14px}
.pill .dot{width:7px;height:7px;border-radius:50%;background:#22d3ee;box-shadow:0 0 10px #22d3ee;animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.35}}
h1.title{margin:0 0 8px;font-size:clamp(34px,7vw,58px);font-weight:900;letter-spacing:8px;background:linear-gradient(90deg,#22d3ee,#818cf8,#f472b6);-webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(0 4px 24px rgba(56,130,246,.35))}
h1.title.win{background:linear-gradient(90deg,#34d399,#22d3ee);-webkit-background-clip:text;background-clip:text}
h1.title.lose{background:linear-gradient(90deg,#f87171,#f472b6);-webkit-background-clip:text;background-clip:text}
.sub{color:#94a3b8;font-size:14px;margin:0 0 14px}
.hint{font-size:14px;color:#e2e8f0;margin:6px 0 16px}
.hint b{color:#67e8f9}
.controls{display:flex;flex-direction:column;gap:7px;font-size:12px;color:#94a3b8;margin-bottom:6px}
kbd{background:rgba(148,163,184,.14);border:1px solid rgba(148,163,184,.3);border-bottom-width:2px;border-radius:6px;padding:2px 7px;font-size:11px;font-family:inherit;color:#e2e8f0}
.btn{margin-top:14px;padding:13px 34px;font-size:14px;font-weight:800;letter-spacing:2px;color:#050b14;background:linear-gradient(90deg,#22d3ee,#818cf8);border:none;border-radius:999px;cursor:pointer;box-shadow:0 10px 34px rgba(34,211,238,.35);transition:transform .15s,box-shadow .15s;font-family:inherit}
.btn:hover{transform:translateY(-2px) scale(1.04);box-shadow:0 14px 40px rgba(34,211,238,.5)}
.stats{display:flex;gap:14px;justify-content:center;margin:10px 0 4px;flex-wrap:wrap}
.stats div{background:rgba(148,163,184,.08);border:1px solid rgba(148,163,184,.15);border-radius:12px;padding:10px 18px;min-width:86px}
.stats span{display:block;font-size:9px;letter-spacing:2px;color:#7dd3fc;font-weight:700}
.stats b{font-size:20px;font-weight:800}
```
Media query small screens: reduce hud padding, box min-width.
```
@media (max-width:480px){ #hud{padding:8px 10px} .box{padding:6px 10px;min-width:0} #buff{top:76px} }
```
Now demo overlay content:
```
overlayEl.innerHTML = `
<div class="card">
<div class="pill"><span class="dot"></span>DEMO MODE · AI IS PLAYING</div>
<h1 class="title">BREAKOUT</h1>
<p class="sub">A self-playing demo is running behind this card.</p>
<p class="hint"><b>Click or press any key</b> to take control of the paddle</p>
<div class="controls">
<span><kbd>🖱 mouse</kbd> or <kbd>←</kbd> <kbd>→</kbd> / <kbd>A</kbd> <kbd>D</kbd> — move paddle</span>
<span><kbd>Click</kbd> or <kbd>Space</kbd> — launch ball</span>
<span><kbd>P</kbd> pause · <kbd>M</kbd> mute · <kbd>R</kbd> restart</span>
</div>
</div>`;
```
End overlay as planned.
fmtTime(s): `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`.
Brick count HUD: brickCount element; update each frame: count alive.
Now, one more check on requirement "paddle controlled with the mouse and with the left/right arrow keys" — done. "bounce angle depends on where ball hits" — done. "One level with at least 6 rows" — 8 rows. "mixing normal bricks and tougher bricks that need 2 hits and visibly crack after the first one" — done. "Some bricks visibly carry a power-up icon; when broken they drop it, catching with paddle gives wider paddle (10s) or multi-ball (3 balls)" — done. "ball never pass through bricks or paddle" — substeps. "Particles when brick breaks" — done. "3 lives, score displayed. Game over and victory screens with restart option" — done.
Also demo mode requirement — done with overlay.
Potential console errors: careful with undefined vars. Let me write the code in full now, being meticulous.
Also note: `ctx.roundRect` — I'll write rr() helper:
```
function rr(c,x,y,w,h,r){ r=Math.min(r,w/2,h/2); c.beginPath(); c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r); c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath(); }
```
circle helper:
```
function circ(c,x,y,r){ c.beginPath(); c.arc(x,y,r,0,6.2832); c.fill(); }
```
Gradients bgGrad/vig created in resize — but resize called before buildLevel; render uses them; ensure defined before first render. Boot: resize() then ... rAF. Good.
Also on resize, paddle.y update; attached ball follows paddle anyway.
One subtlety: canvas CSS size set via style width/height 100%? I set style.width=W+'px'. Or CSS inset:0 with width/height 100% — canvas element with position absolute inset 0 gets stretched by CSS if width/height attrs differ — that would scale drawing. I set explicit style width/height = W/H px and also CSS `canvas{width:100%;height:100%}` conflict — inline style overrides. Actually simpler: CSS canvas{position:absolute;inset:0;width:100%;height:100%} and set canvas.width=W*DPR — then drawing buffer W*DPR mapped to CSS W — exactly what setTransform(DPR) expects. So no inline style needed; CSS 100% works. Use that. Then pointer clientX maps directly to logical W (since canvas spans viewport exactly). Good.
stars count based on area.
Now think about performance of shadowBlur on many bricks — bricks don't use shadow. Paddle/ball/capsules use shadow — few. OK.
Text rendering for pops:
```
function drawPop(p){ ctx.save(); ctx.globalAlpha=Math.max(0,p.life); ctx.font='800 13px system-ui,sans-serif'; ctx.textAlign='center'; ctx.fillStyle=p.color; ctx.shadowColor=p.color; ctx.shadowBlur=6; ctx.fillText(p.txt,p.x,p.y); ctx.restore(); }
```
HUD update per frame:
```
setScore(score);
const bc=bricks.reduce((n,b)=>n+(b.alive?1:0),0); if(bcEl.textContent!=bc) bcEl.textContent=bc;
bestVal: if changed set.
```
Alright — also initial `lastScore=-1`.
Let me also double-check hitBrick scoring popup positions and that tough brick first hit gives crack visuals: br.cracked=true set when hp drops to 1 (from 2). Since tough hp=2, first hit hp→1 → cracked. Normal hp=1 → destroyed immediately. In hitBrick: `br.hp--; if(br.hp<=0) destroy else {cracked...}` — good.
Edge: powerup on tough brick drops only on destroy — correct per spec ("when broken").
Multi-ball from capsule: multiBall() uses balls not attached. If ball attached (just lost life) and catch? Capsules cleared on life loss, so unlikely. Guard anyway.
Now, the AI demo: paddle follows ball; ball served upward; bricks break; score accumulates; maybe powerups caught (AI aims at ball only, capsules may fall past). Fine.
Also demo: user might never interact; game could reach win → victory screen with "PLAY AGAIN" — good showcase.
One more: prevent arrow keys from scrolling — page not scrollable; preventDefault anyway.
Also `touch-action:none` on body to prevent pull-refresh on mobile; pointermove works for touch drag. Good.
Also add `window.addEventListener('blur', ...)` to clear keys? If iframe loses focus, keys stay stuck. Add blur → keys.l=keys.r=false; paused maybe. I'll clear keys on blur.
Also context: The overlay backdrop-filter blur over animated canvas — perf ok.
Let me also make sure the game area top: bricks top at max(84, H*0.14). At H=800 → 112. HUD height ~ 70. Buff at 86px — could overlap bricks top at 112? Buff bar height ~ 20 (label+bar) ends ~106 — close but ok. Move buff top to 92? H*0.14=112, buff ends ~112. Tight; set buff top: 88px, label 9px + gap 4 + bar 8 = ~21 → ends 109 < 112 ok. On small H (e.g., 600): top=max(84,84)=84; buff at 88 overlaps bricks (84). Hmm. Make L.top = max(110, H*0.15) to leave room for HUD+buff: H=800→120; H=600→110... max(110, 90)=110. Buff at top 88 ends ~109 — just above 110. OK set L.top=Math.max(112,H*0.15). H=800→120. Fine. On 800 height, 8 rows * (bh 24 + gap 6) ≈ 240 → bricks end ~360, plenty of play space below. Good.
Actually H*0.15 at 800 = 120 ≥112 → 120. fine.
Paddle y = H - clamp(H*0.075,46,70): H=800 → 800-60=740. Good.
Ball speed base = clamp(H*0.6,330,520): H=800 → 480. maxSpeed=888. Good.
Let me now also consider: at 1280×800, cols = clamp(round((1280-2*64)/92),8,12) = round(1152/92)=round(12.5)=13→clamp 12. bw=(1152-11*8)/12=(1152-88)/12=88.7. Nice.
margin=clamp(1280*0.055=70.4→64). gap=clamp(8.96→8). bh=clamp(24,18,26)=24. top=120.
Bricks: 8 rows × 12 cols = 96 bricks. Tough: rows 0,1 (24) + row2 c%3==1 (4) + row3 c%4==2 (3) = 31 tough, 65 normal. Good mix.
Powerups 8. Good.
Now write everything. Also remember: no localStorage. best is in-memory.
Also the requirement "Games must start in a self-playing demo mode ... behind a 'Click or press a key to start' message" — my overlay says "Click or press any key to take control" — includes the phrase essentially. Good; maybe include exact-ish wording "Click or press a key to start". I'll word hint: "<b>Click or press any key to start</b> — you take control of the paddle". Good.
Let me write the final file now, carefully, complete.
Also ensure `overlay` initial content set in boot via showDemo().
Potential issue: keydown ' ' preventDefault also when overlay button focused — fine.
Another: pointerdown fires for mouse click on overlay → takeControl; but also the subsequent click may focus button — no click handlers, fine.
Also after restart from end screen via pointerdown on overlay: state playing, overlay hidden. Ball attached, serveTimer 1.2 → auto serve. Toast 'Get ready!'.
Also on takeControl during demo: ball in play; nothing else.
Write update() carefully with all pieces; also decay brick flashes there.
Also: when state==='demo' and user presses key → takeControl → but also that keydown may set keys.l etc. fine.
Let me write out the code.
```js
(function(){
'use strict';
// ---------- DOM ----------
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const scoreVal=document.getElementById('scoreVal');
const bestVal=document.getElementById('bestVal');
const brickVal=document.getElementById('brickVal');
const livesEl=document.getElementById('lives');
const muteBtn=document.getElementById('muteBtn');
const buffEl=document.getElementById('buff');
const buffLabel=document.getElementById('buffLabel');
const buffFill=document.getElementById('buffFill');
const toastEl=document.getElementById('toast');
const overlayEl=document.getElementById('overlay');
// ---------- helpers ----------
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const rand=(a,b)=>a+Math.random()*(b-a);
const now=()=>performance.now()/1000;
function rr(c,x,y,w,h,r){r=Math.min(r,w/2,h/2);c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function circ(c,x,y,r){c.beginPath();c.arc(x,y,r,0,6.2832);c.fill();}
// ---------- state ----------
let W=0,H=0,DPR=1;
let bgGrad=null,vig=null;
let stars=[];
let L={margin:0,gap:6,top:112,bh:24,bw:80,cols:12,rows:8};
let state='demo'; // demo | playing | over | win
let paused=false;
let score=0,best=0,lives=3,elapsed=0,lastScore=-1,lastBest=-1,lastBricks=-1;
let bricks=[],balls=[],parts=[],pops=[],caps=[];
const paddle={x:0,y:0,w:120,h:14,vx:0};
let basePW=120,ballR=7,keySpeed=800,baseSpeed=420,maxSpeed=800;
let mouseX=null;
const keys={l:false,r:false};
let wideUntil=-1,shake=0,flash=0,serveTimer=0;
let muted=false,AC=null;
let toastT=0;
const ROW_COLORS=['#f43f5e','#fb923c','#facc15','#4ade80','#2dd4bf','#60a5fa','#a78bfa','#f472b6'];
```
etc. Let me continue writing all functions then boot. I'll now produce the final answer code in one pass, being careful.
For drawBrick tough colors: normal tough base '#5b6b80'; cracked '#414c5e'. Rivets.
Also add slight per-brick hue variation? skip.
drawGlyph shadow — set/reset.
Let me make sure `hitBrick` also gives small score popup for crack '+30'.
Also brick flash decay: in update: for(const br of bricks){ if(br.flash>0) br.flash=Math.max(0,br.flash-dt*5); }
update parts cap 500.
Let me also handle `serve()` when ball attached exists — sets velocity; also clear trail.
serveNow():
```
function serveNow(){ if(state!=='playing'&&state!=='demo')return; const b=balls.find(x=>x.attached); if(b){ b.attached=false; const a=rand(-0.42,0.42); b.vx=Math.sin(a)*baseSpeed; b.vy=-Math.cos(a)*baseSpeed; b.speed=baseSpeed; b.trail.length=0; sfx('serve'); } }
```
serve() same — just call serveNow in timer.
In update:
```
serveTimer-=dt;
if(serveTimer<=0){ if(balls.some(b=>b.attached)) serveNow(); else serveTimer=0.3; }
```
loseLife():
```
function loseLife(){
lives--; renderLives(); flash=0.5; shake=14; sfx('lose'); caps.length=0;
if(lives<=0){ gameOver(); }
else { attachBall(); serveTimer=1.2; toast('Ball lost — '+(lives===1?'1 life':lives+' lives')+' left'); }
}
```
gameOver(): state='over'; if(score>best)best=score; sfx('over'); showEnd(false);
winGame(): state='win'; if(score>best)best=score; balls.length=0; confetti(); sfx('win'); showEnd(true);
showEnd builds HTML; also update bestVal.
restart(toPlaying):
```
function restart(toPlaying){
buildLevel();
score=0; lives=3; elapsed=0; parts.length=0; pops.length=0; caps.length=0;
wideUntil=-1; shake=0; flash=0; paused=false;
paddle.x=W/2; paddle.w=basePW; paddle.vx=0;
renderLives();
attachBall(); serveTimer=toPlaying?1.3:0.8;
state=toPlaying?'playing':'demo';
if(toPlaying){ hideOverlay(); toast('Get ready!'); } else { showDemo(); }
}
```
buildLevel():
```
function buildLevel(){
layoutMetrics();
bricks=[];
for(let r=0;r<L.rows;r++)for(let c=0;c<L.cols;c++){
const kind=kindFor(r,c);
bricks.push({r,c,kind,hp:kind==='tough'?2:1,alive:true,cracked:false,flash:0,cracks:null,pow:null,color:ROW_COLORS[r%ROW_COLORS.length],x:0,y:0,w:0,h:0,cx:0,cy:0});
}
const defs=[[1,2,'wide'],[1,7,'multi'],[2,5,'wide'],[3,9,'multi'],[4,1,'multi'],[5,6,'wide'],[6,3,'wide'],[3,4,'multi']];
const used={};
for(const d of defs){ const rr2=Math.min(d[0],L.rows-1), cc=Math.min(d[1],L.cols-1), key=rr2+'_'+cc; if(used[key])continue; used[key]=1; const br=bricks.find(b=>b.r===rr2&&b.c===cc); if(br)br.pow=d[2]; }
layoutBricks();
}
```
Careful: variable name rr conflicts with helper rr — rename to pr,pc.
kindFor:
```
function kindFor(r,c){ if(r<2)return 'tough'; if(r===2)return (c%3===1)?'tough':'normal'; if(r===3)return (c%4===2)?'tough':'normal'; return 'normal'; }
```
layoutMetrics():
```
function layoutMetrics(){
L.margin=clamp(W*0.055,20,64);
L.gap=clamp(W*0.007,4,8);
L.top=Math.max(112,H*0.15);
L.bh=clamp(H*0.03,18,26);
L.cols=clamp(Math.round((W-2*L.margin)/92),8,12);
L.rows=8;
}
function layoutBricks(){
layoutMetrics();
if(!bricks.length)return;
const areaW=W-2*L.margin;
L.bw=(areaW-L.gap*(L.cols-1))/L.cols;
for(const br of bricks){ br.w=L.bw; br.h=L.bh; br.x=L.margin+br.c*(L.bw+L.gap); br.y=L.top+br.r*(L.bh+L.gap); br.cx=br.x+br.w/2; br.cy=br.y+br.h/2; }
}
```
resize():
```
function resize(){
DPR=clamp(window.devicePixelRatio||1,1,2);
W=window.innerWidth; H=window.innerHeight;
canvas.width=Math.max(1,Math.round(W*DPR));
canvas.height=Math.max(1,Math.round(H*DPR));
ctx.setTransform(DPR,0,0,DPR,0,0);
basePW=clamp(W*0.13,84,170);
ballR=clamp(Math.min(W,H)*0.011,6,9);
keySpeed=clamp(W*1.15,650,1100);
baseSpeed=clamp(H*0.6,330,520);
maxSpeed=baseSpeed*1.9;
paddle.y=H-clamp(H*0.075,46,70);
paddle.h=clamp(H*0.02,12,17);
if(!paddle.x)paddle.x=W/2;
paddle.x=clamp(paddle.x,paddle.w/2+6,W-paddle.w/2-6);
layoutBricks();
stars=[];
const n=clamp(Math.round(W*H/15000),40,140);
for(let i=0;i<n;i++)stars.push({x:rand(0,W),y:rand(0,H),r:rand(0.6,1.7),p:rand(0,6.28),s:rand(0.6,2.2)});
bgGrad=ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#0a0f22'); bgGrad.addColorStop(0.55,'#070a18'); bgGrad.addColorStop(1,'#04050c');
vig=ctx.createRadialGradient(W/2,H*0.42,Math.min(W,H)*0.3,W/2,H*0.55,Math.max(W,H)*0.78);
vig.addColorStop(0,'rgba(0,0,0,0)'); vig.addColorStop(1,'rgba(0,0,0,0.5)');
}
```
Note paddle.w initial 120; after resize if basePW smaller... paddle.w set at restart; initial set paddle.w=basePW in boot.
Boot:
```
resize();
paddle.w=basePW; paddle.x=W/2;
buildLevel();
renderLives();
attachBall();
serveTimer=0.9;
state='demo';
showDemo();
window.addEventListener('resize',resize);
requestAnimationFrame(loop);
```
attachBall():
```
function attachBall(){ balls=[{x:paddle.x,y:paddle.y-ballR-1,vx:0,vy:0,r:ballR,speed:baseSpeed,attached:true,trail:[]}]; }
```
multiBall():
```
function multiBall(){
const src=balls.filter(b=>!b.attached);
if(!src.length)return;
const clones=[];
for(const b of src){
for(let k=0;k<2;k++){
if(balls.length+clones.length>=7)break;
const sp=Math.hypot(b.vx,b.vy)||b.speed;
const a=Math.atan2(b.vy,b.vx)+(k===0?1:-1)*rand(0.35,0.6);
let vx=Math.cos(a)*sp, vy=Math.sin(a)*sp;
if(vy>0)vy=-vy;
clones.push({x:b.x,y:b.y,vx,vy,r:b.r,speed:sp,attached:false,trail:[]});
}
}
balls.push.apply(balls,clones);
}
```
applyPower(type):
```
function applyPower(type){
if(type==='wide'){ wideUntil=now()+10; toast('WIDE PADDLE · 10s'); burst(paddle.x,paddle.y-12,'#fbbf24',14); }
else { multiBall(); toast('MULTI-BALL ×3!'); burst(paddle.x,paddle.y-12,'#f472b6',20); }
sfx('power');
}
```
burst():
```
function burst(x,y,color,n){ for(let i=0;i<n;i++){ const a=rand(0,6.28), sp=rand(50,300); parts.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-60,life:rand(0.35,0.9),max:0.9,color,size:rand(1.5,3.6),grav:430,sq:Math.random()<0.4}); } if(parts.length>500)parts.splice(0,parts.length-500); }
```
Set max=life actually: life:rand(...), max same. I'll store life and max separately: const lf=rand(...); life:lf,max:lf.
puff small = burst with fewer.
confetti():
```
function confetti(){ for(let i=0;i<160;i++){ const lf=rand(1.8,3.4); parts.push({x:rand(0,W),y:rand(-H*0.25,-8),vx:rand(-50,50),vy:rand(60,230),life:lf,max:lf,color:ROW_COLORS[i%ROW_COLORS.length],size:rand(3,6),grav:110,sq:true}); } }
```
addPop(x,y,txt,color){ pops.push({x,y,txt,color,life:1}); if(pops.length>24)pops.shift(); }
genCracks(br):
```
function genCracks(br){
br.cracks=[];
const n=3+(Math.random()*2|0);
for(let i=0;i<n;i++){
let px=br.cx+rand(-br.w*0.3,br.w*0.3), py=br.y+rand(4,br.h-4);
let ang=rand(0,6.28); const pts=[[px,py]];
for(let s=0;s<5;s++){ ang+=rand(-1.3,1.3); const len=rand(3,br.w*0.18); px=clamp(px+Math.cos(ang)*len,br.x+3,br.x+br.w-3); py=clamp(py+Math.sin(ang)*len,br.y+3,br.y+br.h-3); pts.push([px,py]); }
br.cracks.push(pts);
}
}
```
destroyBrick(br):
```
function destroyBrick(br){
br.alive=false;
const pts=br.kind==='tough'?100:50;
score+=pts;
addPop(br.cx,br.cy,'+'+pts,br.color);
sfx('break');
burst(br.cx,br.cy,br.color,16);
burst(br.cx,br.cy,'#ffffff',5);
for(const b of balls){ if(b.attached)continue; const old=Math.hypot(b.vx,b.vy); if(old>0){ const ns=Math.min(b.speed+4,maxSpeed); b.speed=ns; b.vx*=ns/old; b.vy*=ns/old; } }
if(br.pow){ caps.push({x:br.cx,baseX:br.cx,y:br.cy,vy:rand(140,185),type:br.pow,w:38,h:20,taken:false}); sfx('drop'); }
if(!bricks.some(b=>b.alive)) winGame();
}
```
hitBrick as before.
aiDir as before.
update(dt):
```
function update(dt){
const t=now();
if(state==='demo'||state==='playing')elapsed+=dt;
shake=Math.max(0,shake-dt*26);
flash=Math.max(0,flash-dt*1.6);
for(const br of bricks)if(br.flash>0)br.flash=Math.max(0,br.flash-dt*5);
const wide=t<wideUntil;
paddle.w+=(basePW*(wide?1.7:1)-paddle.w)*Math.min(1,12*dt);
let dir=0;
if(state==='playing'){ if(keys.l)dir-=1; if(keys.r)dir+=1; }
else if(state==='demo'){ dir=aiDir(); }
const spd=keySpeed*(state==='demo'?0.92:1);
if(dir!==0){ paddle.vx=dir*spd; paddle.x+=dir*spd*dt; }
else if(state==='playing'&&mouseX!=null){ const d=mouseX-paddle.x; paddle.vx=d*16; paddle.x+=d*Math.min(1,16*dt); }
else { paddle.vx*=Math.pow(0.0001,dt); }
paddle.x=clamp(paddle.x,paddle.w/2+6,W-paddle.w/2-6);
serveTimer-=dt;
if(serveTimer<=0){ if(balls.some(b=>b.attached))serveNow(); else serveTimer=0.3; }
if(state==='demo'||state==='playing'){
for(const b of balls){
if(b.attached){ b.x=paddle.x; b.y=paddle.y-b.r-1; continue; }
collideWorld(b,dt);
b.trail.push({x:b.x,y:b.y}); if(b.trail.length>10)b.trail.shift();
}
balls=balls.filter(b=>!b.dead);
if(!balls.length)loseLife();
for(const c of caps){
c.y+=c.vy*dt; c.x=c.baseX+Math.sin(c.y*0.06)*10;
if(!c.taken&&c.y+c.h/2>=paddle.y&&c.y-c.h/2<=paddle.y+paddle.h+16&&Math.abs(c.x-paddle.x)<=paddle.w/2+c.w/2){ c.taken=true; applyPower(c.type); }
}
caps=caps.filter(c=>!c.taken&&c.y-c.h/2<H+30);
}
// particles
for(const p of parts){ p.life-=dt; p.vy+=p.grav*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; }
parts=parts.filter(p=>p.life>0&&p.y<H+40);
// pops
for(const p of pops){ p.life-=dt*0.85; p.y-=36*dt; }
pops=pops.filter(p=>p.life>0);
// HUD
setScore(score);
const bc=bricks.reduce((n,b)=>n+(b.alive?1:0),0);
if(bc!==lastBricks){ lastBricks=bc; brickVal.textContent=bc; }
if(best!==lastBest){ lastBest=best; bestVal.textContent=best; }
if(wide){ buffEl.style.display='flex'; const rem=Math.max(0,wideUntil-t); buffFill.style.width=(rem/10*100)+'%'; buffLabel.textContent='WIDE PADDLE · '+rem.toFixed(1)+'s'; }
else buffEl.style.display='none';
}
```
Wait: loseLife() inside update when balls empty — but loseLife may set state='over' via gameOver; then subsequent code still runs — fine.
Also careful: loseLife calls attachBall which sets balls — but we already filtered; order: filter → check empty → loseLife attaches. Good.
setScore:
```
function setScore(v){ if(v!==lastScore){ lastScore=v; scoreVal.textContent=v; scoreVal.classList.remove('pop'); void scoreVal.offsetWidth; scoreVal.classList.add('pop'); } }
```
render():
```
function render(){
const t=now();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle=bgGrad; ctx.fillRect(0,0,W,H);
for(const s of stars){ const a=(0.25+0.55*Math.abs(Math.sin(t*s.s+s.p)))*0.6; ctx.fillStyle='rgba(148,163,184,'+a.toFixed(3)+')'; ctx.fillRect(s.x,s.y,s.r,s.r); }
ctx.save();
if(shake>0)ctx.translate(rand(-1,1)*shake*0.4,rand(-1,1)*shake*0.4);
for(const br of bricks)if(br.alive)drawBrick(br);
for(const c of caps)drawCapsule(c,t);
drawPaddle(t);
for(const b of balls)drawBall(b);
ctx.globalCompositeOperation='lighter';
for(const p of parts)drawPart(p);
ctx.globalCompositeOperation='source-over';
for(const p of pops)drawPop(p);
ctx.restore();
ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
if(flash>0){ ctx.fillStyle='rgba(248,113,113,'+(flash*0.45).toFixed(3)+')'; ctx.fillRect(0,0,W,H); }
if(paused&&state==='playing'){ overlayText('PAUSED'); }
else if(state==='playing'&&balls.some(b=>b.attached)){ pulsing hint }
}
function overlayText(txt){ ctx.fillStyle='rgba(2,6,16,0.55)'; ctx.fillRect(0,0,W,H); ctx.fillStyle='#e2e8f0'; ctx.font='900 34px system-ui,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.shadowColor='#22d3ee'; ctx.shadowBlur=24; ctx.fillText(txt,W/2,H/2); ctx.shadowBlur=0; ctx.textBaseline='alphabetic'; }
```
Hint:
```
const a=0.55+0.45*Math.sin(t*5); ctx.globalAlpha=a; ctx.fillStyle='#cbd5e1'; ctx.font='700 12px system-ui,sans-serif'; ctx.textAlign='center'; ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,paddle.y-30); ctx.globalAlpha=1;
```
drawBrick:
```
function drawBrick(br){
const x=br.x,y=br.y,w=br.w,h=br.h,r=Math.min(5,w/4,h/3);
rr(ctx,x,y,w,h,r);
ctx.fillStyle=br.kind==='tough'?(br.cracked?'#414c5e':'#5b6b80'):br.color;
ctx.fill();
ctx.save(); rr(ctx,x,y,w,h,r); ctx.clip();
ctx.fillStyle='rgba(255,255,255,0.20)'; ctx.fillRect(x,y,w,h*0.42);
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(x,y+h*0.62,w,h*0.38);
if(br.kind==='tough'){ ctx.fillStyle='rgba(255,255,255,0.35)'; circ(ctx,x+5,y+5,1.6); circ(ctx,x+w-5,y+5,1.6); circ(ctx,x+5,y+h-5,1.6); circ(ctx,x+w-5,y+h-5,1.6); }
if(br.pow){ const br2=Math.min(h*0.46,w*0.3); ctx.fillStyle='rgba(255,255,255,0.16)'; circ(ctx,br.cx,br.cy,br2); ctx.strokeStyle='rgba(255,255,255,0.65)'; ctx.lineWidth=1.4; ctx.beginPath(); ctx.arc(br.cx,br.cy,br2,0,6.2832); ctx.stroke(); drawGlyph(br.pow,br.cx,br.cy,Math.min(7,h*0.3),'#ffffff'); }
ctx.restore();
if(br.cracked&&br.cracks){ ctx.strokeStyle='rgba(8,11,18,0.9)'; ctx.lineWidth=1.4; ctx.lineJoin='round'; for(const cr of br.cracks){ ctx.beginPath(); ctx.moveTo(cr[0][0],cr[0][1]); for(let i=1;i<cr.length;i++)ctx.lineTo(cr[i][0],cr[i][1]); ctx.stroke(); } }
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1; rr(ctx,x,y,w,h,r); ctx.stroke();
if(br.flash>0){ rr(ctx,x,y,w,h,r); ctx.fillStyle='rgba(255,255,255,'+(br.flash*0.55).toFixed(3)+')'; ctx.fill(); }
}
```
drawGlyph:
```
function drawGlyph(type,cx,cy,s,color){
ctx.save(); ctx.fillStyle=color; ctx.shadowColor=color; ctx.shadowBlur=7;
if(type==='wide'){ rr(ctx,cx-s*0.85,cy-s*0.24,s*1.7,s*0.48,Math.min(s*0.24,3)); ctx.fill(); }
else { const r=s*0.27; circ(ctx,cx,cy-s*0.32,r); circ(ctx,cx-s*0.36,cy+s*0.3,r); circ(ctx,cx+s*0.36,cy+s*0.3,r); }
ctx.restore();
}
```
drawCapsule:
```
function drawCapsule(c,t){
const y=c.y+Math.sin(t*5+c.baseX)*2;
const col=c.type==='wide'?'#22d3ee':'#f472b6';
ctx.save();
ctx.shadowColor=col; ctx.shadowBlur=14;
const g=ctx.createLinearGradient(0,y-c.h/2,0,y+c.h/2);
g.addColorStop(0,'#ffffff'); g.addColorStop(0.45,col); g.addColorStop(1,col);
rr(ctx,c.x-c.w/2,y-c.h/2,c.w,c.h,c.h/2);
ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.2; ctx.stroke();
drawGlyph(c.type,c.x,y,7,'#0b1220');
ctx.restore();
}
```
Hmm glyph dark with shadowColor dark — ok.
drawPaddle:
```
function drawPaddle(t){
const wide=now()<wideUntil;
const x=paddle.x-paddle.w/2,y=paddle.y,w=paddle.w,h=paddle.h;
ctx.save();
ctx.shadowColor=wide?'#fbbf24':'#22d3ee'; ctx.shadowBlur=wide?24:16;
const g=ctx.createLinearGradient(0,y,0,y+h);
if(wide){ g.addColorStop(0,'#fde68a'); g.addColorStop(1,'#f59e0b'); }
else { g.addColorStop(0,'#a5f3fc'); g.addColorStop(0.55,'#38bdf8'); g.addColorStop(1,'#2563eb'); }
rr(ctx,x,y,w,h,h/2); ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='rgba(255,255,255,0.4)'; rr(ctx,x+3,y+2,Math.max(0,w-6),h*0.34,h*0.17); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.lineWidth=1; rr(ctx,x,y,w,h,h/2); ctx.stroke();
ctx.restore();
}
```
drawBall:
```
function drawBall(b){
for(let i=0;i<b.trail.length;i++){ const p=b.trail[i]; const f=i/b.trail.length; ctx.fillStyle='rgba(165,243,252,'+(f*0.2).toFixed(3)+')'; circ(ctx,p.x,p.y,b.r*(0.35+0.65*f)); }
const g=ctx.createRadialGradient(b.x-b.r*0.35,b.y-b.r*0.35,b.r*0.15,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff'); g.addColorStop(1,'#bae6fd');
ctx.save(); ctx.shadowColor='#e0f2fe'; ctx.shadowBlur=14; ctx.fillStyle=g; circ(ctx,b.x,b.y,b.r); ctx.restore();
}
```
drawPart:
```
function drawPart(p){ ctx.globalAlpha=Math.max(0,p.life/p.max); ctx.fillStyle=p.color; if(p.sq)ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size); else circ(ctx,p.x,p.y,p.size/2); ctx.globalAlpha=1; }
```
drawPop as above.
collideWorld & circleRect & hitBrick as above.
serveNow, attachBall, loseLife, gameOver, winGame, restart, takeControl, showDemo, showEnd, hideOverlay, toast, renderLives, fmtTime.
Events:
```
window.addEventListener('resize',resize);
window.addEventListener('pointermove',e=>{ mouseX=e.clientX; },{passive:true});
window.addEventListener('pointerdown',e=>{
mouseX=e.clientX;
if(e.target&&e.target.closest&&e.target.closest('#muteBtn'))return;
ensureAudio();
if(state==='demo')takeControl();
else if(state==='over'||state==='win')restart(true);
else if(state==='playing')serveNow();
});
window.addEventListener('keydown',e=>{
const k=e.key;
if(k===' '||k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown')e.preventDefault();
ensureAudio();
if(state==='demo')takeControl();
if(k==='ArrowLeft'||k==='a'||k==='A')keys.l=true;
else if(k==='ArrowRight'||k==='d'||k==='D')keys.r=true;
if(k===' '){ if(state==='over'||state==='win')restart(true); else serveNow(); }
else if(k==='r'||k==='R')restart(true);
else if(k==='m'||k==='M')toggleMute();
else if(k==='p'||k==='P'||k==='Escape'){ if(state==='playing'){ paused=!paused; if(paused)toast('Paused — press P to resume'); } }
});
window.addEventListener('keyup',e=>{ const k=e.key; if(k==='ArrowLeft'||k==='a'||k==='A')keys.l=false; if(k==='ArrowRight'||k==='d'||k==='D')keys.r=false; });
window.addEventListener('blur',()=>{ keys.l=keys.r=false; });
muteBtn.addEventListener('click',e=>{ e.stopPropagation(); toggleMute(); });
```
Wait: muteBtn click also bubbles pointerdown? pointerdown fires before click; our pointerdown handler checks closest('#muteBtn') and returns — good. The click handler toggles mute. stopPropagation on click not needed.
But note: pointerdown on muteBtn returns early without ensureAudio — then click toggles mute without audio ctx — fine.
toggleMute: muted=!muted; muteBtn.textContent=muted?'🔇':'🔊'; if(!muted)ensureAudio();
Loop:
```
let last=performance.now();
function loop(t){
requestAnimationFrame(loop);
let dt=(t-last)/1000; last=t;
if(!(dt>0))dt=0.0001;
if(dt>0.033)dt=0.033;
if(!paused)update(dt);
render();
}
```
Boot at end:
```
resize();
paddle.w=basePW; paddle.x=W/2;
buildLevel();
renderLives();
attachBall();
serveTimer=0.9;
showDemo();
requestAnimationFrame(loop);
```
state initialized 'demo'.
showDemo():
```
function showDemo(){
overlayEl.innerHTML=
'<div class="card">'+
'<div class="pill"><span class="dot"></span>DEMO MODE · AI IS PLAYING</div>'+
'<h1 class="title">BREAKOUT</h1>'+
'<p class="sub">A self-playing demo is running behind this card.</p>'+
'<p class="hint"><b>Click or press any key to start</b> — you take control of the paddle</p>'+
'<div class="controls">'+
'<span><kbd>Mouse</kbd> or <kbd>←</kbd><kbd>→</kbd> — move paddle</span>'+
'<span><kbd>Click</kbd> / <kbd>Space</kbd> — launch ball</span>'+
'<span><kbd>P</kbd> pause · <kbd>M</kbd> sound · <kbd>R</kbd> restart</span>'+
'</div></div>';
overlayEl.classList.remove('hidden');
}
```
showEnd(win):
```
function showEnd(win){
const t=fmtTime(elapsed);
overlayEl.innerHTML=
'<div class="card">'+
'<h1 class="title '+(win?'win':'lose')+'">'+(win?'YOU WIN!':'GAME OVER')+'</h1>'+
'<p class="sub">'+(win?'Every brick destroyed. Legendary!':'Out of lives. The bricks remain…')+'</p>'+
'<div class="stats">'+
'<div><span>SCORE</span><b>'+score+'</b></div>'+
'<div><span>BEST</span><b>'+best+'</b></div>'+
'<div><span>TIME</span><b>'+t+'</b></div>'+
'</div>'+
'<button class="btn" type="button">▶ PLAY AGAIN</button>'+
'<p class="hint" style="margin-top:12px">or click anywhere / press <kbd>R</kbd></p>'+
'</div>';
overlayEl.classList.remove('hidden');
}
```
toast(msg): as above.
renderLives():
```
function renderLives(){ livesEl.innerHTML=''; for(let i=0;i<3;i++){ const s=document.createElement('span'); s.className='heart'+(i<lives?'':' off'); s.textContent='\u2665'; livesEl.appendChild(s); } }
```
fmtTime:
```
function fmtTime(s){ s=Math.max(0,Math.floor(s)); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
```
Audio functions as above.
takeControl():
```
function takeControl(){ if(state!=='demo')return; state='playing'; hideOverlay(); toast('Your paddle! Mouse or \u2190 \u2192'); }
```
hideOverlay: overlayEl.classList.add('hidden');
aiDir():
```
function aiDir(){
let target=W/2,bb=null,by=-1e9;
for(const b of balls){ if(b.attached)continue; if(b.vy>0&&b.y>by){ by=b.y; bb=b; } }
if(bb){ const tt=Math.max(0,(paddle.y-bb.y)/bb.vy); target=clamp(bb.x+bb.vx*tt,paddle.w/2+10,W-paddle.w/2-10); }
else target=W/2+Math.sin(now()*0.6)*W*0.22;
target+=Math.sin(now()*2.1)*7;
const d=target-paddle.x;
return Math.abs(d)<7?0:(d>0?1:-1);
}
```
hitBrick final:
```
function hitBrick(b,br){
const cx=clamp(b.x,br.x,br.x+br.w),cy=clamp(b.y,br.y,br.y+br.h);
let dx=b.x-cx,dy=b.y-cy; let nx,ny;
const d2=dx*dx+dy*dy;
if(d2>1e-6){ 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,pt=b.y-br.y,pb=br.y+br.h-b.y,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 dot=b.vx*nx+b.vy*ny;
b.vx-=2*dot*nx; b.vy-=2*dot*ny;
const sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
if(Math.abs(b.vy)<0.2*sp){ b.vy=(b.vy<0?-1:1)*0.22*sp; const m=Math.hypot(b.vx,b.vy)||1; b.vx*=sp/m; b.vy*=sp/m; }
br.flash=1;
br.hp--;
if(br.hp<=0){ destroyBrick(br); }
else{ br.cracked=true; genCracks(br); score+=30; addPop(br.cx,br.cy-8,'+30','#cbd5e1'); sfx('crack'); burst(br.cx,br.cy,'#94a3b8',6); }
}
```
collideWorld:
```
function collideWorld(b,dt){
const sp=Math.hypot(b.vx,b.vy)||b.speed; b.speed=sp;
const steps=clamp(Math.ceil(sp*dt/(b.r*0.55)),1,12);
const sdt=dt/steps;
for(let s=0;s<steps;s++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
if(b.x<b.r){ b.x=b.r; b.vx=Math.abs(b.vx); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
else if(b.x>W-b.r){ b.x=W-b.r; b.vx=-Math.abs(b.vx); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
if(b.y<b.r){ b.y=b.r; b.vy=Math.abs(b.vy); sfx('wall'); burst(b.x,b.y,'#94a3b8',2); }
const pw=paddle.w;
if(b.vy>0&&b.y+b.r>=paddle.y&&b.y-b.r<=paddle.y+paddle.h&&b.x>=paddle.x-pw/2-b.r&&b.x<=paddle.x+pw/2+b.r){
const rel=clamp((b.x-paddle.x)/(pw/2),-1,1);
const ang=rel*1.15;
const ns=Math.min(b.speed*1.015+2,maxSpeed); b.speed=ns;
let vx=Math.sin(ang)*ns+paddle.vx*0.15, vy=-Math.cos(ang)*ns;
const m=Math.hypot(vx,vy)||1; vx*=ns/m; vy*=ns/m;
b.vx=vx; b.vy=vy;
b.x=clamp(b.x,paddle.x-pw/2,paddle.x+pw/2);
b.y=paddle.y-b.r-0.5;
sfx('paddle'); burst(b.x,paddle.y,'#67e8f9',6);
}
for(let i=0;i<bricks.length;i++){ const br=bricks[i]; if(!br.alive)continue; if(circleRect(b,br)){ hitBrick(b,br); break; } }
if(b.y-b.r>H){ b.dead=true; return; }
}
}
function circleRect(b,r){ const cx=clamp(b.x,r.x,r.x+r.w),cy=clamp(b.y,r.y,r.y+r.h); const dx=b.x-cx,dy=b.y-cy; return dx*dx+dy*dy<b.r*b.r; }
```
One concern: paddle bounce when ball hits paddle from below-side while vy>0 but ball actually below paddle top (e.g., passed beside)? Condition requires vertical overlap of ball with paddle rect expanded — since substeps small, if ball is below paddle and moving up (vy<0) no trigger. If ball moving down and overlaps → bounce. If ball moving down but is below paddle already (missed), bottom check kills it. OK.
Another: after paddle bounce we set b.x clamped within paddle — fine.
Also ensure ball doesn't get stuck bouncing on top of brick horizontally oscillating — min vertical enforcement handles.
Edge: ball attached and user moves paddle fast — ball follows; on serve ok.
Now the demo AI and mouse: during demo, if user moves mouse, mouseX set but not used. Good.
Also during 'playing', if user uses arrows AND mouse, arrows take precedence when pressed (dir!=0 branch). On release, mouse lerp resumes. Good.
CSS: canvas cursor none — but HUD boxes pointer-events none so cursor stays none over them; mute button pointer-events auto but cursor none looks odd — set #muteBtn{cursor:pointer}. Buttons/overlay cursor pointer/default.
Also #wrap cursor:none — overlay inside wrap inherits none; set #overlay{cursor:pointer}. Card button cursor pointer via .btn.
Hmm, but on touch devices cursor irrelevant.
One more consideration: `backdrop-filter` on #overlay blurs canvas — nice.
Let me also add subtle scanline/grid on canvas? skip — stars + vignette enough.
Check: initial paint — canvas drawn on first rAF (immediately), overlay demo visible. Screenshot at 3s: bricks visible, ball moving, AI paddle, HUD, overlay card centered.
Potential issue: overlay covers center of screen with blur — bricks behind partially hidden but visible around card. Card max-width 540 in 1280 — plenty visible. Good.
Now double-check `update` references `brickVal` etc defined. Yes.
Also `lastBricks` init -1.
Also in render, when state 'over'/'win', balls empty (win) or frozen (over: balls empty since last ball died → balls=[] then loseLife → gameOver without attach → balls empty). So over screen shows bricks remaining + particles. Fine.
Wait in loseLife when lives becomes 0: we don't attach; balls=[] already. Good.
Also on gameOver/win, overlay shows; render continues; particles from last break still animate. Good.
Check restart during 'over': pointerdown → restart(true) → buildLevel etc. overlay hidden. Good.
R key during demo: keydown → state==='demo' → takeControl() (state playing) → then k==='r' → restart(true) → playing fresh. OK.
Space during demo: takeControl + serveNow (no attached ball → nothing). fine.
Now audio ensureAudio on keydown/pointerdown — creates context within gesture. Good.
Potential problem: In sandboxed iframe, AudioContext may exist; guarded try/catch.
Also `e.target.closest` — e.target may be canvas (Element) — has closest. If target is document? pointerdown target is element. OK guard with `e.target instanceof Element`? Use `e.target.closest && e.target.closest('#muteBtn')`. Done.
Now sizes on 375×~667 (mobile): W=375: margin=clamp(20.6→20), gap=clamp(2.6→4), cols=clamp(round((375-40)/92)=round(3.64)=4 → 8). bw=(335-7*4)/8=(335-28)/8=38.4. bh=clamp(20,18,26)=20. top=max(112,100)=112. Paddle basePW=clamp(48.75→84). ballR=clamp(4.1→6). baseSpeed=clamp(400,330,520)=400... H=667*0.6=400. keySpeed=clamp(431→650)=650. HUD fits: score box + level + lives — 375 width: boxes ~90+60+90=240 + padding ok.
Brick pow defs col min(c,7). fine.
Font sizes clamp.
Overlay card padding clamp — fits 375.
No horizontal scrollbar: body overflow hidden; canvas 100%. Good.
Now, is there any use of external resources? No. Emojis in HUD/mute are system emoji — fine (not external). kbd symbols fine.
Let me also double check the `h1.title` gradient text with `filter: drop-shadow` — works in Chrome.
Also `.val.pop` animation — re-trigger via reflow hack — ok.
Edge: `scoreVal.classList` on span — fine.
One more requirement re-check: "paddle controlled with the mouse and with the left/right arrow keys" ✓. "bounce angle off paddle depends on where ball hits (center = straight up, edges = sharp angle)" — rel=0 → ang=0 → straight up ✓; edges rel=±1 → 65.9° ✓.
"tougher bricks need 2 hits and visibly crack after first one" ✓ (cracks drawn).
"Some bricks visibly carry a power-up icon" ✓ badge+glyph on brick; capsule same icon.
"wider paddle (10 seconds)" ✓ wideUntil 10s + HUD timer. "multi-ball (3 balls)" ✓.
"ball must never pass through bricks or paddle" ✓ substeps.
"Particles when brick breaks" ✓.
"3 lives, score displayed" ✓. "Game over and victory screens with restart option" ✓.
Demo mode ✓.
Now assemble final HTML. Also add `<meta name="color-scheme" content="dark">` maybe.
Also title of level box: show "BRICKS" count. Let me define center box: `<div class="box center"><span class="lbl">LEVEL 1</span><span class="lbl" style="color:#64748b">BRICKS <b id="brickVal" style="color:#94a3b8;font-size:11px">96</b></span></div>`. Hmm nested styling; simpler: `<div class="box center"><span class="lbl">LEVEL 1</span><span class="val" id="brickVal" style="font-size:clamp(13px,2vw,17px)">96</span></div>` — shows brick count as the big value with label "LEVEL 1"? Confusing. Make label "BRICKS LEFT" and value count; plus tiny "LEVEL 1" above? I'll do: label row1 'LEVEL 1', value = bricks remaining with small label? Let me structure center box: `<span class="lbl">LEVEL 1 · BRICKS</span><span class="val" id="brickVal">96</span>`. Good.
Write CSS for .box.center{align-items:center;text-align:center}.
Also right box contains hearts + mute.
Let me finalize the demo overlay wording includes "Click or press a key to start" ✓.
Time to write the complete file. I'll be thorough and double check syntax as I go.
Also one thing: in update, when state==='over'/'win', we still run HUD updates and particles — fine. serveTimer decrement continues; balls empty → serveTimer keeps resetting 0.3 — harmless.
Also in render hint only when playing.
Also when paused, update skipped so elapsed freezes — good; render shows PAUSED.
Let me also ensure `showEnd` uses current best — best updated in gameOver/winGame before showEnd ✓.
Potential bug: In destroyBrick → winGame() sets state='win' and balls.length=0 — but we're inside collideWorld loop over balls (for...of over `balls` array in update). Mutating balls (length=0) during iteration: the for...of in update iterates `balls` — we set balls.length=0 inside → iterator sees empty → ends. Then `balls=balls.filter(...)` — fine. Also loseLife not triggered since block guard already entered... after win, `if(!balls.length) loseLife()` — uh oh! In update: after filtering, `if(!balls.length) loseLife();` — on win, balls emptied by winGame → loseLife called → lives-- and possibly gameOver overwriting win! Bug. Fix: check state before loseLife: `if(!balls.length && (state==='demo'||state==='playing')) loseLife();` — but we're inside block where state was demo/playing at entry; state may have changed to 'win' by winGame. So guard with current state: `if(!balls.length && state!=='win' && state!=='over') loseLife();`. Add that.
Similarly gameOver path: loseLife→gameOver sets state 'over'; then code after in update continues fine.
Also in loseLife, after gameOver we return early? loseLife: if lives<=0 gameOver() else attach... fine.
Another subtle: winGame during demo — overlay victory shows while demo — fine.
Also `hitBrick` may be called for ball that's dead? no.
Also multiple bricks in one substep: we break after first — with substeps ≤ r*0.55 movement, fine.
Also capsule catch check uses paddle.w current (smooth) ✓.
Also `applyPower` multi when only attached ball exists (edge) — src empty → nothing; fine.
Let me also ensure `serveNow` uses baseSpeed but if balls were sped up, new serve uses baseSpeed — acceptable.
Also on life loss, ball speed reset — fine.
I think we're ready. Also add `image-rendering` nothing.
One more polish: draw a faint line at paddle "danger zone"? skip.
Also add slight brick entrance animation? skip (first paint must show bricks — they're there).
Write final code now. Also include `aria-label`s minor.
Counting rows: L.rows=8 ✓ ≥6.
Let me write CSS fully and HTML fully.
Also: `#wrap{cursor:none}` might hide cursor over overlay — set `#overlay{cursor:pointer}` overrides since more specific? #overlay is child of #wrap; cursor:none on wrap inherits to children unless overridden — #overlay{cursor:pointer} sets it. Buttons inside get pointer from .btn. Card text default → pointer from overlay. OK.
During demo, cursor hidden over wrap but overlay covers everything with pointer cursor — fine.
After takeControl, overlay hidden → cursor none over canvas — immersive. Good.
Also `touch-action:none` on html,body to prevent scroll/zoom gestures on touch.
Final check on `render()` when W/H zero? resize called first.
Alright, also the `pulsing hint` in render references `balls.some(...)` — fine.
Also in render I use variable t for both time and... careful not to shadow. Use `const t=now();` and pass to draws.
drawCapsule(c,t) uses t ✓.
Now writing everything out. I'll aim for clean formatting.
One more: `stars` drawn as fillRect squares — fine.
Also add a faint top "ceiling" glow line? skip.
Double-check `showEnd` innerHTML uses `▶` for ▶.
Emoji in mute: '🔊'/'🔇'.
In demo controls I used 🖱? I wrote `<kbd>Mouse</kbd>` — fine, avoid emoji.
Let me also handle `visibilitychange` to auto-pause when playing: document.addEventListener('visibilitychange',()=>{ if(document.hidden&&state==='playing'&&!paused){paused=true;} }); — nice. But keydown 'p' toast... fine, silent pause. OK include.
Now produce final answer. Watch out: inside template literals in script, no `</script>` sequences. I'm using string concat for HTML — safe.
Let me write the whole thing.
Also remember: `const levelBox` not needed.
brickVal initial text set by update (lastBricks=-1 → first frame sets). Good.
Let me write out, then re-scan for errors.
Additional check — `resize` listener: on resize, bricks re-layout (alive ones keep hp). balls may be out of bounds — clamp ball positions in resize: for balls clamp x,y. Add.
Also paddle.w may exceed basePW if wide active — after resize paddle.w=basePW? I only set paddle.w at restart; in resize I clamp paddle.x with current paddle.w — fine; width animates to target each frame.
In resize, also `if(paddle.w>basePW*1.7)paddle.w=basePW*1.7;` eh skip.
OK writing now.
Actually one more thought about `steps` calc: sp up to maxSpeed ~ 520*1.9=988; dt max 0.033 → dist 32.6; r*0.55 = 7*0.55≈3.85 → steps=ceil(8.5)=9 ≤12 ✓.
Also `paddle.vx*0.15` added then renormalized to ns — includes paddle motion influence ✓.
Also ball speed increment on paddle hit: min(b.speed*1.015+2, maxSpeed) — grows to max over time ✓.
Everything set. Write it.
Also careful: in HTML, the `♥` char — use ♥ or literal ♥. Literal fine with UTF-8.
Let me now also make sure no `alert` etc. ✓.
Final code:
I'll write ~450 lines. Ensure balanced braces.
Also `function overlayText` used for paused.
Let me write the script in order:
1. DOM refs
2. helpers
3. state vars
4. resize/layout/build
5. entities (attach/serve/multi/apply/powers)
6. particles/pops
7. collisions
8. update
9. render + draws
10. HUD/overlay/toast
11. audio
12. input handlers
13. loop + boot
Go.
Also note: `showDemo` etc use overlayEl.innerHTML with single quotes concat — ensure no stray backticks issues; fine.
One more possible pitfall: `e.key` for space is `' '` — handled. Also `'Spacebar'` legacy — ignore.
In keydown, when state==='demo' we call takeControl() for ANY key including 'm' etc — that's per spec ("press a key to start"). Then 'm' also toggles mute — minor, acceptable. Actually pressing 'm' would take control AND mute — slightly odd but harmless.
Hmm, maybe restrict takeControl to any key — spec says press a key to start. Keep any key.
Also prevent default for space even on end screens to avoid button re-click — we have no button click handlers, fine.
Write final.
Let me also compute: does `backdrop-filter` on #overlay require prefix in Chrome? No, supported. Add -webkit- anyway.
Also `#overlay.hidden` transition opacity — with pointer-events none. ✓
Now the code.
After writing, mentally lint: check every function defined before use at runtime (hoisting covers function declarations). Use function declarations everywhere. Variables with let defined at top.
Potential name collision: function `rand` and local `rand` — I used rand(...) helper; in genCracks I don't shadow. In destroyBrick none. OK. In buildLevel I used `rr2, cc` — avoid. Use `pr,pc`.
In update, variable `t` = now() — also used in render separately. fine.
`circ` helper uses c.fill() with current fillStyle — set before call ✓.
In drawBrick, after ctx.restore() I stroke and flash — flash fill uses rr path again ✓.
drawGlyph uses rr for wide — rr path then fill ✓ (rr does beginPath).
In drawCapsule, after rr fill, I call ctx.stroke() — but rr() begins new path; fill then stroke same path: I did `rr(...); ctx.fillStyle=g; ctx.fill();` then later `ctx.strokeStyle=...; ctx.stroke();` — path persists after fill ✓.
drawPaddle similar ✓.
OK, writing final output now.
Also remember meta viewport with user-scalable=no to avoid double-tap zoom on mobile.
Let me also double check `buffEl` display flex when active — CSS sets display:none default; JS sets 'flex'/'none' ✓ and CSS has flex-direction column etc. In CSS I wrote `#buff{display:none;...}` — JS overrides inline. ✓
Edge: buffLabel width changes text — fine.
Now final answer.