GLM 5.3
by Z.aiReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 15 min 39 s
- Cost
- $0.255
- Output tokens
- 70,872
- Spent thinking
- 58,037 tokens
- Speed
- 75.5 tok/s
- Console errors
- 1
- First token after
- 350 ms
- First visible text after
- 14 min 3 s
- Prompt tokens
- 507
- Code size
- 34.4 KB
- Finish reason
- stop
- Served by
- InferenceNet
- Model id
z-ai/glm-5.3- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
Failed to execute 'createRadialGradient' on 'CanvasRenderingContext2D': The provided double value is non-finite.
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 · 869 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Neon Breakout</title>
<style>
html,body{height:100%;margin:0}
body{
background-color:#04050c;
background-image:radial-gradient(1100px 640px at 50% 18%,#0c1028 0%,#070918 55%,#04050c 100%);
display:flex;align-items:center;justify-content:center;
overflow:hidden;
font-family:ui-monospace,'Cascadia Mono',Menlo,Consolas,monospace;
color:#e8ecff;
-webkit-user-select:none;user-select:none;
}
#stage{position:relative}
canvas{
display:block;border-radius:14px;background:#070813;touch-action:none;
box-shadow:0 0 0 1px rgba(120,150,255,.25),0 0 80px rgba(70,110,255,.16),0 30px 80px rgba(0,0,0,.6);
}
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:5;padding:14px}
.overlay.hidden{display:none}
.panel{
background:rgba(9,12,27,.78);
border:1px solid rgba(130,152,255,.3);
border-radius:16px;
padding:clamp(14px,2.6vh,28px) clamp(20px,4vw,44px);
text-align:center;line-height:1.5;
box-shadow:0 0 46px rgba(80,120,255,.16),inset 0 0 34px rgba(90,120,255,.05);
backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
max-width:min(94vw,560px);
animation:panelIn .45s cubic-bezier(.2,.9,.25,1.2);
}
@keyframes panelIn{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:none}}
.title{font-size:clamp(22px,4.4vw,40px);font-weight:800;letter-spacing:.14em;color:#f0f4ff;
text-shadow:0 0 14px rgba(120,170,255,.95),0 0 46px rgba(90,130,255,.45)}
.title.win{color:#ffd166;text-shadow:0 0 14px rgba(255,200,90,.95),0 0 46px rgba(255,180,60,.45)}
.title.lose{color:#ff5d7f;text-shadow:0 0 14px rgba(255,80,120,.95),0 0 46px rgba(255,60,110,.45)}
.sub{margin:8px 0 0;font-size:10px;letter-spacing:.26em;color:#7683b3}
.cta{margin-top:16px;font-size:clamp(10px,1.7vw,14px);font-weight:800;letter-spacing:.26em;color:#ffd166;
text-shadow:0 0 12px rgba(255,190,80,.6);animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.ctrl{display:flex;gap:clamp(10px,2.4vw,26px);justify-content:center;margin-top:16px;font-size:9.5px;
letter-spacing:.14em;color:#8fa0cf;flex-wrap:wrap}
.ctrl div{display:flex;flex-direction:column;gap:5px}
.ctrl span{color:#5d6a95}
.ctrl b{color:#e8eeff;font-weight:700;letter-spacing:.1em}
.legend{display:flex;gap:14px;justify-content:center;align-items:center;margin-top:14px;font-size:10px;
color:#9fb0dd;letter-spacing:.1em;flex-wrap:wrap}
.legend em{font-style:normal}
.chip{display:inline-flex;align-items:center;justify-content:center;gap:3px;min-width:34px;height:20px;
padding:0 6px;border-radius:10px;font-weight:800;font-size:12px;line-height:1}
.chip.amber{background:linear-gradient(180deg,#ffe9a8,#f0a941);color:#3a2410;box-shadow:0 0 12px rgba(255,180,80,.5)}
.chip.teal{background:linear-gradient(180deg,#a8f7e0,#2ec4b6);box-shadow:0 0 12px rgba(60,220,200,.45)}
.chip.teal i{width:5px;height:5px;border-radius:50%;background:#06403a;display:block}
.scoreline{margin:12px 0 0;font-size:16px;font-weight:800;letter-spacing:.2em;color:#fff}
#endNote{margin:8px 0 0;font-size:10px;letter-spacing:.2em;color:#7683b3;line-height:1.7}
button{
margin-top:18px;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:.22em;
color:#041020;background:linear-gradient(180deg,#9ef1ff,#49b3ff);
border:0;border-radius:10px;padding:13px 30px;cursor:pointer;
box-shadow:0 0 24px rgba(90,190,255,.5);
transition:transform .12s ease,box-shadow .12s ease;
}
button:hover{transform:translateY(-2px);box-shadow:0 0 34px rgba(90,190,255,.75)}
button:active{transform:translateY(0) scale(.98)}
.hintline{margin:12px 0 0;font-size:9px;letter-spacing:.24em;color:#5d6a95}
</style>
</head>
<body>
<div id="stage">
<canvas id="cv" width="900" height="620"></canvas>
<div class="overlay" id="ovStart">
<div class="panel">
<div class="title">NEON BREAKOUT</div>
<p class="sub">SMASH THE GRID · CATCH THE DROPS</p>
<div class="cta">CLICK OR PRESS ANY KEY TO START</div>
<div class="ctrl">
<div><span>MOVE</span><b>MOUSE · ← →</b></div>
<div><span>LAUNCH</span><b>SPACE · CLICK</b></div>
<div><span>PAUSE / RESET</span><b>P · R</b></div>
</div>
<div class="legend">
<span class="chip amber">↔</span><em>WIDE PADDLE 10s</em>
<span class="chip teal"><i></i><i></i><i></i></span><em>MULTI-BALL</em>
</div>
</div>
</div>
<div class="overlay hidden" id="ovEnd">
<div class="panel">
<div class="title lose" id="endTitle">GAME OVER</div>
<p class="scoreline" id="endScore">SCORE 000000</p>
<p id="endNote">the ball slipped past your paddle</p>
<button id="btnRestart" type="button">PLAY AGAIN</button>
<p class="hintline">CLICK ANYWHERE · OR PRESS R</p>
</div>
</div>
</div>
<script>
'use strict';
/* ============================== constants ============================== */
const W=900,H=620,TOP=64;
const COLS=12,ROWS=8;
const SIDE=26,GAP=6,BH=22,BTOP=TOP+22;
const BW=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const BALL_R=8,BASE_SPEED=380,MAX_SPEED=640,PADDLE_Y=H-46;
const MONO='ui-monospace,Menlo,Consolas,monospace';
const TAU=Math.PI*2;
const ROWC=[null,'#ff4d6d','#ff9e4f','#ffd166','#52e5a3','#4cc9f0','#5a7bff','#b06cff'];
const CONF=['#ffd166','#ff5d7e','#5eead4','#7aa2ff','#c084fc','#f9a8d4'];
const POWER_SPOTS=[
{r:0,c:2,k:'multi'},{r:0,c:9,k:'wide'},
{r:2,c:5,k:'multi'},{r:3,c:2,k:'wide'},{r:3,c:9,k:'multi'},
{r:4,c:6,k:'wide'},{r:6,c:1,k:'multi'},{r:6,c:10,k:'wide'},{r:7,c:5,k:'multi'}
];
/* ============================== dom / helpers ============================== */
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const ovStart=document.getElementById('ovStart'),ovEnd=document.getElementById('ovEnd');
const endTitle=document.getElementById('endTitle'),endScore=document.getElementById('endScore'),endNote=document.getElementById('endNote');
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
const pick=a=>a[(Math.random()*a.length)|0];
function hexA(h,a){const n=parseInt(h.slice(1),16);return 'rgba('+(n>>16&255)+','+(n>>8&255)+','+(n&255)+','+a+')';}
function mixc(a,b,t){
const A=parseInt(a.slice(1),16),B=parseInt(b.slice(1),16);
const r=Math.round((A>>16&255)*(1-t)+(B>>16&255)*t);
const g=Math.round((A>>8&255)*(1-t)+(B>>8&255)*t);
const bl=Math.round((A&255)*(1-t)+(B&255)*t);
return '#'+((1<<24)+(r<<16)+(g<<8)+bl).toString(16).slice(1);
}
function rrect(g,x,y,w,h,r){r=Math.min(r,w/2,h/2);g.beginPath();g.moveTo(x+r,y);g.arcTo(x+w,y,x+w,y+h,r);g.arcTo(x+w,y+h,x,y+h,r);g.arcTo(x,y+h,x,y,r);g.arcTo(x,y,x+w,y,r);g.closePath();}
function mulberry(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function ls(v){try{ctx.letterSpacing=v;}catch(e){}}
/* ============================== audio (synth, no assets) ============================== */
let AC=null;
function initAudio(){try{
if(!AC)AC=new (window.AudioContext||window.webkitAudioContext)();
if(AC&&AC.state==='suspended')AC.resume().catch(()=>{});
}catch(e){}}
function beep(f,dur,type,vol,slide,delay){
dur=dur||0.07;type=type||'square';vol=(vol==null?0.05:vol);slide=slide||0;delay=delay||0;
if(!AC)return;
try{
const t=AC.currentTime+delay;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.setValueAtTime(f,t);
if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(40,f+slide),t+dur);
g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(0.0006,t+dur);
o.connect(g);g.connect(AC.destination);o.start(t);o.stop(t+dur+0.03);
}catch(e){}
}
function chime(fs,type,dur,gap,vol){type=type||'triangle';dur=dur||0.1;gap=gap||0.09;vol=vol||0.05;
fs.forEach((f,i)=>beep(f,dur,type,vol,0,i*gap));}
/* ============================== state ============================== */
let mode='demo',phase='serve',paused=false;
let score=0,lives=3,combo=0,ballSpeed=BASE_SPEED,bricksLeft=0,bonus=0;
let bricks=[],balls=[],caps=[],parts=[],pops=[],rings=[];
const paddle={x:W/2,y:PADDLE_Y,w:110,h:16,squash:0,wideT:0};
let shake=0,flashA=0,flashC='255,255,255';
let time=0,serveT=0,demoT=-1;
const keys={l:false,r:false};
const flies=[];
for(let i=0;i<26;i++)flies.push({x:rand(0,W),y:rand(0,H),vy:rand(-16,-5),r:rand(0.7,1.9),ph:rand(0,TAU),a:rand(0.12,0.4)});
/* ============================== prerendered art ============================== */
let DPR=1,bgCv=null,iconWide=null,iconMulti=null,spriteCache={};
function fit(){
DPR=Math.min(2,window.devicePixelRatio||1);
cv.width=Math.round(W*DPR);cv.height=Math.round(H*DPR);
const s=Math.max(0.2,Math.min((innerWidth-14)/W,(innerHeight-14)/H));
cv.style.width=(W*s)+'px';cv.style.height=(H*s)+'px';
spriteCache={};buildBG();iconWide=makeIcon('wide');iconMulti=makeIcon('multi');
}
function buildBG(){
bgCv=document.createElement('canvas');
bgCv.width=Math.round(W*DPR);bgCv.height=Math.round(H*DPR);
const g=bgCv.getContext('2d');g.scale(DPR,DPR);
const lg=g.createLinearGradient(0,0,0,H);
lg.addColorStop(0,'#0b0e22');lg.addColorStop(0.5,'#080a18');lg.addColorStop(1,'#05060f');
g.fillStyle=lg;g.fillRect(0,0,W,H);
const rg=g.createRadialGradient(W/2,60,20,W/2,60,430);
rg.addColorStop(0,'rgba(90,120,255,0.12)');rg.addColorStop(1,'rgba(90,120,255,0)');
g.fillStyle=rg;g.fillRect(0,0,W,H);
g.strokeStyle='rgba(98,124,220,0.05)';g.lineWidth=1;
for(let x=30;x<W;x+=30){g.beginPath();g.moveTo(x+0.5,TOP+4);g.lineTo(x+0.5,H);g.stroke();}
for(let y=TOP+30;y<H;y+=30){g.beginPath();g.moveTo(0,y+0.5);g.lineTo(W,y+0.5);g.stroke();}
g.fillStyle='rgba(4,6,15,0.35)';g.fillRect(0,0,W,TOP); // HUD strip
g.strokeStyle='rgba(112,148,255,0.85)';g.lineWidth=2;
g.shadowColor='rgba(96,140,255,0.9)';g.shadowBlur=7;
g.beginPath();g.moveTo(1,TOP);g.lineTo(1,H-2);g.moveTo(W-1,TOP);g.lineTo(W-1,H-2);g.stroke();
g.beginPath();g.moveTo(0,TOP);g.lineTo(W,TOP);g.stroke();
g.shadowBlur=0;
const hz=g.createLinearGradient(0,H-44,0,H);
hz.addColorStop(0,'rgba(255,60,90,0)');hz.addColorStop(1,'rgba(255,60,90,0.15)');
g.fillStyle=hz;g.fillRect(0,H-44,W,44);
const vg=g.createRadialGradient(W/2,H*0.45,240,W/2,H*0.45,600);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,0.45)');
g.fillStyle=vg;g.fillRect(0,0,W,H);
}
function makeIcon(kind){
const S=34,c=document.createElement('canvas');
c.width=Math.round(S*DPR);c.height=Math.round(S*DPR);
const g=c.getContext('2d');g.scale(DPR,DPR);g.translate(S/2,S/2);
g.lineCap='round';g.lineJoin='round';
if(kind==='wide'){
const path=()=>{g.beginPath();g.moveTo(-9,0);g.lineTo(9,0);
g.moveTo(-4.5,-5);g.lineTo(-9.5,0);g.lineTo(-4.5,5);
g.moveTo(4.5,-5);g.lineTo(9.5,0);g.lineTo(4.5,5);};
path();g.strokeStyle='#0c1226';g.lineWidth=6;g.stroke();
path();g.strokeStyle='#ffffff';g.lineWidth=3;g.stroke();
}else{
const dots=[[-5.5,4],[5.5,4],[0,-5]];
for(const d of dots){g.beginPath();g.arc(d[0],d[1],5.6,0,TAU);g.fillStyle='#0c1226';g.fill();}
for(const d of dots){g.beginPath();g.arc(d[0],d[1],3.4,0,TAU);g.fillStyle='#ffffff';g.fill();}
}
return c;
}
function makeColorSprite(color){
const pad=8,c=document.createElement('canvas');
c.width=Math.ceil((BW+pad*2)*DPR);c.height=Math.ceil((BH+pad*2)*DPR);
const g=c.getContext('2d');g.scale(DPR,DPR);
const x=pad,y=pad;
rrect(g,x,y,BW,BH,5);
g.shadowColor=hexA(color,0.55);g.shadowBlur=9;g.fillStyle=color;g.fill();g.shadowBlur=0;
const gr=g.createLinearGradient(0,y,0,y+BH);
gr.addColorStop(0,mixc(color,'#ffffff',0.34));gr.addColorStop(0.45,color);gr.addColorStop(1,mixc(color,'#000000',0.36));
rrect(g,x,y,BW,BH,5);g.fillStyle=gr;g.fill();
const gl=g.createLinearGradient(0,y+1,0,y+BH*0.55);
gl.addColorStop(0,'rgba(255,255,255,0.5)');gl.addColorStop(1,'rgba(255,255,255,0)');
rrect(g,x+2,y+2,BW-4,BH*0.5,4);g.fillStyle=gl;g.fill();
rrect(g,x+0.5,y+0.5,BW-1,BH-1,4.5);g.strokeStyle='rgba(255,255,255,0.3)';g.lineWidth=1;g.stroke();
return {c,pad,w:BW+pad*2,h:BH+pad*2};
}
function makeToughSprite(cracked,seed){
const pad=8,c=document.createElement('canvas');
c.width=Math.ceil((BW+pad*2)*DPR);c.height=Math.ceil((BH+pad*2)*DPR);
const g=c.getContext('2d');g.scale(DPR,DPR);
const x=pad,y=pad;
rrect(g,x,y,BW,BH,5);
g.shadowColor='rgba(150,185,240,0.45)';g.shadowBlur=cracked?5:9;g.fillStyle='#9fb4d4';g.fill();g.shadowBlur=0;
const gr=g.createLinearGradient(0,y,0,y+BH);
gr.addColorStop(0,'#e9effb');gr.addColorStop(0.45,'#aebfd9');gr.addColorStop(1,'#63779b');
rrect(g,x,y,BW,BH,5);g.fillStyle=gr;g.fill();
g.fillStyle='rgba(255,255,255,0.28)';g.fillRect(x+4,y+BH*0.34,BW-8,1.6); // sheen
g.fillStyle='rgba(24,38,66,0.55)';
const riv=[[6,6],[BW-6,6],[6,BH-6],[BW-6,BH-6]];
for(const rv of riv){g.beginPath();g.arc(x+rv[0],y+rv[1],1.7,0,TAU);g.fill();}
rrect(g,x+0.5,y+0.5,BW-1,BH-1,4.5);g.strokeStyle='rgba(255,255,255,0.35)';g.lineWidth=1;g.stroke();
if(cracked){
rrect(g,x,y,BW,BH,5);g.fillStyle='rgba(10,15,30,0.3)';g.fill();
const rnd=mulberry(seed*2654435761+11);
g.strokeStyle='rgba(10,15,28,0.95)';g.lineWidth=1.8;g.lineJoin='round';
let px=x+BW*(0.28+rnd()*0.3),py=y-1;
g.beginPath();g.moveTo(px,py);
for(let i=1;i<=5;i++){px+=(rnd()-0.5)*16;py=y-1+(BH+2)*i/5;g.lineTo(px,py);}
g.stroke();
g.beginPath();g.moveTo(px,y+BH*0.4);
let bx=px,by=y+BH*0.4;
for(let i=0;i<3;i++){bx+=(rnd()-0.15)*14;by+=(rnd()-0.5)*8;g.lineTo(bx,by);}
g.stroke();
g.fillStyle='#070b18';
for(let i=0;i<3;i++){ // chipped edges
const cx0=x+6+rnd()*(BW-16);
g.beginPath();g.moveTo(cx0,y);g.lineTo(cx0+5+rnd()*6,y);g.lineTo(cx0+2+rnd()*4,y-2.5-rnd()*2.5);g.closePath();g.fill();
}
}
return {c,pad,w:BW+pad*2,h:BH+pad*2};
}
function getSprite(br){
const key=br.tough?('T'+br.hp+'v'+(br.seed%6)):('N'+br.r);
let s=spriteCache[key];
if(!s){s=br.tough?makeToughSprite(br.hp===1,br.seed%6):makeColorSprite(ROWC[br.r]);spriteCache[key]=s;}
return s;
}
/* ============================== level / game setup ============================== */
function isTough(r,c){
if(r===0)return true;
if(r===2)return c%2===0;
if(r===4)return c%2===1;
if(r===6)return c%4===1;
return false;
}
function buildBricks(){
bricks=[];
const pm=new Map();
for(const s of POWER_SPOTS)pm.set(s.r*COLS+s.c,s.k);
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const tough=isTough(r,c);
bricks.push({r,c,x:SIDE+c*(BW+GAP),y:BTOP+r*(BH+GAP),w:BW,h:BH,
tough,hp:tough?2:1,power:pm.get(r*COLS+c)||null,alive:true,seed:r*31+c*7});
}
bricksLeft=ROWS*COLS;
}
function serve(){
balls=[{x:paddle.x,y:paddle.y-BALL_R-3,vx:0,vy:0,stuck:true,trail:[]}];
phase='serve';serveT=0;combo=0;
}
function resetGame(){
score=0;lives=3;combo=0;ballSpeed=BASE_SPEED;bonus=0;
caps=[];parts=[];pops=[];rings=[];balls=[];
paddle.x=W/2;paddle.w=110;paddle.wideT=0;paddle.squash=0;
shake=0;flashA=0;paused=false;demoT=-1;
buildBricks();
serve();
}
function startGame(){
if(mode!=='demo')return;
mode='game';
ovStart.classList.add('hidden');ovEnd.classList.add('hidden');
resetGame();
}
function restartGame(){ovEnd.classList.add('hidden');resetGame();}
/* ============================== fx spawners ============================== */
function pop(x,y,txt,c,size){pops.push({x:clamp(x,70,W-70),y,txt,c,size,t:0,life:0.9});}
function burst(x,y,color,n){
for(let i=0;i<n;i++){
const a=rand(0,TAU),s=rand(50,330);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-rand(20,110),life:rand(0.35,0.85),t:0,
size:rand(1.8,4.6),color,rot:rand(0,TAU),vr:rand(-9,9),g:880});
}
if(parts.length>420)parts.splice(0,parts.length-420);
}
function confetti(){
for(let i=0;i<150;i++){
parts.push({x:rand(0,W),y:rand(-60,-4),vx:rand(-50,50),vy:rand(60,180),life:rand(1.2,2.4),t:0,
size:rand(2,5),color:pick(CONF),rot:rand(0,TAU),vr:rand(-7,7),g:60});
}
}
function splashFx(x,y){
for(let i=0;i<24;i++){
const a=rand(-Math.PI*0.95,-0.05),s=rand(80,320);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(0.35,0.8),t:0,
size:rand(2,4.5),color:'#ff5d78',rot:rand(0,TAU),vr:rand(-9,9),g:700});
}
rings.push({x,y,max:60,t:1,c:'#ff5d78'});
}
function wallFx(x,y){
for(let i=0;i<4;i++){
const a=rand(0,TAU),s=rand(30,120);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(0.15,0.35),t:0,
size:rand(1,2.4),color:'#9fc0ff',rot:0,vr:0,g:0});
}
beep(150,0.03,'sine',0.028);
}
function onPaddle(b){
combo=0;paddle.squash=1;
rings.push({x:b.x,y:paddle.y,max:26,t:1,c:'#8ef1ff'});
beep(230,0.05,'sine',0.055,70);
}
/* ============================== physics ============================== */
function colOf(x){return clamp(Math.floor((x-SIDE)/(BW+GAP)),0,COLS-1);}
function rowOf(y){return clamp(Math.floor((y-BTOP)/(BH+GAP)),0,ROWS-1);}
function setSpeeds(){
for(const b of balls){if(b.stuck)continue;const m=Math.hypot(b.vx,b.vy);if(m>0){const k=ballSpeed/m;b.vx*=k;b.vy*=k;}}
}
function hitBrick(b,br){
br.hp--;
const cx=br.x+br.w/2,cy=br.y+br.h/2;
if(br.hp<=0){
br.alive=false;bricksLeft--;combo++;
const pts=(br.tough?120:50)+(br.power?100:0)+(combo-1)*10;
score+=pts;
const col=br.tough?'#aebfd9':(ROWC[br.r]||'#ffffff');
burst(cx,cy,col,br.tough?16:12);
rings.push({x:cx,y:cy,max:br.tough?44:32,t:1,c:col});
pop(cx,cy,'+'+pts,br.power?'#ffd166':'#eef3ff',br.power?14:12);
if(br.power)spawnCap(br);
shake=Math.min(9,shake+(br.tough?2.6:1.6));
ballSpeed=Math.min(MAX_SPEED,ballSpeed+5);setSpeeds();
beep(300+(ROWS-br.r)*42,0.08,'square',0.05,-130);
if(combo%7===0)pop(W/2,TOP+130,'COMBO \u00d7'+combo+'!','#ffd166',18);
if(bricksLeft<=0)onCleared();
}else{
score+=30;
pop(cx,cy,'+30','#aab6e0',11);
burst(cx,cy,'#8ea3c8',6);
shake=Math.min(9,shake+1);
beep(170,0.06,'square',0.045);
}
}
function resolveBrick(b,br,dx,dy){
let nx,ny,pen;
if(dx===0&&dy===0){
const l=b.x-br.x,rt=br.x+br.w-b.x,t=b.y-br.y,bo=br.y+br.h-b.y;
const m=Math.min(l,rt,t,bo);
if(m===t){nx=0;ny=-1;}else if(m===bo){nx=0;ny=1;}else if(m===l){nx=-1;ny=0;}else{nx=1;ny=0;}
pen=b.r+m;
}else{
const d=Math.hypot(dx,dy);nx=dx/d;ny=dy/d;pen=b.r-d;
}
if(pen>0){
b.x+=nx*(pen+0.1);b.y+=ny*(pen+0.1);
const dot=b.vx*nx+b.vy*ny;
if(dot<0){b.vx-=2*dot*nx;b.vy-=2*dot*ny;}
hitBrick(b,br);
}
}
function brickCollide(b){
const r0=rowOf(b.y-b.r),r1=rowOf(b.y+b.r),c0=colOf(b.x-b.r),c1=colOf(b.x+b.r);
for(let r=r0;r<=r1;r++){
const base=r*COLS;
for(let c=c0;c<=c1;c++){
const br=bricks[base+c];
if(!br||!br.alive)continue;
const nx=clamp(b.x,br.x,br.x+br.w),ny=clamp(b.y,br.y,br.y+br.h);
const dx=b.x-nx,dy=b.y-ny;
if(dx*dx+dy*dy<=b.r*b.r){resolveBrick(b,br,dx,dy);return;}
}
}
}
function collideWorld(b){
if(b.x<b.r){b.x=b.r;if(b.vx<0)b.vx=-b.vx;wallFx(2,b.y);}
else if(b.x>W-b.r){b.x=W-b.r;if(b.vx>0)b.vx=-b.vx;wallFx(W-2,b.y);}
if(b.y<TOP+b.r){b.y=TOP+b.r;if(b.vy<0)b.vy=-b.vy;wallFx(b.x,TOP+2);}
const p=paddle;
if(b.vy>0&&b.y+b.r>=p.y&&b.y-b.r<=p.y+p.h&&Math.abs(b.x-p.x)<=p.w/2+b.r){
const rel=clamp((b.x-p.x)/(p.w/2+b.r*0.6),-1,1); // -1 edge .. 0 center .. +1 edge
const ang=rel*1.05; // up to ~60 degrees
const s=Math.hypot(b.vx,b.vy)||ballSpeed;
b.vx=Math.sin(ang)*s;b.vy=-Math.cos(ang)*s;
b.y=p.y-b.r-0.5;
onPaddle(b);
}
brickCollide(b);
const s2=Math.hypot(b.vx,b.vy); // never let it go flat
if(s2>1&&Math.abs(b.vy)<0.16*s2){
const sy=b.vy===0?-1:Math.sign(b.vy);
b.vy=sy*0.16*s2;
const k=s2/Math.hypot(b.vx,b.vy);b.vx*=k;b.vy*=k;
}
}
function moveBalls(dt){
const dead=[];
for(const b of balls){
if(b.stuck)continue;
const sp=Math.hypot(b.vx,b.vy)||1;
const steps=Math.max(1,Math.ceil(sp*dt/3.5)); // sub-stepping: no tunneling
const sdt=dt/steps;
for(let i=0;i<steps;i++){
b.x+=b.vx*sdt;b.y+=b.vy*sdt;
collideWorld(b);
if(phase!=='play')break;
}
b.trail.push({x:b.x,y:b.y});
if(b.trail.length>11)b.trail.shift();
if(b.y-b.r>H+4)dead.push(b);
}
for(const b of dead){
const i=balls.indexOf(b);if(i>=0)balls.splice(i,1);
splashFx(b.x,Math.min(b.y,H-6));
}
if(!balls.length&&phase==='play')loseLife();
}
function launchAll(){
let did=false;
for(const b of balls){
if(!b.stuck)continue;
b.stuck=false;
const a=rand(-0.3,0.3);
b.vx=Math.sin(a)*ballSpeed;b.vy=-Math.cos(a)*ballSpeed;
did=true;
}
if(did){phase='play';serveT=0;beep(480,0.08,'triangle',0.05,220);}
}
/* ============================== power-ups ============================== */
function spawnCap(br){caps.push({bx:br.x+br.w/2,x:br.x+br.w/2,y:br.y+br.h/2,kind:br.power,t:rand(0,1),vy:30});}
function updCaps(dt){
const p=paddle;
for(let i=caps.length-1;i>=0;i--){
const cp=caps[i];
cp.t+=dt;cp.vy=Math.min(250,cp.vy+170*dt);
cp.y+=cp.vy*dt;
cp.x=cp.bx+Math.sin(cp.t*3.1)*7;
if(cp.y+11>=p.y&&cp.y-11<=p.y+p.h+6&&Math.abs(cp.x-p.x)<=p.w/2+16){
applyPower(cp.kind);caps.splice(i,1);continue;
}
if(cp.y-14>H)caps.splice(i,1);
}
}
function applyPower(kind){
paddle.squash=0.7;
if(kind==='wide'){
paddle.wideT=10;
pop(paddle.x,paddle.y-34,'WIDE PADDLE!','#ffd166',15);
chime([620,830,1100]);
}else{
const pool=balls.filter(b=>!b.stuck);
const src=pool.length?pool[0]:null;
if(src){
const base=Math.atan2(src.vx,-src.vy);
const offs=[0.55,-0.55,0.95,-0.95,0.3,-0.3];
let k=0;
while(balls.length<3&&k<offs.length){
const ang=base+offs[k];
balls.push({x:src.x,y:src.y,vx:Math.sin(ang)*ballSpeed,vy:-Math.cos(ang)*ballSpeed,stuck:false,trail:[]});
k++;
}
}
pop(paddle.x,paddle.y-34,'MULTI-BALL!','#5eead4',15);
chime([520,700,940]);
}
burst(paddle.x,paddle.y-8,kind==='wide'?'#ffd166':'#5eead4',18);
rings.push({x:paddle.x,y:paddle.y-10,max:70,t:1,c:kind==='wide'?'#ffd166':'#5eead4'});
shake=Math.min(10,shake+2);
}
/* ============================== lives / end ============================== */
function loseLife(){
lives--;combo=0;paddle.wideT=0;
shake=Math.min(14,shake+9);flashA=0.32;flashC='255,70,95';
chime([300,220,150],'sawtooth',0.13,0.11,0.05);
if(lives<=0){
if(mode==='demo'){lives=3;serve();}
else{phase='over';showEnd(false);}
}else serve();
}
function onCleared(){
if(mode==='demo'){phase='clearwait';demoT=2.2;confetti();return;}
bonus=lives*250;score+=bonus;
phase='win';flashA=0.28;flashC='255,214,102';
confetti();chime([523,659,784,1047],'triangle',0.16,0.12,0.06);
showEnd(true);
}
function showEnd(won){
endTitle.textContent=won?'LEVEL CLEARED!':'GAME OVER';
endTitle.classList.toggle('win',won);
endTitle.classList.toggle('lose',!won);
endScore.textContent='SCORE '+String(score).padStart(6,'0');
endNote.textContent=won?('perfect clearance \u2014 life bonus +'+bonus):'the ball slipped past your paddle';
ovEnd.classList.remove('hidden');
}
/* ============================== paddle control / demo AI ============================== */
function clampPaddle(){paddle.x=clamp(paddle.x,paddle.w/2+4,W-paddle.w/2-4);}
function playerUpdate(dt){
const v=720;
if(keys.l)paddle.x-=v*dt;
if(keys.r)paddle.x+=v*dt;
}
function foldX(x,min,max){
const range=max-min,period=range*2;
let p=(x-min)%period;if(p<0)p+=period;
return p<range?min+p:min+period-p;
}
function aiUpdate(dt){
const p=paddle;
let target=W/2,best=1e9,tx=null;
for(const b of balls){
if(b.stuck||b.vy<=40)continue;
const t=(p.y-b.r-b.y)/b.vy;
if(t>-0.15&&t<best){best=t;tx=foldX(b.x+b.vx*t,b.r,W-b.r);}
}
if(tx!==null){
target=tx+Math.sin(time*2.3)*8+Math.sin(time*4.7)*5; // human-ish wobble
}else if(caps.length){
let c=caps[0],dd=1e9;
for(const cp of caps){const d=Math.abs(cp.x-p.x);if(d<dd){dd=d;c=cp;}}
target=c.x; // go catch drops
}else{
const b=balls.find(x=>!x.stuck)||balls[0];
target=(b&&!b.stuck)?b.x:W/2+Math.sin(time*0.9)*140;
}
p.x+=clamp(target-p.x,-660*dt,660*dt);
}
/* ============================== update ============================== */
function update(dt){
time+=dt;
for(const f of flies){f.y+=f.vy*dt;f.x+=Math.sin(time*0.5+f.ph)*8*dt;if(f.y<-6){f.y=H+6;f.x=rand(0,W);}}
shake*=Math.pow(0.0002,dt);if(shake<0.04)shake=0;
flashA=Math.max(0,flashA-dt*1.5);
paddle.squash=Math.max(0,paddle.squash-dt*5);
for(let i=parts.length-1;i>=0;i--){const p=parts[i];p.t+=dt;if(p.t>=p.life){parts.splice(i,1);continue;}
p.vy+=p.g*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;}
for(let i=pops.length-1;i>=0;i--){const p=pops[i];p.t+=dt;p.y-=30*dt;if(p.t>=p.life)pops.splice(i,1);}
for(let i=rings.length-1;i>=0;i--){rings[i].t-=dt*2.6;if(rings[i].t<=0)rings.splice(i,1);}
if(phase==='over'||phase==='win')return;
if(paused)return;
if(phase==='clearwait'){demoT-=dt;if(demoT<=0)resetGame();return;}
if(mode==='demo')aiUpdate(dt);else playerUpdate(dt);
clampPaddle();
const tw=paddle.wideT>0?170:110;
paddle.w+=(tw-paddle.w)*Math.min(1,dt*9);
clampPaddle();
if(paddle.wideT>0){
paddle.wideT-=dt;
if(paddle.wideT<=0){paddle.wideT=0;pop(paddle.x,paddle.y-34,'PADDLE NORMAL','#9fb0dd',12);}
}
if(phase==='serve'){
serveT+=dt;
for(const b of balls)if(b.stuck){b.x=paddle.x;b.y=paddle.y-BALL_R-3;}
if(serveT>(mode==='demo'?0.9:4.5))launchAll();
}
if(phase==='play'){
moveBalls(dt);
updCaps(dt);
}
}
/* ============================== drawing ============================== */
function drawBricks(){
for(const br of bricks){
if(!br.alive)continue;
const spr=getSprite(br);
ctx.drawImage(spr.c,br.x-spr.pad,br.y-spr.pad,spr.w,spr.h);
if(br.power){
const pu=0.5+0.5*Math.sin(time*3.5+br.seed);
ctx.strokeStyle=hexA('#ffffff',0.25+0.45*pu);
ctx.lineWidth=1.6;
rrect(ctx,br.x+1.5,br.y+1.5,br.w-3,br.h-3,4);ctx.stroke();
const ic=br.power==='wide'?iconWide:iconMulti;
const s=1+0.1*pu;
ctx.save();ctx.translate(br.x+br.w/2,br.y+br.h/2);ctx.scale(s,s);
ctx.drawImage(ic,-17,-17,34,34);ctx.restore();
}
}
}
function drawCap(cp){
const wide=cp.kind==='wide';
ctx.save();
ctx.translate(cp.x,cp.y);
ctx.rotate(Math.sin(cp.t*2.6)*0.12);
ctx.shadowColor=wide?'rgba(255,180,80,0.8)':'rgba(70,225,200,0.8)';
ctx.shadowBlur=13;
rrect(ctx,-19,-11,38,22,11);
const g=ctx.createLinearGradient(0,-11,0,11);
if(wide){g.addColorStop(0,'#ffe9a8');g.addColorStop(0.55,'#f0a941');g.addColorStop(1,'#96591d');}
else{g.addColorStop(0,'#a8f7e0');g.addColorStop(0.55,'#2ec4b6');g.addColorStop(1,'#0e5b54');}
ctx.fillStyle=g;ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,255,255,0.7)';ctx.lineWidth=1.3;ctx.stroke();
ctx.drawImage(wide?iconWide:iconMulti,-17,-17,34,34);
ctx.restore();
}
function drawParts(){
if(!parts.length)return;
ctx.globalCompositeOperation='lighter';
for(const p of parts){
ctx.globalAlpha=Math.max(0,1-p.t/p.life);
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.rot);
ctx.fillStyle=p.color;ctx.fillRect(-p.size/2,-p.size/2,p.size,p.size);
ctx.restore();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
}
function drawRings(){
for(const r of rings){
ctx.beginPath();
ctx.arc(r.x,r.y,4+(r.max-4)*(1-r.t*r.t),0,TAU);
ctx.strokeStyle=hexA(r.c,r.t*0.5);
ctx.lineWidth=1+2.5*r.t;ctx.stroke();
}
}
function drawBall(b){
const n=b.trail.length;
if(n>1){
ctx.globalCompositeOperation='lighter';
for(let i=0;i<n;i++){
const t=b.trail[i],k=(i+1)/n;
ctx.globalAlpha=k*0.18;ctx.fillStyle='#7fd4ff';
ctx.beginPath();ctx.arc(t.x,t.y,b.r*k,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
}
ctx.save();
ctx.shadowColor='rgba(120,210,255,0.9)';ctx.shadowBlur=12;
const g=ctx.createRadialGradient(b.x-2.5,b.y-3,1,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff');g.addColorStop(0.55,'#d6efff');g.addColorStop(1,'#7cc7ff');
ctx.fillStyle=g;
ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,TAU);ctx.fill();
ctx.restore();
}
function drawPaddle(){
const p=paddle,hw=p.w/2,wide=p.wideT>0;
const blink=(wide&&p.wideT<2)?(0.45+0.55*Math.abs(Math.sin(time*8))):1;
ctx.save();
ctx.translate(p.x,p.y+p.h/2);
const sq=p.squash;
ctx.scale(1+sq*0.18,1-sq*0.3);
ctx.globalAlpha=blink;
ctx.shadowColor=wide?'rgba(255,180,80,0.85)':'rgba(90,200,255,0.85)';
ctx.shadowBlur=15;
rrect(ctx,-hw,-p.h/2,p.w,p.h,p.h/2);
const g=ctx.createLinearGradient(0,-p.h/2,0,p.h/2);
if(wide){g.addColorStop(0,'#ffe9a8');g.addColorStop(1,'#f59e2b');}
else{g.addColorStop(0,'#a9f2ff');g.addColorStop(1,'#3f7dff');}
ctx.fillStyle=g;ctx.fill();
ctx.shadowBlur=0;
rrect(ctx,-hw+3,-p.h/2+2,p.w-6,4,2);
ctx.fillStyle='rgba(255,255,255,0.55)';ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.9)';
ctx.beginPath();ctx.arc(-hw+7,0,2.2,0,TAU);ctx.fill();
ctx.beginPath();ctx.arc(hw-7,0,2.2,0,TAU);ctx.fill();
ctx.restore();
ctx.globalAlpha=1;
}
function drawPops(){
ctx.textAlign='center';ctx.textBaseline='alphabetic';
for(const p of pops){
const a=p.t<0.1?p.t/0.1:Math.max(0,1-(p.t-0.1)/(p.life-0.1));
ctx.globalAlpha=a*0.95;
ctx.font='800 '+p.size+'px '+MONO;
ctx.fillStyle=p.c;ctx.shadowColor=p.c;ctx.shadowBlur=8;
ctx.fillText(p.txt,p.x,p.y);
}
ctx.globalAlpha=1;ctx.shadowBlur=0;
}
function drawHUD(){
ctx.textBaseline='alphabetic';
ls('2px');
ctx.textAlign='left';
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('SCORE',26,25);
ls('1px');
ctx.font='800 21px '+MONO;ctx.fillStyle='#f0f4ff';
ctx.fillText(String(score).padStart(6,'0'),26,48);
ctx.textAlign='center';
ls('6px');
ctx.font='800 12px '+MONO;ctx.fillStyle='#8093ff';
ctx.fillText('NEON BREAKOUT',W/2+3,26);
ls('2px');
if(paddle.wideT>0){
ctx.font='700 9px '+MONO;ctx.fillStyle='#ffca6b';
ctx.fillText('WIDE PADDLE',W/2+1,43);
const bw2=120,bx=W/2-bw2/2,by=47;
ctx.strokeStyle='rgba(255,202,107,0.45)';ctx.lineWidth=1;
ctx.strokeRect(bx+0.5,by+0.5,bw2,5);
const gg=ctx.createLinearGradient(bx,0,bx+bw2,0);
gg.addColorStop(0,'#ffe9a8');gg.addColorStop(1,'#ff9f43');
ctx.fillStyle=gg;
ctx.fillRect(bx+1.5,by+1.5,Math.max(0,(bw2-3)*clamp(paddle.wideT/10,0,1)),3);
}else if(combo>=2){
ctx.font='800 13px '+MONO;ctx.fillStyle='#ffd166';
ctx.fillText('COMBO \u00d7'+combo,W/2+1,47);
}else{
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('LEVEL 1',W/2+1,46);
}
ctx.textAlign='right';
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('LIVES',W-26,25);
for(let i=0;i<3;i++){
const lx=W-26-22-i*30,ly=33;
rrect(ctx,lx,ly,22,7,3.5);
if(i<lives){
const gg=ctx.createLinearGradient(0,ly,0,ly+7);
gg.addColorStop(0,'#9ef1ff');gg.addColorStop(1,'#3f8cff');
ctx.fillStyle=gg;ctx.fill();
}else{
ctx.strokeStyle='rgba(150,168,220,0.28)';ctx.lineWidth=1;ctx.stroke();
}
}
ls('0px');
}
function draw(){
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle='#05060f';ctx.fillRect(0,0,W,H);
ctx.save();
if(shake>0.05)ctx.translate(rand(-shake,shake),rand(-shake,shake));
ctx.drawImage(bgCv,0,0,W,H);
ctx.globalCompositeOperation='lighter';
for(const f of flies){
ctx.globalAlpha=f.a*(0.5+0.5*Math.sin(time*1.6+f.ph));
ctx.fillStyle='#8fa6ff';
ctx.beginPath();ctx.arc(f.x,f.y,f.r,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
drawBricks();
for(const cp of caps)drawCap(cp);
drawParts();
drawRings();
for(const b of balls)drawBall(b);
drawPaddle();
drawPops();
if(mode==='game'&&phase==='serve'&&!paused){
const a=0.45+0.5*(0.5+0.5*Math.sin(time*5));
ctx.textAlign='center';ctx.textBaseline='alphabetic';
ctx.globalAlpha=a;ctx.fillStyle='#cfe0ff';ctx.font='700 12px '+MONO;
ls('3px');ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,H-92);ls('0px');
ctx.globalAlpha=1;
}
ctx.restore();
if(flashA>0){ctx.fillStyle='rgba('+flashC+','+flashA.toFixed(3)+')';ctx.fillRect(0,0,W,H);}
ctx.fillStyle='rgba(255,70,100,'+(0.10+0.06*Math.sin(time*2.2)).toFixed(3)+')';
ctx.fillRect(0,H-3,W,3);
drawHUD();
if(paused&&mode==='game'){
ctx.fillStyle='rgba(4,6,15,0.55)';ctx.fillRect(0,0,W,H);
ctx.textAlign='center';ctx.textBaseline='alphabetic';
ctx.fillStyle='#e6ecff';ctx.font='800 30px '+MONO;
ls('6px');ctx.fillText('PAUSED',W/2+3,H/2-6);ls('0px');
ctx.fillStyle='#8b98c9';ctx.font='700 12px '+MONO;
ls('2px');ctx.fillText('PRESS P TO RESUME',W/2,H/2+22);ls('0px');
}
}
/* ============================== input ============================== */
addEventListener('resize',fit);
ovStart.addEventListener('pointerdown',()=>{initAudio();startGame();});
ovEnd.addEventListener('pointerdown',()=>{initAudio();if(mode==='game')restartGame();});
cv.addEventListener('pointermove',e=>{
if(mode!=='game'||paused||phase==='over'||phase==='win')return;
const r=cv.getBoundingClientRect();
if(!r.width)return;
paddle.x=clamp((e.clientX-r.left)/r.width*W,paddle.w/2+4,W-paddle.w/2-4);
});
cv.addEventListener('pointerdown',()=>{
initAudio();
if(mode==='demo'){startGame();return;}
if(phase==='serve'&&!paused)launchAll();
});
addEventListener('keydown',e=>{
const c=e.code;
if(c==='ArrowLeft'||c==='ArrowRight'||c==='ArrowUp'||c==='ArrowDown'||c==='Space')e.preventDefault();
initAudio();
if(mode==='demo')startGame();
if(phase==='over'||phase==='win'){
if(!e.repeat&&(c==='KeyR'||c==='Enter'||c==='Space'))restartGame();
return;
}
if(c==='ArrowLeft')keys.l=true;
else if(c==='ArrowRight')keys.r=true;
else if(c==='KeyP'&&!e.repeat&&(phase==='play'||phase==='serve'))paused=!paused;
else if(c==='KeyR'&&!e.repeat)restartGame();
else if((c==='Space'||c==='ArrowUp'||c==='Enter')&&phase==='serve'&&!paused)launchAll();
});
addEventListener('keyup',e=>{
if(e.code==='ArrowLeft')keys.l=false;
if(e.code==='ArrowRight')keys.r=false;
});
document.addEventListener('visibilitychange',()=>{if(document.hidden&&mode==='game'&&(phase==='play'||phase==='serve'))paused=true;});
addEventListener('blur',()=>{if(mode==='game'&&(phase==='play'||phase==='serve'))paused=true;});
/* ============================== boot: demo plays itself ============================== */
fit();
resetGame();
let last=performance.now();
requestAnimationFrame(function loop(now){
let dt=(now-last)/1000;last=now;
if(dt>0.033)dt=0.033;if(dt<0)dt=0;
update(dt);draw();
requestAnimationFrame(loop);
});
</script>
</body>
</html>Reasoning trace (58,037 tokens)
Let me plan this Breakout game carefully. I need a polished single-file HTML game with:
1. Canvas-based Breakout
2. Paddle: mouse + arrow keys
3. Angle-based bounce off paddle
4. 6+ rows of bricks, mixing normal (1 hit) and tough (2 hits, cracks after first)
5. Power-up bricks with icons: wide paddle (10s) or multi-ball (3 balls)
6. No pass-through (proper collision detection with continuous-ish handling)
7. Particles on brick break
8. 3 lives, score, game over/victory screens, restart
Also per system rules:
- Self-playing demo mode (AI plays) behind "Click or press a key to start" message
- Everything visible within first seconds
- No horizontal scrollbar, responsive 1280×800 → 375px
- Zero console errors
- No localStorage etc.
Let me design the game:
**Layout**: Full-page dark background with the canvas centered. HUD drawn on canvas (score, lives, level, active power-ups). Use a fixed internal resolution (e.g., 900×640 logical) scaled to fit via CSS with devicePixelRatio for crispness.
Actually, let me make it responsive: canvas fills a container, logical coordinates fixed (e.g., W=900, H=620), scale via CSS transform or set canvas size to container * dpr and scale context. Simpler: keep fixed logical size and use CSS `width: min(...)`. I'll set canvas width/height attributes to logical*dpr and scale ctx, then CSS width to fit. Mouse coordinates need mapping from CSS pixels to logical.
**Game design**:
- Walls: left, right, top. Bottom = death.
- Paddle: rounded rect, gradient, glow. Width 110 base, 165 when wide power-up. Speed for keyboard: ~700px/s.
- Ball: radius 8, speed starts ~420, increases slightly over time or per bricks broken (cap).
- Bricks: grid, e.g., 12 cols × 8 rows. Row patterns: type normal (1 hp), tough (2 hp, cracks), power bricks carry icon (either type? Make power bricks normal 1-hit usually, but some tough ones could also carry... simpler: power-up carriers are designated bricks with icon; when broken, drop capsule). Let me assign: rows 0-7. Colors per row. Tough bricks in a pattern (e.g., checkerboard in some rows, or specific columns). Power bricks randomly ~10% but deterministic for demo consistency? Random is fine, seeded random maybe for reproducibility. I'll just use Math.random at level init.
- Collision: To avoid pass-through, use sub-stepping: move ball in small steps (e.g., max step = ball radius * 0.5 or based on speed dt). Each step, check brick collisions (grid lookup by cell), paddle collision, walls. Also handle simultaneous near-corner by resolving with smaller penetration axis. Sub-stepping with step ≤ radius/2 makes tunneling practically impossible at reasonable speeds.
For brick collision: compute ball's AABB overlap with brick rect; determine hit side by comparing previous position or minimal penetration axis. With sub-steps it's fine.
For paddle: only bounce when ball moving downward and ball bottom crosses paddle top. With sub-stepping, check if ball intersects paddle rect and vy > 0, then reflect with angle based on hit position: `rel = (ballX - paddleCenter)/ (paddleHalf)` clamp to [-1,1], angle = rel * maxAngle (e.g., 60°), so center = straight up. Speed preserved (slightly boosted). Also set ball y above paddle to prevent sticking.
**Power-ups**: falling capsules with icon (drawn: arrows out for wide — I'll draw "↔" style arrows; multi-ball — three small circles). Rotation/animated. Caught by paddle → apply. Wide: paddle width ×1.5 for 10s, HUD shows timer bar. Multi-ball: split each existing ball into... "gives multi-ball (3 balls)" — spawn so total = 3 balls? Requirement says "gives either a wider paddle (10 seconds) or multi-ball (3 balls)". I'll interpret: multiball results in 3 balls total (spawn balls up to 3; if already 3+, maybe add clones anyway—cap at, say, 6). Let me do: ensure at least 3 balls; if fewer than 3, add clones at angles from existing balls. Actually simplest faithful: when caught, set ball count to 3 (clone existing ball(s) with varied angles). If ball already ≥3, add 2 more? Let me cap at 6 and add clones. Hmm, requirement "multi-ball (3 balls)" — I'll make it: balls are duplicated until there are 3 balls (from 1 → 3, i.e., 2 clones). If already ≥3, add one extra? Keep simple: bring total to min 3, cap 6 total by adding up to... I'll do: while balls.length < 3 and balls.length>0: clone. If already ≥3, skip (or still fine). That satisfies "3 balls".
Lives: lose life only when ALL balls are lost (multi-ball grace). 3 lives. Lose ball → if balls remaining 0, life--, reset ball on paddle (serve state) or demo continues.
**Demo mode / AI**: On load, game auto-starts in demo mode with an AI paddle: AI moves paddle toward nearest ball's predicted x (with some smoothing/imperfection so it looks natural, misses occasionally? Better to look good: track ball with slight lag and error). Demo shows overlay "Click or press any key to start". On click/keydown → reset game to fresh state, player controls, hide overlay.
During demo, also serve automatically after brief delay when ball lost, keep lives infinite (or just reset lives silently when game over in demo — restart demo). Simpler: in demo mode, when game over happens, auto-restart demo. Overlay stays visible.
Keyboard: arrows move paddle; Space could launch/serve ball? Add serve on click too. Also pause with P maybe. Keep requirements: mouse + arrows. I'll add: ball starts attached to paddle, launch on mouse click or Space or Up. Actually for smoothness in play mode: after losing a life, ball sits on paddle; press Space/click to launch, or auto-launch after 2s? Let's auto-launch after 1.5s to keep flow, plus manual launch with click/space/up. Hmm, for polish, a "serve" state with hint text "Click or press Space to launch". I'll do manual launch with also auto-launch after 3 seconds so game never stalls. Actually in demo mode AI launches automatically after short delay.
**Screens**:
- Start overlay: title "NEON BREAKOUT", "Click or press any key to start", controls hint. Semi-transparent over live demo game.
- Game over screen: "GAME OVER", final score, restart button (click anywhere / press R / button drawn on canvas). I'll draw overlay on canvas with a styled "RESTART" button (canvas-drawn, clickable via hit test). Simpler & robust: click anywhere restarts, plus a visible button; keyboard R restarts.
- Victory: "YOU WIN / LEVEL CLEARED", score, maybe bonus, restart.
**Visual polish**:
- Dark background with subtle grid/vignette.
- Neon aesthetic: bricks with glow (shadowBlur), gradients.
- Ball with trail (store last positions, draw fading circles).
- Particles: colored squares/circles with velocity, gravity, fade.
- Screen shake on brick break (tiny) and on life lost (bigger).
- Floating score popups ("+50").
- Power-up timer bar in HUD.
- Combo multiplier? Extra flair: consecutive brick hits without paddle touch increase multiplier — nice touch, displayed. Keep it but simple: combo counter resets on paddle hit; points = base * (1 + combo*?) Hmm keep score simple: base points, maybe small combo bonus. I'll add combo text but keep scoring straightforward: normal 50, tough 80 (first hit 20?), power brick 100 + powerup. Let's do: normal 50, tough first hit 25 crack, destroy 75, power brick 100. Combo: each brick broken without touching paddle adds +10*combo bonus. Fine.
- Lives shown as small paddle icons or hearts.
**Fonts**: No external fonts (no network requests). Use monospace/system stack with letter-spacing for arcade feel. I'll style a font stack: `"Courier New", monospace`? A bit dated. Use system-ui with bold + letterspacing looks fine. Since no external resources allowed, stick with generic families. Maybe use `font-family: 'Segoe UI', system-ui` for HUD, and for the retro title use monospace bold with letter-spacing. I'll draw HUD text on canvas with such fonts.
**Responsiveness**: Container: `display:flex; center; height:100dvh`. Canvas CSS size: `width: min(100vw - 24px, ...)` maintain aspect ratio; use aspect-ratio CSS. Logical size 900×620? For 1280×800 viewport: available height ~800 minus page padding; width up to ~1256. Aspect ratio 900/620 ≈ 1.45. At width 1256 → height ~866 too tall; so constrain by height: canvas height ≈ 760 → width ≈ 1104. Use CSS: `max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); aspect-ratio: 900/620; width: auto; height: calc(100dvh - 24px)`? Simpler: set both maxes and use `object-fit`-like via computing in JS on resize. I'll just do CSS:
```css
canvas {
aspect-ratio: 900 / 620;
height: min(calc(100dvh - 24px), calc((100vw - 24px) * 620 / 900));
width: auto; /* derived from aspect ratio via height */
}
```
Hmm, with aspect-ratio and height set, width auto-computes. But if width would exceed max-width... Set `max-width: calc(100vw - 24px)` too — when max-width kicks in, aspect-ratio keeps height consistent if height is auto. Conflicts when both constrained. Safer: JS resize handler: compute scale = min(availW/W, availH/H), set canvas.style.width/height. I'll do JS-driven sizing (also needed for dpr crispness).
Mobile 375px: width ~351, height ~242 — small but fine. Touch controls? Not required but maybe add touch to move paddle — nice: touchmove maps to paddle x. I'll add pointer events (pointer covers mouse+touch). Use pointermove on canvas.
**Collision details**:
Sub-stepping:
```
const steps = Math.ceil((speed * dt) / (BALL_R * 0.6));
for each step: move ball by v*dt/steps; resolve collisions.
```
Max speed maybe 700; dt capped at 1/30. steps ≤ 700/60 / (8*0.6)= 11.6/4.8 ≈ 3. Fine.
Brick grid: cols=12, rows=8. Brick width computed from playfield. Field logical: W=900, H=620. Side walls thickness maybe 0 (canvas edge) but visually add border. Bricks area: margin 24 each side, top offset 70 (below HUD). brickW = (900-48 - gaps)/12. gap 6. So brickW = (852 - 11*6)/12 = (852-66)/12 = 786/12=65.5. brickH = 24, gap 6.
8 rows: rows 0-7. Pattern:
- Row 0-1: tough (2hp) mostly, with some normal.
- Rows 2-6: mix.
Let me design: use deterministic-ish pattern with randomness: for each brick, type = tough if pattern (e.g., (r + c) % 5 === 0 or random < 0.25 in top rows). Power carriers: choose ~9 bricks randomly among all, at least spread. Ensure visible icons: draw icon on brick: wide = "↔" double arrow; multi = three dots triangle. I'll draw vector icons with canvas paths (no text glyphs to avoid font issues — actually "↔" glyph may render inconsistently; draw shapes). Wide icon: two horizontal arrows outward. Multi icon: 3 circles.
Cracked state: tough brick after 1 hit → draw crack lines (deterministic zigzag based on brick index) + slightly darker.
Brick colors by row: gradient palette neon: row colors from a palette array, tough bricks have distinct style (e.g., steel/silver with rivets, or darker with border). Let me: normal bricks colored by row (cyan→magenta→amber etc.), tough bricks: darker with metallic sheen and white border. Power bricks: pulsing outline + icon.
Score popups: floating texts array.
**Ball-brick resolution**: For each sub-step, find candidate bricks by grid cell(s) around ball. Compute overlap with each alive brick: if overlap, compute penetration on x and y, reflect on axis of least penetration, push out, damage brick, break loop for this step (only one brick per step to avoid double reflect). Also handle ball hitting two bricks same frame — subsequent steps handle.
Edge case: ball radius small vs brick gaps 6 — ball r=8 > gap, so it can hit sides of neighbors; fine.
**Paddle collision**: rect with rounded top. If ball intersects paddle AABB and vy>0 and ball.y < paddle.y + paddle.h (came from above-ish): compute rel, set angle: `theta = rel * 1.05` rad (~60°), speed = current speed (maybe +2%), vx = sin*speed, vy = -cos*speed, y = paddle.y - r - 0.1. Add slight paddle motion influence? Keep pure position-based per requirement. Also prevent multiple triggers via flag since after setting vy<0 it won't re-trigger.
Also side hits of paddle: if ball hits paddle side while moving down near edge — treat same formula (rel ~ ±1 gives sharp angle). Fine.
**Ball speed ramp**: base 380 → +6 per brick broken, cap 620. Multi-ball clones same speed.
**Lives lost**: when balls.length===0: lives--; shake; if lives<=0 → game over screen. else serve state.
Serve state: ball follows paddle; hint text. Launch: angle slightly random ±0.25 from vertical, or based on last rel? Random small angle.
**Demo AI**:
- Compute target: nearest ball that is below some line and moving down; predict x at paddle y using linear prediction with wall bounces (reflect predicted x across walls). Add error: target += sin(time)*20 and cap paddle speed so it sometimes lags. Move paddle toward target at speed (e.g., 520 px/s). If serve state: after 0.8s launch.
- To keep demo lively, AI should also try to catch power-ups: if a capsule is falling and no ball in danger, target capsule x. Nice.
- Demo never game over: if demo lives run out, just reset demo silently.
Actually simpler: in demo, infinite lives — when all balls lost, just re-serve. Keeps demo running forever. And if all bricks cleared (victory) in demo, re-init level.
**State machine**: `state ∈ {demo, serve, play, over, win}`. Demo overlaps: state='demo' has its own inner serve/play. Let me restructure: `mode = 'demo' | 'game'`, `phase = 'serve' | 'play' | 'over' | 'win'`. On user start: mode='game', reset everything, phase='serve'.
Overlay drawn for: demo (start overlay), over, win.
**Input**:
- pointermove on canvas → paddle target x (in game mode).
- click on canvas: if demo → start game; if over/win → restart (via button hit or anywhere? Use button hit + Enter/R/Space). If phase serve → launch. If play → nothing (or nothing). Click also focuses.
- keydown: any key starts from demo. Arrows set flags. Space/Up: launch or restart on over/win. R: restart anytime. P: pause. Prevent default for arrows/space to avoid scrolling.
Overlay message must show "Click or press a key to start" (per rules).
**HUD**: Top bar inside canvas: SCORE (left), center: title small or combo, right: LIVES icons + power-up timer. Also LEVEL 1 text. HUD drawn on canvas above play area, bricks start below.
**Pause**: P toggles pause with overlay "PAUSED". Not required but polish. Fine, low cost. Also pause when tab hidden (visibilitychange) — avoid huge dt anyway via dt cap.
**Victory bonus**: remaining lives × 250 bonus added on win screen.
**Particles**: on brick break: 12-18 particles, brick color, rects with rotation, gravity 900, life 0.5-0.9s, fade. On ball lost: red burst at bottom. On powerup catch: sparkle ring.
Also "shockwave" ring on brick break — subtle.
**Ball trail**: store positions each frame (cap 10), draw with decreasing alpha/size. With multiple balls, per-ball trail.
**Background**: dark navy with faint grid lines + radial vignette. Maybe slow-moving stars? Keep subtle: static grid + vignette. Also playfield border neon.
**Code structure**:
```
const W=900,H=620; setup canvas, ctx with dpr scaling.
resize(): fit canvas CSS size.
Game object: paddle, balls[], bricks[], capsules[], particles[], popups[], lives, score, combo, shake, timers.
initLevel(): build bricks.
resetGame(mode).
update(dt), draw().
loop with rAF, dt clamp.
```
Let me write the brick layout:
```
COLS=12, ROWS=8, brickH=22, gap=6, topOffset=86, sideMargin=26.
brickW = (W - 2*sideMargin - (COLS-1)*gap)/COLS = (900-52-66)/12 = 782/12 ≈ 65.17
```
Rows 8 = at least 6 required ✓.
Pattern per row (r index 0 top):
- r=0: tough bricks all? Make top row all tough (satisfies "mixing" visibly), maybe with pattern: alternate tough/normal? I want a designed pattern, not random noise. Design:
- Row 0: all tough (steel).
- Row 1: normal.
- Row 2: tough at even columns.
- Row 3: normal.
- Row 4: tough at odd columns?
Hmm let me make it aesthetic: a "fortress" pattern: rows 0-1 tough frame? Let me do:
rows type table:
- r0: T T T T T T T T T T T T (all tough)
- r1: N N P? ... normal with a couple tough accents at c=2, c=9.
- r2: alternate T N T N...
- r3: all normal
- r4: alternate N T N T...
- r5: all normal
- r6: tough at c in {0,1, 10,11}? maybe pattern "T . . T T . . . T T . ." Hmm.
Simpler deterministic: `tough = (r===0) || (r%2===0 && (c+r)%2===0)`? Let me just define: tough if (r===0) or ((r + c) % 4 === 0 && r < 5)? Random-ish is fine too but deterministic pattern looks designed.
I'll go with:
```
isTough(r,c) {
if (r===0) return true; // top row solid steel
if (r===7) return (c%3===0); // bottom accents
return ((r*7 + c*3) % 5) === 0; // scattered
}
```
Hmm scattered deterministic. Or cleaner: checkerboard tough in rows 2 and 5: `(r===2||r===5) && c%2===(r/2|0)`? Let me do visually pleasing:
- Row 0: all tough.
- Row 1: normal all.
- Row 2: tough at even cols.
- Row 3: normal all.
- Row 4: tough at odd cols.
- Row 5: normal all.
- Row 6: tough at cols where c%4===1?
- Row 7: normal all.
That's a clean diagonal-ish rhythm. Tough count: 12 + 6 + 6 + 3 = 27 tough, 69 normal. Total 96 bricks. Breaking all takes a while but demo + game fine. Maybe reduce to make demo win feasible... whatever, it's fine. Actually a full clear in play takes several minutes — acceptable for a game. Maybe 8 rows × 12 = 96 is a lot; could do COLS=11, ROWS=7 → 77. Requirement: at least 6 rows. I'll keep 12×8 but maybe remove some bricks for design (gaps forming a pattern)? Keep full grid; fine.
Power bricks: choose 8 spread positions deterministically: e.g., positions list: (r,c) pairs: (1,2),(1,9),(3,5),(3,6)?,(4,1),(4,10),(6,4),(6,7) with types alternating wide/multi. Assign type alternating: even index → wide, odd → multi. Also maybe a couple tough bricks carry power-ups? Icon on tough brick after crack still shows. Keep power on normal bricks for clarity (1 hit to release). Actually mixing is nice: make (r0,c5) and (r0,c6) tough power carriers? They'd need 2 hits — shows icon persists through crack, cool. I'll add one tough power brick at (0,5) multi and (0,6) wide? Hmm they'd be released early (top row hit first). Let's do a set of 9 power bricks:
```
powers = [
{r:0,c:2,kind:'multi'}, {r:0,c:9,kind:'wide'},
{r:2,c:5,kind:'multi'}, {r:3,c:2,kind:'wide'}, {r:3,c:9,kind:'multi'},
{r:4,c:6,kind:'wide'}, {r:6,c:1,kind:'multi'}, {r:6,c:10,kind:'wide'},
{r:7,c:5? or 6,kind:'multi'}
]
```
Wait row 7 c5: fine. 9 power bricks: 5 multi? count: multi at (0,2),(2,5),(3,9),(6,1),(7,5) =5; wide at (0,9),(3,2),(4,6),(6,10)=4. Balance ok.
But some of these positions might be tough per pattern — that's ok, tough + power exists and works: icon drawn on tough brick; needs 2 hits; crack shows around icon.
Check pattern collisions: row2 tough at even cols: (2,5) is c=5 odd → normal ✓ good (power on normal). (3,2): row3 normal ✓. (4,6): row4 tough at odd cols → c=6 even → normal ✓. (6,1): row6 tough where c%4===1 → c=1 → tough! So (6,1) is tough+power. Fine, acceptable. (6,10): c%4=2 → normal ✓. (7,5): normal ✓. (0,2),(0,9): tough+power ✓.
**Colors**: palette per row (normal bricks):
```
rows colors: ['#ff5d7e','#ff9f45','#ffd166','#7ce38b','#4cc9f0','#7b6cff','#b16cea','#ff5d7e'?]
```
8 rows:
r0 tough steel (independent of row color)
r1 '#ff6b6b'
r2 '#ffb347'? Let me craft:
- r1: #ff4d6d (rose)
- r2: #ff9e4f (orange)
- r3: #ffd166 (amber)
- r4: #06d6a0? maybe #52e5a3 (green)
- r5: #4cc9f0 (cyan)
- r6: #5a7bff (blue)
- r7: #b06cff (violet)
Tough bricks: base #8fa3b8 steel with darker cracks and metallic gradient; when cracked, add fissures and reduced brightness; hp indicator maybe small notches.
Draw brick: rounded rect fill with vertical gradient (lighter top), inner top highlight line, subtle shadowBlur glow with row color at low alpha. Tough: gradient gray-blue + border + 2 rivets? Keep perf ok: 96 bricks with shadowBlur each frame could be heavy. Optimization: prerender bricks to offscreen canvas! Yes — render brick sprites (per row color × state) once to offscreen canvases, then drawImage. That keeps 60fps easily. Cracked tough variant also prerendered. Power icon drawn dynamically (pulse) — or prerender icon too and animate alpha/scale overlay. I'll prerender base brick sprites; draw pulsing outline + icon dynamically on top (icon prerendered as small sprite too).
DPR: sprites should be rendered at dpr scale for crispness. Render sprite canvas at brickW*dpr etc., draw with drawImage(sprite, x, y, w, h). Manage: `makeBrickSprite(color, tough, cracked)` returns canvas of size (bw+pad*2)*dpr? Glow needs padding: pad = 8. Draw at (x-pad, y-pad, bw+2pad, bh+2pad). OK.
Simpler alternative: skip shadowBlur per brick; use gradients + strokes only — cheap enough for 96 bricks without prerender? 96 rounded rects with 2 gradients each per frame = fine, actually. Gradients per brick per frame: creating linear gradients each frame for 96 bricks is okay-ish but wasteful. Prerender is cleaner. I'll prerender. Cache keyed `type_color_cracked`.
Ball: white core with cyan glow (shadowBlur ok, few balls). Paddle: gradient with glow, rounded.
**Capsules (falling power-ups)**: rounded pill ~ 34×18, colored (wide = green? wide=amber? Let's: wide = #ffd166 amber with ↔ icon; multi = #ff5d7e pink/magenta with 3-dot icon). Falls at 150 px/s, slight sway, rotating shine. Catch when intersect paddle rect.
Icons drawn with canvas paths:
- wide: horizontal line with arrowheads both ends (chevrons).
- multi: three circles (two bottom, one top) or triangle arrangement.
Icon also shown on carrier brick (small, dark on light brick? draw white icon with dark outline).
**HUD power-up timer**: when wide active, show bar with remaining time under lives or center-bottom? Draw at top center: "WIDE PADDLE" with shrinking bar. Also paddle could blink when about to expire (last 2s pulse).
**Screen shake**: shake magnitude decays; apply translate before drawing world.
**Score popups**: text rising, fading.
**Sounds**: Web Audio beeps? Not required; could add tiny synth sounds (no external files allowed but WebAudio oscillator is fine, no network). Adds polish. But autoplay policies: audio context must be created after user gesture. In demo mode (no gesture) sounds would be blocked — create/resume on first user interaction. I'll add a minimal sound system: `beep(freq, dur, type, gain)` with try/catch, initialized lazily on first user gesture. Keep volume low. Add sounds: brick hit (short blip pitch by row), brick break (noise-ish), paddle bounce, wall, powerup catch, life lost, win. This is nice polish. Must ensure zero console errors — guard everything.
Edge: In sandboxed iframe, AudioContext allowed? Should be, with user gesture. We'll wrap in try/catch anyway.
**Start overlay**: DOM element over canvas (HTML div) — easier for crisp text & button styling than canvas. I'll use DOM overlays: `#overlayStart` with title, subtitle, "Click or press any key to start", controls list. Game over/win overlays: DOM too, with a real `<button>` "RESTART" (or "PLAY AGAIN") + "press R". DOM overlays with backdrop blur look polished. Buttons clickable. Also whole canvas click starts from demo.
The rules say game must start in self-playing demo mode behind a "Click or press a key to start" message. ✓.
DOM overlay styling: absolute positioned over canvas container, pointer-events management: overlay covers canvas; clicks on it should start game / restart.
Structure:
```html
<div id="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay"> ... dynamic content ...</div>
</div>
```
One overlay div reused with innerHTML per state? Or three separate. I'll use one overlay with sections toggled. Buttons: start (in demo overlay just whole thing clickable), restart buttons on over/win.
Also show a small footer hint bar under canvas? Keep everything in stage.
Title above canvas? For 1280×800, canvas ~ 800*? Stage fills viewport; canvas centered; maybe page title text top-left minimal. Keep everything inside canvas+overlay for immersion. I'll add slim header inside canvas HUD: "NEON BREAKOUT" small at top-left next to score? HUD: left: SCORE + value; center: title; right: LIVES icons. Fine.
**Pause**: key P; also pause button? Skip button, keep P + auto-pause on blur (window blur → pause if playing). Show "PAUSED" overlay via canvas text (simple) — I'll draw on canvas, DOM overlay unnecessary.
**Preventing pass-through guarantee**: sub-stepping with step ≤ 4px and ball r=8; brick height 22, paddle height 16 — cannot skip 22+ px object with 4px steps given AABB check uses ball bounding box; robust. Also after brick reflect, push ball out of overlap.
One more tunneling vector: extremely high speed after multi-ball + ramp; cap speed 640; steps = ceil(640*dt/4). dt≤0.033 → max move 21px per frame → 6 steps. Fine.
Also ball vs walls: clamp position + reflect.
Bottom: if y - r > H → ball dead → remove, particle splash.
**Paddle keyboard speed**: 640 px/s; while mouse used, set target to mouse x and lerp? Direct set is more responsive: paddle.x = mouseX (clamped). Combine: if arrows pressed, move by keys; mouse move sets paddle.x directly. Both fine.
Smooth paddle for mouse: direct mapping feels best.
**AI details**:
```
aiUpdate(dt):
// choose target
let targetX = W/2;
danger = balls filtered vy>0, lowest? choose ball with max predicted y at paddle
predict x at paddle.y using linear interpolation; reflect across [r, W-r]
if capsules exist and no ball will reach paddle within 1.2s: target capsule.x (closest)
aim also: when ball moving up, drift toward where bricks direct? Just track predicted return.
error: add Math.sin(t*1.3)*14
move paddle toward targetX at maxSpeed 700 with easing: dx clamp.
```
Prediction: ball at (bx,by) vy>0; time to paddle plane t=(paddleY - by)/vy; x = bx + vx*t folded into [r, W-r]: fold: `p = bx+vx*t; period = 2*(W-2r); p = mod(p - r, period); if p<0 p+=period; x = p < (W-2r) ? p + r : period - p + r`? Let me write fold function properly:
```
function foldX(x, min, max){ const range = max-min; const period = range*2; let p = (x-min) % period; if(p<0)p+=period; return p<range ? min+p : min + (period-p); }
```
Check: x=min → 0 → min ✓. x=min+range*1 (i.e., max) → p=range → returns min+(2range-range)=max ✓. x=max+5 → p=range+5 → returns min+(range-5) ✓ reflected. Good.
Imperfection: cap AI speed so sharp-angle shots sometimes missed; also add slight noise. Demo should mostly survive but occasional misses look natural (shows life-loss flow too — but in demo, lives infinite; misses show splash particles — good showcase of effects!). Actually showing a life being lost in demo is GOOD for judges to see the lose flow. But also don't want demo to constantly drain. With infinite lives in demo it's fine.
Hmm wait — should demo show HUD lives decreasing? If infinite, lives stay 3. Maybe in demo decrement then refill at 0. I'll just decrement and on reaching 0 auto-reset to 3 (demo continues). That shows mechanics. Let me implement: in demo, on life loss when lives<0 → lives=3 (silent reset).
Also demo restarts level after victory automatically.
**Ball serving in demo**: AI auto-launch after 0.9s.
**Multi-ball in demo**: AI catches capsules when convenient. Nice showcase.
**Now the drawing order**:
1. clear; background (prerendered background canvas: gradient + grid + vignette).
2. apply shake transform.
3. playfield border glow.
4. bricks (sprites + power icons pulse).
5. capsules.
6. particles (additive? use globalCompositeOperation 'lighter' for sparks — pretty).
7. ball trails + balls.
8. paddle.
9. popups.
10. restore transform.
11. HUD (score, lives, power timers) — draw in fixed area.
12. Messages (serve hint, paused).
HUD inside canvas top: height ~56 region; playfield starts below. Actually I said topOffset for bricks = 86; HUD drawn in 0..70.
Bottom: paddle y = H - 46. Death line at H.
Walls: draw neon border around play area: x from 0..W? Full canvas is playfield; walls at x=0 and x=W and y=HUD bottom (y=70)? If ball reflects at y=70 top (below HUD). Hmm bricks start at 86, ball reflects at top wall y=64? Let me set TOP=64 (wall line), bricks row0 at y=84. Ball top wall: if y - r < TOP → y = TOP + r, vy = |vy|. Side walls at x=r and W-r. Visually draw a line/glow at TOP.
Canvas H=620: paddle at 574, bottom margin 46 for ball death below paddle (ball falls past paddle to H). OK.
**Resize math**:
```
function resize(){
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = W*dpr; canvas.height = H*dpr; // fixed logical, but scaled to fit css
ctx.setTransform(dpr,0,0,dpr,0,0);
const availW = innerWidth - 20, availH = innerHeight - 20;
const s = Math.min(availW/W, availH/H);
canvas.style.width = (W*s)+'px'; canvas.style.height = (H*s)+'px';
// overlay positioned via stage relative
}
```
Mouse mapping: `const rect = canvas.getBoundingClientRect(); mx = (e.clientX - rect.left) / rect.width * W;`
Prerender sprites at dpr too for crispness: sprite canvas width = w*dpr, ctx.scale(dpr,dpr), drawImage with logical sizes. Need current dpr at init; if dpr changes (zoom), re-render — minor; re-render sprites on resize if dpr changed. I'll rebuild sprite cache on resize when dpr differs.
Background prerender similarly.
**Particles perf**: cap array length ~400.
**Popup font**: bold monospace.
Let me also add subtle animated starfield/dust in background? Could do drifting dots drawn each frame cheaply (30 dots). Adds life to first screenshot. I'll add slow parallax dust particles in bg. Actually prerendered static bg + a few floating glow dots animated. Let me add ~24 "fireflies" drifting upward slowly with alpha pulse — cheap, adds motion at first paint. Good for "visible within first seconds" — plus demo mode itself is motion.
**Serve hint text**: "CLICK OR PRESS SPACE TO LAUNCH" blinking, drawn above paddle. In demo, no hint (AI auto).
Wait — the start overlay covers the canvas; demo plays behind it. The overlay should be non-blocking visually (semi transparent, maybe gradient panel at center with title + "Click or press a key to start" + controls hints) so demo is visible around it. Center panel with backdrop-filter blur small.
First-paint screenshot at ~3s: canvas with demo game in action + overlay visible.
**Game over overlay**: big "GAME OVER" in red/magenta glow, score, best? (no storage) — show "SCORE 12,340" and restart button. Victory: "LEVEL CLEARED!" gold, score + bonus, restart button.
**Restart**: resets score, lives, bricks, balls, powerups, combo.
Let me now think about "The ball must never pass through bricks or the paddle" — sub-stepping covers it. Also ensure when ball spawns overlapping nothing. When cloning multiball, spawn at existing ball pos with different angles; if spawn inside brick? Balls spawn near paddle area, below bricks — safe.
Paddle: also handle case ball moving down fast and clipping paddle top corner: sub-step AABB handles.
One subtlety: after paddle reflect we set y above paddle; but if paddle moves (mouse) into ball horizontally, ball could be inside paddle from side — then vy<0 (moving up) and our condition (vy>0) fails, ball may pass through paddle from below? Ball moving up shouldn't collide with paddle top; but if paddle slides into ball laterally... Ball above paddle moving up: no issue. Ball below paddle top but inside paddle rect moving up: means it got under paddle — can't normally since paddle is at bottom and ball dies below. When ball dies it's removed. When serving, ball sits ON paddle (y = paddle top - r). Paddle moving sideways under a serving ball just carries it. OK. Also when paddle widens (powerup) while ball is beside it — ball could overlap paddle edge; then ball moving down triggers reflect — acceptable (bounces off). Edge case fine.
Multiball clone angles: from source ball direction rotated ±25°.
**Combo**: increments per brick destroyed; resets when ball hits paddle. Display "COMBO ×N" popup at paddle? Show small combo indicator in HUD center when >2. Points: base + (combo-1)*10.
**Let me write scoring**: normal brick: 50; tough destroy: 120; tough crack (first hit): 30; power brick destroyed: 100 (plus normal/tough base? power brick gives its base + 100 bonus). I'll simplify: destroyPoints = (tough?120:50) + (power?100:0), crack 30. Combo bonus added: +10*(combo).
**HUD**:
- Left: "SCORE" label + value (7-digit padded? e.g., 6 padded).
- Right: "LIVES" + 3 mini paddle/heart shapes.
- Center: level name "LEVEL 1 — NEON GRID"? Keep "LEVEL 1".
- Power-up status chips below top wall? Draw at top-right under lives? Place active wide timer as a bar at bottom? I'll draw a small bar at top center under HUD: label "WIDE" + bar 120px shrinking. Also multiball doesn't need timer.
**Now, code writing.** Let me carefully write everything, ~800-1000 lines. I need to be careful about correctness. Let me draft.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neon Breakout</title>
<style>
:root{ ... }
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
background: radial-gradient(...#05060f...);
display:flex; align-items:center; justify-content:center;
overflow:hidden;
font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
color:#e8ecff;
}
#stage{position:relative; line-height:0}
canvas{display:block; border-radius:12px; box-shadow: 0 0 0 1px rgba(120,140,255,.25), 0 0 60px rgba(80,110,255,.18), 0 24px 80px rgba(0,0,0,.6); background:#070813; touch-action:none;}
.overlay{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; line-height:1.4; }
...
</style>
```
Border-radius on canvas with gameplay at edges: ball bounces at canvas edge — radius corners clip? Rounded 12px clips the corner pixels where ball may touch — ball at corner region x<12,y<12? Top corners: ball rarely exactly there but could. Radius 12, minor clip of glow — acceptable visually? Ball at x=r=8, y wall TOP+r — corner distance from center (8, 72): far from corner (canvas corner 0,0 → rounded region within 12px of corner). Ball near top-left corner at (8,72) — 72>12, safe. Side walls near bottom corners: ball dies at bottom, and paddle at y=574, canvas corner bottom at y=620 — ball could be at (8, 600) falling to death near rounded corner (12px radius) — slight visual clip of ball edge for a frame before death. Negligible. Fine. Or use smaller radius 10. OK.
Overlay HTML:
```html
<div id="stage">
<canvas id="cv" width="900" height="620"></canvas>
<div class="overlay" id="startOverlay">
<div class="panel">
<div class="title">NEON<span>BREAKOUT</span></div>
<p class="tag">Bricks. Balls. Power-ups.</p>
<div class="cta">CLICK OR PRESS ANY KEY TO START</div>
<div class="controls">
<div><b>MOUSE / ← →</b><span>move paddle</span></div>
<div><b>SPACE / CLICK</b><span>launch ball</span></div>
<div><b>P</b><span>pause</span> <b>R</b><span>restart</span></div>
</div>
</div>
</div>
<div class="overlay hidden" id="endOverlay">
<div class="panel">
<div class="title small" id="endTitle">GAME OVER</div>
<p id="endInfo">...</p>
<button id="restartBtn">RESTART</button>
</div>
</div>
</div>
```
Overlays: pointer-events: start overlay covers everything → clicking anywhere on it starts game. End overlay similar with button.
CSS panel: background rgba(8,10,24,.72), backdrop blur 6px, border 1px rgba(140,160,255,.35), glow, padding, rounded, text-align center. Title: gradient text via background-clip? Use color + text-shadow glow (simpler, widely fine).
CTA blinking: CSS animation pulse.
Now JS. Full code:
```js
'use strict';
const W=900,H=620;
const TOP=64; // top wall y
const cv=document.getElementById('cv');
const ctx=cv.getContext('2d');
let DPR=1;
function fit(){ ... }
window.addEventListener('resize', fit);
// ---------- helpers
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
const TAU=Math.PI*2;
// ---------- audio
let AC=null;
function initAudio(){ if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(AC&&AC.state==='suspended'){AC.resume().catch(()=>{});} }
function beep(f, dur=0.07, type='square', vol=0.05, slide=0){
if(!AC) return;
try{
const t=AC.currentTime;
const o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.setValueAtTime(f,t);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(40,f+slide), t+dur);
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0008,t+dur);
o.connect(g).connect(AC.destination);
o.start(t); o.stop(t+dur+0.02);
}catch(e){}
}
```
Sounds on events: only if AC exists (created after gesture). Demo has no sound (no gesture) — fine.
Game state:
```js
const game = {
mode:'demo', phase:'serve', // serve|play
over:false,
score:0, lives:3, combo:0,
bricks:[], cols:12, rows:8,
balls:[], capsules:[], parts:[], pops:[],
paddle:{x:W/2, y:H-46, w:110, h:16, baseW:110, wideUntil:0, targetX:W/2},
shake:0, time:0, serveTimer:0, launchAngleSeed:0,
speedBase: 380,
bricksLeft:0,
paused:false,
};
```
paddle y: H-46 = 574. paddle top = y.
Keys: `keys={left:false,right:false}`.
Brick creation:
```js
const ROWCOLORS=['','#ff4d6d','#ff9e4f','#ffd166','#52e5a3','#4cc9f0','#5a7bff','#b06cff'];
const POWER_SPOTS=[
{r:0,c:2,k:'multi'},{r:0,c:9,k:'wide'},
{r:2,c:5,k:'multi'},{r:3,c:2,k:'wide'},{r:3,c:9,k:'multi'},
{r:4,c:6,k:'wide'},{r:6,c:1,k:'multi'},{r:6,c:10,k:'wide'},{r:7,c:5,k:'multi'}
];
function isTough(r,c){ if(r===0) return true; if(r===2) return c%2===0; if(r===4) return c%2===1; if(r===6) return c%4===1; return false; }
```
Brick fields: x,y,w,h,row,col,hp,maxHp,tough,power(kind),alive.
Geometry:
```
sideM=26, gap=6, topOff=TOP+22 (86);
bw=(W-sideM*2-(cols-1)*gap)/cols;
bh=22;
```
bw = (900-52-66)/12 = 782/12 = 65.1667.
initLevel: create bricks; bricksLeft count.
resetGame(mode): score=0(or keep in demo?), lives=3, combo 0, paddle reset, capsules/parts/pops cleared, initLevel, spawnServeBall().
spawnServeBall(): balls=[{x:paddle.x, y:paddle.y - R-2, vx:0, vy:0, stuck:true, r:8, trail:[]}]; phase='serve'; serveTimer=0.
launchBall(b, angle): b.stuck=false; speed=speedBase; vx=sin(a)*sp, vy=-cos(a)*sp.
Ball speed management: keep `game.speed` current (ramps). When reflect off paddle use game.speed; on brick hits maybe keep per-ball magnitude. Simpler: each ball has its own speed = magnitude maintained; ramp: game.speed += 4 per brick (cap 640) — but existing balls keep their speed? All balls should share ramp. Let me store ball.speed and when ramping, update all balls' velocity magnitude to new speed: on brick break, `setSpeed(newSpeed)` rescales each ball's v to magnitude. OK: `function setBallSpeed(s){ for(b of balls){ const m=Math.hypot(b.vx,b.vy)||1; b.vx*=s/m; b.vy*=s/m; } }`.
Balls: {x,y,vx,vy,r,trail:[]}.
update(dt):
```
game.time+=dt;
// paddle control
if(mode==='demo') aiPaddle(dt); else playerPaddle(dt);
// paddle width tween: paddle.w approaches target (base or wide) smoothly.
const targetW = wide active? 170:110; paddle.w += (targetW-paddle.w)*Math.min(1,dt*10);
clamp paddle.x to [wall+r? ..]
// serve
if(phase==='serve'){ ball follows paddle; serveTimer+=dt; auto launch if demo(timer>0.8) or player timer>3.5 }
if(phase==='play'){ moveBalls(dt); capsules update; }
particles, popups, shake decay always.
check win: bricksLeft===0 → phase 'win' overlay.
```
Wait phases: I'll add phase 'win'/'over' where update only runs particles/cosmetics.
playerPaddle:
```
const spd=640;
if(keys.left) paddle.x-=spd*dt;
if(keys.right) paddle.x+=spd*dt;
if(mouseX!=null && mouseActive?) ...
```
Conflict: mouse sets x directly on pointermove; arrows adjust. Both fine, latest input wins naturally. Store mouseX and if pointer moved recently use it? Simplest: pointermove sets paddle.x immediately (in game mode). Arrow keys adjust per frame. No conflict issues.
Actually pointermove handler:
```
cv.addEventListener('pointermove', e=>{ const rect=cv.getBoundingClientRect(); const x=(e.clientX-rect.left)/rect.width*W; if(game.mode==='game') game.paddle.x = clamp(x, ...); });
```
Clamp: paddle.x within [minX, maxX] where minX = paddle.w/2 + 4, maxX = W - paddle.w/2 - 4. Do clamping in update anyway (after width changes).
aiPaddle:
```
const p=game.paddle;
let target=W/2;
// find most urgent ball: moving down, smallest time to paddle
let bestT=1e9, bx=null;
for(const b of balls){ if(b.stuck){continue} if(b.vy>0){ const t=(p.y - b.y - b.r)/b.vy; if(t>=0&&t<bestT){bestT=t; bx=foldX(b.x + b.vx*t, b.r, W-b.r);} } }
if(bx!=null){ target=bx + Math.sin(game.time*1.7)*10; }
else {
// maybe chase capsule
if(capsules.length){ target = capsules[0].x; }
else { // idle: follow ball x gently
const b=balls[0]; if(b) target=b.x*0.3 + W/2*0.7;?
}
}
const dx=target-p.x.x... move with max speed 720*dt, easing: p.x += clamp(dx, -maxStep, maxStep) where maxStep=680*dt; if |dx|<2 snap.
```
Also AI slight mistake: multiply speed cap 660 so extreme angles occasionally slip. Good.
Demo serve: launch with random angle ±0.3.
moveBalls(dt): for each ball: sub-steps.
```
for(const ball of balls){
const sp=Math.hypot(ball.vx,ball.vy);
const steps=Math.max(1, Math.ceil(sp*dt/4));
const sdt=dt/steps;
for(let i=0;i<steps;i++){
ball.x+=ball.vx*sdt; ball.y+=ball.vy*sdt;
// walls
if(ball.x<ball.r){ball.x=ball.r; ball.vx=Math.abs(ball.vx); wallFx(ball);}
else if(ball.x>W-ball.r){ball.x=W-ball.r; ball.vx=-Math.abs(ball.vx); wallFx(ball);}
if(ball.y<TOP+ball.r){ball.y=TOP+ball.r; ball.vy=Math.abs(ball.vy); wallFx(ball);}
// paddle
const p=paddle;
if(ball.vy>0 && ball.y+ball.r >= p.y && ball.y-ball.r <= p.y+p.h && Math.abs(ball.x-p.x)<=p.w/2+ball.r){
if(ball.y < p.y + p.h) { // ensure from top-ish
const rel=clamp((ball.x-p.x)/(p.w/2+ball.r*0.5), -1, 1);
const maxA=1.05; // 60deg
const a=rel*maxA;
const s=Math.hypot(ball.vx,ball.vy);
ball.vx=Math.sin(a)*s; ball.vy=-Math.cos(a)*s;
ball.y=p.y-ball.r-0.5;
onPaddleHit(ball);
}
}
// bricks
brickCollide(ball);
}
// trail
ball.trail.push({x:ball.x,y:ball.y}); if(ball.trail.length>11) ball.trail.shift();
// death
if(ball.y-ball.r>H){ markDead }
}
balls=balls.filter(alive)
if(balls.length===0) loseLife();
```
Hmm onPaddleHit inside substeps: fine (sound + combo reset + paddle squash anim). Add paddle "flash" anim var paddleGlow=1 decaying.
Careful: paddle check condition `ball.y-ball.r <= p.y+p.h` and vy>0 and ball's bottom >= p.y. If ball enters from side (deep) we still reflect up — could look odd but prevents pass-through (requirement!). Actually to strictly prevent pass-through in weird cases, reflect anyway is good.
brickCollide(ball):
```
function brickCollide(ball){
const m=26, gap=6, topOff=TOP+22, bh=22;
// compute candidate col range
const c0=clamp(Math.floor((ball.x-ball.r-sideM)/(bw+gap)),0,COLS-1); ... but ball.x-... could be in gap; floor works if we treat position: col = floor((x - sideM)/(bw+gap)) valid if within brick x range else gap; simpler: check cols c0..c1 where c0=floor((x-r-sideM)/(bw+gap)) clamped, c1=floor((x+r-sideM)/(bw+gap)) clamped. Rows similar.
for r in r0..r1: for c in c0..c1:
const br=grid[r][c]; if(!br.alive) continue;
// AABB circle overlap
const cx=clamp(ball.x, br.x, br.x+br.w), cy=clamp(ball.y, br.y, br.y+br.h);
const dx=ball.x-cx, dy=ball.y-cy;
if(dx*dx+dy*dy <= ball.r*ball.r){ hitBrick(ball, br); return; } // one per substep
}
```
Circle vs rect via nearest point — nice, handles corners properly. Resolve: determine normal: if ball center inside rect (dx=dy=0 case) fallback: use penetration axis. Compute overlapX = ball.r - |dx|? Since nearest-point method: direction from closest point to center (dx,dy) — if nonzero, normal = normalize(dx,dy), push ball out to distance r along normal, reflect velocity if moving toward (v·n <0). If dx==dy==0 (center inside), compute min penetration to faces: distances to each side; pick smallest; push & reflect.
```
function resolveBrick(ball, br){
let nx, ny, pen;
const cx=clamp(...), cy=...;
let dx=ball.x-cx, dy=ball.y-cy;
if(dx===0&&dy===0){
// inside: face distances
const l=ball.x-br.x, r2=br.x+br.w-ball.x, t=ball.y-br.y, b=br.y+br.h-ball.y;
const m2=Math.min(l,r2,t,b);
if(m2===t){nx=0;ny=-1;pen=t+ball.r;}
else if(m2===b){nx=0;ny=1;pen=b+ball.r;}
else if(m2===l){nx=-1;ny=0;pen=l+ball.r;}
else{nx=1;ny=0;pen=r2+ball.r;}
} else {
const d=Math.hypot(dx,dy);
nx=dx/d; ny=dy/d; pen=ball.r-d;
}
if(pen>0){
ball.x+=nx*pen; ball.y+=ny*pen;
const dot=ball.vx*nx+ball.vy*ny;
if(dot<0){ ball.vx-=2*dot*nx; ball.vy-=2*dot*ny; }
hitBrick(ball,br);
}
}
```
hitBrick(ball, br):
```
br.hp--;
if(br.hp<=0){ br.alive=false; bricksLeft--; combo++; const pts=...; score+=pts; spawnParticles(br); popup(...); shake+=2; if(br.power) dropCapsule(br); setSpeed ramp; beep; if(bricksLeft===0) win(); }
else { // cracked
score+=30? ; small particles (few, sparks); popup? tiny; beep lower; shake+=1;
}
```
Wait crack points: add 30 each crack with popup "+30"? popups everywhere may clutter; small popup fine.
Combo bonus: pts = base + (combo-1)*10. Show popup "+pts". Combo resets on paddle hit.
dropCapsule(br): capsules.push({x:br.x+br.w/2, y:br.y+br.h/2, vy:150? Let's 140 + slight accel; sway phase, kind:br.power, rot:0}).
Capsule update: y+=vy*dt (vy ~ 150, accel to 220?), x sway: x0 + sin(t*3+phase)*6. Catch: if capsule rect (w:36,h:20 centered) intersects paddle rect → apply, remove, particles, sound. Miss: if y>H+20 remove.
applyPower(kind):
- wide: paddle.wideUntil = time+10; popup "WIDE PADDLE!"; sparkle particles green/amber.
- multi: clone balls to reach 3:
```
const cur=[...balls]; for(const b of cur){ if(balls.length>=6)break; if(b.stuck) continue; for each clone? }
```
Need exactly up to 3: while(balls.length<3): pick source = balls[i % balls.length] (non-stuck preferred); clone at same pos, angle rotate by ±0.4*(k) rad. If all balls stuck (serve) — can't happen since capsule only during play... Actually capsule could be caught during serve phase? Capsules only update in play phase; but if all balls died, phase changed to serve and capsules remain frozen mid-air — then resume when serve→play. When caught in serve? Not updated in serve so fine. Edge: capsule caught while a serve ball stuck — multi: clone stuck ball? Just rotate: if source stuck, clone stuck ball also stuck on paddle, launches together. Handle: if b.stuck, clone also stuck (it will launch with same serve). Actually serve launch launches balls[0] only. Let me make launch apply to all stuck balls with slightly different angles. OK good.
Multiball total: `while(balls.length<3)` push clones; if already ≥3, do nothing extra? The requirement says "gives either ... multi-ball (3 balls)". If player collects twice, staying at ≥3 fine. I'll also allow: if balls.length>=3, add 2 clones anyway (capped 6)? Keep simple: bring to 3 minimum; if already 3+, add 1 clone (fun) cap 6. Hmm—simplest deterministic: `const target=Math.max(3, balls.length+1)`? Overthinking. Requirement: multi-ball = 3 balls. Implement: while(balls.length<3) clone; that yields 3. If already 3+, no new balls (popup "MULTIBALL" anyway). Fine.
loseLife():
```
lives--; combo=0; shake=6; red flash particles at bottom; beep sad;
wideUntil=0? keep powerup? Typically lose powerups on death. I'll clear wide on life loss (fair).
if(lives<=0){ if(mode==='demo'){ lives=3; spawnServe(); } else { phase='over'; showEnd(false); } }
else spawnServe();
```
win(): phase='win'; bonus = lives*250; score+=bonus; showEnd(true); confetti particles.
showEnd(won): set DOM overlay content: title, score, bonus line; display. In demo mode: if demo somehow clears all bricks, auto restart demo after 2.5s (no overlay). Handle: in demo, on win → after 2s resetGame('demo'). Actually simpler: in demo when bricksLeft hits 0, just initLevel() again + spawnServe, keep score? reset fully. Do: `setTimeout`-like via timer: demoResetTimer. Use a countdown var.
Hmm, complexity: let me define game.over as phase. I'll manage:
```
function onBricksCleared(){
if(mode==='demo'){ demoRestartTimer=2.0; phase='cleardemo'; } // brief pause then reset
else { phase='win'; bonus... showEnd(true); }
}
```
And in update, if phase==='cleardemo': demoRestartTimer-=dt; at 0 → resetGame('demo').
Similarly demo game over handled inline (lives reset to 3) so demo never ends. OK.
Player game over: phase='over'; showEnd(false).
End overlay DOM: `endTitle` text: "GAME OVER" (class red) or "LEVEL CLEARED" (class gold); info: `SCORE ${score}` + bonus text if win; button "PLAY AGAIN" / also "press R or click". Button restart: resetGame('game') and hide overlay. Also clicking overlay background restarts? Make button explicit + R key + Enter. I'll make the whole end overlay clickable to restart (simpler for users) with button as visual affordance. Add note "press R or click anywhere".
Pause: only in game mode; phase play/serve. keydown 'p'. Draw canvas dim + "PAUSED — press P" text (canvas draw since quick).
Visibility change: if document.hidden && mode game && !paused → paused=true.
Now drawing.
**Background prerender**:
```
bg = canvas W×H (dpr): fill vertical gradient #0a0c1c→#05060f? plus subtle radial glow top center. Grid: lines every 30px, strokeStyle rgba(90,110,200,0.06). Vignette: radial gradient transparent→rgba(0,0,0,.45) at edges. Also faint scanline? skip.
Also draw top wall line: glowing line at TOP: gradient stroke with shadowBlur cyan; side walls: lines at x=1 and W-1? Canvas border via CSS box-shadow; inside draw subtle 2px walls on left/right/top with glow.
```
Draw walls each frame or in bg? Static → bg. But shake translate would move walls relative to canvas edge... shake moves everything including bg: I draw bg inside shake transform too? If bg drawn in shake, edges show gaps (background clear color). Solution: fill canvas with base color first without transform, then translate for world. Walls move with world — good (they're part of playfield). Base fill color matches bg bottom so gaps invisible.
Fireflies: 26 dots {x,y,vy(-8..-20),r,phase,alpha}; update drift; wrap y. Draw with 'lighter' small glow circles.
**Brick sprite prerender**:
```
function brickSprite(key, w, h, draw){ cache }
function makeSprite(w,h,pad,drawFn){ const c=document.createElement('canvas'); c.width=(w+2*pad)*DPR; c.height=(h+2*pad)*DPR; const g=c.getContext('2d'); g.scale(DPR,DPR); drawFn(g, pad); return c; }
```
Normal brick sprite (color, w, h, pad=6):
- rounded rect r=5 (path)
- fill gradient: color light at top (mix with white?) — implement shade(hex, amt) helper to lighten/darken.
- glow: shadowColor=color, shadowBlur=10, draw fill once with shadow (then again without for solid core). Draw path twice: first with shadowBlur for glow, then reset shadow and fill for crisp body? Actually shadow appears around shape; drawing shape once with shadowColor set gives body+glow. Do: g.shadowColor=colorA(0.5); g.shadowBlur=8; fill; g.shadowBlur=0; then overlay gradient fill again (gradient body over glow) + inner highlight: lighter rounded rect top strip; stroke rgba(255,255,255,0.25) 1px; bottom darker strip.
- Slight 3D: bottom edge darker line.
Tough brick: steel colors: base '#93a7c4'? gradient from '#cfd9ea' to '#7e8fae'? With metallic horizontal sheen line. Border '#e6edff' alpha .5, corner rivets: small circles at 4 corners darker. When cracked: same but with crack lines: dark jagged polyline across, plus color darker & some chips missing (small dark triangles at edge). Draw crack: from top edge random walk to bottom: deterministic seed by brick col/row passed in. Also add small cracks radiating.
Simplify cracks: draw 2 polylines with strokeStyle rgba(10,14,25,0.85), width 2, using seeded RNG per brick. Plus overlay darker tint rgba(0,0,0,0.18).
Seeded rng: mulberry32(seed).
Power icon sprite: draw icon on top of brick dynamically (pulse scale) — draw icon each frame for power bricks: small icon centered, white with dark outline, subtle glow, plus animated dashed border? Keep: brick gets pulsing white corner brackets or a subtle animated ring. I'll draw: rounded outline stroke white alpha 0.5+0.3*sin(t*4), and icon (two kinds) prerendered sprite 18×18 at center.
Icon draw functions:
```
function drawWideIcon(g, s){ // s=size, arrows out
g.lineWidth=2.2; g.strokeStyle='#0b1020'; g.lineCap='round';
// center bar
line(-s*0.28,0 → s*0.28,0)
// left arrowhead: lines from (-s*0.28,0) to (-s*0.28 - k, -k)...
}
```
Actually for visibility, icons on bricks: dark navy on colored brick? Colored bricks vary; white icon with dark outline reads on all. Let me draw icon in deep navy '#10142a' with white outline? Or white icon with navy shadow. Do: stroke path twice: first navy width 4 (outline), then white width 2. Readable everywhere.
Wide icon: double-headed horizontal arrow: line + arrowheads at both ends.
Multi icon: three small filled circles in triangle (2 bottom 1 top) or triangle of 3 dots.
Capsule drawing: pill 36×20 rounded 10, gradient body (kind color light→dark), white outline, icon inside (white w/ navy outline), glow shadow, slight bob rotation? Add subtle pulsing scale and a small trail? Just draw with glow + spinning shine: overlay white gradient diagonal moving? Keep: glow + icon + slight wobble rotation sin.
Paddle draw: rounded rect, gradient cyan body (#67e8f9 → #3b82f6?), top highlight, glow shadowBlur 14 color cyan; if wide active: color amber/gold gradient to distinguish + timer. Squash anim on hit: scaleY brief. Draw thruster dots? Cute: two small glow dots at edges.
Ball draw: trail (line strip fading or circles), body: radial gradient white→#bfe3ff, glow shadowBlur 12 cyan. Balls colored white/cyan.
Particles: {x,y,vx,vy,life,max,size,color,rot,vr,shape:'rect'|'spark'}; update gravity 700 for rects; draw with 'lighter' composite: rects rotated, sparks as small lines along velocity. Life fade alpha.
Also "shockwave" rings: {x,y,r,max,life} stroke circle expanding fading. On brick break small ring; on life loss big red ring.
Popups: {x,y,text,life,color,size} rise 30px, fade.
HUD draw:
```
g.font='700 13px ui-monospace, Menlo, Consolas, monospace'
left: 'SCORE' small gray at (26,26); value big 20px bold at (26,46) color #fff: pad score 6: String(score).padStart(6,'0')
center: title 'NEON BREAKOUT' small letterspaced at center? draw text with spacing manually or just normal. Use color #8ea2ff, font 12 bold, letterSpacing via canvas 'letterSpacing' property (Chrome supports ctx.letterSpacing). Use it guardedly (just set; unsupported browsers ignore assignment? Assigning unknown property is harmless).
right: 'LIVES' label right aligned at W-26; icons: 3 rounded mini-paddles 22×6 at right, filled cyan if available else dark outline. Draw at y=40.
level center? 'LEVEL 1' small under title? Put center: LEVEL 1 · small.
combo: if combo>=3 show 'COMBO ×n' center in accent color pulsing.
wide timer: if active: bar at bottom center above paddle? Draw at top center under wall: x centered width 140 h 4 outline + fill amber proportional remaining/10, label 'WIDE'.
```
Positions: HUD text at top-left within 0..TOP(64) zone. Bricks start 86. Wall line at y=64. Layout: labels at y=22, values y=44.
Serve hint: draw blinking text "CLICK OR PRESS SPACE TO LAUNCH" at y=H-90 center, alpha pulsing. In game mode only.
Death zone: below paddle maybe subtle red gradient line at bottom? Draw thin dark red line at y=H-6 pulsing when ball near bottom? Subtle bottom hazard: gradient rgba(255,60,80,0.08→0.25) height 30 at bottom. Nice cue.
Now overlay behaviors:
startOverlay click → startGame(). Any keydown → startGame(). Ensure keydown also initializes audio (user gesture). Also click inits audio.
startGame(): mode='game'; resetGame(); hide startOverlay; (audio init).
restartBtn click / endOverlay click → restart: mode='game'; resetGame(); hide endOverlay.
Keydown handling:
```
window.addEventListener('keydown', e=>{
const k=e.key;
if(['ArrowLeft','ArrowRight',' ','ArrowUp','ArrowDown'].includes(k)) e.preventDefault();
initAudio(); // gesture
if(game.mode==='demo'){ startGame(); return; }
if(k==='ArrowLeft') keys.left=true;
else if(k==='ArrowRight') keys.right=true;
else if(k==='r'||k==='R'){ restartGame(); }
else if(k==='p'||k==='P'){ togglePause(); }
else if(k===' '||k==='ArrowUp'||k==='Enter'){
if(phase==='serve') launchAll();
else if(phase==='over'||phase==='win') restartGame();
}
});
keyup for arrows.
```
Careful: pressing arrow in demo starts game — good ("press a key to start").
But keydown 'p' in demo → also just starts (any key). Fine since check demo first.
Pointer events on canvas: pointermove sets paddle x (game mode, phase play or serve) + also updates for serve ball follow. pointerdown: initAudio; if demo → startGame; else if serve → launch; if over/win ignore (overlay covers anyway); overlay has its own click handlers, so canvas pointerdown in game mode with overlay hidden works.
Note: overlay covers canvas → canvas pointermove blocked while overlay visible. Fine.
Restart function: resetGame() then phase='serve'. Also unpause.
resetGame(full):
```
score=0; lives=3; combo=0; speedBase=380; time? keep global time running (used for wide timer — wideUntil uses game.time; reset wideUntil=0).
paddle.x=W/2; paddle.w=110;
capsules=[]; parts=[]; pops=[]; balls=[]; bricks rebuild; bricksLeft=count;
spawnServe();
```
Demo reset uses same.
**Win check**: after hitBrick destroys last brick → onCleared.
Edge: hitBrick called during substep; game over/wins sets phase mid-update; subsequent substeps continue for same frame — guard: in moveBalls loop, `if(phase!=='play') break`. OK.
**Ball speed on paddle hit**: keep same speed (magnitude preserved by reflection since we set from angle with s=hypot). Add tiny speed-up per paddle hit? Speed ramps via bricks; keep paddle neutral.
**Minimum vertical velocity**: to avoid nearly-horizontal infinite bouncing, enforce min |vy| after paddle/brick? After paddle, angle ≤ 60° guarantees |vy| ≥ cos(60°)=0.5s ✓. After brick side reflections, vy unchanged ✓. Corner reflections could create shallow? Fine. Also add tiny safeguard: if |vy| < 0.18*speed after any bounce, nudge: vy = sign*0.18*speed, renormalize. I'll add in wall bounce and brick resolve. Eh, add a normalizeStep after each substep? Cheap: after collisions, check.
**Demo AI paddle & wide powerup catch** shows in demo.
**Draw serve ball stuck**: follows paddle.x.
**launchAll()**: for each stuck ball: angle = rand(-0.3,0.3) or based? For player, maybe launch angle depends on recent paddle movement? Keep random ±0.25 + base slight. phase='play'.
Demo auto-launch uses same.
**Time & loop**:
```
let last=performance.now();
function frame(now){
let dt=(now-last)/1000; last=now;
dt=Math.min(dt, 0.033);
if(!game.paused || game.mode==='demo') update(dt); else updatePaused(dt) (still animate fireflies? keep simple: skip)
draw();
requestAnimationFrame(frame);
}
```
When paused: skip update but still draw (with pause overlay) and popups frozen. Fine. Fireflies update inside update() — pause stops them, acceptable.
dt clamp 0.033: at high speed, substeps handle.
**Score popups font** on canvas: `700 14px ui-monospace...`.
**letterSpacing**: ctx.letterSpacing = '2px' — supported in Chrome 99+. Safe assignment.
**Potential console errors to avoid**:
- `new AudioContext` guarded.
- All DOM lookups after DOM placed (script at end of body).
- No localStorage.
**Now HTML/CSS polish**: page bg: radial gradient dark; small footer? The canvas centered. Add subtle page backdrop glow. Title? All in-canvas/overlay.
Media query small screens: overlay panel scale down: font sizes with clamp(); padding.
Panel CSS:
```css
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:auto;cursor:pointer;}
.overlay.hidden{display:none}
.panel{background:rgba(10,13,28,.78);border:1px solid rgba(130,150,255,.28);border-radius:14px;padding:clamp(18px,3.5vw,34px) clamp(24px,5vw,48px);text-align:center;box-shadow:0 0 40px rgba(90,120,255,.15), inset 0 0 30px rgba(90,120,255,.06); backdrop-filter: blur(6px); line-height:1.45; animation:panelIn .5s ease-out;}
.title{font-size:clamp(26px,4.6vw,44px);font-weight:800;letter-spacing:.14em;color:#fff;text-shadow:0 0 18px rgba(110,160,255,.8),0 0 44px rgba(110,160,255,.4);}
.title span{color:#5eead4? or gradient}
```
Maybe title "NEON BREAKOUT" with two-tone: NEON in cyan glow, BREAKOUT in magenta glow.
CTA: `#startCta{margin-top:...;font-size:clamp(11px,1.8vw,15px);letter-spacing:.22em;color:#ffd166;animation:blink 1.2s infinite}` blink = opacity .35→1.
Controls row: three columns flex, small text.
End overlay title classes: `.win{color:#ffd166; text-shadow gold}` `.lose{color:#ff5d7f; ...}`.
Button:
```css
button{margin-top:18px;pointer? cursor:pointer;background:linear-gradient(180deg,#67e8f9,#3b82f6);border:0;color:#06131f;font:700 14px/1 inherit... padding:12px 26px;border-radius:9px;letter-spacing:.18em; box-shadow:0 0 20px rgba(80,180,255,.45); transition:transform .15s, box-shadow .15s;}
button:hover{transform:translateY(-2px) scale(1.03)}
```
Font family on button: inherit family via font-family:inherit.
Restart note small under button.
Also ensure no horizontal scrollbar: body overflow hidden.
Let me also add tiny "hint" text at page bottom? Not needed.
**Check first-paint visibility**: canvas 900×620 fit into viewport; demo running: ball bouncing (launched after 0.8s; screenshot at 3s → mid-play ✓), AI paddle moving, bricks visible, overlay panel visible ✓. Good.
**Numbers audit**:
- W=900,H=620; TOP=64.
- Bricks: topOff=TOP+22=86; 8 rows × (22+6) − 6 = 8*28-6=218 → bricks end at 86+218=304. Paddle at 574. Space between bricks and paddle: 270 — good play room.
- Paddle base w=110; wide=170.
- Ball r=8, speed 380→640.
- Capsule falls ~160 px/s from up to ~300 → ~1.7s to reach paddle. OK.
**AI target with capsules**: if no threatened ball and capsule exists → target capsule.x else center follow ball. Implement.
**Multi-ball catch in demo could cause demo life loss chain** — fine.
**Ball trail rendering**: for each ball, iterate trail with alpha i/len * 0.35, radius r*(i/len)*0.9, color cyan-ish. Composite 'lighter'.
**Score popup on crack** might spam; only for destroy and crack small "+30" is okay actually every tough hit — ~27 tough bricks → fine.
**Shake**: `game.shake` decays *= exp(-8dt) or shake=max(0,shake-12*dt*shake?) use shake *= Math.pow(0.001, dt)?? Let me: shake decays: `game.shake=Math.max(0, game.shake - game.shake*10*dt + 0.0)` → exponential decay; offset = rand(-1,1)*shake each frame.
**Wall hit fx**: small spark particles + beep tick + tiny shake 0.5? Minimal.
**Paddle hit fx**: squash + ring small + beep. Combo reset.
**Life lost fx**: red flash: screenFlash red alpha 0.35 decaying; big shake; particles at ball death x along bottom.
Screen flash var: game.flash (color+alpha decays) drawn as overlay rect. On life lost: flash={c:'255,70,90',a:0.32}; on win: gold flash.
**Victory confetti**: 120 particles from top with random bright colors, gravity slow, plus flash gold.
**Ensure "bricks carry a power-up icon" visible**: prerendered icon sprite drawn each frame for power bricks — visible in screenshot ✓. Pulse alpha.
Alright — also **responsive 375px**: canvas ~327×225 CSS px; game still playable via touch (pointermove). Overlay panel small: fonts clamp. Fine.
**Draw icon on carrier brick**: prerender icon sprites at DPR: wideIconCv (size 26×26 logical), multiIconCv. Draw centered with slight pulse scale: s=1+0.08*sin(t*5 + (r+c)). Draw at brick center.
Also capsules use same icons (white version), bricks use navy-with-white-outline version? Same icon works: white icon with dark outline reads on colored bricks and on colored capsules. Good — one icon set.
Icon sprite drawing:
```
function makeIcon(kind){
const s=26,pad=4; c=canvas (s+2pad)*DPR; g.scale(DPR,DPR); g.translate(pad+s/2, pad+s/2);
g.lineCap='round'; g.lineJoin='round';
const drawPath=()=>{ if(kind==='wide'){ // arrow
g.beginPath(); g.moveTo(-8,0); g.lineTo(8,0);
g.moveTo(-3.5,-4.5); g.lineTo(-8.5,0); g.lineTo(-3.5,4.5); // left head pointing left
g.moveTo(3.5,-4.5); g.lineTo(8.5,0); g.lineTo(3.5,4.5);
g.stroke(); } else { // multi: three dots
dots at (-5,3.5),(5,3.5),(0,-4.5) radius 3: fill circles
}};
drawPath with strokeStyle dark navy width 5 (outline) — but for dots outline: draw circle fill dark r+2.2 then white r.
}
```
For stroke icons: first pass navy '#0c1226' lineWidth 5, second pass white '#fff' lineWidth 2.4. For multi: dark circle r=3+2 then white r=3. Result: white icon w/ dark halo.
For capsule maybe invert (navy icon on light capsule)? Keep same, works.
**Prerender brick sprites cache**: key = `${kind}|${color}|${cracked}|${seed}`. Rebuild on DPR change.
Normal brick sprite function:
```
function spriteNormal(color){
makeSprite(bw,bh,8,(g,pad)=>{
const x=pad,y=pad;
// glow
g.save();
roundRect(g,x,y,bw,bh,5);
g.shadowColor=shade(color, .55, .6)? need rgba: use hexToRgba(color,0.55); g.shadowBlur=9;
g.fillStyle=color; g.fill();
g.restore();
// body gradient
const gr=g.createLinearGradient(0,y,0,y+bh);
gr.addColorStop(0, lighten(color,0.28)); gr.addColorStop(0.45,color); gr.addColorStop(1, darken(color,0.28));
roundRect; g.fillStyle=gr; g.fill();
// top gloss
roundRect(g,x+2,y+2,bw-4,bh*0.38,3); fill rgba(255,255,255,0.28→0.08 gradient)
// border
roundRect; stroke rgba(255,255,255,0.22) lw1
});
}
```
Helpers: hex→rgb, lighten/darken via mixing with #fff/#000, rgba string.
Tough sprite:
```
base colors: light '#dbe6f5', mid '#9fb2cd', dark '#5c7290'? Steel-blue.
glow color '#9fb8e8'.
gradient vertical from '#e9eff9' → '#a9bcd6' → '#6d82a3'? plus sheen: diagonal light band? Add horizontal mid sheen line rgba(255,255,255,.35) 1.5px at y+30%.
border rgba(255,255,255,.35) lw1.2.
rivets: 4 dots r1.6 at corners inset 5: fill rgba(30,45,70,.55) + top-left highlight? just dark dots.
hp pips? draw two small notches on right? skip.
cracked variant: draw base then:
tint rgba(20,26,44,0.28) over full roundRect;
crack lines: seeded: main crack polyline from (x+bw*0.3, y) zigzag to bottom (x+bw*0.65,y+bh); secondary short from left edge mid.
stroke '#141a2c' lw 1.8 alpha .9; plus white-ish highlight offset? Keep dark only. Also chips: two small triangles bg-color at top edge (missing chunks) fill '#0a0f1e'? chips would show bg dark through: fill with dark color small triangles on edges.
```
Cracks must look clearly "cracked" — dark zigzag across + darker tint + maybe reduced glow. Good.
Seed per brick: r*13+c*7.
**Sprite draw size**: sprite canvas logical size (bw+16)×(bh+16); drawImage(spr, br.x-8, br.y-8, bw+16, bh+16).
bw ~65.17 — non-integer fine.
**Sprite for power brick outline pulse**: drawn dynamically: stroke rounded rect white alpha .35+.25sin, lw 1.5, maybe expand ±1. Plus icon.
Perf: 96 bricks drawImage — trivial.
**Background walls**: draw in bg canvas:
- top wall: line y=TOP from x=0..W: gradient? stroke '#3b4d8f' with glow cyan shadowBlur 8. Also tick marks? Keep line + glow.
- side walls: vertical lines x=0.5 and W-0.5 from TOP..H: subtle.
Hmm side wall glow at canvas edge gets clipped by rounded corner CSS. Draw side lines at x=2, W-2. Ball bounces at x=r (8) — mismatch small; purely aesthetic. Better make ball bounce at x= r? Ball touching visual line at x=2 vs bounce at 8: 6px gap, fine visually (ball r 8 → ball edge at x=0 when center 8 — actually ball edge reaches canvas edge 0! bounce at center=r=8 means ball edge touches x=0 exactly. Line at x=2 is under the ball. Good: draw side wall line at x=1.5 and W-1.5.
Bottom hazard: gradient rect from H-34 to H, rgba(255,60,90,0)→0.14 red + line at bottom? Draw in bg with static alpha; also dynamic pulse when ball below y> H-140? Add dynamic: draw each frame alpha 0.1+0.05sin(t*2) small. Fine — static in bg plus dynamic glow line at very bottom edge (rgba(255,70,100,.35), 2px) pulsing.
Top wall at y=TOP: ball bounces center y ≥ TOP+r; ball top touches TOP ✓ line at TOP.
**HUD divider**: bg includes subtle line? The wall line at TOP serves.
Let me also draw "NEON BREAKOUT" centered small in HUD: color '#7f8ff0', letterSpacing 4px, font 12 bold, y=30; below it "LEVEL 1" '#556' y=48? Level text center: "LEVEL 1" small. Or center shows combo when active replacing? Draw both: title y=24, level y=44. Combo shown at center y=44 in gold replacing level when combo≥3? Keep combo as popup text near paddle instead + HUD center shows "LEVEL 1". OK.
**Popups for combo**: at brick pos show "+pts"; occasionally "COMBO ×n!" at center? Skip, popup at brick: `+${pts}` and if combo>=4 add small second line? Keep simple: popup text = `+${pts}` and when combo multiple of 5: "COMBO ×${combo}!" bigger gold popup at center. Nice touch cheap.
**Victory bonus popup** handled in end overlay.
**Multi-ball popup**: "MULTI-BALL!" gold at paddle; "WIDE PADDLE!" cyan.
**Now write aiPaddle fully**:
```js
function aiUpdate(dt){
const p=game.paddle;
let target=W/2;
let best=1e9, tx=null;
for(const b of game.balls){
if(b.stuck) continue;
if(b.vy>40){
const t=(p.y - b.r - b.y)/b.vy;
if(t>-0.1 && t<best){ best=t; tx=foldX(b.x+b.vx*t, b.r, W-b.r); }
}
}
if(tx!=null){
target=tx + Math.sin(game.time*2.3)*9 + Math.sin(game.time*5.1)*4;
} else if(game.capsules.length){
// catch capsules when no urgency
let c=game.capsules[0], d2=1e9;
for(const cp of game.capsules){ const dd=Math.abs(cp.x-p.x); if(dd<d2){d2=dd;c=cp;} }
target=c.x;
} else {
const b=game.balls[0];
if(b && !b.stuck) target=b.x*0.4+W*0.3; // drift
else target=W/2 + Math.sin(game.time*0.8)*120; // idle sway during serve
}
const maxV=560*dt; // px per frame cap
const dx=clamp(target-p.x, -maxV, maxV);
p.x+=dx;
}
```
maxV 560 px/s might miss sharp-angle balls (ball vx up to sin60*640=554) — equal, will just barely keep up, misses on wide targets when paddle far. That's realistic demo. Maybe 620 for good demo survival. Use 640.
Ball vx max = sin(1.05)*640 = 0.867*640= 555. AI 640 keeps up if not too far — occasional misses when far. Good balance.
**playerPaddle**:
```js
function playerUpdate(dt){
const p=game.paddle;
const v=680;
if(keys.left) p.x-=v*dt;
if(keys.right) p.x+=v*dt;
// mouse sets p.x directly in pointermove; also allow analog follow if pointerActive
}
```
Also on touch: pointermove covers.
Clamp in update after: `p.x=clamp(p.x, p.w/2+6, W-p.w/2-6)`.
Serve stuck ball follows: in update, for stuck balls: b.x=p.x; b.y=p.y-b.r-3.
**Launch**:
```js
function launchAll(){
let any=false;
for(const b of game.balls){ if(b.stuck){ b.stuck=false; const a=rand(-0.28,0.28); const s=game.speed; b.vx=Math.sin(a)*s; b.vy=-Math.cos(a)*s; any=true; } }
if(any){ game.phase='play'; beep launch; }
}
```
**Serve auto-launch timers**: serveTimer+=dt; demo: if serveTimer>0.9 launch; player: if serveTimer>4 launch.
**Bricks hit sound pitch** by row: beep(320 + row*40, ...). Break: two beeps quick + noise? Use beep with slide down for explosion feel: `beep(520,0.09,'square',0.05,-260)`.
Paddle: beep(220,0.05,'sine',0.06, 40)? plus click 'triangle' 300.
Wall: beep(180,0.03,'sine',0.03).
Powerup catch: ascending arpeggio: three beeps scheduled? beep uses AC.currentTime; schedule 3 with delays: write helper `beepAt(f,t0,dur,...)`. Simplify: setTimeout chain? Use AC scheduling: pass `when` param. I'll add optional delay param using t+delay.
Life lost: beep(140,0.4,'sawtooth',0.06,-90) descending.
Win: arpeggio up: delays 0,0.12,0.24,0.36.
All guarded by AC null.
**Volume**: keep ≤0.06.
**Draw code**:
```js
function draw(){
ctx.setTransform(DPR,0,0,DPR,0,0); // reset each frame (also after resize)
// base
ctx.fillStyle='#05060f'; ctx.fillRect(0,0,W,H);
ctx.save();
const sh=game.shake;
if(sh>0.05){ ctx.translate(rand(-sh,sh), rand(-sh,sh)); }
ctx.drawImage(bgCv, 0,0, W,H);
// fireflies
...
// bricks
for(const br of game.bricks){ if(!br.alive) continue; drawImage sprite; if power: pulse + icon }
// capsules
// particles (lighter)
// rings
// balls trails + balls
// paddle
// popups
// serve hint (game mode)
ctx.restore();
// flash overlay
if(flashA>0) fill rect rgba
// HUD
drawHUD();
// paused overlay
}
```
bgCv is DPR-scaled canvas; drawImage(bgCv,0,0,W,H) scales correctly since bgCv.width=W*DPR and we draw to W,H logical with current transform DPR — drawImage(bgCv, 0,0,W,H) draws source full canvas into WxH logical rect → correct.
Fireflies drawn each frame (cheap 26 arcs with lighter).
**Particles update** in update(dt) always (even during 'over' phase? yes cosmetic). balls update only phase 'play'. capsules update only 'play'. During 'over', freeze balls — hide them anyway. Draw everything else.
Actually during 'over'/'win', stop drawing balls/paddle? Keep drawing paddle; balls array cleared on game over? On lose, balls already empty. On win, balls still exist — freeze them (don't update) but draw static, slightly dimmed under overlay. Fine — overlay covers center; canvas edges visible with frozen scene + confetti. Nice.
**End overlay show**:
```js
function showEnd(won){
endTitle.textContent = won? 'LEVEL CLEARED!' : 'GAME OVER';
endTitle.className = 'title end ' + (won?'win':'lose');
endInfo.innerHTML = `SCORE <b>${score}</b>` + (won? ` · LIFE BONUS +${bonus}` : '') + `<br><small>${won?'You cleared every brick.':'The ball got away...'}</small>`;
restartBtn.textContent='PLAY AGAIN';
endOverlay.classList.remove('hidden');
}
```
**Demo win path**: mode demo & bricksLeft==0 → set demoReset=2.0, phase='clearwait'; big confetti; after timer → resetGame(). In 'clearwait', balls frozen. OK.
**resetGame** signature: function resetGame(){...} then if mode==='demo' nothing else; called by startGame/restart with mode set before.
Let me now also handle: after game over overlay visible, keys.left etc ignored (phase check in playerUpdate? paddle still moves — harmless under overlay; but let's gate input by phase over/win: skip paddle update in those phases).
**pause**: only when mode==='game' && (phase==='play'||phase==='serve'). Draw "PAUSED" center canvas + dim rect. Keyboard 'p'. Also pause hides serve hint; draw instead.
Also handle window blur: `window.addEventListener('blur', ()=>{ if(game.mode==='game'&&(game.phase==='play')) game.paused=true; })`.
**One more requirement check**: "A paddle controlled with the mouse and with the left/right arrow keys" ✓. "bounce angle depends on where ball hits paddle (center straight up, edges sharp)" ✓ rel→angle mapping. "6 rows+ mixing normal and tougher 2-hit bricks that visibly crack" ✓ 8 rows, tough with crack visuals. "Some bricks visibly carry a power-up icon; when broken drop it; catching gives wider paddle (10s) or multi-ball (3 balls)" ✓. "Ball never passes through bricks or paddle" ✓ substepping + pushout. "Particles when brick breaks" ✓. "3 lives, score displayed. Game over and victory screens with restart" ✓.
Also system: demo mode AI ✓ with "Click or press a key to start" ✓ visible in overlay ✓.
Let me write final code carefully now. I'll aim for clean organized code with comments.
Detailed writing:
```js
// ===== Constants =====
const W=900, H=620, TOP=64;
const COLS=12, ROWS=8;
const SIDE=26, GAP=6, BH=22, BTOP=TOP+22;
const BW=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const BALL_R=8, BASE_SPEED=380, MAX_SPEED=640;
```
State variables (not nested object for brevity but grouping is nicer; I'll use flat lets inside an object `S`):
Actually flat module-level variables are simplest to read:
```js
let mode='demo', phase='serve';
let score=0, lives=3, combo=0, ballSpeed=BASE_SPEED;
let bricks=[], balls=[], caps=[], parts=[], pops=[], rings=[], flies=[];
let paddle={x:W/2,y:H-46,w:110,h:16,baseW:110,squash:0,wideT:0};
let shake=0, flashA=0, flashC='255,255,255';
let time=0, serveT=0, demoResetT=-1, bonus=0;
let paused=false;
const keys={l:false,r:false};
```
paddle.wideT = remaining seconds (countdown) — simpler than until-time: wideT decays; active if >0.
Functions:
`resetGame()`:
```js
function resetGame(){
score=0; lives=3; combo=0; ballSpeed=BASE_SPEED;
caps=[]; parts=[]; pops=[]; rings=[]; balls=[];
paddle.x=W/2; paddle.baseW=110; paddle.w=110; paddle.wideT=0; paddle.squash=0;
shake=0; flashA=0; paused=false; demoResetT=-1; bonus=0;
buildBricks();
serve();
}
function serve(){
balls=[{x:paddle.x, y:paddle.y-BALL_R-3, vx:0, vy:0, stuck:true, trail:[]}];
phase='serve'; serveT=0; combo=0;
}
```
`buildBricks()`:
```js
function buildBricks(){
bricks=[];
const powerMap=new Map();
for(const s of POWER_SPOTS) powerMap.set(s.r*COLS+s.c, s.k);
let aliveCount=0;
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const tough=isTough(r,c);
const k=powerMap.get(r*COLS+c);
bricks.push({r,c,x:SIDE+c*(BW+GAP), y:BTOP+r*(BH+GAP), w:BW, h:BH, tough, power:k||null, hp:tough?2:1, maxHp:tough?2:1, alive:true, seed:r*13+c*7});
aliveCount++;
}
}
```
Wait if power on tough: hp 2 ✓ (works). bricksLeft = count alive = ROWS*COLS initially minus none. Store `let bricksLeft=ROWS*COLS;` in buildBricks.
isTough:
```js
function isTough(r,c){
if(r===0) return true;
if(r===2) return c%2===0;
if(r===4) return c%2===1;
if(r===6) return (c%4)===1;
return false;
}
```
Row colors: ROWC = [null,'#ff4d6d','#ff9e4f','#ffd166','#52e5a3','#4cc9f0','#5a7bff','#b06cff'];
POWER_SPOTS defined earlier; ensure spread; check (6,1): r6 c1 → c%4==1 → tough+multi ✓ fine. (0,2),(0,9) tough ✓.
**update(dt)**:
```js
function update(dt){
time+=dt;
// fireflies always
for(const f of flies){ f.y+=f.vy*dt; f.x+=Math.sin(time*0.6+f.ph)*6*dt; if(f.y<-8){f.y=H+8; f.x=rand(0,W);} }
// decay cosmetic
shake*=Math.pow(0.0015,dt); if(shake<0.03)shake=0;
flashA=Math.max(0,flashA-dt*1.6);
paddle.squash=Math.max(0,paddle.squash-dt*5);
updParts(dt); updPops(dt); updRings(dt);
if(phase==='clearwait'){ demoResetT-=dt; if(demoResetT<=0) resetGame(); return; }
if(phase==='over'||phase==='win') return;
// paddle
if(mode==='demo') aiUpdate(dt); else if(!paused) playerUpdate(dt);
clampPaddle();
// paddle width tween
const tw = paddle.wideT>0 ? 170 : 110;
paddle.w += (tw-paddle.w)*Math.min(1,dt*8);
if(paddle.wideT>0){ paddle.wideT-=dt; if(paddle.wideT<=0){ paddle.wideT=0; pop(paddle.x,paddle.y-30,'NORMAL SIZE', '#9aa7d0', 12);} }
// serve
if(phase==='serve'){
serveT+=dt;
for(const b of balls){ if(b.stuck){ b.x=paddle.x; b.y=paddle.y-BALL_R-3; } }
const auto = mode==='demo' ? 0.9 : 4.0;
if(serveT>auto) launchAll();
}
if(paused) return; // hmm paused should stop earlier
```
Wait ordering with pause: if paused, skip paddle & serve & balls but keep cosmetic — reorganize:
```js
if(paused) return; // after cosmetics
```
Put `if(paused) return;` right after cosmetics decay, before phase clearwait? clearwait only demo (never paused). Order:
```
cosmetics...
if(phase==='over'||phase==='win') return;
if(paused) return;
if(phase==='clearwait'){...}
paddle...
if(phase==='serve'){...}
if(phase==='play'){
moveBalls(dt);
updCaps(dt);
}
```
paddle during clearwait (demo): frozen fine.
`moveBalls(dt)`:
```js
function moveBalls(dt){
for(const b of balls){
const sp=Math.hypot(b.vx,b.vy);
const steps=Math.max(1, Math.ceil((sp*dt)/3.5));
const sdt=dt/steps;
for(let i=0;i<steps && phase==='play';i++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
collideWorld(b);
}
b.trail.push(b.x,b.y); // flat array pairs? use array of objects; shift
if(b.trail.length>12) b.trail.splice(0, b.trail.length-12);
}
// deaths
for(let i=balls.length-1;i>=0;i--){
if(balls[i].y-BALL_R>H+6){ balls.splice(i,1); splash(balls? ...)
}
if(balls.length===0 && phase==='play') loseLife();
}
```
splash at death: spawn red particles at (x, H-6) before removal.
Trail: store objects {x,y} — 12 entries per ball; with up to 6 balls fine.
collideWorld(b):
```js
function collideWorld(b){
// walls
if(b.x<b.r){ b.x=b.r; if(b.vx<0)b.vx=-b.vx; wallFx(b.x,b.y); }
else if(b.x>W-b.r){ b.x=W-b.r; if(b.vx>0)b.vx=-b.vx; wallFx(b.x,b.y); }
if(b.y<TOP+b.r){ b.y=TOP+b.r; if(b.vy<0)b.vy=-b.vy; wallFx(b.x,TOP); }
// paddle
const p=paddle;
if(b.vy>0){
const hw=p.w/2;
if(b.y+b.r>=p.y && b.y-b.r<=p.y+p.h && b.x>=p.x-hw-b.r && b.x<=p.x+hw+b.r){
const rel=clamp((b.x-p.x)/(hw+b.r*0.6), -1, 1);
const ang=rel*1.05; // ±60°
const s=Math.hypot(b.vx,b.vy);
b.vx=Math.sin(ang)*s;
b.vy=-Math.cos(ang)*s;
b.y=p.y-b.r-0.5;
onPaddle();
}
}
// bricks
brickCollide(b);
// anti-shallow safeguard
const s2=Math.hypot(b.vx,b.vy);
if(s2>1 && Math.abs(b.vy)<0.15*s2){ b.vy=(b.vy<0?-1:1)*0.15*s2; const k=s2/Math.hypot(b.vx,b.vy); b.vx*=k; b.vy*=k; }
}
```
onPaddle(): combo=0; paddle.squash=1; small ring at contact? we don't know ball here — pass ball: onPaddle(b) → rings.push small cyan at (b.x,p.y), beep.
brickCollide(b):
```js
function brickCollide(b){
const c0=colOf(b.x-b.r), c1=colOf(b.x+b.r), r0=rowOf(b.y-b.r), r1=rowOf(b.y+b.r);
for(let r=r0;r<=r1;r++){
const rowBase=r*COLS;
for(let c=c0;c<=c1;c++){
const br=bricks[rowBase+c];
if(!br||!br.alive) continue;
const nx=clamp(b.x,br.x,br.x+br.w), ny=clamp(b.y,br.y,br.y+br.h);
const dx=b.x-nx, dy=b.y-ny;
if(dx*dx+dy*dy<=b.r*b.r){ resolveBrick(b,br,dx,dy); return; }
}
}
}
function colOf(x){ return clamp(Math.floor((x-SIDE)/(BW+GAP)),0,COLS-1); }
function rowOf(y){ return clamp(Math.floor((y-BTOP)/(BH+GAP)),0,ROWS-1); }
```
colOf for x slightly less than SIDE gives -1→clamp 0 — safe (then AABB test rejects if no overlap? col 0 brick x=SIDE; ball at x=10 r=8 → nearest point SIDE → dx=-8... distance 8 ≤ r → collision! But ball at x=10 is left of brick start 26 — nearest point (26, cy), dx=-16? b.x=10, clamp(10,26,...)=26 → dx=-16, dist 16>8 no collide ✓. Good, the clamp-nearest test is accurate; colOf bounds just need to cover, extra candidates rejected by distance test. But careful: candidate range must include any brick whose rect could be within r of ball. Ball at x=30: colOf(30-8=22)=floor((22-26)/71.17)=floor(-0.056)=-1→0 ✓ covers brick 0. Ball between bricks: colOf fine.
Edge: bricks only span y BTOP..BTOP+8*28-6=304; rowOf clamps to 0..7 — below 304+ball r, rows clamp to 7, candidate brick row 7 y=280..302; ball at y=340: nearest 302, dy=38>r ✓ no collision. Fine — but clamping causes always testing row 7 for deep balls, cheap anyway (12+ columns... actually c0..c1 up to 2 cols, ≤ ~4 brick tests per substep). OK.
But one bug: rowOf/colOf clamp means ball at y=305 → rowOf=7 tests row 7 bricks — distance rejects. ✓.
resolveBrick:
```js
function resolveBrick(b,br,dx,dy){
let nx,ny,pen;
if(dx===0&&dy===0){
const l=b.x-br.x, rt=br.x+br.w-b.x, t=b.y-br.y, bo=br.y+br.h-b.y;
const m=Math.min(l,rt,t,bo);
if(m===t){nx=0;ny=-1;} else if(m===bo){nx=0;ny=1;} else if(m===l){nx=-1;ny=0;} else {nx=1;ny=0;}
pen=b.r+m;
} else {
const d=Math.hypot(dx,dy);
nx=dx/d; ny=dy/d; pen=b.r-d;
}
b.x+=nx*pen+nx*0.1; b.y+=ny*pen+ny*0.1;
const dot=b.vx*nx+b.vy*ny;
if(dot<0){ b.vx-=2*dot*nx; b.vy-=2*dot*ny; }
hitBrick(b,br);
}
```
hitBrick:
```js
function hitBrick(b,br){
br.hp--;
const cx=br.x+br.w/2, cy=br.y+br.h/2;
if(br.hp<=0){
br.alive=false; bricksLeft--;
combo++;
let pts=(br.tough?120:50)+(br.power?100:0)+(Math.max(0,combo-1))*10;
score+=pts;
burst(cx,cy, ROWC[br.r]||'#cdd8ff', br.tough?16:12);
rings.push({x:cx,y:cy,r:6,max:br.tough?46:34,life:1,t:1, c:br.tough?'#a8c0e8':(ROWC[br.r]||'#fff')});
pop(cx,cy,'+'+pts, br.power?'#ffd166':'#eaf0ff', br.power?15:13);
if(br.power) spawnCap(br);
shake=Math.min(10, shake+ (br.tough?3:1.8));
ballSpeed=Math.min(MAX_SPEED, ballSpeed+5);
setSpeeds();
beep(300+ (ROWS-br.r)*45, 0.08,'square',0.05,-120);
if(combo>0 && combo%6===0) pop(W/2, TOP+120, 'COMBO ×'+combo+'!', '#ffd166', 18);
if(bricksLeft<=0) onCleared();
} else {
// cracked
score+=30;
pop(cx,cy,'+30','#c3cf1? no '#aab8e8',12);
burst(cx,cy,'#8fa3c8',6);
shake=Math.min(10,shake+1);
beep(180,0.06,'square',0.045);
}
}
```
setSpeeds(): rescale all ball velocities to ballSpeed:
```js
function setSpeeds(){ for(const b of balls){ if(b.stuck)continue; const m=Math.hypot(b.vx,b.vy); if(m>0){ const k=ballSpeed/m; b.vx*=k; b.vy*=k; } } }
```
spawnCap(br):
```js
caps.push({x:br.x+br.w/2, y:br.y+br.h/2, kind:br.power, t:0, vy:0});
```
updCaps(dt):
```js
for(let i=caps.length-1;i>=0;i--){
const cp=caps[i]; cp.t+=dt; cp.vy=Math.min(240, cp.vy+160*dt); cp.y+=cp.vy*dt; cp.x=cp.x0?
```
Hmm sway: store baseX; x=baseX+sin. Let me: {bx, y, kind, t, vy}; x=cp.bx+Math.sin(cp.t*3)*7.
Catch: `if(cp.y+10>=paddle.y && cp.y-10<=paddle.y+paddle.h && Math.abs(cp.x-paddle.x)<=paddle.w/2+14)` → applyPower(cp.kind); remove; sparkle.
Miss: if cp.y-12>H remove.
applyPower(kind):
```js
function applyPower(kind){
if(kind==='wide'){ paddle.wideT=10; pop(paddle.x,paddle.y-40,'WIDE PADDLE!','#ffd166',16); ring gold big; beep arpeggio; }
else { // multi
const src=balls.filter(b=>!b.stuck);
const pool=src.length?src:balls;
let guard=0;
while(balls.length<3 && guard++<10){
const b=pool[balls.length % pool.length];
const nb={x:b.x,y:b.y,vx:b.vx,vy:b.vy,stuck:b.stuck,trail:[]};
if(!b.stuck){ const a=Math.atan2(b.vx,-b.vy)+rand(0.5,0.9)*(balls.length%2?1:-1); nb.vx=Math.sin(a)*ballSpeed; nb.vy=-Math.cos(a)*ballSpeed; }
balls.push(nb);
}
pop(paddle.x,paddle.y-40,'MULTI-BALL!','#ff9ecb'? '#7ee8fa'? ,'#5eead4'? pick '#5eead4',16);
}
burst(paddle.x,paddle.y-10, kind==='wide'?'#ffd166':'#5eead4', 18);
shake=Math.min(10,shake+2);
// sound: ascending
chime(kind==='wide'? [660,880,1180] : [520,700,900]);
}
```
Careful: pool might be length 0 if balls empty — but applyPower only during play with ≥1 ball. If stuck ball only (serve with leftover capsule? capsules don't update during serve) — but multi caught right when ball stuck? Not possible (caps update only in play). OK. Also guard pool.length>0: `if(!pool.length) return;`.
Angle math: atan2(b.vx, -b.vy) gives angle from "up". Rotating ±(0.5..0.9) rad. Fine.
loseLife():
```js
function loseLife(){
lives--; combo=0; paddle.wideT=0;
shake=12; flashA=0.35; flashC='255,70,95';
burst(paddle.x? or last death x — use paddle.x, H-60 ...)
```
Better: splash at death point collected in moveBalls before removal: burst red there. Then loseLife adds shake+flash.
```js
chime([300,220,160], 'sawtooth', 0.09);
if(lives<=0){
if(mode==='demo'){ lives=3; serve(); }
else { phase='over'; showEnd(false); }
} else serve();
}
```
onCleared():
```js
function onCleared(){
if(mode==='demo'){ phase='clearwait'; demoResetT=2.2; confetti(); return; }
bonus=lives*250; score+=bonus;
phase='win'; flashA=0.3; flashC='255,214,102';
confetti(); chime([523,659,784,1046]);
showEnd(true);
}
```
confetti(): 130 particles from top area random colors falling with gravity low + drift.
Particles system:
```js
function burst(x,y,color,n){
for(let i=0;i<n;i++){
const a=rand(0,TAU), s=rand(60,340);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s - rand(20,120),life:rand(.4,.9),t:0,size:rand(2,5),color,rot:rand(0,TAU),vr:rand(-8,8),g:900});
}
if(parts.length>420) parts.splice(0,parts.length-420);
}
function splash(x,y){ // red on ball death
for(let i=0;i<26;i++){ const a=rand(-Math.PI,0)... upward arcs; color '#ff5d78' }
}
function confetti(){
for(let i=0;i<140;i++){ parts.push({x:rand(0,W),y:rand(-40,10),vx:rand(-40,40),vy:rand(40,160),life:rand(1,2.2),t:0,size:rand(2,5),color:pick(CONF),rot:rand(0,TAU),vr:rand(-6,6),g:120}); }
}
function updParts(dt){
for(let i=parts.length-1;i>=0;i--){ const p=parts[i]; p.t+=dt; if(p.t>=p.life){parts.splice(i,1);continue;} p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.rot+=p.vr*dt; }
}
```
Draw parts:
```js
ctx.globalCompositeOperation='lighter';
for(const p of parts){ const a=1-p.t/p.life; ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.globalAlpha=a; ctx.fillStyle=p.color; ctx.fillRect(-p.size/2,-p.size/2,p.size,p.size); ctx.restore(); }
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
```
Rings:
```js
updRings: r.t-=dt (t from 1→0); rr=lerp(r.max? ...
draw: alpha=t, radius = r.max*(1-t)+6*t? expand: radius=r.max*(1-t*t)? Use: p=1-r.t; radius=r.max*(0.2+0.8*p)? simple: radius=r.max*(1-r.t); alpha=r.t*0.5; stroke lineWidth 2.5*r.t+0.5.
```
Ring store: {x,y,max,t:1,c}; update t-=dt*2.2; remove t<=0.
Pops:
```js
pops.push({x,y,txt,c,size,t:0,life:0.9? 1});
upd: t+=dt; y-=28*dt; remove t>life.
draw: alpha=1-t/life; font `800 ${size}px mono`; textAlign center; fillStyle c; slight shadow glow: shadowColor=c, shadowBlur=8? ok few pops.
```
pop(x,y,txt,c,size): clamp x within 60..W-60 so text not clipped.
Fireflies init:
```js
flies=[]; for(28): {x:rand(0,W),y:rand(0,H),vy:rand(-14,-5),r:rand(0.8,2),ph:rand(0,TAU),a:rand(.15,.5)}
draw: lighter, alpha = a*(0.6+0.4*sin(time*1.5+ph)); fill '#9fb4ff' circles.
```
**Paddle draw**:
```js
function drawPaddle(){
const p=paddle, hw=p.w/2;
const wide=p.wideT>0;
const c1=wide?'#ffe08a':'#8ef1ff', c2=wide?'#ff9f43':'#3b82f6';
const warn = wide && p.wideT<2 ? (Math.sin(time*12)>0?1:0.25) : 1;
ctx.save();
ctx.translate(p.x,p.y+p.h/2);
const sq=1+p.squash*0.35; // squash: wider & shorter on hit
ctx.scale(sq, 1/sq? (1-p.squash*0.3));
ctx.globalAlpha=warn;? maybe blink via alpha
// glow
ctx.shadowColor=wide?'rgba(255,190,80,.9)':'rgba(90,200,255,.9)'; ctx.shadowBlur=16;
roundRect(ctx,-hw,-p.h/2,p.w,p.h,p.h/2);
const g=ctx.createLinearGradient(0,-p.h/2,0,p.h/2);
g.addColorStop(0,c1); g.addColorStop(1,c2);
ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
// top highlight
roundRect(ctx,-hw+3,-p.h/2+2,p.w-6,4,2); ctx.fillStyle='rgba(255,255,255,.55)'; ctx.fill();
// center notch glow dot
ctx.restore();
// edge lights
...
}
```
Blink: when wideT<2, alpha oscillates → signals expiry.
roundRect helper (path only):
```js
function roundRect(g,x,y,w,h,r){ r=Math.min(r,w/2,h/2); g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
```
**Ball draw**:
```js
function drawBall(b){
// trail
const n=b.trail.length;
ctx.globalCompositeOperation='lighter';
for(let i=0;i<n;i++){ const p=b.trail[i]; const k=(i+1)/n; ctx.globalAlpha=k*0.22; ctx.fillStyle='#7fd4ff'; ctx.beginPath(); ctx.arc(p.x,p.y,b.r*k*0.9,0,TAU); ctx.fill(); }
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
ctx.save(); ctx.shadowColor='rgba(120,210,255,.9)'; ctx.shadowBlur=12;
const g=ctx.createRadialGradient(b.x-2,b.y-3,1,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff'); g.addColorStop(0.6,'#cfeeff'); g.addColorStop(1,'#7cc7ff');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
ctx.restore();
}
```
Radial gradient per ball per frame — few balls, fine.
**Capsule draw**:
```js
function drawCap(cp){
const wide=cp.kind==='wide';
const col=wide? ['#ffe9a8','#f0a941','#8a5a1d'] : ['#a8f7e0','#2ec4b6','#0f6b63'];
const bob=Math.sin(cp.t*4)*0.12;
ctx.save(); ctx.translate(cp.x,cp.y); ctx.rotate(Math.sin(cp.t*3)*0.15);
ctx.shadowColor=wide?'rgba(255,190,80,.8)':'rgba(70,220,200,.8)'; ctx.shadowBlur=12;
roundRect(ctx,-19,-11,38,22,11);
const g=ctx.createLinearGradient(0,-11,0,11);
g.addColorStop(0,col[0]); g.addColorStop(0.5,col[1]); g.addColorStop(1,col[2]);
ctx.fillStyle=g; ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,255,255,.75)'; ctx.lineWidth=1.4; ctx.stroke();
ctx.drawImage(wide? iconWideCv : iconMultiCv, -13,-13, 26,26);
ctx.restore();
}
```
Icon sprite size 26+pad. I'll make icons 26 logical with 4 pad → canvas 34 logical; drawn at -17? Let me define makeIcon returns canvas of logical 34×34 (content 26 centered); draw at (-17,-17,34,34).
Brick icon draw: `ctx.drawImage(icon, br.x+br.w/2-17, br.y+br.h/2-17, 34,34)` with pulse scale:
```js
const s=1+0.06*Math.sin(time*4+br.seed);
ctx.save(); ctx.translate(cx,cy); ctx.scale(s,s); ctx.drawImage(icon,-17,-17,34,34); ctx.restore();
```
Plus pulsing outline:
```js
ctx.strokeStyle=`rgba(255,255,255,${0.35+0.25*Math.sin(time*4+br.seed)})`; lw 1.5; roundRect(br.x+1.5,br.y+1.5,w-3,h-3,4); stroke.
```
**drawHUD**:
```js
function drawHUD(){
ctx.textBaseline='alphabetic';
// score
ctx.textAlign='left';
ctx.fillStyle='#7381b8'; ctx.font='700 11px '+MONO; ctx.letterSpacing? set ctx.letterSpacing='2px' once.
ctx.fillText('SCORE',26,26);
ctx.fillStyle='#f2f6ff'; ctx.font='800 22px '+MONO;
ctx.fillText(String(score).padStart(6,'0'),26,50);
// title center
ctx.textAlign='center';
ctx.fillStyle='#8b9bff'; ctx.font='800 13px '+MONO; ctx.fillText('N E O N B R E A K O U T'?
```
Manual spacing with spaces is simplest & robust: 'N E O N B R E A K O U T' — or use letterSpacing property (Chrome ok). I'll use string with spaces? Looks hacky but reliable: `ctx.fillText('NEON BREAKOUT', W/2, 30)` with ctx.letterSpacing='6px'. Since target is latest Chrome, fine; other browsers ignore (they'd throw? Setting ctx.letterSpacing on unsupported: property just won't exist → assignment creates plain property, harmless). OK use letterSpacing.
Center: title y=28 color #93a5ff, subtitle 'LEVEL 1' y=48 color #5a6690 font 11.
Combo center: if combo>=2: draw at W/2 y=48 gold 'COMBO ×n' replacing level? Level 1 static: draw combo instead when active:
```js
if(combo>=2){ ctx.fillStyle='#ffd166'; ctx.font='800 13px'; ctx.fillText('COMBO ×'+combo, W/2, 50); } else { level }
```
Nice.
Lives right:
```js
ctx.textAlign='right'; ctx.fillStyle='#7381b8'; ctx.font='700 11px'; ctx.fillText('LIVES',W-26,26);
for(let i=0;i<3;i++){ const x=W-26- (3-1-i)*30 ...
```
Draw mini paddles right-aligned: positions: for i in 0..2: x = W-26 - i*30 - 12 (right to left). mini w=24,h=7 rounded, y=40. filled if i<lives? Order: draw filled for lives: first `lives` from right? Draw at x=W-26-24 - i*30, i=0..2; filled = i < lives. Color filled: gradient cyan; empty: stroke rgba(255,255,255,.18).
Wide timer bar top center? conflicts combo. Put wide timer bar bottom-left? Or under HUD at left below score? Score occupies. Place vertical? Simplest: small bar under the center HUD (y=56? that's near wall TOP=64). Bar centered width 130 at y=52..57 — overlaps combo text at y=50. When wide active, replace combo display (combo hidden ok) — priority wide bar:
```js
if(paddle.wideT>0){
label 'WIDE' left of bar? bar: x=W/2-65,y=49,w=130,h=5: outline stroke rgba(255,209,102,.5); fill w*wideT/10 color gradient amber; label above? tiny 'WIDE PADDLE' text 9px at y=45 color #ffca6b.
}
```
combo hidden then — fine.
Hmm y=45..57 above wall line TOP=64 ✓.
**Serve hint** (game mode, phase serve):
```js
const a=0.55+0.45*Math.sin(time*4);
ctx.textAlign='center'; ctx.globalAlpha=a; ctx.fillStyle='#cfe0ff'; ctx.font='700 13px '+MONO;
ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH', W/2, H-110);
ctx.globalAlpha=1;
```
In demo, no hint.
**Paused overlay**:
```js
if(paused){ dim rect rgba(4,6,14,.55) over playfield; 'PAUSED' big center + 'press P to resume' }
```
**Flash draw**:
```js
if(flashA>0){ ctx.fillStyle=`rgba(${flashC},${flashA})`; ctx.fillRect(0,0,W,H); }
```
flashC is 'r,g,b' string.
**Background prerender**:
```js
function buildBG(){
bgCv=document.createElement('canvas'); bgCv.width=W*DPR; bgCv.height=H*DPR;
const g=bgCv.getContext('2d'); g.scale(DPR,DPR);
// base gradient
const lg=g.createLinearGradient(0,0,0,H);
lg.addColorStop(0,'#0b0e21'); lg.addColorStop(0.55,'#080a18'); lg.addColorStop(1,'#05060f');
g.fillStyle=lg; g.fillRect(0,0,W,H);
// faint grid
g.strokeStyle='rgba(96,120,220,0.055)'; g.lineWidth=1;
for(let x=15;x<W;x+=30){ g.beginPath(); g.moveTo(x,0? TOP); ... }
```
Grid below TOP only (playfield): lines from TOP to H, vertical every 30, horizontal every 30. Also above HUD subtle none.
```
// radial glow top center
const rg=g.createRadialGradient(W/2,40,10,W/2,40,420); rg.addColorStop(0,'rgba(80,110,255,0.10)'); rg.addColorStop(1,'rgba(80,110,255,0)'); g.fillStyle=rg; g.fillRect(0,0,W,H);
// walls
g.strokeStyle='rgba(110,150,255,0.9)'; g.lineWidth=2; g.shadowColor='rgba(90,140,255,0.8)'; g.shadowBlur=8;
g.beginPath(); g.moveTo(1,TOP); g.lineTo(1,H); g.moveTo(W-1,TOP); g.lineTo(W-1,H); g.stroke();
g.beginPath(); g.moveTo(0,TOP); g.lineTo(W,TOP); g.stroke();
g.shadowBlur=0;
// bottom hazard
const hz=g.createLinearGradient(0,H-40,0,H); hz.addColorStop(0,'rgba(255,60,90,0)'); hz.addColorStop(1,'rgba(255,60,90,0.16)'); g.fillStyle=hz; g.fillRect(0,H-40,W,40);
// vignette
const vg=g.createRadialGradient(W/2,H/2,260,W/2,H/2,560); vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)'); g.fillStyle=vg; g.fillRect(0,0,W,H);
}
```
**Brick sprite cache**:
```js
let spriteCache={};
function getSprite(br){
if(br.tough){ key='t'+(br.hp===1?'c':'n')+br.seed; }
else key='n'+br.r;
let s=spriteCache[key]; if(!s){ s= br.tough? makeToughSprite(br) : makeColorSprite(ROWC[br.r]); spriteCache[key]=s; }
return s;
}
```
Tough cracked key must depend on hp → in draw: key by hp. But cache built on demand fine. Tough sprite with seed-dependent cracks: key includes seed; but we render each unique seed (12*? tough bricks ~27 unique seeds → 27 sprites of 81×38 — fine memory).
Actually simpler: one tough-normal sprite + a few (say 4) crack variants chosen by seed%4 — fewer canvases. Meh, 27 sprites fine too (each ~ (BW+16)*(BH+16)*DPR²*4 bytes ≈ 81*38*4*4 ≈ 49KB → 27 → 1.3MB, fine).
makeColorSprite(color):
```js
function makeColorSprite(color){
const pad=7;
const c=document.createElement('canvas'); c.width=Math.ceil((BW+pad*2)*DPR); c.height=Math.ceil((BH+pad*2)*DPR);
const g=c.getContext('2d'); g.scale(DPR,DPR);
const x=pad,y=pad;
// glow pass
g.beginPath(); rrect(g,x,y,BW,BH,5);
g.shadowColor=rgba(color,0.55); g.shadowBlur=8; g.fillStyle=color; g.fill(); g.shadowBlur=0;
// body
const gr=g.createLinearGradient(0,y,0,y+BH);
gr.addColorStop(0,mix(color,'#ffffff',0.32));
gr.addColorStop(0.42,color);
gr.addColorStop(1,mix(color,'#000000',0.34));
g.beginPath(); rrect(g,x,y,BW,BH,5); g.fillStyle=gr; g.fill();
// gloss
const gl=g.createLinearGradient(0,y+1,0,y+BH*0.5);
gl.addColorStop(0,'rgba(255,255,255,0.42)'); gl.addColorStop(1,'rgba(255,255,255,0)');
g.beginPath(); rrect(g,x+2,y+2,BW-4,BH*0.5,4); g.fillStyle=gl; g.fill();
// border
g.beginPath(); rrect(g,x+0.5,y+0.5,BW-1,BH-1,4.5); g.strokeStyle='rgba(255,255,255,0.28)'; g.lineWidth=1; g.stroke();
return {c, pad};
}
```
Return object with canvas & pad. Store in cache.
mix(hexA,hexB,t) helper returns hex; rgba(hex,a) returns 'rgba(...)'.
makeToughSprite(cracked, seed):
```js
colors: hi='#e8eefb', mid='#aebfd8', lo='#64789b', edge='rgba(255,255,255,0.35)'
glow 'rgba(150,180,235,0.5)'
body gradient hi→mid→lo.
sheen: rect y+BH*0.32 h2 rgba(255,255,255,.30)
rivets: circles at (x+6,y+6),(x+BW-6,y+6),(x+6,y+BH-6),(x+BW-6,y+BH-6) r1.7 fill 'rgba(28,40,66,0.6)' plus tiny highlight? add stroke rgba(255,255,255,.25).
border stroke.
if cracked:
overlay: rrect fill 'rgba(12,17,32,0.30)'
cracks: rnd=mulberry32(seed*99991+7)
main: start (x+BW*(0.25+rnd()*0.3), y-1); points down: 5 segments zigzag: each: px += (rnd()-0.5)*14; py += (BH+2)/5;
stroke '#0d1322' lw1.8, alpha .95; then offset highlight stroke rgba(255,255,255,0.12)? skip.
branch: from mid of main go left to edge.
chips: at top edge two triangles fill '#0a0f1e': path small notch.
```
Draw chips as small dark triangles overlapping brick top edge (looks like broken off pieces). Fine.
mulberry32:
```js
function mulberry32(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; } }
```
**Icons**:
```js
function makeIcon(kind){
const S=34, c=document.createElement('canvas'); c.width=S*DPR; c.height=S*DPR;
const g=c.getContext('2d'); g.scale(DPR,DPR);
g.translate(S/2,S/2); g.lineCap='round'; g.lineJoin='round';
if(kind==='wide'){
const path=()=>{ g.beginPath(); g.moveTo(-9,0); g.lineTo(9,0); g.moveTo(-4.5,-5); g.lineTo(-9.5,0); g.lineTo(-4.5,5); g.moveTo(4.5,-5); g.lineTo(9.5,0); g.lineTo(4.5,5); };
path(); g.strokeStyle='#0c1226'; g.lineWidth=6; g.stroke();
path(); g.strokeStyle='#ffffff'; g.lineWidth=3; g.stroke();
} else {
const dots=[[-5.5,4],[5.5,4],[0,-5]];
for(const [dx,dy] of dots){ g.beginPath(); g.arc(dx,dy,5.6,0,TAU); g.fillStyle='#0c1226'; g.fill(); }
for(const [dx,dy] of dots){ g.beginPath(); g.arc(dx,dy,3.4,0,TAU); g.fillStyle='#ffffff'; g.fill(); }
}
return c;
}
```
Draw with drawImage(icon, x-17,y-17,34,34).
**fit()**:
```js
function fit(){
DPR=Math.min(2, window.devicePixelRatio||1);
cv.width=Math.round(W*DPR); cv.height=Math.round(H*DPR);
const availW=Math.max(200, innerWidth-16), availH=Math.max(200, innerHeight-16);
const s=Math.min(availW/W, availH/H);
cv.style.width=(W*s)+'px'; cv.style.height=(H*s)+'px';
buildBG(); buildSprites(); // rebuild caches for DPR
}
```
spriteCache reset: `spriteCache={}` then lazily rebuilt. icons rebuilt: iconWide=makeIcon('wide'); iconMulti=makeIcon('multi').
Stage: canvas inside #stage; overlays inset 0 relative to stage which sizes to canvas — #stage display inline-block wrapping canvas; overlay absolute matches canvas box. Since canvas style width set, stage shrinks to content. Good.
**Draw bricks**:
```js
for(const br of bricks){
if(!br.alive) continue;
const spr=getSprite(br);
ctx.drawImage(spr.c, br.x-spr.pad, br.y-spr.pad, BW+spr.pad*2, BH+spr.pad*2);
if(br.power){ ...pulse outline + icon... }
}
```
getSprite key: tough ? ('T'+br.hp+':'+(br.seed%6)) : ('N'+br.r). Crack variants limited to 6 by seed%6 → cache small. Good.
**Sprite pad**: glow blur 8 + margin: pad=7 might clip glow slightly at 8 blur; use pad=8. Size (65.17+16)=81 × 38.
**Demo start**: initial resetGame() with mode='demo'; loop starts.
**startGame()**:
```js
function startGame(){
if(mode==='game') return;
mode='game';
startOverlay.classList.add('hidden');
endOverlay.classList.add('hidden');
resetGame();
}
```
Restart when already game: restartGame(){ endOverlay hidden; resetGame(); } also unpauses (resetGame sets paused=false).
**Demo reset on clearwait** uses resetGame() which keeps mode — ✓.
**Check: resetGame sets phase via serve() → 'serve'. balls set. ✓**
**showEnd** DOM update. Also on win/over, balls: on win leave frozen; on lose balls empty. Capsules freeze.
**Events wiring**:
```js
startOverlay.addEventListener('pointerdown', e=>{ initAudio(); startGame(); });
endOverlay.addEventListener('pointerdown', e=>{ initAudio(); restartGame(); });
restartBtn same (bubbles to endOverlay handler; fine—double call harmless but avoid: just rely on overlay handler; button pointerdown bubbles → single handler). Keep button for affordance & keyboard focus? Simple: overlay handles.
cv.addEventListener('pointermove', e=>{
if(mode!=='game'||paused) return;
const r=cv.getBoundingClientRect();
const x=(e.clientX-r.left)/r.width*W;
paddle.x=clamp(x, paddle.w/2+6, W-paddle.w/2-6);
});
cv.addEventListener('pointerdown', e=>{
initAudio();
if(mode==='demo') return startGame(); // (overlay covers, but safe)
if(phase==='serve') launchAll();
});
```
Wait pointerdown on canvas when overlay visible never fires (overlay intercepts) — the overlay handler covers. Keep canvas handler for serve launch during play. Also clicking canvas during play: could also launch stuck ball (serve) ✓.
Keyboard as planned. Note: keydown 'Any key' in demo: `if(mode==='demo'){ initAudio(); startGame(); ... maybe if key is arrow also set flag? After starting, set keys accordingly: fallthrough: startGame() then continue to arrow handling so first press registers movement. Implement:
```js
window.addEventListener('keydown', e=>{
const k=e.code;
if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].includes(k)) e.preventDefault();
else if(k==='KeyR'||k==='KeyP'){ /* fine */ }
initAudio();
if(mode==='demo'){ startGame(); }
if(phase==='over'||phase==='win'){ if(k==='KeyR'||k==='Enter'||k==='Space') restartGame(); return; }
if(k==='ArrowLeft') keys.l=true;
else if(k==='ArrowRight') keys.r=true;
else if(k==='KeyP'){ if(mode==='game') togglePause(); }
else if(k==='KeyR'){ if(mode==='game') restartGame(); }
else if(k==='Space'||k==='ArrowUp'||k==='Enter'){ if(phase==='serve'&&!paused) launchAll(); }
});
```
Demo + keypress starts and then arrow also sets flag ✓.
Edge: pressing 'x' in demo starts game — intended ("press a key").
keyup: clear flags.
**launchAll in demo**: auto after 0.9 — via update serve branch.
**visibilitychange** pause.
**AI in serve phase**: aiUpdate handles stuck: target = W/2 + sin(t*0.8)*120 sway → paddle sways, ball follows ✓ then auto-launch at 0.9s with random angle — could launch at sharp wall angle? angle ±0.28 rad fine.
**Draw order for balls stuck**: ball drawn at paddle top ✓.
**Trail on serve**: trail entries at same pos → fine.
**Popups & HUD fonts**: define MONO string: `const MONO="ui-monospace,'Cascadia Mono',Menlo,Consolas,monospace";` Canvas font accepts multi families with quotes — need proper quoting inside: font='700 12px ui-monospace, Menlo, Consolas, monospace' works (generic + names). ui-monospace is a CSS keyword supported in canvas font in Chrome ✓.
**letterSpacing**: set ctx.letterSpacing='2px' before HUD labels, reset to '0px' after (it persists otherwise). I'll set per usage. Guard: assignment to ctx.letterSpacing — in Chrome supported. Wrap: `try{ctx.letterSpacing='2px'}catch(e){}`? Property assignment won't throw. Just set.
Careful: letterSpacing applies to all text drawn after; I'll set '0px' default each drawHUD start, then tweak.
**End overlay text shadow classes**: `.end.win{color:#ffd166; text-shadow:0 0 24px rgba(255,200,80,.75)} .end.lose{color:#ff5d7f; text-shadow:0 0 24px rgba(255,60,110,.7)}`.
**Start overlay content**:
```html
<div class="panel">
<div class="title">NEON BREAKOUT</div>
<p class="sub">smash the grid · catch the drops</p>
<div class="cta">CLICK OR PRESS ANY KEY TO START</div>
<div class="ctrl">
<div><span>MOVE</span><b>MOUSE / ◄ ►</b></div>
<div><span>LAUNCH</span><b>SPACE / CLICK</b></div>
<div><span>PAUSE·RESTART</span><b>P / R</b></div>
</div>
</div>
```
Use "← →" arrows chars fine in HTML.
Also mention power-ups: small legend row: two chips with canvas-drawn icons? DOM chips: `↔ wide paddle · ∙∙∙ multi-ball`? Text approximations fine: "⇔ WIDE PADDLE" and "◉◉◉ MULTI-BALL"? Use CSS dots: `<i class="dot"></i>`×3. Simple: legend line: `<div class="legend"><span class="chip wide">↔</span> WIDE PADDLE <span class="chip multi">•••</span> MULTI-BALL</div>`. "↔" glyph widely available ✓. Multi chip: "●●●"? Use "⁙"? Just three bullet chars "•••" ok.
End overlay:
```html
<div class="panel">
<div class="title end" id="endTitle">GAME OVER</div>
<p class="scoreline" id="endScore">SCORE 000123</p>
<p class="sub" id="endNote">...</p>
<button id="restartBtn">PLAY AGAIN</button>
<p class="hint">or press R / Enter</p>
</div>
```
**CSS details**:
body bg: `radial-gradient(1200px 700px at 50% 20%, #0d1130 0%, #070918 55%, #04050c 100%)`.
Panel entrance animation keyframes panelIn: from opacity 0 translateY(10px) scale(.96).
CTA pulse keyframes.
Fonts: `font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace;`
Everything must fit: at 1280×800 canvas ≈ 1130×779? s=min(1264/900=1.40, 784/620=1.264)=1.264 → 1138×783. Overlay panel centered within.
**Draw HUD "NEON BREAKOUT" letterSpacing 5px at 12px font** — width ~ (13 chars) fine.
**Now, thinking about update loop when phase==='over'/'win'** — cosmetics continue (parts/pops/rings/flies updated before the early return) ✓ confetti animates behind overlay ✓.
But `time` increments — wideT frozen (not decremented since we return before; but paddle.wideT decay happens after return point? Order: cosmetics; if over/win return; then paddle code. So wideT frozen on end screens — irrelevant.
**Pause**: returns before paddle/balls but after cosmetics — fireflies keep moving during pause (nice, alive feel). But `time` also drives serve hint pulse etc fine.
**drawHUD during demo**: shows score/lives of demo — good (judge sees HUD). Lives in demo refills silently.
**One concern: substep loop `for(let i=0;i<steps && phase==='play';i++)` — after win/over triggered mid-frame, phase changes; loop stops ✓. Also loseLife→serve sets balls new array while iterating old array in moveBalls — we iterate `for(const b of balls)` and loseLife called after loop ✓. But hitBrick → onCleared → phase='win' → outer for continues other balls? moveBalls loop `for(const b of balls)` continues — balls frozen mid-air but their remaining substeps skipped via inner check; the outer loop still processes next ball with phase!=='play' inner guard → no movement. OK. Deaths check after: `if(balls[i].y-BALL_R>H+6)` still runs — could remove a ball below? No ball below (phase play). loseLife guarded by phase==='play' ✓.
Also onCleared during demo → phase='clearwait'; update returns early next frames after cosmetics ✓.
**hitBrick when phase over?** can't happen since collideWorld only in play.
**serve() called from loseLife during iteration of moveBalls outer for? loseLife called after substep loops in the same function after processing all balls — actually structure:
```js
function moveBalls(dt){
for(const b of balls){ ...substeps... }
// trail & death handled inside loop above
...
}
```
Death removal inside outer loop mutates? I said deaths after loop. Let me restructure precisely:
```js
function moveBalls(dt){
const dead=[];
for(const b of balls){
if(b.stuck) continue;
const sp=Math.hypot(b.vx,b.vy)||1;
const steps=Math.max(1,Math.ceil(sp*dt/3.5));
const sdt=dt/steps;
for(let i=0;i<steps;i++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
collideWorld(b);
if(phase!=='play') break;
}
b.trail.push({x:b.x,y:b.y});
if(b.trail.length>11) b.trail.shift();
if(b.y-BALL_R>H+4) dead.push(b);
}
for(const b of dead){
const i=balls.indexOf(b); if(i>=0) balls.splice(i,1);
splashFx(b.x, Math.min(b.y,H-4));
}
if(balls.length===0 && phase==='play') loseLife();
}
```
Dead balls removed after loop, no mutation issues.
splashFx: red burst + ring.
**trail while stuck**: skip push when stuck (b.stuck continue before) ✓ — but serve ball has empty trail fine.
**AI stuck-only case**: balls all stuck → tx null → capsules? none → drift branch: `const b=balls[0]` exists stuck → target=W/2+sway ✓.
**clampPaddle**:
```js
function clampPaddle(){ paddle.x=clamp(paddle.x, paddle.w/2+4, W-paddle.w/2-4); }
```
After width grows, re-clamped ✓.
**playerUpdate**:
```js
function playerUpdate(dt){
const v=700;
if(keys.l) paddle.x-=v*dt;
if(keys.r) paddle.x+=v*dt;
}
```
Mouse: pointermove direct set (with clamp).
**Demo AI speed**:
```js
const MAXAI=650;
paddle.x += clamp(target-paddle.x, -MAXAI*dt, MAXAI*dt);
```
**Sound chime helper**:
```js
function chime(freqs, type='triangle', dur=0.09, gap=0.09){
freqs.forEach((f,i)=>beep(f, dur, type, 0.05, 0, i*gap));
}
function beep(f,dur,type,vol,slide,delay=0){ ... const t=AC.currentTime+delay; ... }
```
Sound events:
- paddle: beep(240,0.05,'sine',0.05,60)
- wall: beep(150,0.035,'sine',0.03)
- brick crack: beep(170,0.06,'square',0.045)
- brick break: beep(freq based row, slide -140)
- cap catch: chime([620,830,1100])
- lose: chime([320,230,150],'sawtooth',0.12,0.11)
- launch: beep(500,0.07,'triangle',0.05,180)
- win: chime([523,659,784,1047],0.14,0.12)
All no-op before audio init.
**Draw functions order & popups clamping**:
```js
function pop(x,y,txt,c,size){ pops.push({x:clamp(x,70,W-70),y,txt,c,size,t:0,life:0.9}); }
```
**Rings update/draw**:
```js
updRings: for i--: rg.t-=dt*2.4; if<=0 splice.
draw: const p=1-rg.t; radius=6+(rg.max-6)*p? ease-out: p2=1-rg.t*rg.t; radius=rg.max*p2; alpha=rg.t*0.55; lineWidth=1+3*rg.t; strokeStyle rgba? rg.c hex → use rgba(rg.c...). Need hex→rgba helper: hexA(c,a).
```
Store ring color as hex, draw with `ctx.strokeStyle=hexA(rg.c, alpha)`.
**hexA**:
```js
function hexA(hex,a){ const n=parseInt(hex.slice(1),16); return `rgba(${n>>16&255},${n>>8&255},${n&255},${a})`; }
function mix(a,b,t){ const A=parseInt(a.slice(1),16),B=parseInt(b.slice(1),16); const r=Math.round((A>>16&255)*(1-t)+(B>>16&255)*t), ... return '#'+... hex }
```
Write carefully with clamp of channel to 0-255 (mix keeps in range ✓).
**Sprite for tough: draw before cracking uses hp**: getSprite(br) with br.hp: key `T${br.hp}v${br.seed%6}` — but cracks must be same for both hp states? Only cracked state shows cracks (hp1). Normal tough hp2 no cracks. seed%6 variant fine.
**Power bricks that are tough**: (0,2),(0,9),(6,1) — icon on steel brick ✓ visible.
**Icons on power bricks pulse** — ✓.
**Level look check**: rows colored: r0 steel tough row full width at top with 3 power? (0,2) & (0,9) power on steel. r1 rose, r2 orange with tough at even cols + power at c5 (normal) ✓, r3 amber + 2 power, r4 green with tough odd + power c6, r5 cyan, r6 blue with tough c%4==1 (c=1,5,9) + power at c1(tough) and c10(normal), r7 violet + power c5. Nice variety.
**Demo gameplay sanity**: Ball speed 380 start; AI decent; demo shows: hits, cracks, powerup drops, catches, multiball, wide, maybe life loss & refill, particles galore.
**Screen text sizes on small screens**: canvas scales down; text scales with it — fine.
**Cursor**: hide cursor over canvas during play? `cursor:none` on canvas would confuse; keep default. Maybe crosshair. Keep default.
**No horizontal scroll**: body overflow hidden ✓.
**Check no console errors**: careful with ctx.letterSpacing — Chrome supports since 99, ok. roundRect custom (don't rely on native). All good.
**Title on page**: `<title>Neon Breakout</title>`.
**Meta viewport** ✓.
Edge: `endOverlay` initial hidden class ✓.
**Restart via click on end overlay uses pointerdown — also triggers cv pointerdown? No, overlay covers.
**One more**: startOverlay pointerdown → startGame; but user might click intending to also launch — after start, phase serve, needs another click to launch. Auto: after startGame, don't launch; show hint. Fine. Or start immediately with launch? Serve state with hint is standard; auto-launch at 4s anyway.
**Demo overlay pointer-events**: yes (must catch clicks).
**Testing ball-paddle formula**: center hit rel=0 → vx=0, vy=-s straight up ✓. Edge rel=1 → 60° sharp ✓.
**Ball hitting paddle side low (below top)**: y set to p.y-r-0.5 teleports ball above paddle — if ball was deep below (y up to p.y+p.h), teleport up 24px — could place ball inside... above paddle is free space ✓. It "pops" over — prevents pass-through per requirement. With substeps it rarely gets deep. OK.
**Ball under paddle moving down passes below paddle bottom then up? Once below paddle bottom (y>p.y+p.h+r) and moving down, condition b.y-b.r<=p.y+p.h fails → falls to death ✓ correct.
**Paddle moving horizontally into ball resting near wall**: ball squeezed between paddle & wall: paddle clamped to w/2+4 from wall; ball r=8 at wall x=8: gap = paddle left edge (4+55=59?) hmm baseW=110 → paddle min center 59; left edge 4; ball at 8+8=16 right edge > 4 overlap → ball overlaps paddle while maybe moving down → reflect up ✓ fine, no tunnel.
**Serving ball at wall**: paddle near wall, ball stuck at paddle.x → fine.
**Numbers**: BTOP=86, 8 rows ends 304. Capsule spawn y≈ up to 300, falls 240 max speed → catch ~1.2s. Good.
**combo bonus math**: pts includes (combo-1)*10 — with combo increment before? I increment combo first then pts uses combo-1 → first brick combo=1 → bonus 0 ✓.
**Score popups color**: normal '#eaf0ff', power gold, crack '#aab6e0'.
**Win check**: bricksLeft-- → 0 → onCleared ✓.
**Now write drawBricks pulse outline & icon** — done above.
**Serve hint y=H-110=510** — above paddle 574 ✓ below bricks.
**Paused overlay draws over everything including HUD** — draw after HUD.
Also **demo should not show serve hint** ✓ (game mode only).
**HUD in demo shows "LIVES 3"** etc ✓.
**Init sequence**:
```js
fit(); resetGame(); requestAnimationFrame(loop);
```
mode='demo' initial. flies init once at top (not in resetGame).
Wait: resetGame() → serve() → balls with stuck ✓.
**loop**:
```js
let last=performance.now();
function loop(now){
let dt=Math.min(0.033,(now-last)/1000); last=now;
update(dt); draw();
requestAnimationFrame(loop);
}
```
If dt negative (tab), clamp min 0: `dt=Math.max(0,dt)`.
**draw()** full:
```js
function draw(){
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect? just fill base:
ctx.fillStyle='#05060f'; ctx.fillRect(0,0,W,H);
ctx.save();
if(shake>0.05) ctx.translate(rand(-shake,shake)*0.9, rand(-shake,shake)*0.9);
ctx.drawImage(bgCv,0,0,W,H);
// fireflies
ctx.globalCompositeOperation='lighter';
for(const f of flies){ const a=f.a*(0.5+0.5*Math.sin(time*1.7+f.ph)); ctx.globalAlpha=a; ctx.fillStyle='#8fa6ff'; ctx.beginPath(); ctx.arc(f.x,f.y,f.r,0,TAU); ctx.fill(); }
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
// bricks
drawBricks();
// capsules
for(const cp of caps) drawCap(cp);
// particles
drawParts();
// rings
drawRings();
// balls
for(const b of balls) drawBall(b);
// paddle
drawPaddle();
// pops
drawPops();
// serve hint
if(mode==='game'&&phase==='serve'&&!paused){ hint }
ctx.restore();
// flash
if(flashA>0){...}
drawHUD();
if(paused){ ...dim + text... }
}
```
HUD after restore so HUD steady during shake? Shake whole field including HUD feels chaotic; HUD stable nicer ✓.
**drawBricks** with icons:
```js
function drawBricks(){
for(const br of bricks){
if(!br.alive) continue;
const spr=getSprite(br);
ctx.drawImage(spr.c, br.x-spr.pad, br.y-spr.pad, spr.w, spr.h);
```
spr.w=BW+2pad etc — store logical w,h in sprite object.
```
if(br.power){
const pu=0.5+0.5*Math.sin(time*3.5+br.seed);
ctx.strokeStyle=hexA('#ffffff',0.25+0.45*pu);
ctx.lineWidth=1.6;
rrect(ctx,br.x+1.5,br.y+1.5,br.w-3,br.h-3,4); ctx.stroke();
const s=1+0.1*pu;
const ic=br.power==='wide'?iconWide:iconMulti;
const cx=br.x+br.w/2, cy=br.y+br.h/2;
ctx.save(); ctx.translate(cx,cy); ctx.scale(s,s); ctx.drawImage(ic,-17,-17,34,34); ctx.restore();
}
}
}
```
**drawPops**:
```js
ctx.textAlign='center';
for(const p of pops){
const a=p.t<p.life*0.7?1:1-(p.t-p.life*0.7)/(p.life*0.3);
ctx.globalAlpha=a;
ctx.font='800 '+p.size+'px '+MONO;
ctx.fillStyle=p.c;
ctx.shadowColor=p.c; ctx.shadowBlur=6;
ctx.fillText(p.txt,p.x,p.y);
ctx.shadowBlur=0;
}
ctx.globalAlpha=1;
```
**drawHUD** code with lives icons:
```js
function drawHUD(){
ctx.textBaseline='alphabetic';
try{ctx.letterSpacing='2px';}catch(e){}
ctx.textAlign='left'; ctx.font='700 10px '+MONO; ctx.fillStyle='#66739e';
ctx.fillText('SCORE',26,24);
try{ctx.letterSpacing='1px';}catch(e){}
ctx.font='800 21px '+MONO; ctx.fillStyle='#f0f4ff';
ctx.fillText(String(score).padStart(6,'0'),26,48);
// center
ctx.textAlign='center';
try{ctx.letterSpacing='6px';}catch(e){}
ctx.font='800 12px '+MONO; ctx.fillStyle='#8093ff';
ctx.fillText('NEON BREAKOUT', W/2+3, 27);
try{ctx.letterSpacing='2px';}catch(e){}
if(paddle.wideT>0){
ctx.font='700 9px '+MONO; ctx.fillStyle='#ffca6b';
ctx.fillText('WIDE PADDLE', W/2, 44);
// bar
const bw=120, bx=W/2-bw/2, by=49;
ctx.strokeStyle='rgba(255,202,107,.45)'; ctx.lineWidth=1; ctx.strokeRect(bx,by,bw,5);
const f=bw*clamp(paddle.wideT/10,0,1);
const g=ctx.createLinearGradient(bx,0,bx+bw,0); g.addColorStop(0,'#ffe9a8'); g.addColorStop(1,'#ff9f43');
ctx.fillStyle=g; ctx.fillRect(bx+1,by+1,Math.max(0,f-2),3);
} else if(combo>=2){
ctx.font='800 12px '+MONO; ctx.fillStyle='#ffd166';
ctx.fillText('COMBO ×'+combo, W/2, 47);
} else {
ctx.font='700 10px '+MONO; ctx.fillStyle='#66739e';
ctx.fillText('LEVEL 1', W/2, 46);
}
// lives
ctx.textAlign='right'; ctx.font='700 10px '+MONO; ctx.fillStyle='#66739e';
ctx.fillText('LIVES', W-26, 24);
for(let i=0;i<3;i++){
const x=W-26-22-(i)*30, y=34, w=22,h=7;
rrect(ctx,x,y,w,h,3.5);
if(i<lives){ const g=ctx.createLinearGradient(x,y,x,y+h); g.addColorStop(0,'#9ef1ff'); g.addColorStop(1,'#3f8cff'); ctx.fillStyle=g; ctx.fill(); }
else { ctx.strokeStyle='rgba(160,175,220,.3)'; ctx.lineWidth=1; ctx.stroke(); }
}
try{ctx.letterSpacing='0px';}catch(e){}
}
```
letterSpacing assignments: in Chrome, `ctx.letterSpacing='6px'` valid; wrapping try not needed but harmless. Actually careful: letterSpacing with textAlign center offsets by trailing space — minor. Add +3 shift for the 6px title as above; fine.
**Paused overlay**:
```js
if(paused){
ctx.fillStyle='rgba(4,6,15,0.55)'; ctx.fillRect(0,0,W,H);
ctx.textAlign='center'; ctx.fillStyle='#dfe7ff'; ctx.font='800 30px '+MONO;
ctx.fillText('PAUSED', W/2, H/2-8);
ctx.font='700 12px '+MONO; ctx.fillStyle='#8b98c9';
ctx.fillText('press P to resume', W/2, H/2+20);
}
```
**serve hint**:
```js
const a=0.5+0.5*Math.sin(time*5);
ctx.textAlign='center'; ctx.globalAlpha=0.45+0.5*a; ctx.fillStyle='#cfe0ff'; ctx.font='700 12px '+MONO;
ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH', W/2, H-96);
ctx.globalAlpha=1;
```
Also in demo show nothing.
**CSS overlay hidden class** & pointer events.
**HTML skeleton** final:
```html
<body>
<div id="stage">
<canvas id="cv" width="900" height="620"></canvas>
<div class="overlay" id="ovStart">
<div class="panel">
<div class="title glow-c">NEON BREAKOUT</div>
<p class="sub">SMASH THE GRID · CATCH THE DROPS</p>
<div class="cta">CLICK OR PRESS ANY KEY TO START</div>
<div class="ctrl">
<div><span>MOVE</span><b>MOUSE · ← →</b></div>
<div><span>LAUNCH</span><b>SPACE · CLICK</b></div>
<div><span>PAUSE / RESET</span><b>P · R</b></div>
</div>
<div class="legend">
<span class="chip amber">↔</span><em>WIDE PADDLE 10s</em>
<span class="chip teal">●●●</span><em>MULTI-BALL</em>
</div>
</div>
</div>
<div class="overlay hidden" id="ovEnd">
<div class="panel">
<div class="title end" id="endTitle">GAME OVER</div>
<p class="score" id="endScore">SCORE 000000</p>
<p class="sub" id="endNote">The grid won this round.</p>
<button id="btnRestart" type="button">PLAY AGAIN</button>
<p class="hintline">click anywhere · or press R</p>
</div>
</div>
</div>
<script>...</script>
</body>
```
"●●●" three black circles — use white dots? chip teal bg dark, dots white: `<span class="chip teal"><i></i><i></i><i></i></span>` with i as 5px dots. Do that for crispness. Wide chip: "↔" arrow char — color white.
CSS:
```css
.legend{display:flex;gap:10px;justify-content:center;align-items:center;margin-top:16px;font-size:11px;color:#9fb0dd;flex-wrap:wrap}
.legend em{font-style:normal;letter-spacing:.08em}
.chip{display:inline-flex;align-items:center;justify-content:center;gap:3px;width:34px;height:20px;border-radius:10px;font-weight:800;font-size:13px;line-height:1}
.chip.amber{background:linear-gradient(180deg,#ffe9a8,#f0a941);color:#3a2410;box-shadow:0 0 10px rgba(255,180,80,.5)}
.chip.teal{background:linear-gradient(180deg,#a8f7e0,#2ec4b6);color:rgba(4,40,36,.9);box-shadow:0 0 10px rgba(60,220,200,.45)}
.chip.teal i{width:5px;height:5px;border-radius:50%;background:#063b35;display:block}
```
ctrl grid:
```css
.ctrl{display:flex;gap:18px;justify-content:center;margin-top:18px;font-size:10px;color:#8fa0cf}
.ctrl div{display:flex;flex-direction:column;gap:4px;letter-spacing:.12em}
.ctrl span{color:#5d6a95}
.ctrl b{color:#e8eeff;font-weight:700}
```
Score line: `.score{margin-top:10px;font-size:15px;color:#fff;letter-spacing:.18em}` maybe with accent.
Title:
```css
.title{font-size:clamp(24px,4.4vw,42px);font-weight:800;letter-spacing:.16em;color:#eef3ff;text-shadow:0 0 12px rgba(120,170,255,.9),0 0 42px rgba(90,130,255,.45)}
.title.end.lose{color:#ff5d7f;text-shadow:0 0 14px rgba(255,80,120,.85),0 0 48px rgba(255,60,110,.4)}
.title.end.win{color:#ffd166;text-shadow:0 0 14px rgba(255,200,90,.9),0 0 48px rgba(255,180,60,.4)}
```
CTA:
```css
.cta{margin-top:18px;font-size:clamp(10px,1.6vw,14px);font-weight:800;letter-spacing:.28em;color:#ffd166;animation:pulse 1.4s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
```
sub: `.sub{margin-top:6px;font-size:10px;letter-spacing:.24em;color:#7683b3}`
hintline similar small.
button:
```css
button{margin-top:16px;font:800 13px/1 inherit;letter-spacing:.22em;color:#04101e;background:linear-gradient(180deg,#9ef1ff,#49b3ff);border:0;border-radius:10px;padding:13px 30px;cursor:pointer;box-shadow:0 0 22px rgba(90,190,255,.5);transition:transform .12s ease,box-shadow .12s ease}
button:hover{transform:translateY(-2px);box-shadow:0 0 30px rgba(90,190,255,.75)}
button:active{transform:translateY(0)}
```
`font:... inherit` — font shorthand resets family; use `font-family:inherit;font-size:13px;font-weight:800;`.
Overlay:
```css
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;cursor:pointer;line-height:1.5;padding:12px}
.overlay.hidden{display:none}
.panel{...;max-width:min(92%,520px)}
```
Stage:
```css
#stage{position:relative}
canvas{display:block;border-radius:14px;box-shadow:0 0 0 1px rgba(120,150,255,.28),0 0 70px rgba(70,110,255,.22),0 30px 90px rgba(0,0,0,.55)}
```
Overlay inset includes rounded corners — overlay square corners overflow rounded canvas edges visually? Overlay background transparent (only panel centered) — no issue.
Alright — also body margin center; but on small heights, panel might overflow canvas: panel max-height? Canvas at 375×667: s=min(359/900=0.398, 651/620=1.05)=0.398 → 359×247 canvas tiny; panel bigger than canvas — overlay inset 0 of stage (canvas size) → panel overflows visually outside canvas bounds (position absolute doesn't clip). Ugly. Fix: overlay `overflow:visible` but panel centered on tiny canvas → spills onto page bg. Acceptable? Better: make stage min dimensions? Alternative: overlays positioned over viewport instead of canvas when canvas small... Simplest robust: position overlay absolutely relative to stage but allow spill; page bg is dark so it just looks like panel floating over game — actually acceptable visually. But gameplay invisible behind. Hmm.
Better approach for small screens: keep overlay inside canvas area but scale panel: `panel{transform:scale(min(1, ...))}` can't reference canvas size in CSS easily. Use `container queries`? Overkill.
Alternative: fit() also sets font-size scaling? Let me set CSS var on stage: `stage.style.setProperty('--s', s)` and panel styles use `calc()` with var: e.g., `.panel{padding:calc(20px*var(--s,1)) ...}` messy.
Pragmatic: mobile portrait 375×667: game canvas 359 wide? s by width 0.398 → height 247. Tiny gameplay. Maybe on narrow screens, fit by width but allow taller? Canvas aspect fixed 900:620; on tall narrow screens width-limited tiny. Improve: for small widths, use a different logical size? Can't easily (layout constants fixed). Alternative: letterboxed canvas is fine; panel spill over dark bg is actually okay aesthetically (panel is self-contained with backdrop blur; page dark). The overlay is positioned inset:0 of #stage, but panel centered can overflow — visually panel floats over page; since page bg is dark & clean, it looks intentional. Also pointer events: overlay covers only stage box; panel might extend outside overlay bounds — clicks on panel outside overlay area won't register! Overlay div itself is inset:0 with default overflow visible; panel is child — child can render outside but hit-testing works on child even outside parent bounds? For absolutely positioned overlay with no overflow clip, children outside parent's box still receive pointer events (they're painted and hit-tested normally — yes, hit testing isn't clipped unless overflow:hidden or clip). So clicks on panel work. Clicks on stage area not covered: fine.
But to be safer on small screens, let overlay use `position:fixed; inset:0` (whole viewport) instead of absolute within stage! Then panel always centered on viewport, click-to-start works anywhere. During demo (start overlay), whole page click starts game — even better ("Click ... to start"). End overlay fixed too — restart click anywhere. But then overlay blocks canvas pointermove... while overlays visible we don't need canvas events (serve state or demo). ✓ Use position:fixed. Panel max-width 92vw, and max-height concerns: panel content might exceed 667 height? Content ~360px tall — fits.
Also with fixed overlay, panel centers on viewport while canvas might be smaller — fine, looks intentional.
Edge: overlay must not capture events when hidden ✓ class hidden display none.
**Now, the "restart" while overlay fixed and canvas below: after restart, overlay hidden ✓.
**Prevent text selection**: `user-select:none` on body; overlays too.
**Also prevent context menu? skip.
**iOS pointer**: touch-action none on canvas & overlays to prevent scroll gestures: body overflow hidden + touch-action:none on stage.
Let me now also double-check **AI catches wide** making paddle wide — then AI physics fine.
**Demo multiball with 3 balls & AI**: AI tracks most urgent ✓.
**Potential demo scenario**: capsule caught when 3 balls already → nothing but popup "MULTI-BALL!" — slightly odd text. Change pop text: if balls.length>=3 before? Show 'MULTI-BALL!' anyway (it's fine) — or show 'MAX BALLS!'. I'll conditionally: `pop(... balls.length>=3?'BALLS MAXED':'MULTI-BALL!' ...)`. Eh, keep 'MULTI-BALL!' simple. Fine.
**Score display 6 digits** max ~ (96 bricks avg 60 + combos + bonus) < 999999 ✓.
**Ensure bricksLeft reset**: buildBricks sets `bricksLeft=ROWS*COLS;` — declare with let at module scope.
**setSpeeds on ballSpeed ramp** — called every brick destroyed ✓.
**Launch sound** in launchAll ✓.
**One subtle bug source**: `collideWorld` paddle check uses `paddle` even during serve (stuck balls skip collideWorld since stuck skipped in moveBalls) ✓.
**Trail memory**: shift ok.
**Overlay blink animation & panel animation** ok.
**Check `roundRect` name conflict with native** — I define function `rrect` to avoid confusion ✓.
**Demo AI also should sometimes deliberately hit with edges to show angle mechanic** — naturally happens.
**Finally verify pass-through requirement for paddle when ball speed max 640 and dt 0.033**: per frame movement 21px; substep 3.5px; paddle h=16; ball r 8; combined threshold: paddle top detection needs ball crossing within a substep ≤3.5px < paddle h+r=24 ✓ impossible to skip. Bricks h=22+r=8=30 > 3.5 ✓.
**Ball vs brick row candidate when ball above bricks**: rowOf(y) clamps 0 → tests row0 with distance check ✓ no false positives.
**corner case: ball exactly at brick corner** — nearest-point handles, normal diagonal — reflect diagonal ✓ fine.
**Two bricks adjacent hit simultaneously**: we return after first; next substep hits second — both get damaged eventually. Ball pushed out of first could still overlap second → next substep resolves ✓.
**Score popup for crack +30** but pts for tough destroy 120: total tough 150. ok.
Let me also add **subtle idle animation for stuck ball**: slight pulse. Skip.
**Write final code** now, being careful with every referenced identifier.
Define globals order: constants, helpers, canvas refs, sprites, game state, build functions, game logic, draw, input, init.
Also `let iconWide, iconMulti, bgCv;` built in fit().
`buildSprites()` resets spriteCache and builds icons.
Careful: fit() called before first resetGame? bricks sprites built lazily on draw — after fit sets DPR ✓.
getSprite uses global BW/BH constants ✓.
Also note: on window resize, DPR may change; sprites rebuilt. bgCv rebuilt ✓.
**flies** drawn with 'lighter' before bricks — subtle ✓.
**drawParts inside shake transform** ✓.
**Order note**: paddle drawn after balls so ball goes behind paddle when bouncing? Ball resting on paddle top — ball above paddle no overlap mostly; drawing paddle after ball hides ball slightly at contact — nice.
**Now let me carefully also handle `restartGame` when called during demo? Only game mode. `startGame` from demo. R key handled only when mode game (and demo handled earlier returning? In keydown: `if(mode==='demo'){startGame();}` then continues to further checks — after startGame, mode='game', phase='serve'; subsequent key handling applies (e.g., KeyR would restart — harmless). Fine.
Also **keydown repeated fires** (auto-repeat) — keys.l stays true; ok.
**Prevent spacebar scroll** ✓ preventDefault.
**preventDefault for arrows** ✓.
**visibilitychange**:
```js
document.addEventListener('visibilitychange',()=>{ if(document.hidden && mode==='game' && (phase==='play'||phase==='serve')) paused=true; });
window.addEventListener('blur',()=>{ ...same });
```
**togglePause**: `paused=!paused` only if phase play/serve & mode game.
**Draw when phase over/win**: balls frozen drawn, overlay on top ✓. In over, balls array empty ✓.
Also stop AI/paddle updates in over/win ✓ (return early).
**demoResetT flow**: set 2.2 in onCleared demo; update early branch:
```js
if(phase==='clearwait'){ demoResetT-=dt; if(demoResetT<=0) resetGame(); return; }
```
placed after cosmetics ✓ (before paddle). But note phase 'clearwait' in demo only. ✓
Also confetti on demo clear — nice ✓.
**loseLife in demo when lives 3→0**: lives hits 0 → refill 3, serve. ✓ shows HUD dip.
Also on loseLife: burst at bottom + ring red:
```js
rings.push({x:lastDeathX? just paddle.x,y:H-40,max:120,t:1,c:'#ff5d78'})
```
splashFx already bursts at ball death point with ring — good enough; loseLife adds flash+shake+sound+wideT reset.
**splashFx**:
```js
function splashFx(x,y){
for(let i=0;i<24;i++){ const a=rand(-Math.PI*0.95,-0.05); const s=rand(80,320); parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(.35,.8),t:0,size:rand(2,4.5),color:'#ff5d78',rot:rand(0,TAU),vr:rand(-9,9),g:700}); }
rings.push({x,y,max:60,t:1,c:'#ff5d78'});
}
```
**burst colors**: pass color string.
**confetti colors**: `['#ffd166','#ff5d7e','#5eead4','#7aa2ff','#c084fc']`.
**beep slide param**: exponentialRamp requires positive target; slide negative: f+slide could go ≤0 — guard: `Math.max(40, f+slide)` ✓ I have that.
Now **write the whole file**. Also remember: no text outside code block; single code block; complete HTML.
Let me write the JS in full, mentally compiling as I go.
```js
(()=>{ // IIFE? fine but keep plain. Use plain script.
```
I'll write plain top-level script (no modules).
Potential subtle issue: `ctx.letterSpacing` persists across frames — set explicitly each time before text. I'll wrap in a helper:
```js
function ls(v){ if('letterSpacing' in ctx) ctx.letterSpacing=v; }
```
'in' check works on ctx instance ✓.
Full code draft:
```js
'use strict';
/* ---------- constants ---------- */
const W=900,H=620,TOP=64;
const COLS=12,ROWS=8;
const SIDE=26,GAP=6,BH=22,BTOP=TOP+22;
const BW=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const BALL_R=8,BASE_SPEED=380,MAX_SPEED=640,PADDLE_Y=H-46;
const MONO='ui-monospace,Menlo,Consolas,monospace';
const TAU=Math.PI*2;
const ROWC=[null,'#ff4d6d','#ff9e4f','#ffd166','#52e5a3','#4cc9f0','#5a7bff','#b06cff'];
const CONF=['#ffd166','#ff5d7e','#5eead4','#7aa2ff','#c084fc','#f9a8d4'];
const POWER_SPOTS=[
{r:0,c:2,k:'multi'},{r:0,c:9,k:'wide'},
{r:2,c:5,k:'multi'},{r:3,c:2,k:'wide'},{r:3,c:9,k:'multi'},
{r:4,c:6,k:'wide'},{r:6,c:1,k:'multi'},{r:6,c:10,k:'wide'},{r:7,c:5,k:'multi'}
];
```
DOM:
```js
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const ovStart=document.getElementById('ovStart'),ovEnd=document.getElementById('ovEnd');
const endTitle=document.getElementById('endTitle'),endScore=document.getElementById('endScore'),endNote=document.getElementById('endNote');
```
Helpers:
```js
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
const pick=a=>a[(Math.random()*a.length)|0];
function hexA(h,a){const n=parseInt(h.slice(1),16);return `rgba(${n>>16&255},${n>>8&255},${n&255},${a})`;}
function mixc(a,b,t){const A=parseInt(a.slice(1),16),B=parseInt(b.slice(1),16);
const r=Math.round((A>>16&255)*(1-t)+(B>>16&255)*t);
const g=Math.round((A>>8&255)*(1-t)+(B>>8&255)*t);
const bl=Math.round((A&255)*(1-t)+(B&255)*t);
return '#'+((1<<24)+(r<<16)+(g<<8)+bl).toString(16).slice(1);}
function rrect(g,x,y,w,h,r){r=Math.min(r,w/2,h/2);g.beginPath();g.moveTo(x+r,y);g.arcTo(x+w,y,x+w,y+h,r);g.arcTo(x+w,y+h,x,y+h,r);g.arcTo(x,y+h,x,y,r);g.arcTo(x,y,x+w,y,r);g.closePath();}
function mulberry(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function ls(v){try{ctx.letterSpacing=v;}catch(e){}}
```
Audio:
```js
let AC=null;
function initAudio(){ try{ if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)(); if(AC.state==='suspended') AC.resume().catch(()=>{}); }catch(e){} }
function beep(f,dur=0.07,type='square',vol=0.05,slide=0,delay=0){
if(!AC) return;
try{
const t=AC.currentTime+delay;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.setValueAtTime(f,t);
if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(40,f+slide),t+dur);
g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(0.0006,t+dur);
o.connect(g);g.connect(AC.destination);o.start(t);o.stop(t+dur+0.03);
}catch(e){}
}
function chime(fs,type='triangle',dur=0.1,gap=0.09,vol=0.05){fs.forEach((f,i)=>beep(f,dur,type,vol,0,i*gap));}
```
State:
```js
let mode='demo',phase='serve',paused=false;
let score=0,lives=3,combo=0,ballSpeed=BASE_SPEED,bricksLeft=0,bonus=0;
let bricks=[],balls=[],caps=[],parts=[],pops=[],rings=[];
const paddle={x:W/2,y:PADDLE_Y,w:110,h:16,squash:0,wideT:0};
let shake=0,flashA=0,flashC='255,255,255';
let time=0,serveT=0,demoT=-1;
const keys={l:false,r:false};
const flies=[];
for(let i=0;i<26;i++)flies.push({x:rand(0,W),y:rand(0,H),vy:rand(-16,-5),r:rand(0.7,1.9),ph:rand(0,TAU),a:rand(0.12,0.4)});
```
Sprites:
```js
let DPR=1,bgCv=null,iconWide=null,iconMulti=null,spriteCache={};
function buildBG(){...}
function buildIcons(){iconWide=makeIcon('wide');iconMulti=makeIcon('multi');}
function makeIcon(kind){...}
function makeColorSprite(color){...return {c,pad,w:BW+2*pad,h:BH+2*pad};}
function makeToughSprite(cracked,seed){...}
function getSprite(br){...}
```
fit():
```js
function fit(){
DPR=Math.min(2,window.devicePixelRatio||1);
cv.width=Math.round(W*DPR);cv.height=Math.round(H*DPR);
const s=Math.min((innerWidth-14)/W,(innerHeight-14)/H);
cv.style.width=(W*s)+'px';cv.style.height=(H*s)+'px';
spriteCache={};buildBG();buildIcons();
}
```
Guard s≥ small: Math.max? If innerWidth tiny, s could be <0? innerWidth min ~ 300 fine.
buildBG as sketched.
makeIcon as sketched (S=34).
makeColorSprite(color):
```js
function makeColorSprite(color){
const pad=8;
const c=document.createElement('canvas');
c.width=Math.ceil((BW+pad*2)*DPR);c.height=Math.ceil((BH+pad*2)*DPR);
const g=c.getContext('2d');g.scale(DPR,DPR);
const x=pad,y=pad;
rrect(g,x,y,BW,BH,5);
g.shadowColor=hexA(color,0.55);g.shadowBlur=9;g.fillStyle=color;g.fill();
g.shadowBlur=0;
const gr=g.createLinearGradient(0,y,0,y+BH);
gr.addColorStop(0,mixc(color,'#ffffff',0.34));
gr.addColorStop(0.45,color);
gr.addColorStop(1,mixc(color,'#000000',0.36));
rrect(g,x,y,BW,BH,5);g.fillStyle=gr;g.fill();
const gl=g.createLinearGradient(0,y+1,0,y+BH*0.55);
gl.addColorStop(0,'rgba(255,255,255,0.5)');gl.addColorStop(1,'rgba(255,255,255,0)');
rrect(g,x+2,y+2,BW-4,BH*0.5,4);g.fillStyle=gl;g.fill();
rrect(g,x+0.5,y+0.5,BW-1,BH-1,4.5);g.strokeStyle='rgba(255,255,255,0.3)';g.lineWidth=1;g.stroke();
return {c,pad,w:BW+pad*2,h:BH+pad*2};
}
```
makeToughSprite(cracked,seed):
```js
function makeToughSprite(cracked,seed){
const pad=8;
const c=document.createElement('canvas');
c.width=Math.ceil((BW+pad*2)*DPR);c.height=Math.ceil((BH+pad*2)*DPR);
const g=c.getContext('2d');g.scale(DPR,DPR);
const x=pad,y=pad;
rrect(g,x,y,BW,BH,5);
g.shadowColor='rgba(150,185,240,0.45)';g.shadowBlur=cracked?5:9;g.fillStyle='#9fb4d4';g.fill();
g.shadowBlur=0;
const gr=g.createLinearGradient(0,y,0,y+BH);
gr.addColorStop(0,'#e9effb');gr.addColorStop(0.45,'#aebfd9');gr.addColorStop(1,'#63779b');
rrect(g,x,y,BW,BH,5);g.fillStyle=gr;g.fill();
// sheen
g.fillStyle='rgba(255,255,255,0.28)';g.fillRect(x+4,y+BH*0.34,BW-8,1.6);
// rivets
g.fillStyle='rgba(24,38,66,0.55)';
for(const [rx,ry] of [[6,6],[BW-6,6],[6,BH-6],[BW-6,BH-6]]){
g.beginPath();g.arc(x+rx,y+ry,1.7,0,TAU);g.fill();
}
rrect(g,x+0.5,y+0.5,BW-1,BH-1,4.5);g.strokeStyle='rgba(255,255,255,0.35)';g.lineWidth=1;g.stroke();
if(cracked){
rrect(g,x,y,BW,BH,5);g.fillStyle='rgba(10,15,30,0.30)';g.fill();
const rnd=mulberry(seed*2654435761+11);
g.strokeStyle='rgba(10,15,28,0.95)';g.lineWidth=1.8;g.lineJoin='round';
// main crack
let px=x+BW*(0.28+rnd()*0.3),py=y-1;
g.beginPath();g.moveTo(px,py);
const seg=5;
for(let i=1;i<=seg;i++){px+=(rnd()-0.5)*16;py=y-1+(BH+2)*i/seg;g.lineTo(px,py);}
g.stroke();
// branch
g.beginPath();g.moveTo(px,y+BH*0.4);
let bx=px,by=y+BH*0.4;
for(let i=0;i<3;i++){bx+=(rnd()-0.15)*14;by+=(rnd()-0.5)*8;g.lineTo(bx,by);}
g.stroke();
// chips (missing bits on top edge)
g.fillStyle='#070b18';
for(let i=0;i<3;i++){
const cx0=x+6+rnd()*(BW-16);
g.beginPath();g.moveTo(cx0,y);g.lineTo(cx0+5+rnd()*6,y);g.lineTo(cx0+2+rnd()*4,y-2.5-rnd()*2.5);g.closePath();g.fill();
}
}
return {c,pad,w:BW+pad*2,h:BH+pad*2};
}
```
Chips above brick top (y-2.5) — within pad 8 ✓.
getSprite:
```js
function getSprite(br){
const key=br.tough?('T'+br.hp+'v'+(br.seed%6)):('N'+br.r);
let s=spriteCache[key];
if(!s){ s=br.tough?makeToughSprite(br.hp===1,br.seed%6):makeColorSprite(ROWC[br.r]); spriteCache[key]=s; }
return s;
}
```
buildBricks, isTough as planned.
Game logic functions as planned: resetGame, serve, launchAll, playerUpdate, aiUpdate, clampPaddle, moveBalls, collideWorld, brickCollide, resolveBrick, hitBrick, setSpeeds, spawnCap, updCaps, applyPower, loseLife, onCleared, showEnd, splashFx, burst, confetti, pop, updParts/updPops/updRings, update, draw* etc.
Write updCaps:
```js
function updCaps(dt){
const p=paddle;
for(let i=caps.length-1;i>=0;i--){
const cp=caps[i];
cp.t+=dt;cp.vy=Math.min(250,cp.vy+170*dt);
cp.y+=cp.vy*dt;
cp.x=cp.bx+Math.sin(cp.t*3.1)*7;
if(cp.y+11>=p.y && cp.y-11<=p.y+p.h+6 && Math.abs(cp.x-p.x)<=p.w/2+16){
applyPower(cp.kind);caps.splice(i,1);continue;
}
if(cp.y-14>H) caps.splice(i,1);
}
}
function spawnCap(br){caps.push({bx:br.x+br.w/2,x:br.x+br.w/2,y:br.y+br.h/2,kind:br.power,t:rand(0,1),vy:30});}
```
applyPower final:
```js
function applyPower(kind){
if(kind==='wide'){
paddle.wideT=10;
pop(paddle.x,paddle.y-34,'WIDE PADDLE!','#ffd166',15);
chime([620,830,1100]);
} else {
const src=balls.filter(b=>!b.stuck);
const pool=src.length?src:balls;
if(pool.length){
let n=0;
while(balls.length<3&&n++<12){
const b=pool[(balls.length-1)%pool.length]? hmm
```
Simplify clone source: use the first non-stuck ball; clone with rotated angle alternating sign:
```js
const b=pool[0];
let k=1;
while(balls.length<3){
const ang=Math.atan2(b.vx,-b.vy)+k*0.55*(k%2?1:-1);
balls.push({x:b.x,y:b.y,vx:Math.sin(ang)*ballSpeed,vy:-Math.cos(ang)*ballSpeed,stuck:false,trail:[]});
k++;
}
}
pop(paddle.x,paddle.y-34,'MULTI-BALL!','#5eead4',15);
chime([520,700,940]);
}
burst(paddle.x,paddle.y-8,kind==='wide'?'#ffd166':'#5eead4',18);
rings.push({x:paddle.x,y:paddle.y-10,max:70,t:1,c:kind==='wide'?'#ffd166':'#5eead4'});
shake=Math.min(10,shake+2);
}
```
Wait if source stuck (impossible as argued) but pool=balls with stuck ball — then clone stuck=true? my clone sets stuck:false with angle from vx,vy=0,0 → atan2(0,0)=0 → straight up moving — a ball launching from paddle while original stuck remains stuck; weird but unreachable. Guard: if b.stuck, clone stuck following paddle? Skip guard; acceptable unreachable. Actually to be really safe: if pool[0].stuck → give clones stuck:false positioned above paddle moving up — they'd fly while serve ball stays — could happen only if capsule caught in serve (capsules frozen in serve) → unreachable ✓.
Angle clone from same original direction each time (b fixed) with k*±0.55: k=1:+0.55, k=2:-1.10? Wait k%2?1:-1 with k*0.55: k=1→0.55, k=2→-1.10 — too wide. Use explicit list: [0.5,-0.5,0.9,-0.9][k-1]. With while balls.length<3 starting at 1 → 2 clones: +0.5,-0.5. Good:
```js
const offs=[0.55,-0.55,0.95,-0.95,0.3,-0.3];
const ang=Math.atan2(b.vx,-b.vy)+offs[(k-1)%offs.length];
```
loseLife:
```js
function loseLife(){
lives--;combo=0;paddle.wideT=0;
shake=Math.min(14,shake+9);flashA=0.32;flashC='255,70,95';
chime([300,220,150],'sawtooth',0.13,0.11,0.05);
if(lives<=0){
if(mode==='demo'){lives=3;serve();}
else{phase='over';showEnd(false);}
} else serve();
}
```
onCleared:
```js
function onCleared(){
if(mode==='demo'){phase='clearwait';demoT=2.2;confetti();return;}
bonus=lives*250;score+=bonus;
phase='win';flashA=0.28;flashC='255,214,102';
confetti();chime([523,659,784,1047],'triangle',0.16,0.12,0.06);
showEnd(true);
}
```
showEnd:
```js
function showEnd(won){
endTitle.textContent=won?'LEVEL CLEARED!':'GAME OVER';
endTitle.classList.toggle('win',won);
endTitle.classList.toggle('lose',!won);
endScore.textContent='SCORE '+String(score).padStart(6,'0');
endNote.innerHTML=won?('LIFE BONUS +'+bonus+'<br>every brick obliterated'):('the grid outlasted you<br>final score '+String(score).padStart(6,'0'));
```
Hmm endScore already shows score; note for lose: 'the ball got away — try a cooler bounce'. Keep short:
```js
endNote.textContent=won?'perfect clearance — life bonus +'+bonus:'the ball slipped past your paddle';
ovEnd.classList.remove('hidden');
}
```
startGame/restart:
```js
function startGame(){
if(mode!=='demo')return;
mode='game';
ovStart.classList.add('hidden');ovEnd.classList.add('hidden');
resetGame();
}
function restartGame(){
ovEnd.classList.add('hidden');
resetGame();
}
```
update(dt) final:
```js
function update(dt){
time+=dt;
for(const f of flies){f.y+=f.vy*dt;f.x+=Math.sin(time*0.5+f.ph)*8*dt;if(f.y<-6){f.y=H+6;f.x=rand(0,W);}}
shake*=Math.pow(0.0002,dt);if(shake<0.04)shake=0;
flashA=Math.max(0,flashA-dt*1.5);
paddle.squash=Math.max(0,paddle.squash-dt*5);
for(let i=parts.length-1;i>=0;i--){const p=parts[i];p.t+=dt;if(p.t>=p.life){parts.splice(i,1);continue;}p.vy+=p.g*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;}
for(let i=pops.length-1;i>=0;i--){const p=pops[i];p.t+=dt;p.y-=30*dt;if(p.t>=p.life)pops.splice(i,1);}
for(let i=rings.length-1;i>=0;i--){rings[i].t-=dt*2.6;if(rings[i].t<=0)rings.splice(i,1);}
if(phase==='over'||phase==='win')return;
if(paused)return;
if(phase==='clearwait'){demoT-=dt;if(demoT<=0)resetGame();return;}
// paddle
if(mode==='demo')aiUpdate(dt);else playerUpdate(dt);
clampPaddle();
const tw=paddle.wideT>0?170:110;
paddle.w+=(tw-paddle.w)*Math.min(1,dt*9);
clampPaddle();
if(paddle.wideT>0){paddle.wideT-=dt;if(paddle.wideT<=0){paddle.wideT=0;pop(paddle.x,paddle.y-34,'PADDLE NORMAL','#9fb0dd',12);}}
if(phase==='serve'){
serveT+=dt;
for(const b of balls)if(b.stuck){b.x=paddle.x;b.y=paddle.y-BALL_R-3;}
if(serveT>(mode==='demo'?0.9:4.5))launchAll();
}
if(phase==='play'){
moveBalls(dt);
updCaps(dt);
}
}
```
aiUpdate:
```js
function aiUpdate(dt){
const p=paddle;let target=W/2;let best=1e9,tx=null;
for(const b of balls){
if(b.stuck||b.vy<=40)continue;
const t=(p.y-b.r-b.y)/b.vy;
if(t>-0.15&&t<best){best=t;tx=foldX(b.x+b.vx*t,b.r,W-b.r);}
}
if(tx!==null){
target=tx+Math.sin(time*2.3)*8+Math.sin(time*4.7)*5;
}else if(caps.length){
let c=caps[0],dd=1e9;
for(const cp of caps){const d=Math.abs(cp.x-p.x);if(d<dd){dd=d;c=cp;}}
target=c.x;
}else{
const b=balls[0];
target=(b&&!b.stuck)?b.x*0.35+W*0.65*0.5+W*0.3:W/2+Math.sin(time*0.9)*140;
```
hmm messy: just `target = b&&!b.stuck ? b.x : W/2+Math.sin(time*0.9)*140;` — following ball x while it's above is fine (natural).
```js
}
p.x+=clamp(target-p.x,-660*dt,660*dt);
}
function foldX(x,min,max){const range=max-min,period=range*2;let p2=(x-min)%period;if(p2<0)p2+=period;return p2<range?min+p2:min+period-p2;}
```
playerUpdate:
```js
function playerUpdate(dt){
const v=720;
if(keys.l)paddle.x-=v*dt;
if(keys.r)paddle.x+=v*dt;
}
```
clampPaddle:
```js
function clampPaddle(){paddle.x=clamp(paddle.x,paddle.w/2+4,W-paddle.w/2-4);}
```
launchAll:
```js
function launchAll(){
let did=false;
for(const b of balls){
if(!b.stuck)continue;
b.stuck=false;
const a=rand(-0.3,0.3);
b.vx=Math.sin(a)*ballSpeed;b.vy=-Math.cos(a)*ballSpeed;
did=true;
}
if(did){phase='play';serveT=0;beep(480,0.08,'triangle',0.05,220);}
}
```
moveBalls as drafted.
collideWorld as drafted, with onPaddle(b):
```js
function onPaddle(b){
combo=0;paddle.squash=1;
rings.push({x:b.x,y:paddle.y,max:26,t:1,c:'#8ef1ff'});
beep(230,0.05,'sine',0.055,70);
}
```
Hmm combo reset — but combo popup shows after; fine.
Wait — should combo reset on paddle hit? That's the classic combo mechanic. ✓
wallFx:
```js
function wallFx(x,y){
parts.push? small sparks:
for(let i=0;i<4;i++){const a=rand(0,TAU);const s=rand(30,120);parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(.15,.35),t:0,size:rand(1,2.4),color:'#9fc0ff',rot:0,vr:0,g:0});}
beep(150,0.03,'sine',0.028);
}
```
brickCollide + resolveBrick + hitBrick as drafted. hitBrick:
```js
function hitBrick(b,br){
br.hp--;
const cx=br.x+br.w/2,cy=br.y+br.h/2;
if(br.hp<=0){
br.alive=false;bricksLeft--;combo++;
const pts=(br.tough?120:50)+(br.power?100:0)+(combo-1)*10;
score+=pts;
const col=br.tough?'#aebfd9':(ROWC[br.r]||'#fff');
burst(cx,cy,col,br.tough?16:12);
rings.push({x:cx,y:cy,max:br.tough?44:32,t:1,c:col});
pop(cx,cy,'+'+pts,br.power?'#ffd166':'#eef3ff',br.power?14:12);
if(br.power)spawnCap(br);
shake=Math.min(9,shake+(br.tough?2.6:1.6));
ballSpeed=Math.min(MAX_SPEED,ballSpeed+5);setSpeeds();
beep(300+(ROWS-br.r)*42,0.08,'square',0.05,-130);
if(combo%7===0)pop(W/2,TOP+130,'COMBO ×'+combo+'!','#ffd166',18);
if(bricksLeft<=0)onCleared();
}else{
score+=30;
pop(cx,cy,'+30','#aab6e0',11);
burst(cx,cy,'#8ea3c8',6);
shake=Math.min(9,shake+1);
beep(170,0.06,'square',0.045,0);
}
}
```
Now draw functions.
drawBall, drawPaddle, drawCap, drawParts, drawRings, drawPops, drawBricks, drawHUD, hint, paused overlay.
drawRings:
```js
function drawRings(){
for(const r of rings){
const p=1-r.t;
ctx.beginPath();ctx.arc(r.x,r.y,4+(r.max-4)*(1-r.t*r.t),0,TAU);
ctx.strokeStyle=hexA(r.c,r.t*0.5);ctx.lineWidth=1+2.5*r.t;ctx.stroke();
}
}
```
(p unused; remove.)
drawParts:
```js
function drawParts(){
if(!parts.length)return;
ctx.globalCompositeOperation='lighter';
for(const p of parts){
const a=1-p.t/p.life;
ctx.globalAlpha=Math.max(0,a);
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.rot);
ctx.fillStyle=p.color;ctx.fillRect(-p.size/2,-p.size/2,p.size,p.size);
ctx.restore();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
}
```
Confetti with 'lighter' on dark bg = bright, fine. Maybe confetti over flash... ok.
drawPops:
```js
function drawPops(){
ctx.textAlign='center';ctx.textBaseline='alphabetic';
for(const p of pops){
const a=p.t<0.12?p.t/0.12:Math.max(0,1-(p.t-0.12)/(p.life-0.12));
```
simplify: a = 1-(p.t/p.life); with pop-in scale? fine:
```js
ctx.globalAlpha=a*0.95;
ctx.font='800 '+p.size+'px '+MONO;
ctx.fillStyle=p.c;ctx.shadowColor=p.c;ctx.shadowBlur=8;
ctx.fillText(p.txt,p.x,p.y);
}
ctx.globalAlpha=1;ctx.shadowBlur=0;
}
```
life 0.9 fixed in pop().
pop():
```js
function pop(x,y,txt,c,size){pops.push({x:clamp(x,70,W-70),y,txt,c,size,t:0,life:0.9});}
```
drawPaddle:
```js
function drawPaddle(){
const p=paddle,hw=p.w/2;
const wide=p.wideT>0;
const blink=(wide&&p.wideT<2)?(0.45+0.55*Math.abs(Math.sin(time*8))):1;
ctx.save();
ctx.translate(p.x,p.y+p.h/2);
const sq=p.squash;
ctx.scale(1+sq*0.18,1-sq*0.3);
ctx.globalAlpha=blink;
ctx.shadowColor=wide?'rgba(255,180,80,0.85)':'rgba(90,200,255,0.85)';
ctx.shadowBlur=15;
rrect(ctx,-hw,-p.h/2,p.w,p.h,p.h/2);
const g=ctx.createLinearGradient(0,-p.h/2,0,p.h/2);
if(wide){g.addColorStop(0,'#ffe9a8');g.addColorStop(1,'#f59e2b');}
else{g.addColorStop(0,'#a9f2ff');g.addColorStop(1,'#3f7dff');}
ctx.fillStyle=g;ctx.fill();
ctx.shadowBlur=0;
rrect(ctx,-hw+3,-p.h/2+2,p.w-6,4,2);
ctx.fillStyle='rgba(255,255,255,0.55)';ctx.fill();
// side lights
ctx.fillStyle=wide?'rgba(255,255,255,0.9)':'rgba(255,255,255,0.9)';
ctx.beginPath();ctx.arc(-hw+7,0,2.2,0,TAU);ctx.arc(hw-7,0,2.2,0,TAU);ctx.fill();
ctx.restore();
ctx.globalAlpha=1;
}
```
Note arc twice in same path: need moveTo between or separate beginPath per arc — arcs connect with line: second arc draws line from first. Do two separate beginPath. Fix.
drawCap as sketched, uses iconWide/iconMulti drawn at 34 logical.
drawBricks as sketched.
drawHUD as sketched, plus bottom pulsing hazard line:
in draw() after restore? Draw dynamic bottom glow line:
```js
ctx.fillStyle='rgba(255,70,100,'+(0.10+0.06*Math.sin(time*2.2))+')';
ctx.fillRect(0,H-3,W,3);
```
inside shake? outside fine; put before HUD.
Serve hint & paused overlay after HUD.
Full draw():
```js
function draw(){
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle='#05060f';ctx.fillRect(0,0,W,H);
ctx.save();
if(shake>0.05)ctx.translate(rand(-shake,shake),rand(-shake,shake));
ctx.drawImage(bgCv,0,0,W,H);
// fireflies
ctx.globalCompositeOperation='lighter';
for(const f of flies){
ctx.globalAlpha=f.a*(0.5+0.5*Math.sin(time*1.6+f.ph));
ctx.fillStyle='#8fa6ff';
ctx.beginPath();ctx.arc(f.x,f.y,f.r,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
drawBricks();
for(const cp of caps)drawCap(cp);
drawParts();
drawRings();
for(const b of balls)drawBall(b);
drawPaddle();
drawPops();
if(mode==='game'&&phase==='serve'&&!paused){
const a=0.45+0.5*(0.5+0.5*Math.sin(time*5));
ctx.textAlign='center';ctx.globalAlpha=a;ctx.fillStyle='#cfe0ff';ctx.font='700 12px '+MONO;
ls('3px');ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,H-92);ls('0px');
ctx.globalAlpha=1;
}
ctx.restore();
if(flashA>0){ctx.fillStyle='rgba('+flashC+','+flashA.toFixed(3)+')';ctx.fillRect(0,0,W,H);}
ctx.fillStyle='rgba(255,70,100,'+(0.10+0.06*Math.sin(time*2.2)).toFixed(3)+')';
ctx.fillRect(0,H-3,W,3);
drawHUD();
if(paused&&mode==='game'){
ctx.fillStyle='rgba(4,6,15,0.55)';ctx.fillRect(0,0,W,H);
ctx.textAlign='center';
ctx.fillStyle='#e6ecff';ctx.font='800 30px '+MONO;
ls('6px');ctx.fillText('PAUSED',W/2+3,H/2-6);ls('0px');
ctx.fillStyle='#8b98c9';ctx.font='700 12px '+MONO;
ls('2px');ctx.fillText('PRESS P TO RESUME',W/2,H/2+22);ls('0px');
}
}
```
drawBall as sketched.
Loop + init:
```js
fit();
resetGame();
let last=performance.now();
requestAnimationFrame(function loop(now){
let dt=(now-last)/1000;last=now;
if(dt>0.033)dt=0.033;if(dt<0)dt=0;
update(dt);draw();
requestAnimationFrame(loop);
});
```
Events:
```js
addEventListener('resize',fit);
ovStart.addEventListener('pointerdown',e=>{e.preventDefault();initAudio();startGame();});
ovEnd.addEventListener('pointerdown',e=>{e.preventDefault();initAudio();if(mode==='game')restartGame();});
cv.addEventListener('pointermove',e=>{
if(mode!=='game'||paused||phase==='over'||phase==='win')return;
const r=cv.getBoundingClientRect();
if(!r.width)return;
paddle.x=clamp((e.clientX-r.left)/r.width*W,paddle.w/2+4,W-paddle.w/2-4);
});
cv.addEventListener('pointerdown',e=>{initAudio();if(mode==='demo'){startGame();return;}if(phase==='serve'&&!paused)launchAll();});
addEventListener('keydown',e=>{
const c=e.code;
if(c==='ArrowLeft'||c==='ArrowRight'||c==='ArrowUp'||c==='ArrowDown'||c==='Space')e.preventDefault();
initAudio();
if(mode==='demo'){startGame();}
if(phase==='over'||phase==='win'){
if(c==='KeyR'||c==='Enter'||c==='Space'||c==='KeyP')restartGame();
return;
}
if(c==='ArrowLeft')keys.l=true;
else if(c==='ArrowRight')keys.r=true;
else if(c==='KeyP'&&(phase==='play'||phase==='serve'))paused=!paused;
else if(c==='KeyR')restartGame();
else if((c==='Space'||c==='ArrowUp'||c==='Enter')&&phase==='serve'&&!paused)launchAll();
});
addEventListener('keyup',e=>{
if(e.code==='ArrowLeft')keys.l=false;
if(e.code==='ArrowRight')keys.r=false;
});
document.addEventListener('visibilitychange',()=>{if(document.hidden&&mode==='game'&&(phase==='play'||phase==='serve'))paused=true;});
addEventListener('blur',()=>{if(mode==='game'&&(phase==='play'||phase==='serve'))paused=true;});
```
Wait: keydown in demo starts game then immediately continues: phase='serve' now; pressing e.g. 'Space' → falls to last branch launches ball instantly — that's good UX (press space → start & launch).
Also pressing 'R' in demo: startGame then restartGame — same effect.
Note: `if(mode==='demo'){startGame();}` — after that mode==='game' but the following `if(phase==='over'...)` fine.
One more check: pointermove during phase 'serve' — moving mouse moves paddle, stuck ball follows in update ✓. But pointermove sets paddle.x directly even in serve — ✓ allowed.
**drawCap**:
```js
function drawCap(cp){
const wide=cp.kind==='wide';
ctx.save();
ctx.translate(cp.x,cp.y);
ctx.rotate(Math.sin(cp.t*2.6)*0.12);
ctx.shadowColor=wide?'rgba(255,180,80,0.8)':'rgba(70,225,200,0.8)';
ctx.shadowBlur=13;
rrect(ctx,-19,-11,38,22,11);
const g=ctx.createLinearGradient(0,-11,0,11);
if(wide){g.addColorStop(0,'#ffe9a8');g.addColorStop(0.55,'#f0a941');g.addColorStop(1,'#96591d');}
else{g.addColorStop(0,'#a8f7e0');g.addColorStop(0.55,'#2ec4b6');g.addColorStop(1,'#0e5b54');}
ctx.fillStyle=g;ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,255,255,0.7)';ctx.lineWidth=1.3;ctx.stroke();
ctx.drawImage(wide?iconWide:iconMulti,-17,-17,34,34);
ctx.restore();
}
```
**drawBall**:
```js
function drawBall(b){
const n=b.trail.length;
if(n>1){
ctx.globalCompositeOperation='lighter';
for(let i=0;i<n;i++){
const t=b.trail[i];const k=(i+1)/n;
ctx.globalAlpha=k*0.18;
ctx.fillStyle='#7fd4ff';
ctx.beginPath();ctx.arc(t.x,t.y,b.r*k,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
}
ctx.save();
ctx.shadowColor='rgba(120,210,255,0.9)';ctx.shadowBlur=12;
const g=ctx.createRadialGradient(b.x-2.5,b.y-3,1,b.x,b.y,b.r);
g.addColorStop(0,'#ffffff');g.addColorStop(0.55,'#d6efff');g.addColorStop(1,'#7cc7ff');
ctx.fillStyle=g;
ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,TAU);ctx.fill();
ctx.restore();
}
```
**buildBG** as sketched.
**resetGame**:
```js
function resetGame(){
score=0;lives=3;combo=0;ballSpeed=BASE_SPEED;bonus=0;
caps=[];parts=[];pops=[];rings=[];balls=[];
paddle.x=W/2;paddle.w=110;paddle.wideT=0;paddle.squash=0;
shake=0;flashA=0;paused=false;demoT=-1;
buildBricks();
serve();
}
```
serve():
```js
function serve(){
balls=[{x:paddle.x,y:paddle.y-BALL_R-3,vx:0,vy:0,stuck:true,trail:[]}];
phase='serve';serveT=0;combo=0;
}
```
buildBricks:
```js
function buildBricks(){
bricks=[];
const pm=new Map();
for(const s of POWER_SPOTS)pm.set(s.r*COLS+s.c,s.k);
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const tough=isTough(r,c);
bricks.push({r,c,x:SIDE+c*(BW+GAP),y:BTOP+r*(BH+GAP),w:BW,h:BH,tough,hp:tough?2:1,power:pm.get(r*COLS+c)||null,alive:true,seed:r*31+c*7});
}
bricksLeft=ROWS*COLS;
}
```
**collideWorld**:
```js
function collideWorld(b){
if(b.x<b.r){b.x=b.r;if(b.vx<0)b.vx=-b.vx;wallFx(2,b.y);}
else if(b.x>W-b.r){b.x=W-b.r;if(b.vx>0)b.vx=-b.vx;wallFx(W-2,b.y);}
if(b.y<TOP+b.r){b.y=TOP+b.r;if(b.vy<0)b.vy=-b.vy;wallFx(b.x,TOP+2);}
const p=paddle;
if(b.vy>0&&b.y+b.r>=p.y&&b.y-b.r<=p.y+p.h&&Math.abs(b.x-p.x)<=p.w/2+b.r){
const rel=clamp((b.x-p.x)/(p.w/2+b.r*0.6),-1,1);
const ang=rel*1.05;
const s=Math.hypot(b.vx,b.vy)||ballSpeed;
b.vx=Math.sin(ang)*s;b.vy=-Math.cos(ang)*s;
b.y=p.y-b.r-0.5;
onPaddle(b);
}
brickCollide(b);
const s2=Math.hypot(b.vx,b.vy);
if(s2>1&&Math.abs(b.vy)<0.16*s2){
const sy=b.vy===0?-1:Math.sign(b.vy);
b.vy=sy*0.16*s2;
const k=s2/Math.hypot(b.vx,b.vy);
b.vx*=k;b.vy*=k;
}
}
```
Hmm paddle side condition `b.x>=p.x-hw-b.r && b.x<=p.x+hw+b.r` replaced with Math.abs ≤ ✓ same.
Note wallFx calls beep each substep — if ball slides along wall? Reflect prevents repeat. ok.
**brickCollide**:
```js
function brickCollide(b){
const r0=rowOf(b.y-b.r),r1=rowOf(b.y+b.r),c0=colOf(b.x-b.r),c1=colOf(b.x+b.r);
for(let r=r0;r<=r1;r++){
const base=r*COLS;
for(let c=c0;c<=c1;c++){
const br=bricks[base+c];
if(!br||!br.alive)continue;
const nx=clamp(b.x,br.x,br.x+br.w),ny=clamp(b.y,br.y,br.y+br.h);
const dx=b.x-nx,dy=b.y-ny;
if(dx*dx+dy*dy<=b.r*b.r){resolveBrick(b,br,dx,dy);return;}
}
}
}
```
rowOf/colOf as defined.
resolveBrick as drafted.
moveBalls final:
```js
function moveBalls(dt){
const dead=[];
for(const b of balls){
if(b.stuck)continue;
const sp=Math.hypot(b.vx,b.vy)||1;
const steps=Math.max(1,Math.ceil(sp*dt/3.5));
const sdt=dt/steps;
for(let i=0;i<steps;i++){
b.x+=b.vx*sdt;b.y+=b.vy*sdt;
collideWorld(b);
if(phase!=='play')break;
}
b.trail.push({x:b.x,y:b.y});
if(b.trail.length>11)b.trail.shift();
if(b.y-b.r>H+4)dead.push(b);
}
for(const b of dead){
const i=balls.indexOf(b);
if(i>=0)balls.splice(i,1);
splashFx(b.x,Math.min(b.y,H-6));
}
if(!balls.length&&phase==='play')loseLife();
}
```
burst:
```js
function burst(x,y,color,n){
for(let i=0;i<n;i++){
const a=rand(0,TAU),s=rand(50,330);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-rand(20,110),life:rand(0.35,0.85),t:0,size:rand(1.8,4.6),color,rot:rand(0,TAU),vr:rand(-9,9),g:880});
}
if(parts.length>420)parts.splice(0,parts.length-420);
}
```
confetti:
```js
function confetti(){
for(let i=0;i<150;i++){
parts.push({x:rand(0,W),y:rand(-60,-4),vx:rand(-50,50),vy:rand(60,180),life:rand(1.2,2.4),t:0,size:rand(2,5),color:pick(CONF),rot:rand(0,TAU),vr:rand(-7,7),g:60});
}
}
```
Confetti life 2.4s falls from -60 at vy up to 180 & g 60 → reaches H in ~2.5s ✓.
splashFx as drafted.
setSpeeds as drafted.
drawHUD assembled (careful with ls() usage & reset).
Also `drawHUD` uses lives icons & wide bar & combo — as sketched.
**buildBG**:
```js
function buildBG(){
bgCv=document.createElement('canvas');
bgCv.width=Math.round(W*DPR);bgCv.height=Math.round(H*DPR);
const g=bgCv.getContext('2d');
g.scale(DPR,DPR);
const lg=g.createLinearGradient(0,0,0,H);
lg.addColorStop(0,'#0b0e22');lg.addColorStop(0.5,'#080a18');lg.addColorStop(1,'#05060f');
g.fillStyle=lg;g.fillRect(0,0,W,H);
const rg=g.createRadialGradient(W/2,60,20,W/2,60,430);
rg.addColorStop(0,'rgba(90,120,255,0.12)');rg.addColorStop(1,'rgba(90,120,255,0)');
g.fillStyle=rg;g.fillRect(0,0,W,H);
g.strokeStyle='rgba(98,124,220,0.05)';g.lineWidth=1;
for(let x=30;x<W;x+=30){g.beginPath();g.moveTo(x+0.5,TOP+4);g.lineTo(x+0.5,H);g.stroke();}
for(let y=TOP+30;y<H;y+=30){g.beginPath();g.moveTo(0,y+0.5);g.lineTo(W,y+0.5);g.stroke();}
// walls
g.strokeStyle='rgba(112,148,255,0.85)';g.lineWidth=2;
g.shadowColor='rgba(96,140,255,0.9)';g.shadowBlur=7;
g.beginPath();g.moveTo(1,TOP);g.lineTo(1,H-2);g.moveTo(W-1,TOP);g.lineTo(W-1,H-2);g.stroke();
g.beginPath();g.moveTo(0,TOP);g.lineTo(W,TOP);g.stroke();
g.shadowBlur=0;
// bottom hazard
const hz=g.createLinearGradient(0,H-44,0,H);
hz.addColorStop(0,'rgba(255,60,90,0)');hz.addColorStop(1,'rgba(255,60,90,0.15)');
g.fillStyle=hz;g.fillRect(0,H-44,W,44);
// vignette
const vg=g.createRadialGradient(W/2,H*0.45,240,W/2,H*0.45,600);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,0.45)');
g.fillStyle=vg;g.fillRect(0,0,W,H);
}
```
Also HUD zone subtle underline? Wall at TOP is the divider ✓. Maybe darker HUD strip: fill rgba(0,0,0,0.25) rect 0..TOP? Slight: `g.fillStyle='rgba(4,6,15,0.35)';g.fillRect(0,0,W,TOP);` yes adds separation. Add before walls.
**drawHUD final code**:
```js
function drawHUD(){
ctx.textBaseline='alphabetic';
ls('2px');
ctx.textAlign='left';
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('SCORE',26,25);
ctx.font='800 21px '+MONO;ls('1px');
ctx.fillStyle='#f0f4ff';
ctx.fillText(String(score).padStart(6,'0'),26,48);
ctx.textAlign='center';
ls('6px');
ctx.font='800 12px '+MONO;ctx.fillStyle='#8093ff';
ctx.fillText('NEON BREAKOUT',W/2+3,26);
ls('2px');
if(paddle.wideT>0){
ctx.font='700 9px '+MONO;ctx.fillStyle='#ffca6b';
ctx.fillText('WIDE PADDLE',W/2+1,43);
const bw2=120,bx=W/2-bw2/2,by=47;
ctx.strokeStyle='rgba(255,202,107,0.45)';ctx.lineWidth=1;
ctx.strokeRect(bx+0.5,by+0.5,bw2,5);
const gg=ctx.createLinearGradient(bx,0,bx+bw2,0);
gg.addColorStop(0,'#ffe9a8');gg.addColorStop(1,'#ff9f43');
ctx.fillStyle=gg;
ctx.fillRect(bx+1.5,by+1.5,Math.max(0,(bw2-3)*clamp(paddle.wideT/10,0,1)),3);
}else if(combo>=2){
ctx.font='800 13px '+MONO;ctx.fillStyle='#ffd166';
ctx.fillText('COMBO ×'+combo,W/2+1,47);
}else{
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('LEVEL 1',W/2+1,46);
}
ctx.textAlign='right';
ctx.font='700 10px '+MONO;ctx.fillStyle='#66739e';
ctx.fillText('LIVES',W-26,25);
for(let i=0;i<3;i++){
const lx=W-26-22-i*30,ly=33;
rrect(ctx,lx,ly,22,7,3.5);
if(i<lives){
const gg=ctx.createLinearGradient(0,ly,0,ly+7);
gg.addColorStop(0,'#9ef1ff');gg.addColorStop(1,'#3f8cff');
ctx.fillStyle=gg;ctx.fill();
}else{
ctx.strokeStyle='rgba(150,168,220,0.28)';ctx.lineWidth=1;ctx.stroke();
}
}
ls('0px');
}
```
**Icons draw in makeIcon**: translate center; ensure path coordinates within 17 radius: wide arrow extends ±9.5 ✓.
**Check overlays ids match JS.**
**End overlay 'win' class toggles**: endTitle class 'title end'; add win/lose.
**Potential issue: pointerdown on ovStart with `e.preventDefault()`** — fine.
**The button inside ovEnd**: pointerdown on button bubbles to ovEnd handler → restart ✓ single.
Also **ovStart covering full viewport fixed** — during demo, canvas pointermove unreachable (mode demo ignores anyway) ✓.
**After startGame, ovStart hidden; user clicks canvas to launch (serve) ✓.**
**Demo never paused** ✓ (pause only mode game).
**hidden ovEnd initial** ✓.
**One more possible visual**: bricks power icon on tough row0 seed values — icons drawn after sprite ✓.
**Check ROWC indexes**: r 1..7 used (r0 tough). ✓
**tough color pop**: col '#aebfd9'.
**Check `pick` used** only confetti ✓.
**`demoT` unused warning? used ✓.**
**flashC format**: '255,70,95' & '255,214,102' & default '255,255,255' ✓.
**In update, phase 'clearwait' check placed after paused return — demo never paused ✓.**
Now, one important requirement re-read: "Game over and victory screens with a restart option." ✓ both DOM overlays with PLAY AGAIN.
"3 lives, score displayed" ✓.
Also demo must be "self-playing behind message" ✓.
I think there might be an issue with `ctx.letterSpacing` when set then `ls('0px')` resets — good.
**Font string with multiple families in canvas**: `'800 21px ui-monospace,Menlo,Consolas,monospace'` — canvas font parser: family list allowed with commas ✓.
**Edge: `String(score).padStart`** ✓.
**Check getSprite key for normal uses br.r (row)** — row colors per row ✓.
**Brick draw: spr.w/h include pad** ✓ drawImage(spr.c, br.x-spr.pad, br.y-spr.pad, spr.w, spr.h).
**AI target when ball above & moving up**: tx null (vy<=40 filtered... vy>40 required for prediction; ball moving up vy negative → skip) → capsules branch or follow ball ✓.
**Ball vy exactly small positive**: skip until >40 ✓.
**onCleared demo → confetti + clearwait; update decrements demoT; resetGame() rebuilds** ✓.
**update early-return for over/win happens before paddle — but after parts/pops/rings/flies — good (frozen balls + moving confetti)**.
Wait: in 'over' phase, balls array is empty ✓; in 'win', balls frozen mid-field — drawn ✓.
**Lives icons in demo when lives dip**: ✓.
**Sound in demo**: AC null → silent ✓. After user starts, sounds on ✓.
**One risk**: `AC.resume()` promise rejection caught ✓. `new AudioContext` in sandboxed iframe — allowed typically.
**Meta**: also add `<meta name="color-scheme" content="dark">` nice.
**CSS body**:
```css
html,body{height:100%;margin:0}
body{
background:#04050c radial-gradient(1100px 640px at 50% 18%,#0c1028 0%,#070918 55%,#04050c 100%);
display:flex;align-items:center;justify-content:center;
overflow:hidden;
font-family:ui-monospace,'Cascadia Mono',Menlo,Consolas,monospace;
color:#e8ecff;
-webkit-user-select:none;user-select:none;
}
```
background shorthand with color + gradient: `background: radial-gradient(...) #04050c;` order: color last is fine: `background:#04050c radial-gradient(...)` hmm shorthand: `background: radial-gradient(...) fixed? ` Actually `background: <color> <image>`? shorthand accepts color in any layer position but only once, at end typically: `background: radial-gradient(...) , #04050c`? Simplest: two declarations: `background-color:#04050c; background-image:radial-gradient(...)`. Do that.
**Stage/overlay CSS**:
```css
#stage{position:relative;filter:drop-shadow(0 26px 60px rgba(0,0,0,0.55));}
canvas{display:block;border-radius:14px;background:#070813;box-shadow:0 0 0 1px rgba(120,150,255,0.25),0 0 80px rgba(70,110,255,0.16);touch-action:none;}
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:5;padding:14px;}
.overlay.hidden{display:none;}
.panel{
background:rgba(9,12,27,0.78);
border:1px solid rgba(130,152,255,0.3);
border-radius:16px;
padding:clamp(16px,3vh,30px) clamp(20px,4vw,44px);
text-align:center;
box-shadow:0 0 46px rgba(80,120,255,0.16),inset 0 0 34px rgba(90,120,255,0.05);
backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
max-width:min(94vw,560px);
animation:panelIn .45s cubic-bezier(.2,.9,.25,1.2);
}
@keyframes panelIn{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:none}}
.title{font-size:clamp(22px,4.4vw,40px);font-weight:800;letter-spacing:.14em;color:#f0f4ff;text-shadow:0 0 14px rgba(120,170,255,.95),0 0 46px rgba(90,130,255,.45);}
.title.win{color:#ffd166;text-shadow:0 0 14px rgba(255,200,90,.95),0 0 46px rgba(255,180,60,.45);}
.title.lose{color:#ff5d7f;text-shadow:0 0 14px rgba(255,80,120,.95),0 0 46px rgba(255,60,110,.45);}
.sub{margin:8px 0 0;font-size:10px;letter-spacing:.26em;color:#7683b3;}
.cta{margin-top:20px;font-size:clamp(10px,1.7vw,14px);font-weight:800;letter-spacing:.26em;color:#ffd166;text-shadow:0 0 12px rgba(255,190,80,.6);animation:pulse 1.5s ease-in-out infinite;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.ctrl{display:flex;gap:clamp(10px,2.4vw,26px);justify-content:center;margin-top:20px;font-size:9.5px;letter-spacing:.14em;color:#8fa0cf;flex-wrap:wrap}
.ctrl div{display:flex;flex-direction:column;gap:5px}
.ctrl span{color:#5d6a95}
.ctrl b{color:#e8eeff;font-weight:700;letter-spacing:.1em}
.legend{display:flex;gap:14px;justify-content:center;align-items:center;margin-top:18px;font-size:10px;color:#9fb0dd;letter-spacing:.1em;flex-wrap:wrap}
.legend em{font-style:normal}
.chip{display:inline-flex;align-items:center;justify-content:center;gap:3px;min-width:34px;height:20px;padding:0 6px;border-radius:10px;font-weight:800;font-size:12px}
.chip.amber{background:linear-gradient(180deg,#ffe9a8,#f0a941);color:#3a2410;box-shadow:0 0 12px rgba(255,180,80,.5)}
.chip.teal{background:linear-gradient(180deg,#a8f7e0,#2ec4b6);box-shadow:0 0 12px rgba(60,220,200,.45)}
.chip.teal i{width:5px;height:5px;border-radius:50%;background:#06403a}
.scoreline{margin:12px 0 0;font-size:16px;font-weight:800;letter-spacing:.2em;color:#fff}
#endNote{margin-top:8px;font-size:10px;letter-spacing:.2em;color:#7683b3;line-height:1.7}
button{
margin-top:20px;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:.22em;
color:#041020;background:linear-gradient(180deg,#9ef1ff,#49b3ff);
border:0;border-radius:10px;padding:13px 30px;cursor:pointer;
box-shadow:0 0 24px rgba(90,190,255,.5);
transition:transform .12s ease,box-shadow .12s ease;
}
button:hover{transform:translateY(-2px);box-shadow:0 0 34px rgba(90,190,255,.75)}
button:active{transform:translateY(0) scale(.98)}
.hintline{margin-top:12px;font-size:9px;letter-spacing:.24em;color:#5d6a95}
```
Panel line-height default 1.5? set `.panel{line-height:1.5}` inherited from body? body has default. Set explicitly.
The `.overlay` fixed covers viewport — includes panel center. ✓
Hmm `#stage{filter:drop-shadow(...)}` — canvas already has box-shadow; drop-shadow adds soft ambient; fine or skip filter (performance). Skip filter; keep box-shadow big: `0 30px 80px rgba(0,0,0,.6)` added.
**Title text with two-tone**: keep single style.
Now **verify no leftover undefined**: `PADDLE_Y` used in paddle init & paddle.y fixed (never changes) ✓. `iconWide/iconMulti` built in fit before first draw ✓ (fit called before resetGame & rAF).
**makeIcon uses DPR** — set before buildIcons ✓.
**In drawBricks, for power bricks, get sprite** may be tough (row0) ✓ icon drawn over steel ✓.
**One catch**: POWER_SPOTS (3,9) 'multi' and (3,2) 'wide' etc — the map key r*COLS+c ✓.
**isTough(6,1)=true** and spot (6,1) power → tough power brick; icon on steel ✓ cracked shows icon still ✓ (icon drawn over cracked sprite — good visual!).
**AI difficulty**: 660 max, ball vx up to 555 — ok.
**Demo serve sway ±140**: paddle moves, ball follows, launch angle random — fine.
**Check collideWorld paddle when ball radius overlap horizontally** — Math.abs(b.x-p.x)<=p.w/2+b.r includes side approach ✓.
**Substep 3.5px chosen**; steps up to ceil(640*0.033/3.5)=7 ✓.
**Trail draw with 'lighter' inside shake transform** ✓.
**pops drawn with shadowBlur reset after loop** ✓.
**In drawPops I set shadowBlur each iteration then reset after loop** ✓.
**drawCap icon** drawn scaled 34 — icons designed 34 logical ✓.
**Serve ball trail** empty until launch ✓.
**hud score in demo counts** ✓.
**Potential: `ctx.letterSpacing` persists into sprite creation** — sprites use separate contexts (g) — unaffected ✓. But main ctx letterSpacing persists across frames — always reset with ls('0px') at end of drawHUD & hints ✓. Paused overlay also resets ✓.
**font `12px` etc with MONO containing commas** — `'700 12px ui-monospace,Menlo,Consolas,monospace'` ✓.
**One more consideration**: The screenshot at 3s: overlay start panel visible with demo behind — panel is centered & might cover much of small canvas but at 1280×800 canvas 1138×783, panel ~520×~340 centered — demo visible around edges: bricks top area visible above panel? Panel centered vertically — bricks at top visible ✓ paddle at bottom visible ✓.
To improve demo visibility behind overlay, make start panel more compact: reduce paddings/margins; it's fine.
Also the CTA says "CLICK OR PRESS ANY KEY TO START" ✓ exactly-ish.
**Accessibility**: button type=button ✓.
**Now count requirement "at least 6 rows" — 8 ✓ mixing ✓ 2-hit crack ✓.**
**Check: "power-up icon ... when broken they drop it" — spawnCap on destroy ✓; if tough power brick: drops on final hit ✓.**
**"catching it with the paddle gives either a wider paddle (10 seconds) or multi-ball (3 balls)" ✓.**
Edge: wide caught again while active → resets to 10 ✓ good.
**Now assemble everything into final HTML.** Also include small comment header. Watch out for typos in identifiers. Let me write the complete file mentally one more time, then output.
Also — `hitBrick` uses `ROWS-br.r` for pitch: row0 high pitch 300+8*42=636. ✓
`beep` default params fine.
**In keydown, after startGame from demo, `if(phase==='over'||phase==='win')` — phase serve; skip.** ✓
**In restartGame during phase over/win: works. Also during play R restarts ✓.**
**Double-check `update` order**: paddle wide decay after movement; during 'serve' the wideT decays too ✓ fine.
**launchAll sets serveT=0** (used only in serve).
**gameover overlay shows while parts animate behind — end overlay backdrop blur — pretty ✓.**
**Check for the `blur` event listener conflicting with pause on demo? mode==='game' guard ✓.**
**Verify `foldX` math again** with min=b.r, max=W-b.r: returns value in [min,max] ✓.
**AI when ball beyond paddle already (missed)**: t negative large? `(p.y-b.r-b.y)/b.vy` with b.y>p.y → negative t; condition t>-0.15 allows slight-below tracking; else ignore ball → then maybe tracks other ball or capsule; if all balls doomed, target = ball-follow? tx null → capsules or ball[0].x*? my final else uses `b&&!b.stuck ? b.x : ...` — ball[0] could be the dying one; paddle drifts toward its x as it falls — looks natural.
Actually else branch when tx===null but balls moving up — follow ball x: `const b=balls[0]; target=(b&&!b.stuck)?b.x:(W/2+Math.sin(time*0.9)*140);` Wait during serve in demo, ball stuck → sway ✓; during play with ball moving up, follow b.x ✓ (positions under ball, natural ready stance). But balls[0] may be stuck while others play — minor.
Better: pick first non-stuck: `const b=balls.find(x=>!x.stuck)||balls[0];`. fine.
**pointermove clamps using paddle.w which may be tweening** ✓ ok.
**A subtle thing**: pointermove sets paddle.x directly, then update's clampPaddle re-clamps ✓.
**Keyboard + mouse combined**: both fine.
**Testing win in demo**: AI clears 96 bricks could take long (demo just showcases). Balls break multiple bricks — typical clear maybe 2-4 min. Fine; likely screenshot shows mid-game.
**Ball speed ramp +5 per brick ×96 = +480 → hits cap 640 midway** — game speeds up ✓.
**Combo bonus popup every 7** ✓.
**Ensure `rings` cap** — many per frame? per brick ≤ few. ok.
**pops cap**: fine.
Alright, also **make sure `parts` splice cap 420** after pushes in burst only; confetti pushes 150 without cap check — fine.
**`drawParts` when parts empty returns** ✓.
**Canvas text baseline** default alphabetic; set once per drawHUD; drawPops sets center align; fillText for pops uses baseline alphabetic — set in drawPops too: ctx.textBaseline='alphabetic' ✓ set.
Now write **HTML head + body + style + script** in final form. Also `lang="en"`.
One more requirement from the system prompt: "Something meaningful must be visible on first paint" ✓; "no horizontal scrollbar" ✓ overflow hidden.
Also **iframe keyboard**: we display message ✓ and start overlay says click or press key ✓.
Double-check **cv pointerdown in demo**: overlay intercepts, but fallback exists ✓.
**Potential console error**: `ctx.letterSpacing` on Chrome fine. `backdrop-filter` fine. `AudioContext` guarded. `performance.now` fine. `requestAnimationFrame` fine.
**`e.preventDefault()` in pointerdown on overlay prevents focus** — keyboard events go to window listener anyway ✓ (window-level keydown works regardless of focus inside iframe? In sandboxed iframe, keyboard requires iframe focus — clicking gives focus; preventDefault on pointerdown doesn't prevent focus... Actually preventDefault on pointerdown DOES prevent default focus behavior in some browsers. Chrome: preventDefault on mousedown/pointerdown prevents focusing. That could break keyboard after click-start! To be safe, don't preventDefault on overlay pointerdown; instead handle and let default focus happen. Remove e.preventDefault() from overlay handlers. Canvas pointerdown keep default (gives focus) — don't preventDefault anywhere. ✓ Important.
But text selection prevention via user-select none ✓.
Also to ensure focus, call `cv.focus()?` Not needed; window receives keys once iframe has focus from click. Since our keydown listener is on window — after user clicks anywhere in iframe, iframe gets focus ✓.
**Another subtlety**: `pointerdown` handler on ovStart → startGame; then keydown listeners active ✓.
Now finalize the code. I'll also add a resize rebuild note: spriteCache reset each resize — sprites rebuilt lazily ✓ cheap.
Let me also handle **`innerHeight-14` when iframe small** — s could be < 0.2; min canvas ~ maybe 100px; fine.
Write out everything now. Also final check of variable name collisions: `pop` function vs `pops` array — ok. `pick` unused elsewhere ok. `ls` function ok. `time` global (not performance.now conflict) ✓. `parts` variable vs `drawParts` ✓.
`serve()` name vs `serveT` ✓.
`clamp` used widely ✓.
In `hitBrick`, after `onCleared()` phase may become 'win' — continuing code after? onCleared called last ✓.
In `moveBalls` inner loop breaks when phase!=='play' ✓; but note `loseLife` inside `moveBalls` after loops — when phase 'win' and balls empty? balls empty only if lost; if phase==='win' balls exist. Condition `!balls.length&&phase==='play'` ✓.
`updCaps` runs only in 'play' ✓ called within `if(phase==='play')` — but launchAll during serve changes phase to 'play' inside update's serve branch; then `if(phase==='play')` below runs same frame — balls move immediately ✓ fine.
**serve() resets combo=0** — after loseLife also ✓ redundant fine.
Also **paddle.squash on catch power**? add small squash: paddle.squash=0.7 in applyPower ✓ nice.
**pop when wide expires** ✓.
**Ensure `String.prototype.padStart`** ✓.
Now — is there any chance bricks array `bricks[base+c]` index valid: bricks built row-major r*ROWS? buildBricks pushes r outer, c inner → index r*COLS+c ✓ matches.
Alright, compose final file. I'll be meticulous.
Additionally, small nicety: show "DEMO" tag in HUD during demo mode: center small text? During demo the overlay covers center HUD area anyway. Skip... Actually a tiny 'DEMO' tag could clarify self-play behind overlay: add at HUD left-bottom? The start overlay already communicates. Skip.
Let me also reconsider the icon `↔` char in legend — HTML entity `↔` or direct char. Direct char fine (UTF-8 meta ✓).
Final code assembly. Also remember `ctx.imageSmoothingEnabled` default true fine.
Double-check makeIcon wide arrow path drawing both passes with function `path()` — strokes both times; second white stroke narrower ✓.
Edge in `makeIcon` multi: dots radius dark 5.6 + white 3.4 within 17 half-size (offset 5.5+5.6=11.1 ✓).
In sprite drawing `g.lineJoin='round'` set for cracks ✓.
`makeToughSprite` chips: fillStyle '#070b18' — near-black triangles ✓.
Check `resolveBrick` push `+nx*0.1` extra epsilon ✓ prevents re-collide.
`hitBrick` — but wait: resolveBrick called hitBrick AFTER position fix ✓.
One more: `brickCollide` early `return` after first hit per substep — fine.
**In `collideWorld`, paddle reflect happens before brick** — bricks far away ✓.
**Anti-shallow after brick reflect:** ✓ at end.
I also want the **ball to slightly glow-crack chips**... enough.
**Write final answer.** Structure lengths ~ 900 lines total. Let me write it out.
Actually one more check on `fit()` being called on resize: it resets `spriteCache={}` and rebuilds bg & icons — but during gameplay resize this is fine (sprites lazy).
But careful: `fit()` sets cv.width — this clears canvas; next draw repaints ✓.
`innerWidth-14`/`innerHeight-14`: at 1280×800 → 1266/900=1.407, 786/620=1.268 → s=1.268 → canvas 1141×787 ✓.
Small screens 375×667: s=min(361/900=0.401, 653/620=1.053)=0.401 → 361×249 ✓ no scrollbar ✓.
Overlay fixed covers viewport ✓ panel max-width 94vw ✓.
**Demo start also could run before fonts load** — system fonts ✓ no network.
OK writing final output now.
One thing in `drawPops`: I compute alpha as 1 - t/life but with pop-in: use `a = t<0.1 ? t/0.1 : 1-(t-0.1)/(life-0.1)`. Implement.
Also y drift in update pops (p.y-=30*dt) ✓.
Let me also double check `updCaps` catch rectangle: cp half height 11; condition cp.y+11>=p.y && cp.y-11<=p.y+p.h+6 ✓ and |cp.x-p.x|<=p.w/2+16 ✓ generous catch.
`spawnCap vy:30` initial then accel to 250 ✓.
Cap `t:rand(0,1)` for sway variety ✓.
Time to write. I'll include modest comments. Also ensure no stray `console.log`.
Also in `keydown`, guard `e.repeat` for P toggling? pressing P held repeats toggling pause rapidly — add `if(!e.repeat)` around pause toggle & restart. Arrows fine with repeat.
Let me finalize the keydown:
```js
addEventListener('keydown',e=>{
const c=e.code;
if(c==='ArrowLeft'||c==='ArrowRight'||c==='ArrowUp'||c==='ArrowDown'||c==='Space')e.preventDefault();
initAudio();
if(mode==='demo')startGame();
if(phase==='over'||phase==='win'){
if(!e.repeat&&(c==='KeyR'||c==='Enter'||c==='Space'))restartGame();
return;
}
if(c==='ArrowLeft')keys.l=true;
else if(c==='ArrowRight')keys.r=true;
else if(c==='KeyP'&&!e.repeat&&(phase==='play'||phase==='serve'))paused=!paused;
else if(c==='KeyR'&&!e.repeat)restartGame();
else if((c==='Space'||c==='ArrowUp'||c==='Enter')&&phase==='serve'&&!paused)launchAll();
});
```
Note: startGame when demo — then phase==='serve'; pressing e.g. 'a' just starts (no other action) ✓; pressing Space starts AND launches (falls through to launch branch since phase now 'serve') ✓ nice.
restartGame on 'R' also works during serve/play ✓.
Edge: keydown fires in demo for modifier etc — any key ✓ desired.
**keyup** simple ✓.
Final CSS check for `.overlay` line-height? Panel uses its own.
I'm confident. Write it.
Wait, one more: in `update`, I reference `paddle.wideT` decay BEFORE phase checks? Order I wrote: paddle code after clearwait, and wideT decay with paddle code — but I placed wideT decay inside paddle block after movement ✓ (only in serve/play phases) ✓.
But also: during 'serve' the wideT bar shows ✓ decays ✓.
Also don't decay wideT during paused (paused returns before) ✓.
Also `if(phase==='serve')` auto-launch `serveT>(mode==='demo'?0.9:4.5)` ✓.
`demoT` init -1; set 2.2 on demo clear ✓.
Also when demo 'clearwait' → resetGame() → serve() → phase 'serve' ✓.
Also bricks sprite cache persists across resetGame (same bricks) ✓ efficient.
Double-check `buildBricks` uses `pm.get(...)||null` — kind strings truthy ✓.
Alright, produce final answer. Also mention nothing outside code block per rules.
Let me write the complete final HTML file carefully now, keeping all identifiers consistent.
Also small thing: `paddle.squash` drawn scale on paddle translate — squash used as `1-sq*0.3` vertical — at sq=1 → 0.7 height briefly ✓.
In drawPaddle side lights: two separate beginPath ✓.
In drawCap `rrect` then fill and stroke uses the path from rrect ✓ (rrect begins path; fill; then stroke same path ✓ — after fill, path persists ✓).
drawPaddle: after `rrect(...);ctx.fillStyle=g;ctx.fill();` then second rrect highlight ✓.
Check `drawPaddle` sets globalAlpha=blink then restore — restore resets alpha? ctx.save/restore includes globalAlpha ✓; then extra `ctx.globalAlpha=1` harmless.
In drawBricks pulse: `rrect(ctx,br.x+1.5,...)` then stroke ✓.
Ensure `iconWide` etc built before first drawBricks ✓.
`buildBG` HUD strip: draw before walls ✓.
Final: write out. Watch that in `makeIcon` I use `g.translate(S/2,S/2)` after `g.scale(DPR,DPR)` ✓ coordinates logical.
Also `makeIcon` lineCap set ✓.
Let me now also make sure the demo overlay panel doesn't fully hide gameplay: reduce vertical margin: `.cta margin-top:16px`, `.ctrl margin-top:16px`, legend 14px, paddings clamp(14px,2.5vh,26px). Panel height ~ 300px on desktop. Good.
Output final.