Models 44

GLM 5.3 Flash

by Z.aiReasoningClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
6 min 48 s
Cost
$0.022
Output tokens
43,500
Spent thinking
33,825 tokens
Speed
106.8 tok/s
Console errors
0
First token after
831 ms
First visible text after
5 min 56 s
Prompt tokens
508
Code size
27.9 KB
Finish reason
stop
Served by
Venice
Model id
z-ai/glm-5.3-flash
Reasoning sent
{"effort":"high"}
Max output
64,000 tokens
Generated
Nov 10, 2026

What it was asked

The prompt
Build a polished Breakout (brick breaker) game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A paddle controlled with the mouse and with the left/right arrow keys. - The bounce angle off the paddle depends on where the ball hits it (center = straight up, edges = sharp angle). - One level with at least 6 rows of bricks, mixing normal bricks and tougher bricks that need 2 hits and visibly crack after the first one. - Some bricks visibly carry a power-up icon; when broken they drop it, and catching it with the paddle gives either a wider paddle (10 seconds) or multi-ball (3 balls). - The ball must never pass through bricks or the paddle. - Particles when a brick breaks. - 3 lives, score displayed. Game over and victory screens with a restart option.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 750 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>RICOCHET · Brick Breaker</title>
<style>
  :root{
    --bg:#0b0d10; --line:#262c36; --ink:#e9e4d6; --dim:#8a93a2;
    --coral:#ff6b57; --amber:#ffb454; --teal:#3dd6c3; --cream:#f2ecdc;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{background:var(--bg)}
  body{
    min-height:100vh;color:var(--ink);
    font-family:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
    display:flex;flex-direction:column;align-items:center;
    overflow-x:hidden;-webkit-font-smoothing:antialiased;
  }
  .stage{width:min(940px,calc(100% - 28px));margin:20px auto 12px}
  .hud{display:flex;justify-content:space-between;align-items:flex-end;padding:0 4px 12px}
  .label{display:block;font-size:10px;letter-spacing:2.5px;color:var(--dim);margin-bottom:3px}
  .value{font-size:24px;font-weight:700;letter-spacing:1px;color:var(--cream);font-variant-numeric:tabular-nums;line-height:1}
  .wordmark{font-size:13px;letter-spacing:7px;color:var(--dim);padding-bottom:5px;user-select:none}
  .wordmark em{font-style:normal;color:var(--coral)}
  .cursor{color:var(--teal);animation:blink 1.1s steps(2,start) infinite}
  .block.right{text-align:right}
  .lives{display:inline-flex;gap:7px;padding-bottom:4px}
  .life{width:12px;height:12px;border-radius:50%;background:var(--cream);box-shadow:inset 0 -3px rgba(0,0,0,.28)}
  .life.lost{background:transparent;border:1.5px solid #39404d;box-shadow:none}
  .frame{position:relative;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0e1014;box-shadow:0 24px 70px rgba(0,0,0,.55)}
  canvas{display:block;width:100%;height:auto;touch-action:none;cursor:none}
  .hint{margin:12px 0 20px;font-size:10px;letter-spacing:2px;color:#525b69;text-align:center}
  .overlay{position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;
    background:rgba(9,11,14,.52);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);cursor:pointer}
  .overlay.show{display:flex}
  .panel{text-align:center;background:rgba(13,16,21,.93);border:1px solid var(--line);border-radius:12px;
    padding:36px 48px 30px;max-width:88%;box-shadow:0 18px 60px rgba(0,0,0,.5)}
  .chip{display:inline-block;font-size:9px;letter-spacing:2.5px;padding:5px 12px;border-radius:20px;margin-bottom:14px}
  .chip-r{color:var(--coral);border:1px solid rgba(255,107,87,.4)}
  .chip-t{color:var(--teal);border:1px solid rgba(61,214,195,.4)}
  .panel h1{font-size:42px;letter-spacing:9px;text-indent:9px;color:var(--cream);margin:2px 0 8px;font-weight:700;white-space:nowrap}
  .panel h1 em{font-style:normal;color:var(--coral)}
  .tag{font-size:11px;letter-spacing:1.5px;color:var(--dim);margin-bottom:24px}
  .legend{display:flex;gap:20px;justify-content:center;flex-wrap:wrap;margin-bottom:26px}
  .leg{display:flex;align-items:center;gap:8px;font-size:9.5px;letter-spacing:1.5px;color:var(--dim)}
  .cap{width:24px;height:13px;border-radius:7px;flex:none}
  .cap-w{background:var(--teal)}
  .cap-m{background:var(--coral)}
  .brickchip{width:24px;height:12px;border-radius:2px;background:#8d3a33;border:2px solid #5e241f;flex:none}
  .prompt{font-size:13px;letter-spacing:3px;color:var(--cream)}
  .blink{animation:blink 1.25s steps(2,start) infinite}
  .stats{display:flex;gap:30px;justify-content:center;margin:10px 0 26px;flex-wrap:wrap}
  .stat .sval{font-size:20px;font-weight:700;color:var(--cream);font-variant-numeric:tabular-nums}
  @keyframes blink{50%{opacity:.25}}
  @media(max-width:640px){
    .stage{width:calc(100% - 16px);margin-top:10px}
    .value{font-size:17px}
    .panel{padding:22px 18px}
    .panel h1{font-size:24px;letter-spacing:5px;text-indent:5px}
    .wordmark{letter-spacing:4px}
    .stats{gap:16px}
    .hint{font-size:9px}
  }
</style>
</head>
<body>
<div class="stage">
  <header class="hud">
    <div class="block"><span class="label">SCORE</span><span class="value" id="score">000000</span></div>
    <div class="wordmark"><em>R</em>ICOCHET<span class="cursor">▌</span></div>
    <div class="block right"><span class="label">LIVES</span><span class="lives" id="lives"></span></div>
  </header>
  <div class="frame" id="frame">
    <canvas id="game" width="900" height="640"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>
  <footer class="hint">MOUSE / ← → MOVE · SPACE LAUNCH · P PAUSE</footer>
</div>

<script>
(()=>{ 'use strict';
/* ============================== setup ============================== */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const livesEl=document.getElementById('lives');
const frameEl=document.getElementById('frame');

const W=900,H=640,DPR=Math.min(2,window.devicePixelRatio||1);
canvas.width=W*DPR; canvas.height=H*DPR;

const ROWS=7,COLS=12,GAP=7,SIDE=26,TOP=64,BRICK_H=24;
const BRICK_W=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const ROW_COLORS=['#a8433b','#c25444','#e06a4a','#f78e56','#ffb454','#f0c26a','#e6d491'];
const HP_ROWS=[2,2,1,1,2,1,1];
const FONT='ui-monospace,"SF Mono",Menlo,Consolas,monospace';
const TAU=Math.PI*2;

/* ============================== helpers ============================== */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
function shade(hex,f){
  const n=parseInt(hex.slice(1),16);
  const r=Math.round(((n>>16)&255)*f),g=Math.round(((n>>8)&255)*f),b=Math.round((n&255)*f);
  return 'rgb('+r+','+g+','+b+')';
}
function seeded(seed){let s=seed>>>0;return()=>{s=(s*1664525+1013904223)>>>0;return s/4294967296;};}
function rr(c,x,y,w,h,r){
  r=Math.min(r,w/2,h/2);
  c.beginPath();
  c.moveTo(x+r,y);
  c.arcTo(x+w,y,x+w,y+h,r);
  c.arcTo(x+w,y+h,x,y+h,r);
  c.arcTo(x,y+h,x,y,r);
  c.arcTo(x,y,x+w,y,r);
  c.closePath();
}
const fmtScore=n=>String(Math.max(0,Math.round(n))).padStart(6,'0');
const fmtTime=s=>String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0');

/* ============================== audio (tiny synth) ============================== */
const AudioFX=(()=>{
  let ac=null;
  function init(){
    if(!ac){try{ac=new (window.AudioContext||window.webkitAudioContext)();}catch(e){ac=null;}}
    if(ac&&ac.state==='suspended'){try{ac.resume();}catch(e){}}
  }
  function tone(f0,f1,dur,type,vol,delay){
    if(!ac)return;
    try{
      const t=ac.currentTime+(delay||0);
      const o=ac.createOscillator(),g=ac.createGain();
      o.type=type;o.frequency.setValueAtTime(f0,t);
      if(f1!==f0)o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t+dur);
      g.gain.setValueAtTime(vol,t);
      g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
      o.connect(g);g.connect(ac.destination);
      o.start(t);o.stop(t+dur+0.03);
    }catch(e){}
  }
  return{
    init,
    paddle(){tone(240,240,0.07,'square',0.11);},
    wall(){tone(150,150,0.05,'square',0.07);},
    crack(){tone(180,110,0.08,'sawtooth',0.11);},
    pop(){tone(340,540,0.09,'square',0.13);tone(680,680,0.06,'square',0.06,0.05);},
    power(){tone(420,420,0.08,'square',0.11);tone(630,630,0.09,'square',0.11,0.08);tone(840,840,0.12,'square',0.11,0.16);},
    lose(){tone(300,90,0.4,'sawtooth',0.14);},
    launch(){tone(200,360,0.08,'square',0.09);},
    win(){[523,659,784,1046].forEach((f,i)=>tone(f,f,0.14,'square',0.11,i*0.09));},
    over(){[300,240,180,120].forEach((f,i)=>tone(f,f*0.9,0.18,'sawtooth',0.11,i*0.14));}
  };
})();

/* ============================== state ============================== */
let bricks=[],balls=[],capsules=[],particles=[],floats=[];
let score=0,dispScore=0,lives=3,broken=0;
let state='serve',demo=true,paused=false;
let gameTime=0,playTime=0,serveTimer=0,shakeAmp=0;
let pendingEnd=null,endTimer=0,speedBase=380;
let inputMode='mouse';
const keys=new Set();
const paddle={cx:W/2,y:H-46,w:112,baseW:112,wideW:172,h:15,target:W/2,wideUntil:-1};

const ballSpeed=()=>Math.min(560,speedBase+broken*1.6);

/* ============================== level ============================== */
function buildLevel(){
  bricks=[];
  const rng=seeded(987654321);
  const powers={};
  const types=['W','M','M','W','M','W','M'];      // one power brick per row, spread across the field
  for(let r=0;r<ROWS;r++){const c=(rng()*COLS)|0;powers[r*COLS+c]=types[r];}
  for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
    bricks.push({
      x:SIDE+c*(BRICK_W+GAP), y:TOP+r*(BRICK_H+GAP), w:BRICK_W, h:BRICK_H,
      hp:HP_ROWS[r], hpMax:HP_ROWS[r], color:ROW_COLORS[r],
      power:powers[r*COLS+c]||null, seed:(rng()*1e6)|0, dead:false
    });
  }
}

function resetGame(isDemo){
  demo=isDemo; buildLevel();
  score=0;dispScore=0;lives=3;broken=0;playTime=0;
  pendingEnd=null;endTimer=0;speedBase=380;shakeAmp=0;
  capsules.length=0;particles.length=0;floats.length=0;
  paddle.wideUntil=-1;paddle.w=paddle.baseW;paddle.cx=W/2;paddle.target=W/2;
  serve();updateLivesUI();
  scoreEl.textContent='000000';
}

function serve(){
  balls=[{x:paddle.cx,y:paddle.y-8,vx:0,vy:0,r:7,stuck:true,trail:[]}];
  state='serve';serveTimer=demo?0.7:3;
}

function launch(){
  for(const b of balls)if(b.stuck){
    b.stuck=false;
    const a=rand(-0.3,0.3),sp=ballSpeed();
    b.vx=Math.sin(a)*sp; b.vy=-Math.cos(a)*sp;
  }
  state='playing';AudioFX.launch();
}

/* ============================== flow control ============================== */
function startGame(){demo=false;paused=false;resetGame(false);hideOverlay();}
function restart(){paused=false;resetGame(false);hideOverlay();}
function togglePause(){
  if(demo)return;
  if(state!=='playing'&&state!=='serve')return;
  paused=!paused;
  if(paused)showPause();else hideOverlay();
}
function finishEnd(){
  const k=pendingEnd;pendingEnd=null;endTimer=0;
  if(k==='won'){state='won';AudioFX.win();confetti();showEnd('won');}
  else if(k==='over'){state='over';AudioFX.over();showEnd('over');}
  else if(k==='demoreset'){resetGame(true);}
}
function shake(v){shakeAmp=Math.min(9,Math.max(shakeAmp,v));}

/* ============================== input ============================== */
window.addEventListener('keydown',e=>{
  if(e.key==='ArrowLeft'||e.key==='ArrowRight'||e.key===' ')e.preventDefault();
  AudioFX.init();
  if(demo){startGame();return;}
  if(state==='over'||state==='won'){if(e.key===' '||e.key==='Enter')restart();return;}
  if(e.key==='p'||e.key==='P'){togglePause();return;}
  if(paused)return;
  keys.add(e.key);
  if(e.key===' '&&state==='serve')launch();
});
window.addEventListener('keyup',e=>keys.delete(e.key));

frameEl.addEventListener('pointerdown',()=>{
  AudioFX.init();
  if(demo){startGame();return;}
  if(paused){togglePause();return;}
  if(state==='over'||state==='won'){restart();return;}
  if(state==='serve')launch();
});
window.addEventListener('pointermove',e=>{
  if(demo)return;
  const rect=canvas.getBoundingClientRect();
  if(rect.width<1)return;
  inputMode='mouse';
  paddle.target=(e.clientX-rect.left)*(W/rect.width);
});

/* ============================== demo AI ============================== */
function foldPredict(x,vx,dist){
  const r=7,span=W-2*r,period=2*span;
  let m=((x-r+vx*dist)%period+period)%period;
  if(m>span)m=period-m;
  return m+r;
}
function aiControl(dt){
  let best=null,bestY=-1;
  for(const b of balls){if(!b.stuck&&b.vy>0&&b.y>bestY){best=b;bestY=b.y;}}
  let target;
  if(best){
    const t=Math.max(0,(paddle.y-best.y-best.r)/best.vy);
    target=foldPredict(best.x,best.vx,t);
  }else if(capsules.length){
    target=capsules[0].x;
  }else if(balls.length){
    target=balls[0].x;
  }else{
    target=W/2;
  }
  target+=Math.sin(gameTime*1.3)*9;
  const d=target-paddle.cx;
  paddle.target=paddle.cx+clamp(d,-470*dt,470*dt);
}

/* ============================== physics ============================== */
function movePaddle(dt){
  const L=keys.has('ArrowLeft')||keys.has('a')||keys.has('A');
  const R=keys.has('ArrowRight')||keys.has('d')||keys.has('D');
  if(L||R){inputMode='keys';paddle.target+=((R?1:0)-(L?1:0))*640*dt;}
  paddle.target=clamp(paddle.target,paddle.w/2+6,W-paddle.w/2-6);
  const k=inputMode==='mouse'?1-Math.exp(-dt*16):1;
  paddle.cx+=(paddle.target-paddle.cx)*k;
  paddle.cx=clamp(paddle.cx,paddle.w/2+6,W-paddle.w/2-6);
  const targetW=(gameTime<paddle.wideUntil)?paddle.wideW:paddle.baseW;
  paddle.w+=(targetW-paddle.w)*Math.min(1,dt*10);
}

function paddleCollide(b){
  if(b.vy<=0)return;
  const px=paddle.cx-paddle.w/2,py=paddle.y;
  if(b.y+b.r<py||b.y-b.r>py+paddle.h)return;
  if(b.x<px-b.r||b.x>px+paddle.w+b.r)return;
  b.y=py-b.r;
  // bounce angle depends on where the ball lands: center = straight up, edges = sharp 60°
  const t=clamp((b.x-paddle.cx)/(paddle.w/2),-1,1);
  const ang=t*(Math.PI/3);
  const sp=Math.hypot(b.vx,b.vy);
  b.vx=Math.sin(ang)*sp; b.vy=-Math.cos(ang)*sp;
  AudioFX.paddle();
  sparks(b.x,py,'#e9e2d0',3,90);
}

function brickCollide(b){
  for(const br of bricks){
    if(br.dead)continue;
    const cx=clamp(b.x,br.x,br.x+br.w),cy=clamp(b.y,br.y,br.y+br.h);
    const dx=b.x-cx,dy=b.y-cy;
    if(dx*dx+dy*dy>b.r*b.r)continue;
    if(cx!==b.x&&cy!==b.y){                       // corner hit
      const d=Math.hypot(dx,dy)||1,nx=dx/d,ny=dy/d;
      const dot=b.vx*nx+b.vy*ny;
      if(dot<0){b.vx-=2*dot*nx;b.vy-=2*dot*ny;}
      b.x=cx+nx*(b.r+0.5);b.y=cy+ny*(b.r+0.5);
    }else if(cx!==b.x){                           // side face
      const s=dx<0?-1:1;
      b.vx=Math.abs(b.vx)*s;b.x=cx+s*(b.r+0.5);
    }else if(cy!==b.y){                           // top / bottom face
      const s=dy<0?-1:1;
      b.vy=Math.abs(b.vy)*s;b.y=cy+s*(b.r+0.5);
    }else{                                        // center inside: push up
      b.vy=-Math.abs(b.vy);b.y=br.y-b.r-0.5;
    }
    hitBrick(br);
    return;                                       // one brick per substep — no tunneling
  }
}

function hitBrick(br){
  br.hp--;
  if(br.hp<=0){
    br.dead=true;broken++;
    const pts=br.hpMax>1?120:60;
    score+=pts;
    spawnShards(br);
    floats.push({x:br.x+br.w/2,y:br.y+br.h/2,text:'+'+pts,life:0.9,max:0.9,color:'#f2ecdc'});
    if(br.power)capsules.push({x:br.x+br.w/2,y:br.y+br.h/2,vy:150,type:br.power,phase:rand(0,6)});
    shake(2.6);AudioFX.pop();
    if(bricks.every(k=>k.dead)&&pendingEnd===null){
      pendingEnd=demo?'demoreset':'won';endTimer=demo?0.6:0.9;
    }
  }else{
    score+=15;
    sparks(br.x+br.w/2,br.y+br.h/2,br.color,5,150);
    AudioFX.crack();shake(1.2);
  }
}

function updateBalls(dt){
  for(const b of balls){
    if(b.stuck){b.x=paddle.cx;b.y=paddle.y-b.r-1;continue;}
    b.trail.push({x:b.x,y:b.y});
    if(b.trail.length>11)b.trail.shift();
    const sp=Math.hypot(b.vx,b.vy);
    const steps=Math.max(1,Math.ceil(sp*dt/4));   // sub-steps ≤ 4px → nothing is ever skipped
    const sdt=dt/steps;
    let dead=false;
    for(let s=0;s<steps&&!dead;s++){
      b.x+=b.vx*sdt;b.y+=b.vy*sdt;
      if(b.x<b.r){b.x=b.r;b.vx=Math.abs(b.vx);sparks(b.x,b.y,'#6b7280',2,70);AudioFX.wall();}
      else if(b.x>W-b.r){b.x=W-b.r;b.vx=-Math.abs(b.vx);sparks(b.x,b.y,'#6b7280',2,70);AudioFX.wall();}
      if(b.y<b.r){b.y=b.r;b.vy=Math.abs(b.vy);sparks(b.x,b.y,'#6b7280',2,70);AudioFX.wall();}
      paddleCollide(b);
      brickCollide(b);
      if(b.y>H+b.r+6)dead=true;
    }
    if(dead){b.dead=true;sparks(clamp(b.x,10,W-10),H-8,'#5a6472',7,120);}
  }
  balls=balls.filter(b=>!b.dead);
  if(state==='playing'&&balls.length===0&&endTimer<=0){
    if(demo){serve();return;}
    lives--;updateLivesUI();
    capsules.length=0;paddle.wideUntil=-1;
    AudioFX.lose();shake(7);
    if(lives<=0){pendingEnd='over';endTimer=1.0;}
    else{
      floats.push({x:W/2,y:H-150,text:'BALL LOST',life:1.1,max:1.1,color:'#ff6b57'});
      serve();
    }
  }
}

/* ============================== power-ups ============================== */
function updateCapsules(dt){
  for(const c of capsules){c.y+=c.vy*dt;c.phase+=dt*3;}
  capsules=capsules.filter(c=>{
    const py=paddle.y;
    if(c.y+10>=py&&c.y-10<=py+paddle.h&&Math.abs(c.x-paddle.cx)<=paddle.w/2+14){
      sparks(c.x,py,c.type==='W'?'#3dd6c3':'#ff6b57',10,180);
      applyPower(c.type);
      return false;
    }
    if(c.y>H+26){sparks(c.x,H-8,'#4a5160',5,90);return false;}
    return true;
  });
}
function applyPower(type){
  if(type==='W'){
    paddle.wideUntil=gameTime+10;                 // wider paddle for 10 seconds
    floats.push({x:paddle.cx,y:paddle.y-26,text:'WIDE PADDLE',life:1.2,max:1.2,color:'#3dd6c3'});
  }else{
    const n=Math.min(2,Math.max(0,6-balls.length));
    if(n===0){
      score+=500;
      floats.push({x:paddle.cx,y:paddle.y-26,text:'+500',life:1,max:1,color:'#ffb454'});
    }else{
      const sp=ballSpeed();
      for(let i=0;i<n;i++){                       // up to 3 balls in total
        const a=i===0?-0.55:0.55;
        balls.push({x:paddle.cx,y:paddle.y-11,vx:Math.sin(a)*sp,vy:-Math.cos(a)*sp,r:7,stuck:false,trail:[]});
      }
      floats.push({x:paddle.cx,y:paddle.y-26,text:'MULTIBALL',life:1.2,max:1.2,color:'#ff6b57'});
    }
  }
  AudioFX.power();
}

/* ============================== particles & floats ============================== */
function shard(x,y,color,vx,vy,size,life,grav){
  particles.push({x,y,vx,vy,w:size,h:size*rand(0.5,0.9),rot:rand(0,TAU),vr:rand(-9,9),life,max:life,color,grav});
}
function spawnShards(br){
  for(let i=0;i<14;i++){
    shard(br.x+Math.random()*br.w,br.y+Math.random()*br.h,br.color,
      rand(-170,170),rand(-230,40),rand(2.5,6.5),rand(0.5,0.95),900);
  }
}
function sparks(x,y,color,n,spd){
  for(let i=0;i<n;i++)shard(x,y,color,rand(-spd,spd),rand(-spd,spd*0.3),rand(1.5,3),rand(0.25,0.55),520);
}
function confetti(){
  for(let i=0;i<70;i++){
    const l=rand(1.2,2.2);
    particles.push({x:rand(0,W),y:rand(-40,H*0.4),vx:rand(-60,60),vy:rand(40,160),
      w:rand(3,7),h:rand(2,5),rot:rand(0,TAU),vr:rand(-7,7),life:l,max:l,
      color:ROW_COLORS[(Math.random()*ROW_COLORS.length)|0],grav:260});
  }
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=dt;
    if(p.life<=0){particles.splice(i,1);continue;}
    p.vy+=p.grav*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;
  }
  if(particles.length>450)particles.splice(0,particles.length-450);
}
function updateFloats(dt){
  for(let i=floats.length-1;i>=0;i--){
    const f=floats[i];
    f.life-=dt;f.y-=30*dt;
    if(f.life<=0)floats.splice(i,1);
  }
}

/* ============================== update ============================== */
function update(dt){
  gameTime+=dt;
  if(paused)return;
  updateParticles(dt);updateFloats(dt);
  shakeAmp*=Math.exp(-dt*8);

  if(endTimer>0){endTimer-=dt;if(endTimer<=0)finishEnd();}

  if(state==='serve'){
    serveTimer-=dt;
    if(serveTimer<=0)launch();
  }
  if(state==='playing'||state==='serve'){
    if(!demo)playTime+=dt;
    if(demo)aiControl(dt);
    movePaddle(dt);
    updateBalls(dt);
    if(state==='playing'||state==='serve')updateCapsules(dt);
  }

  // HUD score ticks toward the real value
  if(Math.round(dispScore)!==score){
    dispScore+=(score-dispScore)*Math.min(1,dt*10);
    if(Math.abs(dispScore-score)<1)dispScore=score;
    scoreEl.textContent=fmtScore(dispScore);
  }
}

/* ============================== prerendered layers ============================== */
const bg=document.createElement('canvas');
bg.width=W*DPR;bg.height=H*DPR;
{
  const b=bg.getContext('2d');b.scale(DPR,DPR);
  b.fillStyle='#0e1014';b.fillRect(0,0,W,H);
  b.fillStyle='rgba(236,229,212,0.03)';
  for(let y=22;y<H;y+=30)for(let x=18;x<W;x+=30)b.fillRect(x,y,1.5,1.5);
  b.fillStyle='rgba(255,107,87,0.3)';b.fillRect(0,0,W,2);
}
const vig=document.createElement('canvas');
vig.width=W;vig.height=H;
{
  const v=vig.getContext('2d');
  const g=v.createRadialGradient(W/2,H*0.42,H*0.25,W/2,H*0.55,H*0.95);
  g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.42)');
  v.fillStyle=g;v.fillRect(0,0,W,H);
}

/* ============================== drawing ============================== */
function drawCrack(br){
  const rng=seeded(br.seed+7);
  ctx.save();
  ctx.beginPath();ctx.rect(br.x+1,br.y+1,br.w-2,br.h-2);ctx.clip();
  ctx.strokeStyle='rgba(15,8,6,0.6)';ctx.lineWidth=1.6;ctx.lineJoin='round';
  const startX=br.x+br.w*(0.25+rng()*0.5);
  const midY=br.y+br.h*(0.35+rng()*0.3);
  let x=startX,y=br.y;
  ctx.beginPath();ctx.moveTo(x,y);
  while(y<br.y+br.h-1){
    y=Math.min(y+3+rng()*4,br.y+br.h);
    x=clamp(x+(rng()-0.5)*9,br.x+2,br.x+br.w-2);
    ctx.lineTo(x,y);
  }
  ctx.stroke();
  let bx=startX,by=midY;const dir=rng()>0.5?1:-1;   // side branch
  ctx.beginPath();ctx.moveTo(bx,by);
  for(let i=0;i<3;i++){
    bx=clamp(bx+dir*(4+rng()*7),br.x+2,br.x+br.w-2);
    by=clamp(by+(rng()-0.2)*6,br.y+2,br.y+br.h-2);
    ctx.lineTo(bx,by);
  }
  ctx.stroke();
  ctx.restore();
}
function drawPowerIcon(br){
  ctx.save();
  ctx.translate(br.x+br.w/2,br.y+br.h/2);
  ctx.fillStyle='rgba(22,14,10,0.78)';ctx.strokeStyle='rgba(22,14,10,0.78)';
  if(br.power==='W'){
    ctx.lineWidth=2;ctx.lineCap='round';
    ctx.beginPath();
    ctx.moveTo(-7,0);ctx.lineTo(7,0);
    ctx.moveTo(-7,0);ctx.lineTo(-3.5,-3.5);ctx.moveTo(-7,0);ctx.lineTo(-3.5,3.5);
    ctx.moveTo(7,0);ctx.lineTo(3.5,-3.5);ctx.moveTo(7,0);ctx.lineTo(3.5,3.5);
    ctx.stroke();
  }else{
    const dots=[[-4.5,3],[4.5,3],[0,-3.5]];
    for(const d of dots){ctx.beginPath();ctx.arc(d[0],d[1],2.5,0,TAU);ctx.fill();}
  }
  ctx.restore();
}
function drawBricks(){
  for(const br of bricks){
    if(br.dead)continue;
    const tough=br.hpMax>1,cracked=br.hp<br.hpMax;
    ctx.fillStyle=shade(br.color,tough?(cracked?0.68:0.86):1);
    rr(ctx,br.x,br.y,br.w,br.h,3);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.2)';
    ctx.fillRect(br.x+2,br.y+1.5,br.w-4,2);
    ctx.fillStyle='rgba(0,0,0,0.24)';
    ctx.fillRect(br.x+2,br.y+br.h-3.5,br.w-4,2);
    if(tough){
      ctx.strokeStyle=shade(br.color,0.5);ctx.lineWidth=2;
      rr(ctx,br.x+1,br.y+1,br.w-2,br.h-2,2.5);ctx.stroke();
      ctx.fillStyle=shade(br.color,0.45);
      const bolt=[[br.x+5.5,br.y+5.5],[br.x+br.w-5.5,br.y+5.5],[br.x+5.5,br.y+br.h-5.5],[br.x+br.w-5.5,br.y+br.h-5.5]];
      for(const p of bolt){ctx.beginPath();ctx.arc(p[0],p[1],1.4,0,TAU);ctx.fill();}
    }
    if(cracked)drawCrack(br);
    if(br.power)drawPowerIcon(br);
  }
}
function drawCapsules(){
  for(const c of capsules){
    ctx.save();
    ctx.translate(c.x,c.y);
    ctx.rotate(Math.sin(c.phase)*0.13);
    rr(ctx,-20,-10,40,20,10);
    ctx.fillStyle=c.type==='W'?'#3dd6c3':'#ff6b57';ctx.fill();
    ctx.strokeStyle='rgba(0,0,0,0.35)';ctx.lineWidth=1.5;ctx.stroke();
    ctx.fillStyle='rgba(10,14,12,0.8)';ctx.strokeStyle='rgba(10,14,12,0.8)';
    if(c.type==='W'){
      ctx.lineWidth=2;ctx.lineCap='round';
      ctx.beginPath();
      ctx.moveTo(-7,0);ctx.lineTo(7,0);
      ctx.moveTo(-7,0);ctx.lineTo(-3.5,-3.5);ctx.moveTo(-7,0);ctx.lineTo(-3.5,3.5);
      ctx.moveTo(7,0);ctx.lineTo(3.5,-3.5);ctx.moveTo(7,0);ctx.lineTo(3.5,3.5);
      ctx.stroke();
    }else{
      for(const d of [[-4.5,3],[4.5,3],[0,-3.5]]){
        ctx.beginPath();ctx.arc(d[0],d[1],2.5,0,TAU);ctx.fill();
      }
    }
    ctx.restore();
  }
}
function drawParticles(){
  for(const p of particles){
    ctx.save();
    ctx.translate(p.x,p.y);ctx.rotate(p.rot);
    ctx.globalAlpha=clamp(p.life/p.max,0,1);
    ctx.fillStyle=p.color;
    ctx.fillRect(-p.w/2,-p.h/2,p.w,p.h);
    ctx.restore();
  }
  ctx.globalAlpha=1;
}
function drawPaddle(){
  const px=paddle.cx-paddle.w/2,py=paddle.y;
  const wide=gameTime<paddle.wideUntil;
  ctx.save();
  rr(ctx,px,py,paddle.w,paddle.h,7);
  ctx.fillStyle='#e9e2d0';ctx.fill();
  ctx.clip();
  ctx.fillStyle=wide?'#3dd6c3':'#ff6b57';
  ctx.fillRect(px,py,13,paddle.h);
  ctx.fillRect(px+paddle.w-13,py,13,paddle.h);
  if(wide){                                       // diegetic 10s depletion bar
    const frac=clamp((paddle.wideUntil-gameTime)/10,0,1);
    ctx.fillStyle='rgba(0,0,0,0.22)';
    ctx.fillRect(px+18,py+5,paddle.w-36,5);
    ctx.globalAlpha=frac<0.22?(Math.sin(gameTime*18)>0?1:0.35):1;
    ctx.fillStyle='#3dd6c3';
    ctx.fillRect(px+18,py+5,(paddle.w-36)*frac,5);
    ctx.globalAlpha=1;
  }
  ctx.restore();
  rr(ctx,px+0.75,py+0.75,paddle.w-1.5,paddle.h-1.5,6);
  ctx.strokeStyle='rgba(0,0,0,0.4)';ctx.lineWidth=1.5;ctx.stroke();
}
function drawBalls(){
  for(const b of balls){
    for(let i=0;i<b.trail.length;i++){
      const p=b.trail[i],k=(i+1)/b.trail.length;
      ctx.globalAlpha=k*0.14;
      ctx.fillStyle='#f7f2e6';
      ctx.beginPath();ctx.arc(p.x,p.y,b.r*(0.35+0.55*k),0,TAU);ctx.fill();
    }
    ctx.globalAlpha=1;
    if(b.stuck){
      const t=(gameTime*1.4)%1;
      ctx.globalAlpha=(1-t)*0.5;
      ctx.strokeStyle='#ff6b57';ctx.lineWidth=2;
      ctx.beginPath();ctx.arc(b.x,b.y,b.r+3+t*14,0,TAU);ctx.stroke();
      ctx.globalAlpha=1;
    }
    ctx.fillStyle='#f7f2e6';
    ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,TAU);ctx.fill();
    ctx.fillStyle='rgba(0,0,0,0.15)';
    ctx.beginPath();ctx.arc(b.x+1.8,b.y+2.2,3.4,0,TAU);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.85)';
    ctx.beginPath();ctx.arc(b.x-2.2,b.y-2.4,2.1,0,TAU);ctx.fill();
  }
}
function drawFloats(){
  ctx.font='700 13px '+FONT;ctx.textAlign='center';
  for(const f of floats){
    ctx.globalAlpha=clamp(f.life/f.max,0,1);
    ctx.lineWidth=3;ctx.strokeStyle='rgba(10,12,15,0.6)';
    ctx.strokeText(f.text,f.x,f.y);
    ctx.fillStyle=f.color;
    ctx.fillText(f.text,f.x,f.y);
  }
  ctx.globalAlpha=1;
}
function drawHints(){
  if(state==='serve'&&!demo&&!paused){
    ctx.globalAlpha=0.5+0.4*Math.sin(gameTime*5);
    ctx.fillStyle='#8a93a2';ctx.font='12px '+FONT;ctx.textAlign='center';
    ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,H-118);
    ctx.globalAlpha=1;
  }
}
function draw(){
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.drawImage(bg,0,0,W,H);
  ctx.translate((Math.random()*2-1)*shakeAmp,(Math.random()*2-1)*shakeAmp);
  drawBricks();
  drawCapsules();
  drawParticles();
  drawPaddle();
  drawBalls();
  drawFloats();
  drawHints();
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.drawImage(vig,0,0,W,H);
}

/* ============================== HUD & overlays ============================== */
function updateLivesUI(){
  let h='';
  for(let i=0;i<3;i++)h+='<span class="life'+(i<lives?'':' lost')+'"></span>';
  livesEl.innerHTML=h;
}
function hideOverlay(){overlay.classList.remove('show');}
function showStart(){
  overlay.innerHTML=
    '<div class="panel">'+
      '<div class="chip chip-t">ATTRACT MODE · AI PLAYING</div>'+
      '<h1><em>R</em>ICOCHET</h1>'+
      '<p class="tag">BRICKS · CRACKS · FALLING LUCK</p>'+
      '<div class="legend">'+
        '<div class="leg"><span class="cap cap-w"></span>WIDE PADDLE · 10S</div>'+
        '<div class="leg"><span class="cap cap-m"></span>MULTIBALL</div>'+
        '<div class="leg"><span class="brickchip"></span>2-HIT BRICK</div>'+
      '</div>'+
      '<div class="prompt blink">CLICK OR PRESS ANY KEY TO PLAY</div>'+
    '</div>';
  overlay.classList.add('show');
}
function showPause(){
  overlay.innerHTML=
    '<div class="panel">'+
      '<h1 style="letter-spacing:12px;text-indent:12px">PAUSED</h1>'+
      '<div class="prompt" style="animation:none;color:var(--dim)">P OR CLICK TO RESUME</div>'+
    '</div>';
  overlay.classList.add('show');
}
function showEnd(kind){
  const won=kind==='won';
  overlay.innerHTML=
    '<div class="panel">'+
      '<div class="chip '+(won?'chip-t':'chip-r')+'">'+(won?'ALL BRICKS DESTROYED':'OUT OF LIVES')+'</div>'+
      '<h1 style="color:'+(won?'var(--teal)':'var(--coral)')+';letter-spacing:6px;text-indent:6px">'+(won?'LEVEL CLEARED':'GAME OVER')+'</h1>'+
      '<div class="stats">'+
        '<div class="stat"><span class="label">SCORE</span><span class="sval">'+fmtScore(score)+'</span></div>'+
        '<div class="stat"><span class="label">TIME</span><span class="sval">'+fmtTime(playTime)+'</span></div>'+
        '<div class="stat"><span class="label">BRICKS</span><span class="sval">'+broken+'/'+(ROWS*COLS)+'</span></div>'+
      '</div>'+
      '<div class="prompt blink">CLICK OR PRESS SPACE TO PLAY AGAIN</div>'+
    '</div>';
  overlay.classList.add('show');
}

/* ============================== main loop & boot ============================== */
let last=performance.now();
function loop(now){
  const dt=Math.min(0.033,Math.max(0.001,(now-last)/1000));
  last=now;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}

resetGame(true);   // boot straight into the attract-mode demo
showStart();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
Reasoning trace (33,825 tokens)
I need to build a polished Breakout game in a single HTML file. Let me plan the architecture carefully.

**Core requirements checklist:**
1. Paddle controlled by mouse + arrow keys
2. Angle-dependent bounce off paddle (center = straight up, edges = sharp angle)
3. One level, ≥6 rows of bricks, mix of normal (1 hit) and tough (2 hits, visibly crack)
4. Power-up bricks with visible icon; drop power-ups; catching gives wider paddle (10s) or multi-ball (3 balls)
5. Ball never tunnels through bricks or paddle (swept collision / sub-stepping)
6. Particles when bricks break
7. 3 lives, score display, game over + victory screens with restart
8. Demo mode: self-playing AI behind "Click or press a key to start"
9. Visible on first paint at 3s screenshot — the demo mode playing handles that
10. No console errors, responsive 1280×800 down to 375px, no horizontal scrollbar

**Visual design direction:**

I want this to feel authored, not like a generic canvas breakout tutorial. My aesthetic direction: a dark, moody "arcade cabinet" feel but elevated — deep near-black background with a subtle CRT-inspired vignette, a warm amber/coral + teal accent palette (avoiding default blue/purple), crisp typography. I'll use a distinctive font — I can't load external fonts (no network requests allowed except CDN URLs explicitly allowed by task — the task doesn't allow font CDNs, so I'll stick with system fonts but use a monospace stack that feels intentional: "ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace" for HUD numbers, and maybe a condensed feel via letter-spacing for titles). Actually since external fonts are off-limits, I'll lean into monospace-as-identity — a terminal/arcade scoreboard aesthetic works beautifully with monospace and looks deliberate.

Color palette:
- Background: #0d0f12 (deep charcoal) with a very subtle grain/vignette via radial gradient (a background gradient is fine as atmosphere, not decoration — actually I should be careful; I said I avoid decorative gradients. A subtle vignette on a game canvas is functional atmosphere for an arcade game, that's a defensible craft choice, not a "gradient banner". I'll keep it extremely subtle — dark radial vignette to focus the play field. That's standard game rendering, fine.)
- Play field: slightly lighter panel #14171c with a 1px border #2a2f38
- Bricks: warm palette rows — coral #ff6b57, amber #ffb454, sand #e8d5a3, sage/teal #4ecdc4... Hmm, let me pick a cohesive palette. I'll go with a "heat" gradient down the rows: top rows tougher (2-hit) in deep red/rust tones, lower rows normal in amber → cream → teal? Let me think: 7 rows. Rows 0-2 tough (2 hits), rows 3-6 normal. Tough bricks: rust/crimson tones with a metallic sheen; normal bricks: amber/coral/cream.

Actually, for polish, I'll draw bricks with a subtle inner highlight (top edge lighter, bottom darker) to give them physical depth without gradients-as-decoration — that's shading, legitimate.

Tough brick cracked state: draw crack lines (jagged polyline) across it, slightly desaturated/darker.

Power-up bricks: normal/tough brick with a small icon drawn in the center — a circle with "W" (wider) or a triple-dot / "×3" glyph for multi-ball. I'll draw icons with canvas paths: wider paddle icon = horizontal double-arrow ↔; multiball icon = three small dots in triangle formation. When the brick is tough AND has a powerup, the icon stays visible in both states.

**HUD:** top bar outside the canvas or drawn in-canvas? I'll do a DOM HUD above the canvas for crispness: SCORE (mono, large), LIVES shown as small ball dots, level label, active power-up timer bar. Actually a timer bar for the wide paddle is nice feedback. Let me design HUD: left: SCORE with animated counter; center: game title "BREAKOUT" small wordmark; right: LIVES as three dots. Below canvas: hint line for controls. During wide-paddle active, show a slim progress bar under the HUD or on the paddle itself (paddle glows... no glow. Paddle just looks wider and a thin depletion bar sits inside the paddle). I'll put the depletion bar inside the paddle itself — elegant, diegetic.

**Layout:** Center the game area. Canvas logical size 900×640? Keep it classic. The page: header strip with HUD, canvas, footer hint. On small screens scale canvas via CSS while keeping internal resolution — use `canvas { width: 100%; max-width: 900px; }` and handle DPR scaling for crispness. Mouse position mapping must account for scale: use getBoundingClientRect and scale factor.

**Physics — no tunneling:**
Ball speed capped; use fixed timestep with sub-stepping: move ball in small steps (e.g., max step of 4px) and do AABB collision each sub-step with resolution. Also swept AABB approach: compute collision using relative motion. Simplest robust approach: sub-step loop — split dt so ball moves ≤ ~5px per step; each step, check brick collisions (circle vs rect) and resolve by reflecting on the axis of penetration with minimal overlap. This guarantees no pass-through at reasonable speeds.

Ball-brick collision: for each substep, find bricks whose expanded rect contains ball center (circle radius vs rect: closest point distance < r). On hit, determine bounce axis by comparing penetration depths, push ball out, reflect velocity component. Only handle one brick collision per substep (or handle all but reflect once per axis) — handle nearest one to avoid weirdness.

Ball-paddle: circle vs rect; on hit, compute hit position `t = (ballX - paddleCenter) / (paddleHalf + ballR)` clamped to [-1,1]; set angle = t * 60° from vertical; speed preserved (or slight increase). Also add a tiny fraction of paddle movement influence? Keep it: angle from position is the requirement; I can add small paddle-velocity English for feel but keep position dominant. Position-based: vx = speed * sin(angle), vy = -speed * cos(angle). Center = straight up. 

Also side walls and top: reflect. Ball below bottom: lose ball.

Ball speed: base ~ 380 px/s, slight increase per brick broken (cap). Or fixed speed with tiny ramp. Fixed timestep with accumulator: use rAF with dt clamp (max 32ms), then substep.

**Paddle:** width 110 base, 170 when wide. Moves toward mouse target with smoothing (lerp) for nice feel — but requirement says controlled by mouse; direct positioning with slight smoothing is fine. Keyboard: arrows move at ~600 px/s. Demo AI: track ball with lerp toward nearest threatening ball x, capped speed slightly below perfect so it occasionally... actually for a good demo, AI should be competent but I'll cap its speed so it looks natural. AI also catches power-ups? Keep it simple: AI targets the ball that's descending and closest to bottom; if none descending, drift toward ball x.

**Power-ups:**
- Falling capsule (rounded rect with icon) at ~140 px/s with slight wobble? Keep straight fall, maybe gentle sway via sin. Catch with paddle → apply effect, show floating text ("WIDE PADDLE" / "MULTIBALL ×3"), particles burst.
- Wide paddle: 10 seconds, timer bar on paddle. If caught again, reset timer.
- Multi-ball: spawn 2 extra balls from the caught ball's position (or from paddle) with spread angles; total up to... if current balls ≥ some cap (say 6), just add up to cap or convert to points. I'll cap total balls at 6; if at cap, award 500 points instead. Requirement says 3 balls — I spawn 2 more so 3 total from that ball (if only 1 ball). If multiple balls exist, I'll spawn up to making total = existing + 2, capped at 6.
- Chance of drop: power-up bricks always drop their power-up (they visibly carry it, so they should always drop — makes it deterministic and fair). Only power-up bricks drop.
- Distribution: place power-ups on ~6-7 bricks scattered, mix of W and M.

**Bricks layout:** 7 rows × 12 columns? Canvas 900 wide, field margin 40 each side → 820 / 12 ≈ 68 wide, height 24, gap 6. Let me compute: cols=12, brickW = (fieldW - (cols+1)*gap)/cols. With gap 7 and side padding 28: playfield inner width 900-56=844; 12 bricks: 12w + 11*7 = 844 → w = (844-77)/12 = 64.08. Fine. Brick height 26. Rows start at y=70 (below HUD-in-canvas? HUD is DOM so canvas top is play top). Rows at y = 56 + row*(26+7).

Row composition: rows 0–1: tough (2 HP), row 2: mixed? Requirement: mix normal and tougher. I'll do: row 0-1 tough, rows 2-3 normal, row 4 tough... Let me do a pattern: rows 0,1 tough; rows 2,3 normal; row 4 tough; rows 5,6 normal → 7 rows, mixture. Colors per row from palette. Actually visually nicer: assign color by row with 2HP rows in the "hard" color family. Pattern: [2,2,1,1,2,1,1] HP per row. Tough bricks visually distinct (heavier color, rivets/darker outline). After first hit: crack overlay + slightly darkened.

Score: normal brick 50, tough brick 80 per... give points on each hit? 10 per hit + 50 on break? Simpler: tough = 90 on destroy, normal = 50; plus 10 for the non-final hit. Combo? Keep simple but add small "streak" — no, keep score clean. Floating score text on break (+50) as small particle-text.

**Victory:** all bricks with HP>0 destroyed → victory screen. Game over when lives exhausted and last ball lost.

**Lives:** losing last ball of a life (all balls gone) → lives--; if lives>0, respawn ball on paddle in "serve" mode: ball sticks to paddle until click/space or auto-serve after 1s? For demo AI, auto-serve. For player, "click to launch" message. I'll do: ball sits on paddle, launch on click/space, or auto after 2.5s. Show "LAUNCH — CLICK / SPACE" hint.

Ball lost: if balls.length === 0 → lose life, reset paddle width? Keep wide-paddle through life loss? I'll clear power-up on life loss (classic). Multiball naturally ends when balls run out.

**Particles:** on brick break, ~14 particles in brick color: small rects/shards with velocity, gravity, fade, rotation. Also tiny hit sparks on non-breaking hit. Also paddle hit small dust. Use a particle pool array; draw as rotated rects. Also a brief screen shake on brick break? Tiny (2px, 80ms) — adds juice. And a subtle flash on the brick before removal? Particles suffice; add brick "pop" scale? Keep it: shards + score float + micro shake.

**Screens:** 
- Start: "Click or press a key to start" overlay over the live demo game (AI plays behind). Title "BREAKOUT" with a stylized treatment — big monospace, letter-spaced, with a thin rule and subtitle. Overlay panel with slight backdrop blur/dark.
- Game over / victory: overlay with score, bricks remaining stat, "CLICK OR PRESS SPACE TO RESTART".
- Pause on P? Optional; add P to pause — nice extra, low cost. Show "PAUSED" overlay.

**Signature moment:** the ball leaves a fading trail (motion streak) — trail of previous positions drawn as fading circles; and brick-break shards + score pops. Also the serve state has a dotted aim line? Ball sits on paddle with a subtle pulse ring. The trail is a good signature. Also on victory: all remaining... confetti of shards. Good.

**Sound:** WebAudio bleeps? Task says no errors; WebAudio requires user gesture — since game starts on click/keypress, I can init audio then. Small synth blips for paddle/brick/wall, power-up catch, life lost. Adds a lot of polish. I'll implement a tiny audio helper with oscillator envelopes, guarded so it only starts after first user gesture. Demo mode before interaction: silent (no gesture yet) — fine.

**Responsiveness:** Container max-width 940px; canvas CSS width 100%, aspect ratio preserved via width/height attributes and CSS `aspect-ratio` or just height auto. Compute internal resolution fixed 900×640, DPR-scaled backing store for crispness. On resize, recompute backing store? Simpler: set canvas.width = 900*dpr etc. once, and CSS scales. DPR fixed at load — fine, but I'll handle dpr min(2, devicePixelRatio).

Mouse mapping: rect = canvas.getBoundingClientRect(); x = (e.clientX - rect.left) * (900 / rect.width).

Touch: also support touchmove for paddle (mobile-friendly) — pointer events cover it: use pointermove on canvas. preventDefault on touch to avoid scroll. I'll add `touch-action: none` on canvas.

**Structure of code:**

```
const W=900, H=640;
state: 'demo' | 'ready' (serve) | 'playing' | 'over' | 'won' | 'paused'
```

Actually manage: game.started (demo vs real). Overlay DOM elements for screens (HTML overlays are crisper than canvas text for menus). HUD in DOM too. Canvas purely playfield.

Game over/victory overlay: DOM div, hidden by default, populated with final score.

Demo AI plays behind start overlay — overlay semi-transparent so game visible (rgba backdrop with blur). Screenshot at 3s shows AI playing + title overlay. 

Restart: rebuild bricks, reset score/lives, state ready.

**Key handling:** keydown for arrows (preventDefault), space (launch/restart), P pause, and any key to start from demo. Track keys in set.

**Collision detail (brick):**
For substep:
```
for each brick alive:
  if circleRectCollide:
    compute overlap on x and y using previous... 
```
Standard approach: closest point CP on rect to ball center; if dist < r: 
- determine axis: compare |dx| vs |dy| where dx = ball.x - clamp(bx), etc. If ball center inside rect (rare with substeps), use penetration. Reflect: if the collision is more horizontal (ball.x outside rect x-range → hit left/right face) reflect vx, else reflect vy. Corner: reflect both? Choose dominant axis. Then push ball out along that axis. Then damage brick, mark for processing, continue substeps. To avoid double-hit same brick in consecutive substeps, after resolving, the push-out prevents immediate re-collide.

One collision per substep is enough given substep ≤ 5px vs brick height 26.

**Paddle collision:** only when vy > 0 and ball bottom crosses paddle top within x range. Resolve: place ball on top, compute angle. Also prevent side-tunneling: with substepping ≤5px and paddle height 16, fine. Edge case: ball hits paddle side — treat as top hit with clamped t (gives sharp angle) — acceptable and prevents weirdness.

**Ball speed:** base 400; on each paddle hit +4 up to 560? Or speed per hits count. I'll do speed = base * (1 + min(bricksBroken,30)*0.006) → up to ~+18%. Keep same for all balls; store global speed factor. On multiball spawn, new balls inherit speed.

Also ensure vy never too shallow after brick bounces: if |vy| < 0.25*speed after wall/brick bounce, nudge? Only enforce on paddle launch. Brick reflections preserve components; shallow angles could occur from near-horizontal... initial angles are within ±60° of vertical, reflections keep |vy| same magnitude, walls keep vy — so vy magnitude only changes at paddle. After paddle, angle ≤60° → vy ≥ cos60 = 0.5 speed. Brick/wall bounces preserve |vx|,|vy|. So never stuck horizontal. Good. But corner double-reflect could zero something — handle carefully (only reflect one axis per brick contact unless true corner). I'll reflect based on face: if closest point is on left/right face → reflect vx; top/bottom → vy; corner (both clamped) → reflect the axis with smaller penetration... I'll compute: penX, penY and reflect the smaller-penetration axis (the face actually hit). Corner case: reflect both only if both penetrations small and equal-ish. Simpler robust: reflect axis whose clamped coordinate is strictly inside (i.e., the face); if both clamped (corner), reflect both and push out diagonally. Rare; fine.

Wait — reflect the *dominant* axis: if ball moving right hits left face of brick, we reflect vx. Determining face: cx = clamp(ballx, brick.x, brick.x+w); if cx !== ballx → side hit; cy = clamp(bally, ...); if both differ → corner. Use velocity to decide: if side hit, reflect vx only if sign(ballx - cx) matches vx direction. Keep it standard:

```
const px = ball.x - cx, py = ball.y - cy;
if (cx !== ball.x && cy !== ball.y) { // corner
  const d = Math.hypot(px,py); nx = px/d, ny = py/d;
  // reflect velocity about normal
  const dot = ball.vx*nx + ball.vy*ny;
  if (dot < 0) { ball.vx -= 2*dot*nx; ball.vy -= 2*dot*ny; }
  // push out
  ball.x = cx + nx*r; ball.y = cy + ny*r;
} else if (cx !== ball.x) { // side
  ball.vx = Math.abs(ball.vx) * Math.sign(px);
  ball.x = cx + Math.sign(px)*r;
} else if (cy !== ball.y) { // top/bottom
  ball.vy = Math.abs(ball.vy) * Math.sign(py);
  ball.y = cy + Math.sign(py)*r;
} else { // center inside rect: push up
  ball.vy = -Math.abs(ball.vy); ball.y = brick.y - r;
}
```
Hmm the top/bottom branch: if cy clamped equals ball.y, ball.x is within rect x-range, py sign tells top/bottom. But sign(py): py = ball.y - cy = ball.y - ball.y = 0 when clamped equals... no: cy = clamp(ball.y, by, by+h). If ball.y within [by, by+h] then cy = ball.y → cy === ball.y, py = 0. So "top/bottom face" case is when cy !== ball.y, i.e., ball.y outside rect vertically → py ≠ 0. Similarly side. If both inside (center inside rect), cx===ball.x and cy===ball.y. OK my branches: corner (both differ), side (cx differs only), vert (cy differs only), inside (neither). Correct.

Actually for "vert" case: ball.y outside rect y-range but ball.x within — ball is above or below brick, distance to face < r. Reflect vy with sign away. Good.

**Power-up collision with paddle:** AABB vs paddle rect when falling capsule bottom reaches paddle top region.

**Drawing details:**
- Background: fill #0e1014; subtle vignette (radial gradient dark edges) — I'll draw once to an offscreen? Just per-frame radial gradient is fine perf-wise, or precompute. Precompute a background canvas once.
- Playfield border: crisp 1px inner stroke, plus corner ticks? Keep clean.
- Bricks: rounded rect (radius 3), fill row color, top bevel: lighter 2px strip? I'll draw fill, then a subtle top highlight line (rgba white .18, 2px) and bottom shade (rgba black .25). Tough: thicker border stroke (2px darker) + two small rivet dots? Keep: darker outline + inner inset line. Cracked: draw crack polyline in dark, 1.5px, plus lighten? draw crack path seeded per brick (deterministic random from index).
- Ball: white/cream circle with tiny offset highlight; trail behind.
- Paddle: rounded rect, cream/off-white #e8e4d8 with coral edge accents at tips? A center notch? I'll do: body cream, left/right tips in coral (#ff6b57) small caps, and when wide, tips in teal (#3dd6c3) to signal active + depletion bar along the middle showing remaining time in teal. Nice diegetic detail.
- Power-up capsule: rounded pill 34×18, colored per type (W: teal, M: coral), icon glyph, slight bob rotation, soft drop shadow? Small pulse. Letters "W" / "M×"? I'll draw icon: for W, a left-right arrow; for M, three dots triangle. Plus tiny letter maybe unnecessary. Icons drawn with paths, crisp.
- Particles: shards (rects with rotation, brick color, gravity 900, drag), spark dots.
- Floating texts: "+50" rising fading, mono font.
- Screen shake: offset canvas transform by decaying random.
- Serve state: dotted trajectory preview line up from ball? A subtle 3-dot pulse above ball. And hint text drawn in-canvas center-bottom "CLICK OR PRESS SPACE TO LAUNCH" — but in real-game serve; in demo AI auto-launches. Draw hint text on canvas in mono, subtle.

**HUD (DOM):**
Top bar: 
```
SCORE 000000        BRICK BREAKER — NOVA?        ● ● ●
```
Title wordmark: I'll name it "BRICKFALL"? Or keep "BREAKOUT". I'll give it a name: "RICOCHET" — nice. Title in overlay: big "RICOCHET" with tagline. HUD center: small "RICOCHET" wordmark? Maybe HUD: left "SCORE 001250", center small caps "RICOCHET", right "LIVES ●●●". Also a thin strip under HUD showing active effect timer? I put it in paddle. Also show "WIDE 6.2s" small text? The paddle bar suffices; maybe also a tiny HUD chip when active. I'll add a small status chip in HUD center-bottom... keep it minimal: paddle bar + floating text on catch.

HUD lives: three dots (canvas-drawn circles via spans with border-radius). Lost lives become hollow outline.

Score display zero-padded 6 digits, with a quick "tick up" animation? Score increments instantly but I can animate the displayed number toward actual (lerp) — nice detail, cheap.

**Fonts:** monospace stack for everything gives arcade-terminal identity. Headers with heavy letter-spacing. 

**Now let me also handle "demo mode" AI quality:** AI paddle target = predicted ball x when descending (simple: track ball.x + vx*(time to paddle)) for the lowest descending ball; cap speed 480 px/s (ball 400 max, so AI mostly keeps up; edges sometimes missed if sharp? With prediction it'll be near-perfect; slightly nerf: aim with error = sin(t)*8 offset). Also AI should sometimes target power-ups? Skip — but AI should launch promptly (auto-launch after 0.8s in demo). To make demo interesting quickly, launch immediately-ish. Also bricks break, particles show — screenshot at 3s will show mid-game action. 

**Brick count:** 7 rows × 12 = 84 bricks. Victory when all destroyed. That's a decent level length. Maybe 10 cols × 7 rows for chunkier bricks? 12 cols fine.

Power-up assignment: choose ~7 brick indices deterministically (seeded random so consistent): e.g., bricks at (row 1,col 2)=W, (row 3,col 9)=M, (row 5,col 4)=W, (row 2,col 6)=M, (row 6,col 1)=M, (row 4,col 10)=W, (row 0,col 8)=M. I'll pick via seeded RNG: shuffle indices, take 7, alternate types.

**Pause:** P toggles when playing. Show "PAUSED" overlay.

**Restart from over/won:** click or space/enter.

**Edge cases:**
- Multiple balls: score per brick once (brick removed on hp<=0).
- Ball-ball collisions: skip (fine).
- When wide expires, paddle shrinks smoothly (animate width toward target — lerp width for smoothness). Paddle width animated: pw += (target - pw)*min(1,dt*10).

**Audio design (tiny synth):**
```
function beep(freq, dur, type='square', vol=0.15, slide=0)
```
- paddle: 220Hz square short
- wall: 160Hz
- brick break: 320→ up, two-tone; tough hit (crack): 200 short noise-ish (use square low)
- power catch: rising arpeggio (two quick beeps)
- lose ball: descending
- victory: little arpeggio
Guard with `audio.ctx` created on first gesture; all sounds wrapped in try/catch and if(!ctx) return.

**Vignette/atmosphere:** I'll pre-render background: dark base + very subtle grid? A faint dot grid at brick area? Keep: base fill + soft radial vignette + faint horizontal "scanline"? Skip scanlines. Maybe a very faint large radial glow center-top in warm tone at 3% alpha — that's atmosphere. Keep minimal.

**Screen shake + hit-stop:** micro hit-stop (freeze 20ms) on brick break? Could complicate loop; use shake only.

Let me now write the code carefully.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RICOCHET — Brick Breaker</title>
<style>
  :root{
    --bg:#0c0e11; --panel:#12151b; --line:#252b35;
    --ink:#e8e4d8; --dim:#8b93a1;
    --coral:#ff6b57; --amber:#ffb454; --teal:#3dd6c3; --cream:#f2ead9;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%}
  body{
    background:var(--bg); color:var(--ink);
    font-family: ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace;
    display:flex; flex-direction:column; align-items:center;
    min-height:100vh; overflow-x:hidden;
  }
  ...
</style>
```

Layout plan:
```
<div class="stage">
  <header class="hud"> ... </header>
  <div class="frame">
    <canvas id="game"></canvas>
    <div class="overlay" id="overlay"> ... </div>
  </div>
  <footer class="hint">← → or mouse to move · space to launch · P to pause</footer>
</div>
```

Frame has position:relative; overlay absolute inset 0, flex center, pointer-events none except buttons? Overlay is click-through-able since whole overlay click = start/restart. I'll give overlay pointer-events:none and handle clicks on canvas... but overlay covers canvas so canvas won't get clicks. Instead listen on overlay too, or set overlay pointer-events:none and listen click on window? Listen 'pointerdown' on document for start/restart — but that would also restart during play. Guard by state. I'll listen on the frame element (covers canvas+overlay) with pointer-events auto on overlay. Actually simplest: `frame.addEventListener('pointerdown', handleFrameClick)` and overlay has pointer-events:none so clicks pass to frame? Overlay children with pointer-events none too. Hmm, overlay covers frame; if overlay has pointer-events:none, click goes to canvas/frame below. Then document-level keydown for keys. Also pointermove for paddle should work when playing — attach to window for reliability.

But careful: clicking to start shouldn't also launch... it's fine — starting sets state to serve; the same click won't relaunch since handler checks state transitions once. Actually pointerdown → start → state=serve; a later click launches. Good. But the pointerdown that starts could immediately be followed by pointermove setting paddle — fine.

HUD structure:
```
<header class="hud">
  <div class="hud-block"><span class="label">SCORE</span><span id="score" class="value">000000</span></div>
  <div class="wordmark">RICOCHET<span class="cursor">_</span></div>  (blinking cursor, nice terminal touch)
  <div class="hud-block right"><span class="label">LIVES</span><span class="lives" id="lives"></span></div>
</header>
```

Lives dots: spans .life (filled) / .life.lost (hollow). Rebuild on change.

Overlay content variants:
- start: title "RICOCHET", subtitle "A brick-breaker in one file", big prompt "CLICK OR PRESS ANY KEY", small legend of power-ups? Nice: legend showing two capsules with labels. And "demo" tag showing the AI is playing behind: small chip "ATTRACT MODE — AI PLAYING". That's a nice authored touch explaining the self-play.
- over: "GAME OVER", final score, best-streak? keep: SCORE + BRICKS CLEARED count, prompt restart.
- won: "LEVEL CLEARED", score, prompt restart.
- paused: "PAUSED", resume hint.

Overlay style: centered column, background rgba(10,12,15,.72) + backdrop-filter blur(3px). Panel with border? A bordered panel 1px var(--line), padding, background rgba(14,17,22,.85). Title big mono with tight tracking? Wide letter-spacing, coral accent underline bar.

I'll write overlay as a function `showOverlay(html)` / hide.

Now the JS. Let me write it fully and carefully.

```js
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 900, H = 640;
const DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = W * DPR; canvas.height = H * DPR;
// ctx.setTransform(DPR,0,0,DPR,0,0) each frame or once (I won't use save/restore conflicting) — set at draw start.
```

CSS: canvas{ display:block; width:100%; height:auto; } inside frame max-width 900px. On 1280 viewport, stage width min(920px, 100%-32px). Height check: HUD ~64px + canvas at 900 wide → 640 tall → total ~760 < 800. Good. On small screens width shrinks, height auto scales.

Colors per row (7 rows), palette top→bottom: 
- row0 tough: #d94f43 (rust red)
- row1 tough: #e06248
- row2 normal: #ff8a5c? Let me craft a warm ladder: 
  0 #c9564a (2hp), 1 #d96b4a (2hp), 2 #ff8f5e, 3 #ffb454, 4 #e8cf8f? hmm and teal for one row to break monotony: row5 #6fd0c0? Mixing teal in the warm ladder could look off. Alternative cohesive scheme: ember ladder — deep rust → coral → amber → sand → cream. 7 rows:
  0 #b5443c (2hp), 1 #cf5140 (2hp), 2 #e86a4c, 3 #ff8a5c, 4 #ffb454, 5 #f5c86e, 6 #efe0ae? That's a nice heat gradient falling to embers. Tough rows at top darker + 2hp; also make row 4 tough? My HP pattern [2,2,1,1,2,1,1] with row4 tough colored amber would need visual distinction via outline. I'd rather tie toughness to darker/greener? Let me simplify: HP pattern per row: rows 0,1,4 tough? Visual distinction for tough: darker tone + double border + rivets. Colors assigned by row regardless; tough adds treatment. Hmm but then "mixing normal and tougher" reads better if tough looks structurally different, not just darker.

Decision: HP pattern rows: [2,2,1,1,2,1,1]. Tough bricks drawn with a steel-plate look: same row color but with 2px darker outline + two rivet dots + slightly desaturated? I'll implement: tough fill = shade(color, 0.82), stroke = shade(color,0.55), rivets. Cracked: overlay crack polyline in rgba(0,0,0,.45) and small chips missing (a notch)? Crack line is enough + desaturate further (fill shade 0.7). Good, visibly different.

shade helper: hex → darken factor.

Brick colors (7): ['#a8433b','#c25444','#e06a4a','#f78e56','#ffb454','#f2c26b','#e9d593'] — heat ladder. Nice against dark bg.

Power-up icon bricks: draw icon badge centered: small circle (r 7) in cream with type glyph? Or icon directly on brick in dark ink. I'll draw: a small pill/diamond behind icon? Direct glyph in dark (#141312 at .8): for 'W' draw ⇔ double arrow; for 'M' three dots triangle. On dark brick colors, dark glyph reads well. Also add a subtle pulsing ring? Keep static glyph + slight shine: glyph white when...? Dark glyph, fine. Maybe also small "×2" notch? no.

Icons:
- W: horizontal line with arrowheads both ends: moveTo(-6,0)→(6,0), arrowheads at ends.
- M: three filled circles at (-4,3),(4,3),(0,-3.5) r2.2.

Falling power-up capsule: rounded rect 40×20, fill type color (W: teal #3dd6c3, M: coral #ff6b57), dark glyph inside, thin darker outline, slight rotation wobble sin(t*4)*0.12, fall speed 150.

Paddle: base width 112, wide 168, height 16, y = H - 46. Rounded rect r=8. Body cream #ece5d4; tips: 12px end caps in coral (normal) / teal (wide active) — I'll draw base rounded rect, then overlay two tip segments clipped: simpler draw three rounded rects? Draw main body cream, then two small rounded rects at ends inset 0 with accent color, radius matching. Then timer bar when wide: dark track line inside center + teal fill proportion.

Ball: r=7, color #f6f1e4, highlight arc. Trail: store last ~10 positions each frame (or every other), draw circles with decreasing alpha/size in cream at low alpha (composite lighter? use globalAlpha .05-.15). Subtle.

Now the loop:

```js
let last = performance.now();
function frame(now){
  let dt = Math.min(0.033, (now-last)/1000); last = now;
  update(dt); draw();
  requestAnimationFrame(frame);
}
```

Update:
- input: keyboard velocity, mouse target smoothing: paddle.x approaches target with lerp factor (1 - exp(-dt*20)) for mouse; keyboard adds directly.
- state machine:
  - demo: AI update paddle; auto launch; physics run; if lose life in demo just respawn (infinite demo lives).
  - ready (serve): ball follows paddle; player click/space launches; demo auto-launches. Wait — in demo, is there a serve state? I'll unify: demo mode uses same serve state with autoLaunchTimer.
  - playing: physics.
  - over/won: physics halted (or particles keep updating — yes keep updating particles for juice; balls frozen/removed).
  - paused: skip updates except draw.

Physics update per ball:
```
const steps = Math.ceil((speed*dt) / 4); // ≤4px per substep
for i in steps: moveSub(dt/steps)
```
speed up to ~560 → dt .0166*560≈9.3px → 3 substeps. Fine.

moveSub: 
```
b.x += b.vx*s; b.y += b.vy*s;
walls: if b.x < r+left: b.x = ...; b.vx = |vx|; sound wall
top similar.
paddle: if b.vy>0 && circleRect(paddle) → resolve angle
bricks: loop alive bricks, collide → resolve, damage, break loop? Continue checking others? One per substep then break (subsequent substeps handle others). Break after first hit per substep.
bottom: if b.y > H + r: remove ball.
```

Paddle resolve:
```
const pw = paddle.w;
if (b.vy > 0 && b.y + b.r >= paddle.y && b.y - b.r <= paddle.y + paddle.h && b.x >= paddle.x - b.r && b.x <= paddle.x + pw + b.r) {
   b.y = paddle.y - b.r;
   const t = clamp((b.x - (paddle.x + pw/2)) / (pw/2), -1, 1);
   const ang = t * (Math.PI/3); // 60°
   const sp = ballSpeed();
   b.vx = sp * Math.sin(ang); b.vy = -sp * Math.cos(ang);
   sound paddle; tiny particles;
}
```
Note: to avoid re-collide when ball moving down along edge... placing y above paddle top and vy negative solves.

Hmm: t uses pw/2 as denominator so edge hits (t=±1) give 60°. Sharp enough.

Ball speed: global `speedLevel` increases slightly per broken brick: base 380 + broken*1.5, cap 560.

Damage brick:
```
brick.hp--; 
if hp<=0: brick.dead=true; brokenCount++; score += brick.tough?120:60; spawn shards + floatText('+60'); maybe powerup drop (if brick.power) spawn capsule; shake 3; sound break; check win.
else: score += 15; cracked visuals; sound crack; small sparks.
```
Also small chance... no, drops only from power bricks (always drop). 

Power-up catch: overlap capsule rect with paddle rect expanded a bit → apply:
- W: paddle.wideUntil = now+10; floatText('WIDE PADDLE'); sound.
- M: spawn balls: from capsule position? Spawn at paddle? Classic: spawn at caught ball... there's no caught ball. Spawn 2 balls at paddle center above it, angles ±25° from vertical, or at capsule position moving upward? Spawn at capsule pos with upward velocities — dramatic and fine. Cap total 6: add min(2, 6 - balls.length); if none added, +500 score floatText('+500').

Wait — requirement: "multi-ball (3 balls)". If exactly 1 ball and you catch, you get 3 total. My spawn: current balls count n, spawn (2) more → n+2. If n≥3 already... spec says gives 3 balls; I'll interpret as +2 balls up to cap 6. Fine.

Life loss: when balls.length===0:
- if demo: reset serve (demo lives infinite, but keep lives display static 3 in demo? show 3).
- else: lives--; updateLives; clear powerups (paddle.wideUntil=0; capsules cleared? clear falling capsules too — yes). If lives>0: state serve with new ball. else gameOver.

Victory check after each break: if all dead → state 'won' after short delay (0.6s to let particles play)? I'll set a winPending timer 0.8s then show overlay; balls keep flying? freeze balls at win for clarity — I'll just clear balls and let shards finish. Also confetti burst: spawn 60 shards across field in random brick colors. 

Game over: similar small delay.

Restart: rebuild everything, state = serve, started=true (not demo).

Demo AI:
```
function aiUpdate(dt){
  // choose target: lowest ball with vy>0 else any ball
  let target = paddle.cx default center;
  predict: for chosen ball, simulate x at paddle.y given walls bounces: x + vx*t with reflections off [r, W-r].
  t = (paddle.y - b.y)/b.vy if vy>0 else use b.x.
  paddle.target = predX + wobble
  move paddle toward target at maxSpeed 430.
}
```
Reflection prediction: 
```
function predictX(x, vx, dist){
  let px = x, pvx = vx, rem = dist;
  // step-based fold
  if (pvx === 0) return px;
  let span = W - 2*r;
  // simulate loop up to few bounces
  ...
}
```
Simple loop: while rem>0 and iterations<10: distance to next wall = pvx>0 ? (W-r-px) : (px-r); if dist along path = |dtw / pvx|... do in units: timeToWall = dtw/|pvx|; if timeToWall*|pvx| >= rem → px += pvx*rem/... let me param by distance: total distance D=rem; travel x += pvx*step... easier: iterate with reflection formula:
```
let pos = x - r, span = W - 2*r, v = vx;
let d = rem;
let np = pos + v*d;
// fold np into [0, span*2] reflection
let m = ((np % (2*span)) + 2*span) % (2*span);
np = m <= span ? m : 2*span - m;
return np + r;
```
v*d can be large but fold handles it. 

AI paddle speed: 460. Add small reaction error: target += Math.sin(time*1.7)*10. Also AI in demo should occasionally... it'll basically never miss; that's OK for attract mode — game looks competent. But then demo never loses a ball → never shows life mechanics — fine, attract mode shows brick-breaking + power-ups. AI catches power-ups? When a capsule falls, if AI near, it'll catch incidentally since targeting balls. Could bias: if capsule falling and paddle x far from any descending ball, target capsule. Nice: demo shows wide paddle/multiball. Implement: pick priority: if any ball descending → ball; else if capsule exists → capsule x; else ball x or center. Multiball with 3 balls still trackable at speed 460 vs ball 400? Might miss sometimes → in demo that's fine (respawn silently). Cap demo ball speed a bit lower (speedLevel still grows; fine).

Mouse input: pointermove on window: if started, paddle.mouseX = mapped x; mode='mouse'. Keyboard press switches mode to 'keys' while held. Blend: if keys active this frame, use keyboard; else if mouse moved recently use mouse target. Implementation: paddle.target updated by whichever input; keys set paddle.target += dir*speed*dt directly (mode keys). Mouse move sets paddle.target = mouseX (mode mouse). Each frame: if keyLeft/right pressed → mode keys, target = paddle.cx + dir*620*dt (i.e., direct). If mode mouse → x lerp to target. Unified: paddle.x = damp toward paddle.target.

Let me store paddle as {x (left), w, h, y}. cx = x + w/2. When width changes, keep center: adjust x so center preserved on width delta.

Serving ball: balls array with one ball {x,y,vx:0,vy:0,stuck:true}. In serve state, stuck ball rides paddle top center + offset. Launch: vy=-speed, vx = small random ±? give slight angle based on nothing: angle = (Math.random()*0.3 - 0.15) rad? Launch straight up with tiny tilt: sin(0.2*rand). Also allow player to position then launch.

In demo, launch after 0.7s automatically.

**Trail:** each ball keeps trail array push {x,y} every frame cap 12; draw with alpha fading. During stuck, skip trail growth.

**Float texts:** {x,y,text,life,color} rise 30px/s fade over 0.9s, mono 13px bold.

**Shake:** shakeT decays; offset = (rand-0.5)*2*mag*t.

**Draw order:** bg (cached), bricks, capsules, particles, paddle, balls+trails, floatTexts, serve hint, shake transform around all, vignette on top? Vignette drawn last at low alpha multiply-ish — just fill radial gradient with transparent center to rgba(0,0,0,.35) edges. Precompute gradient each frame is fine or cache; cache once.

Also side walls visual: draw thin inner border lines? The frame border via CSS on canvas (border: 1px solid var(--line)). Plus in-canvas subtle top line? OK.

Background pre-render (offscreen canvas 900×640):
- fill #0e1014
- faint dot grid? I'll add extremely faint dots every 36px rgba(255,255,255,0.02) — texture without noise. Actually could look dotty-nice, arcade paper texture. Keep alpha 0.03, r=1.
- radial vignette cached separately (drawn last).
- Also a faint horizontal line where bricks region starts? no.

**HUD updates:** score displayed with lerp: displayScore += (score - displayScore)*min(1,dt*8); format padStart(6,'0'). Lives dots update on change.

Also HUD shows a small "effect chip" when wide active? I'll add span #fx hidden, showing "WIDE 7s". Cheap and useful. Place under? I'll put it in the center HUD block next to wordmark? Add a fourth block? Layout: left score, center wordmark + below it tiny fx chip? Simplest: right side next to lives: chip element. Eh — I'll show remaining wide time as the paddle bar only, plus float text. Skip chip to keep HUD clean. Hmm, but 10s duration feedback: paddle bar is clear. OK.

**Pause:** key 'p'. In pause, draw overlay DOM "PAUSED — P to resume". Only when started and playing/serve.

**Victory stats:** show score + time? Track elapsed play time. Show "TIME 01:23" on end screens. Nice touch.

**Fonts sizes:** wordmark 15px letterspaced; score 22px bold.

Let me now also double check "The ball must never pass through bricks or the paddle" — substepping at ≤4px with ball r 7 and brick h 26: guaranteed overlap detection each substep since movement per substep < r. Paddle h 16 > 4px step. 

One more: ball collisions when ball overlaps two bricks simultaneously (e.g., hitting seam): after resolving first, second may still overlap → next substep resolves. Fine.

Brick region top: bricks start y=64. Wall bounce top at y=... top wall at y=0? There's HUD outside canvas, so play area top is canvas top. Ball radius 7, top bounce at y=r. Bricks at y=64 fine.

**Now compute brick geometry precisely:**
W=900. sidePad=26. cols=12, gap=7.
brickW = (900 - 52 - 11*7)/12 = (900-52-77)/12 = 771/12 = 64.25. Use exact float, fine.
brickH = 24. rowGap = 7. topY = 64.
Bottom-most row y = 64 + 6*31 = 250. Paddle at 594. Space fine.

**Serving hint text:** draw in canvas: "CLICK OR PRESS SPACE TO LAUNCH" centered at y=H-110, dim color, only in serve & !demo. In demo show nothing (overlay covers anyway).

**Now write everything.** I'll be careful about:
- No console errors: guard audio, define all vars.
- Prevent default for arrows/space so page doesn't scroll.
- pointermove: use `window.addEventListener('pointermove')` mapping through canvas rect.

Let me draft the full code.

```js
// ---------- helpers ----------
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const rand = (a,b)=>a+Math.random()*(b-a);
function shade(hex, f){
  const n = parseInt(hex.slice(1),16);
  const r = Math.round(((n>>16)&255)*f), g=..., b=...;
  return `rgb(${r},${g},${b})`;
}
function seeded(seed){ let s=seed; return ()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; }; }
```

Audio:
```js
const AudioFX = (()=>{
  let ac=null;
  function init(){ if(!ac){ try{ ac = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(ac&&ac.state==='suspended') ac.resume(); }
  function tone(f0,f1,dur,type,vol,delay=0){
    if(!ac) return;
    try{
      const t = ac.currentTime + delay;
      const o = ac.createOscillator(), g = ac.createGain();
      o.type=type; o.frequency.setValueAtTime(f0,t);
      if(f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1), t+dur);
      g.gain.setValueAtTime(vol,t);
      g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
      o.connect(g).connect(ac.destination);
      o.start(t); o.stop(t+dur+0.02);
    }catch(e){}
  }
  return {
    init,
    paddle(){ tone(240,240,0.07,'square',0.12); },
    wall(){ tone(150,150,0.05,'square',0.08); },
    crack(){ tone(170,120,0.08,'sawtooth',0.12); },
    break(){ tone(340,520,0.09,'square',0.14); tone(680,680,0.06,'square',0.07,0.05); },
    power(){ tone(420,420,0.08,'square',0.12); tone(630,630,0.09,'square',0.12,0.08); tone(840,840,0.12,'square',0.12,0.16); },
    lose(){ tone(300,90,0.4,'sawtooth',0.15); },
    win(){ [523,659,784,1046].forEach((f,i)=>tone(f,f,0.14,'square',0.12,i*0.09)); },
    launch(){ tone(200,340,0.08,'square',0.1); },
    over(){ [300,240,180,120].forEach((f,i)=>tone(f,f,0.18,'sawtooth',0.12,i*0.14)); },
  };
})();
```
Note `break` as method name is valid as property. OK.

Game state:

```js
const ROWS=7, COLS=12, BRICK_H=24, GAP=7, SIDE=26, TOP=64;
const BRICK_W=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const ROW_COLORS=[...];
const HP_ROWS=[2,2,1,1,2,1,1];

let bricks=[], capsules=[], particles=[], floats=[], balls=[];
let score=0, dispScore=0, lives=3, broken=0, startTime=0, elapsed=0;
let state='demo'; // demo | serve | playing | paused(pausedFrom) | over | won
let demo=true, shakeT=0, shakeMag=0, winTimer=-1, endTimer=-1;
let speedBase=380;
const paddle={x:W/2-56, y:H-46, w:112, baseW:112, wideW:172, h:15, wideUntil:0, target:W/2};
```

paused handling: `paused` boolean separate from state. state stays 'playing'/'serve'; if paused skip update. Simpler.

buildLevel():
```js
function buildLevel(){
  bricks=[];
  const rng = seeded(20240613);
  // choose power bricks
  const idx = [];
  for(let i=0;i<ROWS*COLS;i++) idx.push(i);
  // shuffle
  for(let i=idx.length-1;i>0;i--){ const j=(rng()*(i+1))|0; [idx[i],idx[j]]=[idx[j],idx[i]]; }
  const powers = {};
  const types=['W','M','W','M','W','M','W'];
  for(let i=0;i<7;i++) powers[idx[i]] = types[i];
  for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++){
    const i=r*COLS+c;
    bricks.push({
      x: SIDE + c*(BRICK_W+GAP), y: TOP + r*(BRICK_H+GAP),
      w: BRICK_W, h: BRICK_H, hp: HP_ROWS[r], color: ROW_COLORS[r],
      power: powers[i]||null, seed: rng()*1000, dead:false
    });
  }
}
```

Reset game:
```js
function resetGame(isDemo){
  demo=isDemo; buildLevel();
  score=0; dispScore=0; lives=3; broken=0; elapsed=0; winTimer=-1; endTimer=-1;
  capsules=[]; particles=[]; floats=[];
  paddle.wideUntil=0; paddle.w=paddle.baseW;
  paddle.x=W/2-paddle.w/2; paddle.target=W/2;
  speedBase=380;
  serve();
  updateLivesUI();
}
function serve(){
  balls=[{x:paddle.x+paddle.w/2, y:paddle.y-9, vx:0, vy:0, r:7, stuck:true, trail:[]}];
  state='serve'; serveTimer = demo?0.7:2.6;
}
```
Wait serveTimer for real player: I want the player to launch manually but auto-launch after 3s as convenience? Manual with hint; no auto-launch for player? Auto-launch avoids confusion; but "click to launch" is classic. I'll do: launch on click/space immediately, or auto after 3s. Both. demo 0.7s.

launch():
```js
function launchBall(b){
  b.stuck=false;
  const a = rand(-0.25,0.25);
  const sp = speedBase + broken*1.5;
  b.vx = Math.sin(a)*sp; b.vy = -Math.cos(a)*sp;
}
```

update(dt):

```js
function update(dt){
  if(paused) return;
  // paddle control
  const left = keys.has('ArrowLeft'), right = keys.has('ArrowRight');
  if(left||right){ inputMode='keys'; paddle.target = clamp(paddle.target + (right?1:-1)*640*dt, paddle.w/2+8, W-paddle.w/2-8); }
  // demo AI
  if(demo && (state==='playing'||state==='serve')) aiControl(dt);
  // paddle width animation
  const wide = performance-based? use elapsed game time clock
```

Time management: use a `tNow` accumulated game time (seconds) for wideUntil etc. Let `gameTime += dt` each update; wideUntil in gameTime terms.

Paddle width: targetW = (gameTime < wideUntil) ? wideW : baseW; smooth: paddle.w += (targetW-paddle.w)*Math.min(1,dt*12); keep center: recompute x around center: store paddle.cx separately? Easier: paddle stores cx; x = cx - w/2. Let me refactor paddle: {cx, w, h, y}. x = cx - w/2 when needed.

Paddle movement:
```
const smooth = inputMode==='mouse' ? 1-Math.exp(-dt*18) : 1; // keys set target directly ahead, then snap
paddle.cx += (paddle.target - paddle.cx) * (inputMode==='mouse'? smooth : 1);
```
Hmm for keys I set target = target + v*dt then move cx to target directly (responsive). For mouse, exponential smoothing toward target gives slight ease. Clamp cx.

Ball update (state playing or demo-playing; serve updates stuck ball):
```
for each ball:
  if(b.stuck){ b.x = paddle.cx + (b.off||0); b.y = paddle.y - b.r - 1; continue; }
  const sp = Math.hypot(b.vx,b.vy);
  const steps = Math.max(1, Math.ceil(sp*dt/4));
  const sdt = dt/steps;
  for(let s=0;s<steps;s++){
    b.x += b.vx*sdt; b.y += b.vy*sdt;
    // walls
    if(b.x < b.r){ b.x=b.r; b.vx=Math.abs(b.vx); AudioFX.wall(); }
    else if(b.x > W-b.r){ b.x=W-b.r; b.vx=-Math.abs(b.vx); AudioFX.wall(); }
    if(b.y < b.r){ b.y=b.r; b.vy=Math.abs(b.vy); AudioFX.wall(); }
    // paddle
    paddleCollide(b);
    // bricks
    brickCollide(b);
    // bottom
    if(b.y > H + b.r*2){ b.dead=true; break; }
  }
```
Wall sound spam in demo (no audio ctx until interaction) — fine.

After loop: balls = balls.filter(!dead). If balls.length===0 && (state==='playing'||state==='demo-playing'?) handle life.

State handling with demo: I'll keep state values 'serve'/'playing' shared, with `demo` flag altering behavior (AI control, auto launch, infinite lives, no end screens — on game over in demo just resetGame(true)? Demo losing all 3 lives unlikely with AI; if it happens, silently reset). Actually in demo, lives infinite: on balls empty → serve() again.

paddleCollide:
```js
function paddleCollide(b){
  if(b.vy<=0) return;
  const px = paddle.cx - paddle.w/2, py = paddle.y;
  if(b.y + b.r < py || b.y - b.r > py + paddle.h) return;
  if(b.x < px - b.r || b.x > px + paddle.w + b.r) return;
  b.y = py - b.r;
  const t = clamp((b.x - paddle.cx)/(paddle.w/2), -1, 1);
  const ang = t * (Math.PI/180*60);
  const sp = Math.hypot(b.vx,b.vy);
  b.vx = Math.sin(ang)*sp; b.vy = -Math.cos(ang)*sp;
  // tiny english from paddle motion
  AudioFX.paddle();
  spawnDust(b.x, py, 4);
}
```
Edge: ball hits paddle side while moving down but below top... if b.y - b.r > py (center below top) and within x-range... condition `b.y - b.r > py + paddle.h` excludes fully below; if ball center beside paddle (x beyond range) skip. Ball coming down at paddle edge overlapping corner: t clamps to ±1 → sharp angle, ball placed on top — could look like teleport if it hit side low. Acceptable; substeps are small (≤4px) so vertical penetration ≤4px. Fine.

Also prevent paddle catch when ball already below paddle top significantly — substeps prevent.

brickCollide:
```js
function brickCollide(b){
  for(const br of bricks){
    if(br.dead) continue;
    const cx = clamp(b.x, br.x, br.x+br.w);
    const cy = clamp(b.y, br.y, br.y+br.h);
    const dx = b.x-cx, dy = b.y-cy;
    if(dx*dx+dy*dy > b.r*b.r) continue;
    // resolve
    if(cx!==b.x && cy!==b.y){
      const d=Math.hypot(dx,dy)||1, nx=dx/d, ny=dy/d;
      const dot=b.vx*nx+b.vy*ny;
      if(dot<0){ b.vx-=2*dot*nx; b.vy-=2*dot*ny; }
      b.x=cx+nx*(b.r+0.5); b.y=cy+ny*(b.r+0.5);
    } else if(cx!==b.x){
      const s=Math.sign(dx)||1;
      b.vx = Math.abs(b.vx)*s; b.x = cx + s*(b.r+0.5);
    } else if(cy!==b.y){
      const s=Math.sign(dy)||1;
      b.vy = Math.abs(b.vy)*s; b.y = cy + s*(b.r+0.5);
    } else {
      b.vy = -Math.abs(b.vy); b.y = br.y - b.r - 0.5;
    }
    hitBrick(br, b);
    return; // one brick per substep
  }
}
```

hitBrick:
```js
function hitBrick(br){
  br.hp--;
  if(br.hp<=0){
    br.dead=true; broken++;
    const pts = br.hpMax>1?120:60;   // store hpMax
    score+=pts;
    spawnShards(br);
    floats.push({x:br.x+br.w/2,y:br.y+br.h/2,text:'+'+pts,life:0.9,color:br.color});
    if(br.power) capsules.push({x:br.x+br.w/2,y:br.y+br.h/2,vy:150,type:br.power,phase:Math.random()*6});
    shake(2.5,0.15);
    AudioFX.break_();
    speedBase = Math.min(560, speedBase+2);
    if(bricks.every(k=>k.dead)) winTimer = 0.9;
  } else {
    score+=15;
    spawnSparks(br);
    AudioFX.crack();
    shake(1.2,0.08);
  }
}
```
Name method `brick()` instead of `break()` to avoid confusion — property named break is legal but I'll call it `pop`. 

Capsules update:
```js
for(const c of capsules){ c.y += c.vy*dt; c.phase += dt*3; }
// catch
capsules = capsules.filter(c=>{
  const py=paddle.y;
  if(c.y+10 >= py && c.y-10 <= py+paddle.h && Math.abs(c.x-paddle.cx) < paddle.w/2+16){
    applyPower(c.type); return false;
  }
  return c.y < H+30;
});
```
On miss (falls past bottom) just disappears; maybe tiny fizzle particles — add small gray puff. Nice detail: spawnSparksAt(c.x, H-8, gray, few).

applyPower:
```js
function applyPower(type){
  if(type==='W'){
    wideUntil = gameTime + 10;
    floats.push({x:paddle.cx,y:paddle.y-24,text:'WIDE PADDLE',life:1.2,color:'#3dd6c3'});
    AudioFX.power();
  } else {
    const n = Math.min(2, 6-balls.length);
    if(n<=0){ score+=500; floats.push(...'+500 MAX'); }
    else {
      for(let i=0;i<n;i++){
        const src = balls.find(b=>!b.stuck) || balls[0];
        const a = rand(-0.9,0.9); // wide spread
        const sp = speedBase + broken*1.5;
        balls.push({x:src.x, y:src.y-2, vx:Math.sin(a)*sp, vy:-Math.abs(Math.cos(a))*sp, r:7, stuck:false, trail:[]});
      }
      floats.push({...'MULTIBALL ×3'...});
    }
    AudioFX.power();
  }
  burst particles at paddle in type color
}
```
Hmm if src ball is stuck (serve state can't catch since no capsules falling... could there be capsule falling during serve after life loss? I clear capsules on life loss, and during first serve none exist. But multiball during play, then all balls lost → serve; capsules cleared. OK so when applying, at least one moving ball exists. But safe fallback anyway: if src stuck, unstick? If all stuck (serve) and capsule caught — impossible since capsules cleared on serve. Keep fallback: spawn from paddle top moving up.

Actually cleaner: spawn new balls at paddle position: x=paddle.cx±20, y=paddle.y-10, upward angles ±35°. Classic. Do that:
```
for i in n: ang = (i===0?-0.5:0.5) rad; balls.push({x:paddle.cx, y:paddle.y-12, vx:sin* sp, vy:-cos*sp,...})
```
Wait spawning at paddle with upward velocity immediately — fine.

Multiplier text: 'MULTIBALL' — spec says 3 balls; if you already have 2 balls you'd get 4... fine, "+2 BALLS" text? I'll display 'MULTIBALL ×3' only meaningful in the typical case; better: text 'MULTIBALL'. Keep 'MULTIBALL ×3'? If 2 balls exist it's wrong. Use 'MULTIBALL'. Hmm the float text is flavor; 'MULTIBALL' is safe.

Particles:

```js
function spawnShards(br){
  const n=14;
  for(let i=0;i<n;i++){
    particles.push({
      x:br.x+Math.random()*br.w, y:br.y+Math.random()*br.h,
      vx:rand(-160,160), vy:rand(-220,40),
      w:rand(2,6), h:rand(2,5), rot:rand(0,6.28), vr:rand(-8,8),
      life:rand(0.5,0.9), maxLife: same, color: br.color, grav: 900
    });
  }
}
function spawnSparks(br){ // small hits for crack / paddle dust
  5 particles small, white-ish, short life
}
```
Update particles: vy += grav*dt; x+=vx*dt; life-=dt; alpha = life/maxLife; draw rotated rect.

Cap particles array length ~400 (shift oldest).

Floats: y -= 32*dt; life-=dt; alpha.

**AI:**
```js
function aiControl(dt){
  // choose descending ball closest to paddle
  let best=null, bestY=-1;
  for(const b of balls){ if(!b.stuck && b.vy>0 && b.y>bestY){ best=b; bestY=b.y; } }
  let target;
  if(best){
    const t=(paddle.y - best.y - best.r)/best.vy;
    target = predictX(best.x, best.vx, Math.max(0,t))*? ...
    target = foldPredict(best.x - best.r? ...)
  } else if(balls.length){ target = balls[0].x; }
  else target = W/2;
  // prefer capsules when idle
  if(!best && capsules.length) target = capsules[0].x;
  target += Math.sin(gameTime*1.3)*9;
  move paddle toward target at 470 px/s:
  const d = target - paddle.cx;
  const mv = clamp(d, -470*dt, 470*dt);
  paddle.target = paddle.cx + mv; // then cx snaps
}
```
predictX with fold as designed; use ball radius margin? paddle center should aim so ball hits within paddle; center aim is fine.

foldPredict(x, vx, dist):
```js
function foldPredict(x, vx, dist){
  const r=7, span=W-2*r;
  let p = x - r;
  let np = p + vx*dist;
  const period = 2*span;
  let m = ((np % period)+period)%period;
  if(m>span) m = period-m;
  return m + r;
}
```
vx*dist can be huge if t large (ball high up) — fine numerically.

In serve state demo: paddle drifts to center; auto-launch.

**Draw:**

```js
function draw(){
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.clearRect? drawImage(bgCanvas) covers all — no clear needed.
  // shake
  let sx=0, sy=0;
  if(shakeT>0){ const m=shakeMag*shakeT/shakeDur...; }
```
Simplify shake: shakeT time remaining, mag; k = shakeT/shake0... store shake = {t:0, mag:0}; shake(m,t){ if bigger...}. offset = rand in ±mag*(t/t0). I'll just do offset = (Math.random()*2-1)*mag*shakeT with shakeT decaying — magnitude scales with remaining. Set shakeT=0.2, mag=6 → initial offset ±1.2? too small. Use offset = rand*mag*shakeT*... Let me define shake(power): shakeAmp = max(shakeAmp, power); each frame shakeAmp *= Math.pow(0.001, dt) ~ decay; offset = (rand-.5)*2*shakeAmp. shake(4) on break, shake(2) crack, shake(7) life lost. Decay: shakeAmp *= Math.exp(-dt*9).

Draw:
```
ctx.save(); ctx.translate(sx,sy);
drawBricks(); drawCapsules(); drawParticles(); drawPaddle(); drawBalls(); drawFloats(); serve hint;
ctx.restore();
// vignette
ctx.drawImage(vignette,0,0,W,H);
```
vignette offscreen canvas at DPR? Just create offscreen at W×H (CSS pixels) and drawImage scaled — since ctx transform is DPR-scaled, drawImage(vig,0,0) with vig at W×H internal res will render at logical W×H then scaled by DPR → slightly soft vignette (fine, it's a gradient).

Bg offscreen: same approach; but dot grid would blur slightly — acceptable at alpha .03. Alternatively draw dots directly each frame with fillRect loops — 25×18=450 dots, trivial. I'll pre-render bg at DPR resolution for crispness: bgCanvas width W*DPR, draw with scale(DPR). Then ctx.drawImage(bg, 0,0, W, H). Good.

drawBricks:
```js
for(const br of bricks){
  if(br.dead) continue;
  const tough = br.hpMax>1;
  const col = tough ? shade(br.color, br.hp<br.hpMax?0.72:0.88) : br.color;
  roundRect fill col;
  // top highlight
  ctx.fillStyle='rgba(255,255,255,0.22)'; fillRect(br.x+2,br.y+1.5,br.w-4,2);
  // bottom shade
  ctx.fillStyle='rgba(0,0,0,0.25)'; fillRect(br.x+2, br.y+br.h-3.5, br.w-4, 2);
  if(tough){
    ctx.strokeStyle = shade(br.color,0.5); lineWidth 2; roundRect stroke inset 1;
    rivets: two dots at (x+7,y+h/2),(x+w-7,y+h/2) r1.8 fill shade 0.45;
  }
  if(br.hp<br.hpMax){ // cracked
    drawCrack(br);
  }
  if(br.power) drawPowerIcon(br);
}
```
drawCrack: seeded by br.seed: polyline from top edge random x to bottom, with zigzag:
```js
function drawCrack(br){
  const rng = seeded((br.seed*1e4)|0);
  ctx.strokeStyle='rgba(20,10,8,0.55)'; ctx.lineWidth=1.5;
  ctx.beginPath();
  let x = br.x + br.w*(0.3+rng()*0.4);
  ctx.moveTo(x, br.y+1);
  let y = br.y;
  while(y < br.y+br.h-2){
    y += 4+rng()*4; x += (rng()-0.5)*10; x=clamp(x,br.x+4,br.x+br.w-4);
    ctx.lineTo(clamp(x,...), Math.min(y, br.y+br.h-1));
  }
  // branch
  ...
  ctx.stroke();
}
```
Add a short branch: from midpoint, another 3-segment line going sideways. Good enough.

roundRect helper: use ctx.roundRect if available (Chrome yes) with fallback path function. I'll write my own `rr(x,y,w,h,r)` path helper to be safe.

drawPowerIcon(br): 
```js
ctx.save(); ctx.translate(br.x+br.w/2, br.y+br.h/2);
ctx.fillStyle='rgba(15,12,10,0.75)'; ctx.strokeStyle same;
if(br.power==='W'){
  ctx.lineWidth=2; ctx.lineCap='round';
  ctx.beginPath(); ctx.moveTo(-7,0); ctx.lineTo(7,0);
  ctx.moveTo(-7,0); ctx.lineTo(-3,-4); ctx.moveTo(-7,0); ctx.lineTo(-3,4);
  ctx.moveTo(7,0); ctx.lineTo(3,-4); ctx.moveTo(7,0); ctx.lineTo(3,4);
  ctx.stroke();
} else {
  dots: (-4.5,2.5),(4.5,2.5),(0,-3.5) r 2.6 fill.
}
ctx.restore();
```
But wait — tough bricks have rivets at x+7 and x+w-7 middle; icon also centered — rivets vs icon overlap? Rivets at y center, icon at center → collision. Move rivets to corners? Put rivets at top corners? I'll place rivets near left/right but at y=h/2 only when no power; simpler: skip rivets, tough = outline + darker fill + corner notches. Let me make tough visually: double border (outer stroke + inner thin line) and slightly darker. Distinct enough, especially with crack after hit. Also tough bricks get small "bolt" squares at 4 corners inset 3? Corners don't clash with centered icon. Do: 4 corner dots r1.5 at (±(w/2-5), ±(h/2-5)). With icon in middle, no clash. But icon bricks that are tough: icon + corner bolts, fine.

drawCapsule:
```js
ctx.save(); translate(c.x, c.y); rotate(Math.sin(c.phase)*0.14);
rr(-20,-10,40,20,10); fill type color; stroke darker;
glyph in dark:
 W: double arrow as above scaled; M: three dots;
ctx.restore();
// slight pulse ring? skip
```
Add subtle glow? no. Maybe a faint vertical "drop line"? skip.

drawPaddle:
```js
const px=paddle.cx-paddle.w/2;
rr(px,paddle.y,paddle.w,paddle.h,7); fill '#ece5d4';
// tips
const wide = gameTime < wideUntil;
const tipCol = wide? '#3dd6c3' : '#ff6b57';
ctx.save(); clip rounded rect; fillRect(px,py,12,h) tip; fillRect right; restore;
```
Clip approach: ctx.save(); rr(...); ctx.clip(); fillStyle tip; fillRect(px,y,14,h); fillRect(px+w-14,y,14,h); also timer bar: if wide: track: fillStyle rgba(0,0,0,.25) fillRect(px+18, y+5, w-36, 5); fill teal fillRect(px+18,y+5,(w-36)*frac,5); restore. Then outline: stroke rgba(0,0,0,0.35) lw1.5.

drawBalls:
```js
for(const b of balls){
  // trail
  for(let i=0;i<b.trail.length;i++){
    const p=b.trail[i]; const a=(i+1)/b.trail.length*0.16;
    ctx.globalAlpha=a; ctx.fillStyle='#f6f1e4'; circle(p.x,p.y, b.r*(0.4+0.5*(i+1)/b.trail.length));
  }
  ctx.globalAlpha=1;
  circle fill '#f6f1e4'; 
  // highlight
  ctx.fillStyle='rgba(255,255,255,0.8)'; circle(b.x-2,b.y-2,2.2);
  // outline subtle
}
```
Push trail: every frame in update (not substep): b.trail.push({x,y}); if >10 shift.

Stuck ball pulse ring: if stuck: draw expanding ring: r = 10 + (gameTime*2 %1)*10 alpha fade. Draw ring stroke coral alpha.

drawFloats: ctx.font='bold 13px ...mono'; textAlign center; alpha life-based; fillStyle color with slight dark backing? Just text with shadow? Use fillText with globalAlpha; add thin dark stroke for readability: strokeStyle rgba(0,0,0,.5) lw3 strokeText then fillText. Good.

Serve hint: if state==='serve' && !demo: text at H-96: 'CLICK OR PRESS SPACE TO LAUNCH' dim #6b7280 12px letterspacing — canvas letterspacing manual? Just normal, mono fine. Also blink: alpha = 0.55+0.45*sin(gameTime*4).

Also in demo, canvas shows everything behind overlay.

**HUD/DOM code:**

```js
const scoreEl, livesEl, overlay...
function updateLivesUI(){
  livesEl.innerHTML='';
  for(let i=0;i<3;i++){
    const s=document.createElement('span');
    s.className='life'+(i<lives?'':' lost');
    livesEl.appendChild(s);
  }
}
```

Score in loop: dispScore approach with textContent when changes.

**Overlays:**

```js
function showOverlay(mode){
  overlay.className='overlay show';
  if(mode==='start'){
    overlay.innerHTML = `
      <div class="panel">
        <div class="chip">ATTRACT MODE · AI PLAYING</div>
        <h1>RICOCHET</h1>
        <p class="tag">A brick-breaker with weight, cracks & falling luck</p>
        <div class="legend">
          <div class="leg"><span class="cap cap-w"></span>WIDE PADDLE · 10s</div>
          <div class="leg"><span class="cap cap-m"></span>MULTIBALL</div>
          <div class="leg"><span class="brickchip b2"></span>2-HIT BRICK</div>
        </div>
        <div class="prompt blink">CLICK OR PRESS ANY KEY</div>
      </div>`;
  } else if over/won with stats + restart prompt.
}
```
Legend chips: CSS-drawn capsule shapes: .cap{width:26px;height:14px;border-radius:7px;background:#3dd6c3} .cap-m background coral. .brickchip small rect with crack? simple: 22×10 rect rust with border. These mini legend items use pure CSS — nice authored touch.

CSS blink: @keyframes blink {50%{opacity:.25}}.

Overlay hidden: .overlay{display:none} .overlay.show{display:flex}.

**Events:**

```js
window.addEventListener('keydown', e=>{
  if(['ArrowLeft','ArrowRight',' ','Spacebar'].includes(e.key)) e.preventDefault();
  AudioFX.init();
  if(state==='demo'){ startGame(); return; }
  if(state==='over'||state==='won'){ if(e.key===' '||e.key==='Enter'||e.key.length===1?) restart; return; } // any key? spec: restart option — click or space/enter.
  keys.add(e.key);
  if(e.key===' '){
    if(state==='serve') launch();
    else if(state==='over'||state==='won') restart();
  }
  if(e.key==='p'||e.key==='P') togglePause();
});
keyup: keys.delete.
```
Careful: on start, the same keydown shouldn't launch — fine, startGame sets serve.

But also "press any key to start" — arrows also start. In handler: if state==='demo' → startGame() regardless of key (except maybe ignore modifier keys? fine any).

pointerdown on frame:
```js
frameEl.addEventListener('pointerdown', e=>{
  AudioFX.init();
  if(state==='demo') startGame();
  else if(state==='over'||state==='won') restart();
  else if(state==='serve' && !demo) launch();
});
```
Also pointerdown shouldn't restart when paused... paused state: click resumes? if paused → unpause. Add.

pointermove:
```js
window.addEventListener('pointermove', e=>{
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX-rect.left) * (W/rect.width);
  if(!demo){ inputMode='mouse'; paddle.target = clamp(x, ...); }
});
```
In demo, mouse ignored (AI). touch: pointermove works for touch with touch-action:none on canvas; but pointermove during touch drag also may scroll page — touch-action none prevents. Also add pointerdown moves paddle? Fine as is.

Hmm: clicking to start also fires pointerdown → then user moves mouse; paddle follows. Good.

**startGame:** 
```js
function startGame(){ demo=false; resetGame(false); hideOverlay(); state='serve'; }
```
resetGame(false) sets demo flag etc. — pass flag. Also startTime=gameTime for elapsed stat: track playSeconds += dt while state playing/serve & !demo.

**restart:** resetGame(false); hideOverlay().

**gameOver:**
```js
function doGameOver(){ state='over'; AudioFX.over(); showOverlay('over'); }
function doWin(){ state='won'; AudioFX.win(); confetti(); showOverlay('won'); }
```
Trigger via timers endTimer set when detected; when endTimer elapses → do it. During pending, balls keep moving (win: bricks all dead, balls bounce till timer). On game over condition (balls empty & lives 0): set endTimer=0.8; during that, no balls so nothing happens; particles play. OK.

Wait: on win, balls still bouncing while timer counts — they'd hit nothing (no bricks) just walls/paddle; fine, looks alive.

**Pause:** toggles boolean; show/hide small overlay 'PAUSED'. Only if !demo && (state==='playing'||state==='serve'). Draw still runs (frozen scene). Actually skip update when paused; draw continues.

**Life lost flow (real player):**
```js
function loseLife(){
  AudioFX.lose(); shake(7);
  capsules.length=0; wideUntil=0(gameTime-based → set to -1);
  lives--; updateLivesUI();
  if(lives<=0){ endTimer=0.9; /* then over */ }
  else serve();
}
```
During endTimer pending with no balls — after 0.9s doGameOver. Guard endTimer only counts when balls empty.

Also float 'BALL LOST' text? Add floats.push at paddle 'BALL LOST' in dim red. Nice.

**Demo life loss:** just serve() again immediately (small delay? serve has 0.7s auto-launch anyway).

**gameTime & dt:** gameTime += dt in update (unpaused). Serve timer uses it or simple countdown var.

**Wide bar frac:** remaining = (wideUntil - gameTime)/10 clamped.

**Elapsed time for end screens:** playTime += dt when !demo && !paused && state in (serve, playing). Format mm:ss.

**Victory confetti:** spawn 80 shards from top area with random colors from palette falling — particles with grav 500, life 1.5-2.2. They'll update even in 'won' state (update particles regardless of state, except paused). I'll structure update: always update particles/floats/shake; gameplay only in serve/playing.

**Resize:** CSS handles. Canvas backing fixed. On very narrow screens, canvas scales down; fonts in HUD scale? HUD uses rem-ish sizes; fine.

Also handle `visibilitychange`? Not necessary; dt clamped to 33ms prevents jumps.

**Check for the 3s screenshot:** demo starts immediately with serve auto-launch at 0.7s; by 3s, AI has hit some bricks, particles possibly visible. Overlay shows title + legend + prompt. 

**Potential problem:** The overlay dims the field — I want the game visible: rgba(8,10,13,0.55) + blur(2px). Panel itself more opaque. Good.

Now, one more polish: paddle "hit flash" — when ball hits, paddle briefly brightens? skip.

Wall hit spark: tiny 2-3 particles at contact. Add for side/top walls. Cheap juice. OK add.

Let me also add subtle "impact ripple" on wall? skip.

**Write final HTML now.** I'll be meticulous with syntax.

Also remember: `ctx.setTransform(DPR,0,0,DPR,0,0)` at start of draw; all coordinates logical.

CSS details:

```css
body{ background:#0b0d10; }
.stage{ width:min(940px, calc(100% - 24px)); margin:18px auto 0; }
.hud{ display:flex; align-items:flex-end; justify-content:space-between; padding:0 2px 10px; }
.value{ font-size:22px; font-weight:700; letter-spacing:1px; color:var(--cream); font-variant-numeric:tabular-nums;}
.label{ font-size:10px; letter-spacing:2px; color:var(--dim); display:block; margin-bottom:2px;}
.wordmark{ font-size:14px; letter-spacing:6px; color:var(--dim); padding-bottom:6px;}
.wordmark b{color:var(--coral)} maybe accent one letter.
.cursor{ animation: blink 1.1s steps(1) infinite; color:var(--teal);}
.lives{ display:flex; gap:7px; padding-bottom:4px;}
.life{ width:12px;height:12px;border-radius:50%;background:var(--cream); box-shadow: inset 0 -3px 0 rgba(0,0,0,.25);}
.life.lost{ background:transparent; border:1.5px solid #3a4150; box-shadow:none;}
.frame{ position:relative; border:1px solid var(--line); border-radius:10px; overflow:hidden; background:#0e1014; box-shadow: 0 20px 60px rgba(0,0,0,.5);}
canvas{ display:block; width:100%; height:auto; touch-action:none; cursor:none? } 
```
cursor:none over canvas during play is a nice touch (paddle IS the cursor) — but on overlay need cursor visible. cursor:none on canvas only; overlay covers when menus. When playing, hidden cursor good. I'll set canvas{cursor:none} and overlay{cursor:default}.

Hmm but with cursor:none, if the user's pointer is over canvas they see no cursor — paddle follows anyway. Good.

footer hint: centered dim small text; hide on very small height? fine.

overlay:
```css
.overlay{ position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(8,10,13,.55); backdrop-filter: blur(3px); z-index:2; }
.overlay.show{display:flex;}
.panel{ text-align:center; background:rgba(13,16,21,.92); border:1px solid var(--line); border-radius:12px; padding:34px 44px; max-width:86%; }
.panel h1{ font-size:44px; letter-spacing:10px; margin:10px 0 6px; color:var(--cream); font-weight:700; text-indent:10px;}
h1 .accent { color: var(--coral);} maybe "RICO<span>C</span>HET"? Better: a small square accent before title? Title with coral first letter: R in coral.
.tag{ color:var(--dim); font-size:12px; letter-spacing:1px; margin-bottom:22px;}
.chip{ display:inline-block; font-size:10px; letter-spacing:2px; color:var(--teal); border:1px solid rgba(61,214,195,.35); padding:4px 10px; border-radius:20px;}
.legend{ display:flex; gap:18px; justify-content:center; margin:0 0 24px; flex-wrap:wrap;}
.leg{ font-size:10px; letter-spacing:1px; color:var(--dim); display:flex; align-items:center; gap:8px;}
.cap{ width:24px;height:13px;border-radius:7px; display:inline-block; position:relative;}
.cap-w{background:var(--teal)} .cap-m{background:var(--coral)}
.cap::after{ content:''; position:absolute; inset:3px 8px; ... } skip glyph.
.brickchip{ width:24px;height:11px;border-radius:2px; background:#a8433b; border:2px solid #6e2a25; display:inline-block;}
.prompt{ font-size:14px; letter-spacing:3px; color:var(--cream); animation:blink 1.2s steps(2) infinite;}
.stats{ display:flex; gap:26px; justify-content:center; margin:6px 0 22px;}
.stat .label small... value big.
```

End screens: h1 "GAME OVER" (coral) or "LEVEL CLEARED" (teal), stats SCORE / TIME / BRICKS, prompt "CLICK OR PRESS SPACE TO PLAY AGAIN".

Pause overlay: minimal panel "PAUSED" + "P TO RESUME".

Media query ≤600px: h1 smaller, panel padding smaller, hud value 16px, legend gap smaller.

**Now assemble everything. Also verify no stray errors:** 

- `AudioFX.break_` naming.
- seeded function used in drawCrack each frame — creates closure per frame per cracked brick; fine perf (few).
- shade() parse ok with 6-digit hex.

Let me write the drawCrack more concretely with a branch:

```js
function drawCrack(br){
  const rng = seeded(Math.floor(br.seed*100000)+7);
  ctx.save();
  ctx.beginPath(); ctx.rect(br.x+1,br.y+1,br.w-2,br.h-2); ctx.clip();
  ctx.strokeStyle='rgba(15,8,6,0.6)'; ctx.lineWidth=1.6; ctx.lineJoin='round';
  const startX = br.x + br.w*(0.25+rng()*0.5);
  // main vertical zigzag
  ctx.beginPath();
  ctx.moveTo(startX, br.y);
  let x=startX, y=br.y;
  const midY = br.y + br.h*(0.35+rng()*0.3);
  while(y<br.h+br.y-1){
    y = Math.min(y+3+rng()*4, br.y+br.h);
    x = clamp(x+(rng()-0.5)*9, br.x+2, br.x+br.w-2);
    ctx.lineTo(x,y);
  }
  ctx.stroke();
  // branch from midpoint
  ctx.beginPath();
  let bx=startX, by=midY, dir = rng()>0.5?1:-1;
  ctx.moveTo(bx,by);
  for(let i=0;i<3;i++){ bx += dir*(4+rng()*7); by += (rng()-0.2)*6; ctx.lineTo(clamp(bx,br.x+2,br.x+br.w-2), clamp(by,br.y+2,br.y+br.h-2)); }
  ctx.stroke();
  ctx.restore();
}
```
Note: clip keeps cracks inside brick. Since crack deterministic per brick via seed, stable across frames. 

Also darken cracked fill: in drawBricks, if hp<hpMax fill shade 0.7 (tough) — tough cracked uses 0.62.

Colors: shade of amber at 0.7 might look muddy but ok.

Also normal bricks when hit... normal have hp 1, they break on first hit — no crack needed. Cracked state only tough. Good, matches spec.

**hpMax:** store br.hpMax = HP_ROWS[r].

**Check win condition also when last tough brick's hp hits 0 only.** `bricks.every(k=>k.dead)` fine.

**One more mechanic detail — "ball must never pass through paddle":** if ball is very fast & steep, substeps handle. Also after paddle bounce ball placed above paddle; if ball somehow inside paddle side... fine.

**Ball speed constant enforcement:** speed set at paddle hit and launch; brick reflections preserve magnitude; walls too. Multiball spawn uses current speedBase+broken*1.5 — matches. Good.

**Serve position when paddle near wall:** ball center = paddle.cx, clamped within [r, W-r] naturally since paddle clamped.

Now — demo paddle also clamped.

**HUD label wording:** left: "SCORE"; right: "LIVES". Center wordmark: `RICOCHET` with coral "R"? I'll do `RICOCHET_` blinking cursor teal.

Also show tiny "BEST" ? no persistence allowed (localStorage blocked) — skip.

**Time to write the final file.** Let me write it in full, carefully.

Also I need to double check: keydown 'any key to start' — but then that same key gets added to keys set? In demo branch I return before keys.add, so no stuck movement. Good. But keyup later deletes nothing — fine.

Space preventDefault always to avoid page scroll.

Pause key 'p': also when paused, pressing p resumes; pressing any other key? Keep only p toggles; clicking resumes.

Also while paused, dt accumulates? last=now each frame; update skipped → no drift. Good.

**Particles cap:** if particles.length>500 splice.

**Floats font:** '700 13px ui-monospace, Menlo, Consolas, monospace' — canvas font shorthand with fallback list works.

**Vignette build:**
```js
const vig = document.createElement('canvas'); vig.width=W; vig.height=H;
const vctx=vig.getContext('2d');
const g=vctx.createRadialGradient(W/2,H*0.42, H*0.25, W/2,H*0.55, H*0.95);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.42)');
vctx.fillStyle=g; vctx.fillRect(0,0,W,H);
```

**bg build:**
```js
const bg=document.createElement('canvas'); bg.width=W*DPR; bg.height=H*DPR;
const bctx=bg.getContext('2d'); bctx.scale(DPR,DPR);
bctx.fillStyle='#0e1014'; fillRect;
// dot grid
bctx.fillStyle='rgba(255,255,255,0.028)';
for(y=24;y<H;y+=30) for(x=20;x<W;x+=30) fillRect(x,y,1.4,1.4)? circles nicer: fillRect fine at 1.5px.
// faint top zone line under bricks? skip
```
Draw in main: ctx.drawImage(bg,0,0,W,H).

Dots at every 30 px aligned — subtle texture. OK.

**Bricks area backdrop?** skip.

Alright — also the frame border-radius overflow hidden clips canvas corners nicely.

**Serve hint positioning** avoid overlapping paddle: y=H-120.

**Multiball trail arrays:** each ball own trail.

**When ball lost:** small red flash? floats.push({text:'BALL LOST'}) when last ball (real game). For individual ball of many, small puff at bottom: particles gray at (b.x, H-6). Add puff on any ball death: spawnSparksAt(b.x, H-10, '#5a6272', 6).

Generic spark function:
```js
function sparks(x,y,color,n,spd=140){
  for(i<n) particles.push({x,y,vx:rand(-spd,spd),vy:rand(-spd,10),w:rand(1.5,3),h:rand(1.5,3),rot:rand(0,6.3),vr:rand(-6,6),life:rand(0.25,0.5),max:..., color, grav:500});
}
```
spawnShards uses bigger sizes & colors.

Particle draw:
```js
ctx.save(); translate; rotate; globalAlpha = clamp(p.life/p.max,0,1); fillRect(-w/2,-h/2,w,h); restore();
```
save/restore per particle for ~100 particles OK.

**Score float color:** use brick color but ensure readable — brighten: use '#f6f1e4' for score text? Brick color is nice. Use cream for score, effect colors for power texts. I'll use cream for '+pts'.

**HUD score lerp:** update each frame; only touch DOM when rounded value changes.

**Lives UI initial:** called in resetGame.

Now overlay for 'over'/'won' include stat: SCORE value, TIME, and BRICKS x/84 cleared. Compose HTML with values.

Also demo → if AI somehow loses all balls rapidly... infinite lives so never ends. Good.

**Check: pointermove before start (demo)** — ignored (demo true). After start, works.

**iframe focus for keyboard:** We display "click or press a key" — clicking gives focus; keydown on window works after click. Fine.

**Edge: e.key for arrows** 'ArrowLeft'/'ArrowRight'. Also support 'a'/'d'? Add for comfort: keys 'a','d' map too. Minor; include.

Now writing the complete file. I'll aim for clean, commented, organized code.

One more check on paddle angle spec: "center = straight up, edges = sharp angle" — 60° max = sharp. ✓.

Let me also add the ball speed slight increase on paddle hits? I use brick-count-based ramp; fine.

Wide paddle 10s ✓. Multiball 3 balls ✓ (1+2).

Draw the wide timer bar also maybe as HUD chip? Paddle bar only — plus I'll float 'WIDE PADDLE' text. When it expires, paddle shrinks smoothly — visible.

For extra clarity, when wide expires near, bar blinks? frac<0.25 → bar flashes (alpha oscillation). Cheap, do it.

Now write out. Also ensure `hitBrick` uses br.hpMax.

Potential subtle bug: In brickCollide resolving "side" case: `b.vx = Math.abs(b.vx)*s` — if ball moving away already (vx sign == s) it'd flip wrongly? If ball hit left face from inside... With substeps, ball approaches from outside; if cx<b.x (ball right of left face... hmm cx = clamp; if cx !== b.x means ball.x outside [br.x, br.x+w]. If ball.x < br.x → dx<0 → s=-1 → vx negative (moving left). Ball approaching from left moving right would have vx>0 → flipped to negative. Correct. If ball moving left hits brick's right face: ball.x > br.x+w, dx>0, s=+1, vx=abs → moving right ✓. If ball already moving away (moving left while right of brick) — can't be colliding then (it's leaving; but overlap could exist from corner entry). Rare; acceptable.

But one real issue: hitting left face sets vx negative and pushes ball left — but the ball may ALSO overlap vertically with a brick above/below in same column → handled next substep.

Also after resolving side hit, the ball might still overlap the same brick via corner? Push out to r+0.5 distance ensures separation. ✓

**demo auto launch:** in serve state update:
```js
if(state==='serve'){
  serveTimer -= dt;
  if(demo && serveTimer<=0) launch();
  else if(!demo && serveTimer<=0) launch(); // auto for player too
}
```
Both auto-launch; player can launch earlier. serveTimer: demo 0.7, player 3.0.

launch(): find stuck ball(s) & launch all stuck (only one). Also state='playing'.

**AI serve positioning:** during serve, AI keeps paddle center-ish — ball launches near center fine.

**gameTime in demo:** used for wobble; fine.

Now DOM ids: scoreVal, livesEl, overlay, frame.

Write the code:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>RICOCHET · Brick Breaker</title>
<style> ... </style>
</head>
<body>
<div class="stage">
  <header class="hud">
    <div class="block"><span class="label">SCORE</span><span class="value" id="score">000000</span></div>
    <div class="wordmark">RICOCHET<span class="cursor">▌</span></div>
    <div class="block right"><span class="label">LIVES</span><span class="lives" id="lives"></span></div>
  </header>
  <div class="frame" id="frame">
    <canvas id="game" width="900" height="640"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>
  <footer class="hint">MOUSE / ← → MOVE · SPACE LAUNCH · P PAUSE</footer>
</div>
<script> ... </script>
</body>
</html>
```

Canvas width/height attributes set in JS with DPR; keep attributes as fallback but JS overrides.

The cursor block char '▌' in monospace — ok.

Let me now write JS completely, mentally executing for errors.

```js
(()=>{ 'use strict';
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const W=900,H=640;
const DPR=Math.min(2,window.devicePixelRatio||1);
canvas.width=W*DPR; canvas.height=H*DPR;
canvas.style.aspectRatio='900 / 640'; // CSS width 100%, height auto works anyway since intrinsic ratio from attributes changes... 
```
Careful: I change canvas.width to 900*DPR — CSS `height:auto` uses intrinsic aspect from attributes → 900*DPR : 640*DPR same ratio. OK no need aspectRatio, but harmless to keep width:100%;height:auto.

Constants, helpers, palette...

I'll write the final code now, keeping it organized with section comments. Estimate ~600 lines. Let me write carefully and double-check syntax as I go.

Key detail — `keys` Set and inputMode variable default 'mouse'.

The paddle clamp: cx in [w/2+6, W-w/2-6]. Note w changes → clamp again after width anim.

Also when width animates while ball stuck: ball follows cx. fine.

Floats and fonts: define FONT='"SF Mono",ui-monospace,Menlo,Consolas,monospace' — canvas font: `700 13px ${FONT}` — with quotes inside, build string: `'700 13px ' + FONT` where FONT = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'. OK.

Big canvas texts (none besides hint) fine.

**Overlay HTML injection with template literals — fine.**

Let me also handle the case: user clicks during 'won'/'over' endTimer pending — restart allowed anyway; endTimer pending could then fire doGameOver after restart? Clear endTimer/winTimer in resetGame. ✓

Also when win pending (winTimer>0) and user... fine.

During endTimer countdown for game over, balls array empty; update loop: state still 'playing'? I set a flag `ending=true` with endTimer; state remains 'playing' until fired. Implementation: endTimer>0 → decrement; when ≤0 → doEnd. During pending, if state playing and no balls, paddle still moves — fine.

Simplify: 
```js
if(endTimer>0){ endTimer-=dt; if(endTimer<=0){ if(pendingEnd==='won') doWin(); else doGameOver(); } }
```
pendingEnd set when triggered.

Win detection sets pendingEnd='won', endTimer=0.9, AudioFX.win at doWin.

Game-over detection: in update after ball filtering: if(state==='playing' && balls.length===0 && endTimer<0){ if(demo){ serve(); } else { pendingEnd='over'; endTimer=0.9; loseLifeEffects...} }
But lives decrement should happen immediately for UI feedback: in the else branch: lives--; updateLivesUI(); if lives<=0 pendingEnd='over', endTimer=1.0 else serve(). And demo: serve() directly.

Wait demo state is 'playing' too (shared). ✓ but demo condition uses demo flag.

Serve state in demo — after serve auto-launch, state 'playing'. ✓

Initial: state='demo'? I said states serve/playing shared with demo flag. Initial: demo=true; resetGame(true) → serve() → state='serve', serveTimer=0.7 → auto-launch → playing. 

startGame(): demo=false; resetGame(false); hideOverlay(); → resetGame calls serve → serveTimer=3.0. But clicking to start shouldn't ALSO immediately launch — it won't; launch happens on next click/space or after 3s.

resetGame(isDemo){ demo=isDemo; ... serve(); }

**Pause:** `paused` bool; togglePause: if(demo) return; if(state==='playing'||state==='serve'){ paused=!paused; overlay pause show/hide }. If paused and user clicks → unpause (pointerdown handler: if(paused){paused=false; hideOverlay(); return;}).

Draw while paused: still draws scene (frozen). ✓

**Now HUD score format:** String(Math.round(dispScore)).padStart(6,'0').

**Time format:** mm:ss.

Compose end overlay:
```js
function endOverlay(kind){
  const title = kind==='won' ? 'LEVEL CLEARED' : 'GAME OVER';
  const accent = kind==='won' ? 'var(--teal)' : 'var(--coral)';
  overlay.innerHTML = `<div class="panel">
    <div class="chip ${kind==='won'?'chip-t':'chip-r'}">${kind==='won'?'ALL BRICKS DESTROYED':'OUT OF LIVES'}</div>
    <h1 style="color:${accent}">${title}</h1>
    <div class="stats">
      <div class="stat"><span class="label">SCORE</span><span class="sval">${fmt(score)}</span></div>
      <div class="stat"><span class="label">TIME</span><span class="sval">${fmtTime(playTime)}</span></div>
      <div class="stat"><span class="label">BRICKS</span><span class="sval">${broken}/${ROWS*COLS}</span></div>
    </div>
    <div class="prompt blink">CLICK OR PRESS SPACE TO PLAY AGAIN</div>
  </div>`;
  overlay.classList.add('show');
}
```

Restart via space: in keydown handler when state over/won: space/enter → restart.

**Also `blur` backdrop-filter may not render in some sandbox? It's fine, graceful.**

Let me finalize colors:
- --bg page: #0b0d10
- canvas bg: #0e1014
- ROW_COLORS: ['#a8433b','#c25444','#e06a4a','#f78e56','#ffb454','#f0c26a','#e6d491']
- Paddle cream #ece5d4; ball #f7f2e6.
- HUD text colors as vars.

Check contrast: cream on dark ✓.

**Cracked tough fill:** shade(color, 0.66) plus keep highlight? draw highlight weaker. Fine details in code.

Alright, also "attract mode" demo should keep bricks regenerating? If AI clears the level (possible in long session!) → demo would hit win → win overlay stuck. Handle: in demo, on win: silently resetGame(true) (rebuild). ✓ Add to win trigger: if(demo){ pendingEnd none; resetGame(true); return; } — but give a moment: just reset immediately, ok. Actually immediate rebuild looks like a glitch; add endTimer with pendingEnd='demoReset' → doWin-like without overlay: resetGame(true). I'll implement pendingEnd values: 'over','won','demoreset'.

Similarly demo game-over impossible (infinite lives).

**One subtlety:** multiball balls added while some stuck? Can't happen as argued.

**Ball trail while stuck:** skip pushing when stuck.

Now, writing final code. I'll also add small screen media query.

Also ensure no horizontal scrollbar: body overflow-x hidden, stage width calc.

Font stack note: ui-monospace first? Order: ui-monospace, 'SF Mono', 'Cascadia Mono', 'JetBrains Mono', Menlo, Consolas, monospace.

Write it:

```js
// ====== setup ======
const canvas=..., ctx=...
const ROWS=7, COLS=12, GAP=7, SIDE=26, TOP=64, BRICK_H=24;
const BRICK_W=(W-SIDE*2-(COLS-1)*GAP)/COLS; // 64.25
const ROW_COLORS=['#a8433b','#c25444','#e06a4a','#f78e56','#ffb454','#f0c26a','#e6d491'];
const HP_ROWS=[2,2,1,1,2,1,1];
```

Full update function outline:

```js
function update(dt){
  gameTime+=dt;
  if(paused) return;
  // particles/floats/shake always
  updateParticles(dt); updateFloats(dt);
  shakeAmp*=Math.exp(-dt*8);
  
  if(endTimer>0){ endTimer-=dt; if(endTimer<=0) finishEnd(); }
  
  if(state==='serve'){
    serveTimer-=dt;
    for(const b of balls){ if(b.stuck){ b.x=paddle.cx; b.y=paddle.y-b.r-1; } }
    if(serveTimer<=0) launch();
  }
  
  if(state==='playing'||state==='serve'){
    if(!demo) playTime+=dt;
    moveInput(dt);
    if(demo) aiControl(dt);
    movePaddle(dt);
    updateBalls(dt);
    updateCapsules(dt);
  }
}
```
Hmm serve state should also allow paddle move ✓ (included). Demo aiControl also in serve to center.

moveInput: keyboard adjusts paddle.target & sets inputMode='keys'. movePaddle: cx→target smoothing & clamp.

Actually merge: 
```js
function movePaddle(dt){
  const L=keys.has('ArrowLeft')||keys.has('a')||keys.has('A');
  const R=keys.has('ArrowRight')||keys.has('d')||keys.has('D');
  if(L||R){ inputMode='keys'; paddle.target += ((R?1:0)-(L?1:0))*640*dt; }
  if(!demo){ /* clamp target */ }
  paddle.target=clamp(paddle.target, paddle.w/2+6, W-paddle.w/2-6);
  const k = inputMode==='mouse' ? 1-Math.exp(-dt*16) : 1;
  paddle.cx += (paddle.target-paddle.cx)*k;
  paddle.cx = clamp(paddle.cx, paddle.w/2+6, W-paddle.w/2-6);
  // width anim
  const targetW = (gameTime<paddle.wideUntil)? paddle.wideW : paddle.baseW;
  if(Math.abs(targetW-paddle.w)>0.1){ const nw = paddle.w+(targetW-paddle.w)*Math.min(1,dt*10); paddle.cx += 0; /* center stays since cx independent */ paddle.w=nw; }
}
```
Since paddle stores cx separately, width anim naturally keeps center. 

In demo, AI sets paddle.target then movePaddle snaps (keys mode? inputMode may be 'mouse' from earlier... in demo inputMode irrelevant: k with 'mouse' smoothing would lag AI. Set: in demo, k=1 or use smoothing 1-exp(-dt*14) for natural motion. AI sets target each frame; smoothing at rate 16 is nearly instant. Fine — use same formula; AI target set → cx chases quickly. But AI max speed logic: AI sets target = cx + clamp(d, ±470dt) then smoothing adds lag → effective slower. Fine.

Wait ordering: aiControl runs before movePaddle and sets paddle.target. ✓

**updateCapsules catch check uses paddle bounds.**

**updateBalls:** as designed; includes trail push per frame:
```js
function updateBalls(dt){
  for(const b of balls){
    if(b.stuck) continue;
    b.trail.push({x:b.x,y:b.y}); if(b.trail.length>11) b.trail.shift();
    const sp=Math.hypot(b.vx,b.vy);
    const steps=Math.max(1,Math.ceil(sp*dt/4));
    const sdt=dt/steps;
    let dead=false;
    for(let s=0;s<steps && !dead;s++){
      b.x+=b.vx*sdt; b.y+=b.vy*sdt;
      // walls
      ...
      paddleCollide(b);
      brickCollide(b);
      if(b.y>H+b.r+6){ dead=true; }
    }
    if(dead){ b.dead=true; sparks(b.x, H-8, '#5a6472', 7, 120); }
  }
  balls=balls.filter(b=>!b.dead);
  if(state==='playing' && balls.length===0 && endTimer<0){
    if(demo){ serve(); }
    else{
      lives--; updateLivesUI(); capsules.length=0; paddle.wideUntil=-1;
      AudioFX.lose(); shake(7);
      if(lives<=0){ pendingEnd='over'; endTimer=1.0; }
      else { floats.push({x:W/2,y:H-150,text:'BALL LOST',life:1.1,color:'#ff6b57'}); serve(); }
    }
  }
}
```
serveTimer when serve() called — set inside serve().

Wall sounds could be spammy — fine.

**Wall sparks:** on side wall hits add 2 sparks — but only when !demo? Sounds are silent in demo anyway (no ctx). Sparks visible in demo — nice. But spam: wall hits frequent. Small alpha, ok. I'll add sparks on wall hit only occasionally? Just add, 2 tiny particles, cheap.

Hmm — during demo with AI, lots of wall/paddle sparks — visually lively. OK.

**drawServePulse:** ring around stuck ball.

Also draw dotted aim? skip.

Now finalize the powerup glyph colors on capsules: dark glyph '#10130f'? On teal & coral both dark works: '#0d1114'.

**Check `shade` with rgb output reuse** — shade returns 'rgb(...)'. strokeStyle fine.

**Numbers formatting** fmt = n=>String(n).padStart(6,'0').

Now write ALL of it concretely. Also the pause overlay:
```js
function showPause(){ overlay.innerHTML='<div class="panel small"><h1 class="ph">PAUSED</h1><div class="prompt" style="animation:none;color:var(--dim)">P TO RESUME</div></div>'; overlay.classList.add('show'); }
```
Hide on resume.

The start overlay also needs pointer-events: overlay covers frame; but pointerdown handler is on frame — events on overlay bubble to frame ✓ (overlay is child of frame). And I won't set pointer-events:none. But pointermove for paddle attaches to window — receives moves regardless. During overlay shown (serve), moving mouse updates paddle target — fine, actually helpful (position before launch).

But wait: overlay covers canvas so hover cursor: set overlay cursor:none too during play... only pause overlay mid-game; set .overlay{cursor:none}? On menus we want a visible cursor to feel clickable — mixed. Keep default cursor on overlay, none on canvas.

**Ensure clicking overlay button? No buttons — whole overlay click restarts. Add cursor:pointer on overlay.**

**HUD block alignment right:** .right{text-align:right} with lives flex justify-end.

Now — potential subtle issue: `overlay` innerHTML with backticks containing `${}` — fine.

Also `AudioFX.init()` inside keydown for ANY key including when starting — creates ctx within user gesture ✓.

Also resume on visibilitychange? skip.

**Write CSS fully:**

```css
:root{
  --bg:#0b0d10; --line:#262c36; --ink:#e9e4d6; --dim:#8a93a2;
  --coral:#ff6b57; --amber:#ffb454; --teal:#3dd6c3; --cream:#f2ecdc;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:var(--bg)}
body{
  min-height:100vh; color:var(--ink);
  font-family:ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace;
  display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;
  -webkit-font-smoothing:antialiased;
}
.stage{width:min(940px,calc(100% - 28px));margin:20px auto 12px}
.hud{display:flex;justify-content:space-between;align-items:flex-end;padding:0 4px 12px}
.label{display:block;font-size:10px;letter-spacing:2.5px;color:var(--dim);margin-bottom:3px}
.value{font-size:24px;font-weight:700;letter-spacing:1px;color:var(--cream);font-variant-numeric:tabular-nums;line-height:1}
.wordmark{font-size:13px;letter-spacing:7px;color:var(--dim);padding-bottom:5px;user-select:none}
.wordmark em{font-style:normal;color:var(--coral)}
.cursor{color:var(--teal);animation:blink 1.1s steps(2,start) infinite}
.block.right{text-align:right}
.lives{display:inline-flex;gap:7px}
.life{width:12px;height:12px;border-radius:50%;background:var(--cream);box-shadow:inset 0 -3px rgba(0,0,0,.28)}
.life.lost{background:transparent;border:1.5px solid #39404d;box-shadow:none}
.frame{position:relative;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0e1014;box-shadow:0 24px 70px rgba(0,0,0,.55)}
canvas{display:block;width:100%;height:auto;touch-action:none;cursor:none}
.hint{margin:12px 0 20px;font-size:10px;letter-spacing:2px;color:#525b69;text-align:center}
.overlay{position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;background:rgba(9,11,14,.52);backdrop-filter:blur(3px);cursor:pointer}
.overlay.show{display:flex}
.panel{text-align:center;background:rgba(13,16,21,.93);border:1px solid var(--line);border-radius:12px;padding:36px 48px 30px;max-width:88%;box-shadow:0 18px 60px rgba(0,0,0,.5)}
.chip{display:inline-block;font-size:9px;letter-spacing:2.5px;padding:5px 12px;border-radius:20px;margin-bottom:14px}
.chip-r{color:var(--coral);border:1px solid rgba(255,107,87,.4)}
.chip-t{color:var(--teal);border:1px solid rgba(61,214,195,.4)}
.panel h1{font-size:42px;letter-spacing:9px;text-indent:9px;color:var(--cream);margin:2px 0 8px;font-weight:700;white-space:nowrap}
.tag{font-size:11px;letter-spacing:1.5px;color:var(--dim);margin-bottom:24px}
.legend{display:flex;gap:20px;justify-content:center;flex-wrap:wrap;margin-bottom:26px}
.leg{display:flex;align-items:center;gap:8px;font-size:9.5px;letter-spacing:1.5px;color:var(--dim)}
.cap{width:24px;height:13px;border-radius:7px}
.cap-w{background:var(--teal)}
.cap-m{background:var(--coral)}
.brickchip{width:24px;height:12px;border-radius:2px;background:#8d3a33;border:2px solid #5e241f}
.prompt{font-size:13px;letter-spacing:3px;color:var(--cream)}
.blink{animation:blink 1.25s steps(2,start) infinite}
.stats{display:flex;gap:30px;justify-content:center;margin:10px 0 26px}
.stat .sval{font-size:20px;font-weight:700;color:var(--cream);font-variant-numeric:tabular-nums}
@keyframes blink{50%{opacity:.25}}
@media(max-width:640px){
  .stage{width:calc(100% - 16px);margin-top:10px}
  .value{font-size:17px}
  .panel{padding:22px 18px}
  .panel h1{font-size:26px;letter-spacing:5px;text-indent:5px}
  .wordmark{letter-spacing:4px}
  .stats{gap:16px}
  .hint{font-size:9px}
}
```

For 'GAME OVER'/'LEVEL CLEARED' h1 with nowrap + letter-spacing at 375px: 26px × ~14 chars… "LEVEL CLEARED" 13 chars * (26*0.6+5) ≈ 270px fits. OK.

Start h1 "RICOCHET" 8 chars fits.

Now the full JS. Writing final, careful code:

```js
(()=>{ 'use strict';
/* ---------- dom ---------- */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const livesEl=document.getElementById('lives');

/* ---------- constants ---------- */
const W=900,H=640,DPR=Math.min(2,window.devicePixelRatio||1);
canvas.width=W*DPR; canvas.height=H*DPR;
const ROWS=7,COLS=12,GAP=7,SIDE=26,TOP=64,BRICK_H=24;
const BRICK_W=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const ROW_COLORS=['#a8433b','#c25444','#e06a4a','#f78e56','#ffb454','#f0c26a','#e6d491'];
const HP_ROWS=[2,2,1,1,2,1,1];
const FONT='ui-monospace,"SF Mono",Menlo,Consolas,monospace';

/* ---------- helpers ---------- */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rand=(a,b)=>a+Math.random()*(b-a);
function shade(hex,f){
  const n=parseInt(hex.slice(1),16);
  const r=Math.round(((n>>16)&255)*f),g=Math.round(((n>>8)&255)*f),b=Math.round((n&255)*f);
  return 'rgb('+r+','+g+','+b+')';
}
function seeded(seed){let s=seed>>>0;return()=>{s=(s*1664525+1013904223)>>>0;return s/4294967296;};}
function rr(c,x,y,w,h,r){
  r=Math.min(r,w/2,h/2);
  c.beginPath();
  c.moveTo(x+r,y);
  c.arcTo(x+w,y,x+w,y+h,r);
  c.arcTo(x+w,y+h,x,y+h,r);
  c.arcTo(x,y+h,x,y,r);
  c.arcTo(x,y,x+w,y,r);
  c.closePath();
}
```

Audio module as before (method names: paddle,wall,crack,pop,power,lose,win,launch,over).

State vars:

```js
let bricks=[],balls=[],capsules=[],particles=[],floats=[];
let score=0,dispScore=-1,lives=3,broken=0;
let state='serve',demo=true,paused=false;
let gameTime=0,playTime=0,serveTimer=0,shakeAmp=0;
let pendingEnd=null,endTimer=0,speedBase=380;
let inputMode='mouse';
const keys=new Set();
const paddle={cx:W/2,y:H-46,w:112,baseW:112,wideW:172,h:15,target:W/2,wideUntil:-1};
```

dispScore=-1 forces first paint update.

buildLevel, resetGame, serve, launch as planned.

```js
function buildLevel(){
  bricks=[];
  const rng=seeded(987654321);
  const order=[];for(let i=0;i<ROWS*COLS;i++)order.push(i);
  for(let i=order.length-1;i>0;i--){const j=(rng()*(i+1))|0;const t=order[i];order[i]=order[j];order[j]=t;}
  const powers={};const types=['W','M','W','M','W','M','W'];
  for(let i=0;i<types.length;i++)powers[order[i]]=types[i];
  for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
    bricks.push({x:SIDE+c*(BRICK_W+GAP),y:TOP+r*(BRICK_H+GAP),w:BRICK_W,h:BRICK_H,
      hp:HP_ROWS[r],hpMax:HP_ROWS[r],color:ROW_COLORS[r],power:powers[r*COLS+c]||null,seed:(rng()*1e6)|0,dead:false});
  }
}
```
Hmm powers assigned to random positions could all land in row 6 — acceptable randomness, but nicer to spread: instead pick per designated slots: choose one brick per row? 7 types, 7 rows: for each row pick a random column. Guarantees spread & includes both tough & normal carriers:
```js
const powers={};
const types=['W','M','M','W','M','W','M'];
for(let r=0;r<ROWS;r++){ const c=(rng()*COLS)|0; powers[r*COLS+c]=types[r]; }
```
7 power bricks, one per row. 

resetGame:
```js
function resetGame(isDemo){
  demo=isDemo; buildLevel();
  score=0;dispScore=-1;lives=3;broken=0;playTime=0;
  pendingEnd=null;endTimer=0;speedBase=380;shakeAmp=0;
  capsules.length=0;particles.length=0;floats.length=0;
  paddle.wideUntil=-1;paddle.w=paddle.baseW;paddle.cx=W/2;paddle.target=W/2;
  serve();updateLivesUI();
}
function serve(){
  balls=[{x:paddle.cx,y:paddle.y-8,vx:0,vy:0,r:7,stuck:true,trail:[]}];
  state='serve';serveTimer=demo?0.7:3;
}
function launch(){
  for(const b of balls)if(b.stuck){
    b.stuck=false;
    const a=rand(-0.3,0.3),sp=ballSpeed();
    b.vx=Math.sin(a)*sp;b.vy=-Math.cos(a)*sp;
  }
  state='playing';AudioFX.launch();
}
function ballSpeed(){return Math.min(560,speedBase+broken*1.6);}
```

Input handlers, AI, physics, powers, particles, floats, drawing, loop, boot.

boot:
```js
resetGame(true);
showStart();
requestAnimationFrame(loop);
```

showStart builds start overlay HTML.

Let me make sure `updateLivesUI` initial 3 dots.

Also demo paddle at start: cx W/2 ✓.

**Draw function complete:**

```js
function draw(){
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.drawImage(bg,0,0,W,H);
  const sx=(Math.random()*2-1)*shakeAmp, sy=(Math.random()*2-1)*shakeAmp;
  ctx.translate(sx,sy);
  drawBricks();
  drawCapsules();
  drawParticles();
  drawPaddle();
  drawBalls();
  drawFloats();
  drawHints();
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.drawImage(vig,0,0,W,H);
}
```

drawHints: serve hint text (player only) + maybe demo watermark? In demo, small corner text "DEMO" ? Overlay chip covers it. Skip.

**One more consideration:** score DOM update inside loop:
```js
const target=score;
if(dispScore!==target){
  dispScore += (target-dispScore)*Math.min(1,dt*10);
  if(Math.abs(dispScore-target)<1)dispScore=target;
  scoreEl.textContent=String(Math.round(dispScore)).padStart(6,'0');
}
```
With dispScore starting -1: -1→0 quickly. Use dispScore=0 initial and force one update by calling after reset with direct set. Simplify: in resetGame set scoreEl.textContent directly and dispScore=0. Then loop lerp from 0. But restart from high score: resetGame sets textContent='000000', dispScore=0. ✓

**Lives UI:**
```js
function updateLivesUI(){
  let h='';
  for(let i=0;i<3;i++)h+='<span class="life'+(i<lives?'':' lost')+'"></span>';
  livesEl.innerHTML=h;
}
```

**Keydown full:**
```js
window.addEventListener('keydown',e=>{
  if(e.key==='ArrowLeft'||e.key==='ArrowRight'||e.key===' ')e.preventDefault();
  AudioFX.init();
  if(state==='demoState')... // not used
  if(demo){ startGame(); return; }
  if(state==='over'||state==='won'){
    if(e.key===' '||e.key==='Enter')restart();
    return;
  }
  if(e.key==='p'||e.key==='P'){togglePause();return;}
  if(paused)return;
  keys.add(e.key);
  if(e.key===' '&&state==='serve')launch();
});
window.addEventListener('keyup',e=>keys.delete(e.key));
```
demo flag true only before first start (resetGame(true) at boot; after that demo always false). ✓ (demo reset keeps demo=true only pre-first-start.)

Pointer:
```js
frame.addEventListener('pointerdown',e=>{
  AudioFX.init();
  if(demo){startGame();return;}
  if(paused){togglePause();return;}
  if(state==='over'||state==='won'){restart();return;}
  if(state==='serve')launch();
});
window.addEventListener('pointermove',e=>{
  if(demo)return;
  const rect=canvas.getBoundingClientRect();
  if(rect.width<1)return;
  const x=(e.clientX-rect.left)*(W/rect.width);
  inputMode='mouse';
  paddle.target=x;
});
```
`frame` = getElementById('frame').

togglePause:
```js
function togglePause(){
  if(demo)return;
  if(state!=='playing'&&state!=='serve')return;
  paused=!paused;
  if(paused)showPause();else hideOverlay();
}
```

startGame/restart:
```js
function startGame(){demo=false;paused=false;resetGame(false);hideOverlay();}
function restart(){paused=false;resetGame(false);hideOverlay();}
```

finishEnd:
```js
function finishEnd(){
  const k=pendingEnd;pendingEnd=null;endTimer=0;
  if(k==='won'){state='won';AudioFX.win();confetti();showEnd('won');}
  else if(k==='over'){state='over';AudioFX.over();showEnd('over');}
  else if(k==='demoreset'){resetGame(true);}
}
```
confetti before overlay shows — overlay covers... confetti visible through translucent overlay ✓.

Win trigger in hitBrick: `if(bricks.every(k=>k.dead)){ if(demo){pendingEnd='demoreset';endTimer=0.6;} else {pendingEnd='won';endTimer=0.9;} }`
Also guard pendingEnd null before setting (if already set skip).

Game-over trigger in updateBalls handled.

But careful: in demo, when demoreset triggers, resetGame(true) → serve etc. ✓

**AI in demo after ball lost:** serve() called immediately → state serve → AI recenters → auto-launch 0.7s. ✓

**drawPaddle:**
```js
function drawPaddle(){
  const px=paddle.cx-paddle.w/2,py=paddle.y;
  const wide=gameTime<paddle.wideUntil;
  ctx.save();
  rr(ctx,px,py,paddle.w,paddle.h,7);
  ctx.fillStyle='#e9e2d0';ctx.fill();
  ctx.clip();
  const tip=wide?'#3dd6c3':'#ff6b57';
  ctx.fillStyle=tip;
  ctx.fillRect(px,py,13,paddle.h);
  ctx.fillRect(px+paddle.w-13,py,13,paddle.h);
  if(wide){
    const frac=clamp((paddle.wideUntil-gameTime)/10,0,1);
    ctx.fillStyle='rgba(0,0,0,0.22)';
    ctx.fillRect(px+18,py+5,paddle.w-36,5);
    const blink=frac<0.22?(Math.sin(gameTime*18)>0?1:0.35):1;
    ctx.globalAlpha=blink;
    ctx.fillStyle='#3dd6c3';
    ctx.fillRect(px+18,py+5,(paddle.w-36)*frac,5);
    ctx.globalAlpha=1;
  }
  ctx.restore();
  rr(ctx,px+0.75,py+0.75,paddle.w-1.5,paddle.h-1.5,6);
  ctx.strokeStyle='rgba(0,0,0,0.4)';ctx.lineWidth=1.5;ctx.stroke();
}
```

**drawBalls** with trail and stuck pulse:
```js
function drawBalls(){
  for(const b of balls){
    for(let i=0;i<b.trail.length;i++){
      const p=b.trail[i],k=(i+1)/b.trail.length;
      ctx.globalAlpha=k*0.14;
      ctx.fillStyle='#f7f2e6';
      ctx.beginPath();ctx.arc(p.x,p.y,b.r*(0.35+0.55*k),0,7);ctx.fill();
    }
    ctx.globalAlpha=1;
    if(b.stuck){
      const t=(gameTime*1.4)%1;
      ctx.globalAlpha=(1-t)*0.5;
      ctx.strokeStyle='#ff6b57';ctx.lineWidth=2;
      ctx.beginPath();ctx.arc(b.x,b.y,b.r+3+t*14,0,7);ctx.stroke();
      ctx.globalAlpha=1;
    }
    ctx.fillStyle='#f7f2e6';
    ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,7);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.85)';
    ctx.beginPath();ctx.arc(b.x-2.2,b.y-2.4,2.1,0,7);ctx.fill();
    ctx.fillStyle='rgba(0,0,0,0.18)';
    ctx.beginPath();ctx.arc(b.x+1.8,b.y+2.2,3.4,0,7);ctx.fill(); // bottom shading? draws over... order: draw shade first then highlight.
  }
}
```
Order fix: base fill, then shade arc (bottom-right inner), then highlight. But shade circle r3.4 at +1.8/+2.2 mostly inside r7 ✓. Hmm this shade over base makes dirty look; use subtle. Fine.

arc endAngle 7 > 2π ✓ (7 rad > 6.283). Use Math.PI*2 for clarity.

**drawCapsules:**
```js
function drawCapsules(){
  for(const c of capsules){
    ctx.save();
    ctx.translate(c.x,c.y);
    ctx.rotate(Math.sin(c.phase)*0.13);
    rr(ctx,-20,-10,40,20,10);
    ctx.fillStyle=c.type==='W'?'#3dd6c3':'#ff6b57';
    ctx.fill();
    ctx.strokeStyle='rgba(0,0,0,0.35)';ctx.lineWidth=1.5;ctx.stroke();
    ctx.fillStyle='rgba(10,14,12,0.8)';
    ctx.strokeStyle='rgba(10,14,12,0.8)';
    if(c.type==='W'){
      ctx.lineWidth=2;ctx.lineCap='round';
      ctx.beginPath();
      ctx.moveTo(-7,0);ctx.lineTo(7,0);
      ctx.moveTo(-7,0);ctx.lineTo(-3.5,-3.5);ctx.moveTo(-7,0);ctx.lineTo(-3.5,3.5);
      ctx.moveTo(7,0);ctx.lineTo(3.5,-3.5);ctx.moveTo(7,0);ctx.lineTo(3.5,3.5);
      ctx.stroke();
    }else{
      ctx.beginPath();
      ctx.arc(-4.5,3,2.5,0,6.29);ctx.arc(4.5,3,2.5,0,6.29);ctx.arc(0,-3.5,2.5,0,6.29);
      ctx.fill();
    }
    ctx.restore();
  }
}
```
Multiple arcs in one path: need moveTo before each arc else connecting lines — arcs will connect with lines but fill hides? Lines between circles would create weird fill. Add ctx.moveTo before each arc or separate beginPath per dot. I'll do separate beginPath per dot.

**drawBricks:**
```js
function drawBricks(){
  for(const br of bricks){
    if(br.dead)continue;
    const tough=br.hpMax>1, cracked=br.hp<br.hpMax;
    const f=tough?(cracked?0.68:0.86):1;
    ctx.fillStyle=shade(br.color,f);
    rr(ctx,br.x,br.y,br.w,br.h,3);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.2)';
    ctx.fillRect(br.x+2,br.y+1.5,br.w-4,2);
    ctx.fillStyle='rgba(0,0,0,0.24)';
    ctx.fillRect(br.x+2,br.y+br.h-3.5,br.w-4,2);
    if(tough){
      ctx.strokeStyle=shade(br.color,0.5);ctx.lineWidth=2;
      rr(ctx,br.x+1,br.y+1,br.w-2,br.h-2,2.5);ctx.stroke();
      ctx.fillStyle=shade(br.color,0.45);
      const cs=[[br.x+5.5,br.y+5.5],[br.x+br.w-5.5,br.y+5.5],[br.x+5.5,br.y+br.h-5.5],[br.x+br.w-5.5,br.y+br.h-5.5]];
      for(const p of cs){ctx.beginPath();ctx.arc(p[0],p[1],1.4,0,6.29);ctx.fill();}
    }
    if(cracked)drawCrack(br);
    if(br.power)drawPowerIcon(br);
  }
}
```
Bricks with height 24 and corner bolts at 5.5 inset fine.

**drawPowerIcon:** as before, dark ink 'rgba(20,14,10,0.78)'.

**drawFloats:**
```js
ctx.font='700 13px '+FONT; ctx.textAlign='center';
for(const f of floats){
  ctx.globalAlpha=clamp(f.life/0.9,0,1); // store max
  ...
}
```
store f.max. alpha=f.life/f.max.

**drawHints:**
```js
if(state==='serve'&&!demo&&!paused){
  const a=0.5+0.4*Math.sin(gameTime*5);
  ctx.globalAlpha=a;ctx.fillStyle='#8a93a2';
  ctx.font='12px '+FONT;ctx.textAlign='center';
  ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH',W/2,H-118);
  ctx.globalAlpha=1;
}
```

**particles update/draw** as planned.

**floats:** life-=dt; y-=30*dt.

**capsule catch check** in updateCapsules with paddle rect: py=paddle.y; catch when c.y+10>=py && c.y-10<=py+paddle.h && |c.x-paddle.cx|<=paddle.w/2+14. Since capsule falls 150px/s and paddle h 15 → passes through in ~0.23s, frames at 60fps → many frames of overlap ✓ no tunneling issue (check per frame, fall per frame 2.5px).

Apply & burst: sparks(c.x, paddle.y, typecolor, 10, 180).

**applyPower:**
```js
function applyPower(type){
  if(type==='W'){
    paddle.wideUntil=gameTime+10;
    floats.push({x:paddle.cx,y:paddle.y-26,text:'WIDE PADDLE',life:1.2,max:1.2,color:'#3dd6c3'});
  }else{
    const room=Math.max(0,6-balls.length),n=Math.min(2,room);
    if(n===0){score+=500;floats.push({x:paddle.cx,y:paddle.y-26,text:'+500',life:1,max:1,color:'#ffb454'});}
    else{
      const sp=ballSpeed();
      for(let i=0;i<n;i++){
        const a=i===0?-0.55:0.55;
        balls.push({x:paddle.cx,y:paddle.y-11,vx:Math.sin(a)*sp,vy:-Math.cos(a)*sp,r:7,stuck:false,trail:[]});
      }
      floats.push({x:paddle.cx,y:paddle.y-26,text:'MULTIBALL',life:1.2,max:1.2,color:'#ff6b57'});
    }
  }
  AudioFX.power();
}
```

**hitBrick:** as planned; floats '+60'/'+120' cream.

**shake(v):** shakeAmp=Math.min(9,Math.max(shakeAmp,v));

**confetti:**
```js
function confetti(){
  for(let i=0;i<70;i++){
    particles.push({x:rand(0,W),y:rand(-40,H*0.4),vx:rand(-60,60),vy:rand(40,160),
      w:rand(3,7),h:rand(2,5),rot:rand(0,6.3),vr:rand(-7,7),
      life:rand(1.2,2.2),max:2.2,color:ROW_COLORS[(Math.random()*7)|0],grav:260});
  }
}
```
max used for alpha: alpha=life/max could exceed... set max per particle equal to its initial life: max:lifeVar. I'll compute l=rand(...); life:l,max:l.

**spawnShards:** similar per-particle max.

Let me restructure particle spawn helper:
```js
function shard(x,y,color,vx,vy,size,life,grav){
  particles.push({x,y,vx,vy,w:size,h:size*rand(0.5,0.9),rot:rand(0,6.3),vr:rand(-9,9),life,max:life,color,grav});
}
```

OK. Particle update:
```js
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=dt;
    if(p.life<=0){particles.splice(i,1);continue;}
    p.vy+=p.grav*dt;
    p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;
  }
  if(particles.length>450)particles.splice(0,particles.length-450);
}
```

Draw particles:
```js
for(const p of particles){
  ctx.save();
  ctx.translate(p.x,p.y);ctx.rotate(p.rot);
  ctx.globalAlpha=clamp(p.life/p.max,0,1);
  ctx.fillStyle=p.color;
  ctx.fillRect(-p.w/2,-p.h/2,p.w,p.h);
  ctx.restore();
}
ctx.globalAlpha=1;
```

**Loop:**
```js
let last=performance.now();
function loop(now){
  const dt=Math.min(0.033,(now-last)/1000)||0.016;
  last=now;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(t=>{last=t;requestAnimationFrame(loop);});
```
Simpler: last=performance.now(); requestAnimationFrame(loop).

**bg & vig offscreen:**
```js
const bg=document.createElement('canvas');
bg.width=W*DPR;bg.height=H*DPR;
{
  const b=bg.getContext('2d');b.scale(DPR,DPR);
  b.fillStyle='#0e1014';b.fillRect(0,0,W,H);
  b.fillStyle='rgba(236,229,212,0.03)';
  for(let y=22;y<H;y+=30)for(let x=18;x<W;x+=30)b.fillRect(x,y,1.5,1.5);
  // faint top edge accent
  b.fillStyle='rgba(255,107,87,0.05)';b.fillRect(0,0,W,3);? 
```
A 3px coral strip at very top at 5% — subtle brand line. Actually make it part of frame design: top edge line 2px in #ff6b57 at 25%? Could be nice — a thin accent line at top of playfield. I'll add 2px rgba coral .35 — visible brand accent. Hmm, keep subtle: 2px, alpha .3.

vig as planned.

**Check demo AI paddle during 'over' in demo:** never. ✓

**Potential issue: pointermove sets inputMode='mouse' & target even during over/won — paddle moves behind overlay; harmless.

**Check launch() called with state==='serve' from pointerdown handler: but pointerdown also fires when clicking overlay start... sequence: pointerdown → if(demo) startGame (returns). Second click launches. ✓

**The hint footer text** mention keys; good.

**A11y-ish:** not needed.

**Testing mental pass on brickCollide with fast diagonal & corner:** ok.

**One more requirement check:** "Some bricks visibly carry a power-up icon; when broken they drop it" ✓ (7 bricks, one per row, icon drawn). "catching it with the paddle gives either wider paddle (10 seconds) or multi-ball (3 balls)" ✓.

"3 lives, score displayed" ✓ HUD. "Game over and victory screens with a restart option" ✓.

"at least 6 rows" — 7 ✓. "tougher bricks need 2 hits and visibly crack" ✓.

"Particles when a brick breaks" ✓. "bounce angle depends on hit position" ✓.

"Paddle controlled with mouse and arrow keys" ✓. Demo AI start ✓.

Now also think: should paddle get mouse control via pointermove only over canvas? window-level fine.

**Score points values:** normal 60, tough 120 (+15 per crack hit). OK.

Let me also add tiny detail: bricks remaining counter? HUD center is wordmark; skip.

Now write out the entire file properly. Also wrap script in IIFE. Also make sure `frame` var name doesn't clash with loop function name `loop` — use `frameEl`.

Also careful: I named a function `launch` and AudioFX.launch — distinct ✓.

Also `serve` function vs state string 'serve' — fine.

Edge: `hitBrick` may be called when pendingEnd already set (win pending) — bricks all dead so no more collisions ✓.

Also during endTimer pending for 'over' (balls gone), updateBalls filter fine.

During pendingEnd='won', balls still alive bouncing; if one falls below during pending → balls empty → condition `state==='playing' && balls.length===0 && endTimer<0` — endTimer>0 so skip ✓. But then after finishEnd state='won', balls frozen (not updated) — drawn statically behind overlay ✓.

**drawFloats when overlay shown:** fine.

**Font in canvas 12px hint:** ok.

Write the code now, final. I'll include modest comments. Also ensure the file has no template-literal nesting bugs.

Let me also double check paddle y: H-46=594, paddle h 15 → bottom 609, below 31px to bottom. Ball lost when y>H+r+6=653. ✓

Bricks top at 64; ball top bounce at y=7 — above bricks ✓.

HUD height: ~50px; canvas display height on 1280 viewport: stage 940-? frame width = min(940, 100%-28) = 940? minus border. Canvas 900 logical displayed at ~938 width → height ~667. Total ≈ 20+50+667+30 = 767 < 800 ✓.

Small screens: fine.

One more polish thought — the "signature moment": the multiball catch with upward burst + trail streaks + shake; plus the attract-mode framing. Good enough. Maybe add subtle ball squash on paddle hit? Skip — scope control.

Also: when wide paddle is caught, paddle widens smoothly ✓; the tips turn teal ✓.

Let me write everything now for real.

Additional check on `showStart` HTML — include legend and controls line:
```
<div class="chip">ATTRACT MODE · AI PLAYING</div>
<h1><em>R</em>ICOCHET</h1>
<p class="tag">BRICKS · CRACKS · FALLING LUCK</p>
<div class="legend">
  <div class="leg"><span class="cap cap-w"></span>WIDE PADDLE · 10S</div>
  <div class="leg"><span class="cap cap-m"></span>MULTIBALL</div>
  <div class="leg"><span class="brickchip"></span>2-HIT BRICK</div>
</div>
<div class="prompt blink">CLICK OR PRESS ANY KEY TO PLAY</div>
```

h1 em coral for first letter.

End overlay via showEnd(kind).

Pause overlay via showPause().

Everything set. Also note `backdrop-filter` needs `-webkit-` for safari — add both.

Time to produce the final answer. I'll write clean code with section headers. Careful bracket matching.

Also: in updateBalls, when state==='serve', stuck ball handled in serve branch; but updateBalls also runs in serve (loop skips stuck via continue) ✓. But serve branch sets stuck pos before? I have serve positioning inside `if(state==='serve')` before movePaddle — order: position after paddle move better. Let me handle stuck ball position inside updateBalls:
```js
if(b.stuck){ b.x=paddle.cx; b.y=paddle.y-b.r-1; continue; }
```
and updateBalls called after movePaddle ✓. Then in the serve block only timer. Cleaner.

Also trail push skip when stuck ✓ via continue.

Demo AI targeting when ball stuck (serve): balls[0].x = paddle.cx → target center ✓.

Also AI shouldn't jitter at exactly target: clamp d movement fine.

Let me also make sure keydown 'ArrowLeft' etc prevented default only when needed — always preventDefault for those keys could block page scroll... page has no scroll mostly. Fine.

Final code write-up. Also add `e.repeat` guard? Not needed for keys set.

Counting bricks for stats display: broken counter counts destroyed ✓.

fmtTime:
```js
function fmtTime(s){const m=Math.floor(s/60),ss=Math.floor(s%60);return String(m).padStart(2,'0')+':'+String(ss).padStart(2,'0');}
```

OK writing final answer now.

One more: `ctx.setTransform(DPR,0,0,DPR,0,0)` then translate for shake; at end reset transform then vignette. ✓

Also initial draw before first rAF? First rAF runs quickly; fine.

Also make sure no `console.error` from `AudioContext` before gesture — init only on gesture ✓; tone() checks ac null ✓.

Also iOS: webkitAudioContext guarded ✓.

Deliver.