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
7 min 36 s
Cost
$0.0087
Output tokens
36,073
Spent thinking
27,203 tokens
Speed
79.2 tok/s
Console errors
0
First token after
474 ms
First visible text after
6 min 29 s
Prompt tokens
357
Code size
24.0 KB
Finish reason
stop
Served by
Makora
Model id
z-ai/glm-5.3-flash
Reasoning sent
{"effort":"max"}
Max output
64,000 tokens
Generated
Nov 10, 2026

What it was asked

The prompt
“i want a breakout game where the paddle is a hamburger and every brick you break explodes into tons of little hearts”
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 · 722 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HEARTBREAKER — Burger Breakout</title>
<style>
  :root{
    --cream:#ffe9c2; --red:#e5484d; --pink:#ff2e63; --mustard:#ffc93c; --deep:#20100c;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%}
  body{
    background:
      radial-gradient(1100px 640px at 50% -10%, #38211a 0%, rgba(0,0,0,0) 60%),
      radial-gradient(900px 560px at 50% 115%, #2b1712 0%, rgba(0,0,0,0) 60%),
      var(--deep);
    color:var(--cream);
    font-family:"Trebuchet MS",Verdana,Arial,sans-serif;
    display:flex;flex-direction:column;align-items:center;
    overflow-x:hidden;
    min-height:100vh;
  }
  .awning{width:100%;height:26px;flex:none;
    background:repeating-linear-gradient(90deg,#e5484d 0 46px,#ffe9c2 46px 92px);}
  .scallop{width:100%;height:12px;flex:none;
    background-image:
      radial-gradient(12px 12px at 23px 0px,#e5484d 0 11px,rgba(0,0,0,0) 12px),
      radial-gradient(12px 12px at 69px 0px,#ffe9c2 0 11px,rgba(0,0,0,0) 12px);
    background-size:92px 12px;background-repeat:repeat-x;}
  header{text-align:center;padding:9px 12px 4px;flex:none}
  h1{font-family:"Arial Black","Arimo",Arial,sans-serif;font-weight:900;
     font-size:clamp(20px,3.4vw,32px);letter-spacing:6px;color:var(--cream);
     text-shadow:2px 2px 0 var(--red),4px 4px 0 rgba(0,0,0,.4);
     display:flex;align-items:center;justify-content:center;gap:12px;}
  h1 svg{filter:drop-shadow(2px 2px 0 rgba(0,0,0,.4))}
  .sub{font-size:11px;letter-spacing:3px;color:var(--mustard);margin-top:5px;font-weight:700}
  main{flex:1 0 auto;display:flex;align-items:center;justify-content:center;width:100%;
       padding:6px 14px 8px;min-height:0}
  .cabinet{border:6px solid #4a2c1a;border-radius:14px;background:#160b08;line-height:0;
     box-shadow:0 14px 40px rgba(0,0,0,.55), inset 0 0 0 2px rgba(255,233,194,.12);max-width:100%}
  canvas{display:block;
     width:min(calc(100vw - 76px), 880px, calc((100vh - 198px) * 1.4194));
     aspect-ratio:880/620;height:auto;border-radius:8px;
     touch-action:none;cursor:none}
  .foot{flex:none;padding:5px 10px 12px;font-size:10.5px;letter-spacing:2.5px;
     color:rgba(255,233,194,.55);text-align:center;font-weight:700}
</style>
</head>
<body>
  <div class="awning"></div>
  <div class="scallop"></div>
  <header>
    <h1>
      <svg viewBox="0 0 24 22" width="20" height="18" aria-hidden="true"><path d="M12 21 C5 15 1 10.5 1 6.5 C1 3 3.5 1 6.5 1 C9 1 11 2.5 12 4.5 C13 2.5 15 1 17.5 1 C20.5 1 23 3 23 6.5 C23 10.5 19 15 12 21 Z" fill="#ff2e63" stroke="#7a1029" stroke-width="1.5"/></svg>
      HEARTBREAKER
      <svg viewBox="0 0 24 22" width="20" height="18" aria-hidden="true"><path d="M12 21 C5 15 1 10.5 1 6.5 C1 3 3.5 1 6.5 1 C9 1 11 2.5 12 4.5 C13 2.5 15 1 17.5 1 C20.5 1 23 3 23 6.5 C23 10.5 19 15 12 21 Z" fill="#ff2e63" stroke="#7a1029" stroke-width="1.5"/></svg>
    </h1>
    <div class="sub">BURGER PADDLE &nbsp;·&nbsp; EVERY BRICK BURSTS INTO HEARTS</div>
  </header>
  <main>
    <div class="cabinet"><canvas id="game" width="880" height="620"></canvas></div>
  </main>
  <div class="foot">MOUSE MOVE · CLICK / SPACE LAUNCH · ← → STEER · ONE BRICK ≈ 40 HEARTS</div>

<script>
(()=>{'use strict';
const W=880,H=620,HUD=52,TAU=Math.PI*2;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');

/* heart shape as a unit Path2D (scaled at draw time) */
const HEART=new Path2D();
HEART.moveTo(0,1);
HEART.bezierCurveTo(-1.15,0.35,-0.95,-0.6,-0.5,-0.6);
HEART.bezierCurveTo(-0.12,-0.6,-0.03,-0.35,0,-0.2);
HEART.bezierCurveTo(0.03,-0.35,0.12,-0.6,0.5,-0.6);
HEART.bezierCurveTo(0.95,-0.6,1.15,0.35,0,1);
HEART.closePath();

/* ---------- responsive backing store ---------- */
function resize(){
  const cssW=canvas.clientWidth||W;
  const dpr=Math.min(window.devicePixelRatio||1,2);
  canvas.width=Math.max(1,Math.round(cssW*dpr));
  canvas.height=Math.max(1,Math.round(cssW*(H/W)*dpr));
  const s=canvas.width/W;
  ctx.setTransform(s,0,0,s,0,0);
  ctx.textBaseline='middle';
}
window.addEventListener('resize',resize);
if(window.ResizeObserver){new ResizeObserver(resize).observe(canvas);}

/* ---------- state ---------- */
let mode='demo';            /* 'demo' | 'play' */
let phase='serve';          /* 'serve' | 'play' | 'over' */
let score=0,lives=3,level=1,combo=0,bestCombo=0,comboPop=0;
let bricks=[],hearts=[],pops=[],flashes=[];
let shake=0,bannerT=0,overT=0,serveT=0;
let mouseX=null,keyL=false,keyR=false;
let elapsed=0,last=performance.now();

const paddle={x:W/2,cy:H-34,w:122,speed:600,squish:0};
const ball={x:W/2,y:0,vx:0,vy:0,r:8,speed:330,spin:0,stuck:true,trail:[]};

const COLS=12,ROWS=6,SIDE=26,GAP=6,BH=22,BTOP=HUD+30;
const BW=(W-SIDE*2-(COLS-1)*GAP)/COLS;
const ROWS_COL=['#ff4d6d','#ff7a4d','#ffb020','#8bd450','#e8a952','#ff9ebb'];

function buildBricks(){
  bricks=[];
  for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
    bricks.push({x:SIDE+c*(BW+GAP),y:BTOP+r*(BH+GAP),w:BW,h:BH,
      row:r,hp:r<2?2:1,max:r<2?2:1,alive:true});
  }
}
const brickPoints=r=>(ROWS-r)*10;

function toServe(){
  phase='serve';
  serveT=mode==='demo'?0.7+Math.random()*0.7:0;
  ball.stuck=true;ball.trail.length=0;
  ball.speed=Math.min(640,330+(level-1)*30);
  combo=0;
}
function launch(){
  ball.stuck=false;
  const a=Math.random()*0.6-0.3;
  ball.vx=Math.sin(a)*ball.speed;
  ball.vy=-Math.cos(a)*ball.speed;
  sfx.launch();
}
function startPlay(){
  mode='play';score=0;lives=3;level=1;combo=0;bestCombo=0;
  buildBricks();toServe();sfx.uiStart();
}
function startDemo(){
  mode='demo';score=0;lives=3;level=1;combo=0;bestCombo=0;
  buildBricks();toServe();
}
function levelUp(){
  level++;buildBricks();bannerT=1.8;toServe();sfx.level();
}
function loseBall(){
  sfx.lose();shake=Math.max(shake,6);
  if(mode==='demo'){toServe();return;}
  lives--;
  if(lives>0)toServe();
  else{phase='over';overT=9;}
}

/* ---------- particles ---------- */
const HEART_COLS=['#ff2e63','#ff5d8f','#ff8fab','#ff4d6d','#ffc2d4','#e6437a'];
function burst(x,y,n,pow){
  const budget=Math.max(0,720-hearts.length);
  n=Math.min(n,budget);
  for(let i=0;i<n;i++){
    const a=Math.random()*TAU,sp=(60+Math.random()*260)*pow;
    hearts.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-pow*(40+Math.random()*160),
      rot:Math.random()*TAU,vr:(Math.random()-0.5)*7,
      s:3.2+Math.random()*5.5,life:1,dk:0.62+Math.random()*0.5,
      sw:1.5+Math.random()*3.5,sf:2+Math.random()*4,t:Math.random()*TAU,
      col:HEART_COLS[(Math.random()*HEART_COLS.length)|0],glow:Math.random()<0.14});
  }
}
function popup(x,y,txt,col){pops.push({x,y,txt,col:col||'#ffe9c2',life:1});}
function flash(x,y){flashes.push({x,y,r:6,life:1});}

/* ---------- sfx (lazy WebAudio) ---------- */
const sfx=(()=>{
  let AC=null,master=null;
  function ensure(){
    try{
      if(!AC){
        const C=window.AudioContext||window.webkitAudioContext;
        if(!C)return;
        AC=new C();master=AC.createGain();
        master.gain.value=0.22;master.connect(AC.destination);
      }
      if(AC.state==='suspended')AC.resume();
    }catch(e){AC=null;}
  }
  function tone(f,d,type,vol,slide,delay){
    if(!AC)return;
    try{
      const t=AC.currentTime+(delay||0);
      const o=AC.createOscillator(),g=AC.createGain();
      o.type=type||'square';
      o.frequency.setValueAtTime(f,t);
      if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,slide),t+d);
      g.gain.setValueAtTime(vol||0.4,t);
      g.gain.exponentialRampToValueAtTime(0.0008,t+d);
      o.connect(g);g.connect(master);
      o.start(t);o.stop(t+d+0.03);
    }catch(e){}
  }
  return{
    ensure,
    wall(){tone(190,0.05,'square',0.16);},
    paddle(){tone(140,0.09,'square',0.32,80);},
    brick(c){tone(460+Math.min(c,12)*40,0.09,'triangle',0.38,900);},
    pop(){tone(660,0.1,'sine',0.28,990);tone(990,0.12,'sine',0.2,1320,0.06);},
    crack(){tone(300,0.06,'square',0.2,240);},
    launch(){tone(280,0.12,'square',0.26,540);},
    lose(){tone(220,0.5,'sawtooth',0.28,55);},
    level(){tone(523,0.12,'triangle',0.32);tone(659,0.12,'triangle',0.32,null,0.09);tone(784,0.16,'triangle',0.32,null,0.18);},
    uiStart(){tone(392,0.08,'square',0.28);tone(523,0.12,'square',0.28,null,0.08);}
  };
})();

/* ---------- update ---------- */
function update(dt){
  elapsed+=dt;
  if(bannerT>0)bannerT-=dt;
  if(comboPop>0)comboPop=Math.max(0,comboPop-dt*3);
  if(shake>0)shake=Math.max(0,shake-dt*30);
  paddle.squish=Math.max(0,paddle.squish-dt*5);

  /* paddle */
  if(mode==='demo'){
    const predict=ball.stuck?W/2+Math.sin(elapsed*0.8)*70:ball.x+ball.vx*0.16;
    let target=predict+Math.sin(elapsed*1.4)*12;
    target=Math.max(paddle.w/2+6,Math.min(W-paddle.w/2-6,target));
    const dx=target-paddle.x;
    paddle.x+=Math.sign(dx)*Math.min(Math.abs(dx),430*dt);
    if(phase==='serve'){serveT-=dt;if(serveT<=0)launch();}
  }else{
    const dir=(keyR?1:0)-(keyL?1:0);
    if(dir){paddle.x+=dir*paddle.speed*dt;mouseX=paddle.x;}
    else if(mouseX!=null){paddle.x+=(mouseX-paddle.x)*Math.min(1,dt*28);}
    paddle.x=Math.max(paddle.w/2+4,Math.min(W-paddle.w/2-4,paddle.x));
  }

  /* ball */
  if(ball.stuck){
    const s=(paddle.w+4)/120;
    ball.x=paddle.x;
    ball.y=paddle.cy-36.8*s-ball.r-2;
  }else if(phase==='play'){
    const steps=ball.speed*dt>7?2:1,sdt=dt/steps;
    for(let i=0;i<steps;i++){moveBall(sdt);if(phase!=='play')break;}
    ball.spin+=ball.vx*dt*0.06;
    ball.trail.unshift({x:ball.x,y:ball.y});
    if(ball.trail.length>9)ball.trail.pop();
  }

  /* particles */
  for(let i=hearts.length-1;i>=0;i--){
    const h=hearts[i];
    h.t+=dt;h.vy+=460*dt;
    h.vx-=h.vx*Math.min(0.9,1.4*dt);
    h.x+=(h.vx+Math.sin(h.t*h.sf)*h.sw*14)*dt;
    h.y+=h.vy*dt;h.rot+=h.vr*dt;
    h.life-=h.dk*dt*0.7;
    if(h.life<=0||h.y>H+20){hearts[i]=hearts[hearts.length-1];hearts.pop();}
  }
  for(let i=pops.length-1;i>=0;i--){
    const p=pops[i];p.y-=42*dt;p.life-=dt*1.1;
    if(p.life<=0){pops[i]=pops[pops.length-1];pops.pop();}
  }
  for(let i=flashes.length-1;i>=0;i--){
    const f=flashes[i];f.r+=140*dt;f.life-=dt*4;
    if(f.life<=0){flashes[i]=flashes[flashes.length-1];flashes.pop();}
  }

  if(phase==='over'){overT-=dt;if(overT<=0)startDemo();}
}

function moveBall(dt){
  ball.x+=ball.vx*dt;ball.y+=ball.vy*dt;
  if(ball.x<ball.r){ball.x=ball.r;ball.vx=Math.abs(ball.vx);sfx.wall();}
  else if(ball.x>W-ball.r){ball.x=W-ball.r;ball.vx=-Math.abs(ball.vx);sfx.wall();}
  if(ball.y<HUD+ball.r){ball.y=HUD+ball.r;ball.vy=Math.abs(ball.vy);sfx.wall();}
  if(ball.y-ball.r>H){loseBall();return;}

  /* burger paddle */
  if(ball.vy>0){
    const surf=paddle.cy-22;
    if(ball.y+ball.r>=surf&&ball.y-ball.r<paddle.cy+16&&
       Math.abs(ball.x-paddle.x)<=paddle.w/2+ball.r){
      let rel=(ball.x-paddle.x)/(paddle.w/2);
      rel=Math.max(-1,Math.min(1,rel))+(Math.random()-0.5)*0.04;
      const ang=rel*1.12;
      ball.vx=Math.sin(ang)*ball.speed;
      ball.vy=-Math.cos(ang)*ball.speed;
      ball.y=surf-ball.r-0.5;
      paddle.squish=1;combo=0;
      burst(ball.x,paddle.cy-24,7,0.35);
      sfx.paddle();
    }
  }

  /* bricks */
  for(const b of bricks){
    if(!b.alive)continue;
    const nx=Math.max(b.x,Math.min(ball.x,b.x+b.w));
    const ny=Math.max(b.y,Math.min(ball.y,b.y+b.h));
    const dx=ball.x-nx,dy=ball.y-ny;
    if(dx*dx+dy*dy<=ball.r*ball.r){
      if(Math.abs(dx)>Math.abs(dy)){
        ball.vx=dx>0?Math.abs(ball.vx):-Math.abs(ball.vx);
        ball.x=nx+(dx>0?ball.r:-ball.r);
      }else{
        ball.vy=dy>0?Math.abs(ball.vy):-Math.abs(ball.vy);
        ball.y=ny+(dy>0?ball.r:-ball.r);
      }
      hitBrick(b);
      break;
    }
  }
}

function hitBrick(b){
  b.hp--;
  ball.speed=Math.min(640,ball.speed+2);
  const m=Math.hypot(ball.vx,ball.vy)||1;
  ball.vx*=ball.speed/m;ball.vy*=ball.speed/m;
  const cx=b.x+b.w/2,cy=b.y+b.h/2;
  if(b.hp<=0){
    b.alive=false;
    combo++;bestCombo=Math.max(bestCombo,combo);comboPop=1;
    const pts=Math.round(brickPoints(b.row)*(1+Math.max(0,combo-1)*0.25));
    score+=pts;
    burst(cx,cy,26+Math.min(26,combo*4),1);
    flash(cx,cy);
    popup(cx,cy-6,'+'+pts,combo>=4?'#ff9ebb':'#ffe9c2');
    shake=Math.max(shake,3+Math.min(5,combo*0.7));
    sfx.brick(combo);sfx.pop();
    if(!bricks.some(k=>k.alive))levelUp();
  }else{
    burst(cx,cy,6,0.6);
    sfx.crack();
  }
}

/* ---------- drawing helpers ---------- */
function rrect(c,x,y,w,h,r){
  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();
}
let vignette=null;

function drawBurger(c,cx,cy,w){
  const s=w/120;
  c.save();
  c.translate(cx,cy);c.scale(s,s);
  c.lineWidth=2.4;c.lineJoin='round';
  c.fillStyle='rgba(0,0,0,0.3)';
  c.beginPath();c.ellipse(0,20,52,6,0,0,TAU);c.fill();
  /* bottom bun */
  c.fillStyle='#e8a13b';c.strokeStyle='#9c6218';
  rrect(c,-46,5,92,12,6);c.fill();c.stroke();
  /* patty */
  c.fillStyle='#7a4a21';c.strokeStyle='#4a2a10';
  rrect(c,-54,-4,108,13,6.5);c.fill();c.stroke();
  c.strokeStyle='rgba(40,20,8,0.55)';c.lineWidth=1.8;
  c.beginPath();c.moveTo(-36,1);c.lineTo(-18,1);c.moveTo(2,4);c.lineTo(24,4);c.stroke();
  c.lineWidth=2.4;
  /* cheese with drips */
  c.fillStyle='#ffc93c';c.strokeStyle='#d69a1a';
  c.beginPath();
  c.moveTo(-57,-7);c.lineTo(57,-7);c.lineTo(57,-2);
  c.lineTo(40,-2);c.lineTo(38,7);c.lineTo(34,-2);
  c.lineTo(18,-2);c.lineTo(15,6);c.lineTo(12,-2);
  c.lineTo(-2,-2);c.lineTo(-6,8);c.lineTo(-12,-2);
  c.lineTo(-26,-2);c.lineTo(-29,7);c.lineTo(-32,-2);
  c.lineTo(-57,-2);
  c.closePath();c.fill();c.stroke();
  /* tomato */
  c.fillStyle='#e5484d';c.strokeStyle='#a91f27';
  rrect(c,-46,-13,92,6,3);c.fill();c.stroke();
  /* lettuce (scalloped) */
  c.fillStyle='#8bd450';c.strokeStyle='#4f8f2a';
  c.beginPath();
  c.moveTo(-58,-19);c.lineTo(58,-19);c.lineTo(58,-13);
  let lx=58;
  while(lx>-58+0.01){c.quadraticCurveTo(lx-5.8,-6,lx-11.6,-13);lx-=11.6;}
  c.closePath();c.fill();c.stroke();
  /* top bun */
  c.fillStyle='#f2ac3c';c.strokeStyle='#9c6218';
  c.beginPath();
  c.moveTo(-52,-16);
  c.quadraticCurveTo(-54,-34,0,-35);
  c.quadraticCurveTo(54,-34,52,-16);
  c.closePath();c.fill();c.stroke();
  c.fillStyle='rgba(255,240,200,0.35)';
  c.beginPath();c.ellipse(-18,-27,15,4.5,-0.25,0,TAU);c.fill();
  /* sesame seeds */
  c.fillStyle='#ffe9c2';
  const seeds=[[-36,-22,-0.5],[-22,-28,0.4],[-8,-31,0],[6,-30,-0.3],[20,-27,0.5],[33,-21,-0.4],[-15,-21,0.9],[14,-20,-0.7]];
  for(const sd of seeds){
    c.save();c.translate(sd[0],sd[1]);c.rotate(sd[2]);
    c.beginPath();c.ellipse(0,0,3.4,2,0,0,TAU);c.fill();
    c.restore();
  }
  c.restore();
}

function drawBackground(){
  ctx.fillStyle='#231210';
  ctx.fillRect(-10,-10,W+20,H+20);
  ctx.save();
  ctx.translate(W/2,H/2+30);
  const pulse=1+0.025*Math.sin(elapsed*2.2);
  ctx.scale(150*pulse,150*pulse);
  ctx.fillStyle='rgba(255,120,150,0.05)';
  ctx.fill(HEART);
  ctx.restore();
  if(!vignette){
    vignette=ctx.createRadialGradient(W/2,H/2,180,W/2,H/2,620);
    vignette.addColorStop(0,'rgba(0,0,0,0)');
    vignette.addColorStop(1,'rgba(0,0,0,0.5)');
  }
  ctx.fillStyle=vignette;
  ctx.fillRect(0,0,W,H);
}

function drawBricks(){
  for(const b of bricks){
    if(!b.alive)continue;
    ctx.fillStyle=ROWS_COL[b.row];
    rrect(ctx,b.x,b.y,b.w,b.h,5);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.22)';
    rrect(ctx,b.x+3,b.y+2.5,b.w-6,4.5,2.5);ctx.fill();
    ctx.strokeStyle='rgba(0,0,0,0.35)';ctx.lineWidth=1.5;
    rrect(ctx,b.x+0.75,b.y+0.75,b.w-1.5,b.h-1.5,4);ctx.stroke();
    if(b.max===2){
      if(b.hp===b.max){
        ctx.strokeStyle='rgba(255,255,255,0.5)';ctx.lineWidth=1.2;
        rrect(ctx,b.x+4,b.y+4,b.w-8,b.h-8,3);ctx.stroke();
      }else{
        ctx.strokeStyle='rgba(40,10,15,0.7)';ctx.lineWidth=1.6;
        ctx.beginPath();
        const cx=b.x+b.w*0.5;
        ctx.moveTo(cx-6,b.y+3);ctx.lineTo(cx+2,b.y+b.h*0.45);ctx.lineTo(cx-4,b.y+b.h-3);
        ctx.stroke();
      }
    }
  }
}

function drawHearts(){
  for(const h of hearts){
    const a=Math.max(0,Math.min(1,h.life));
    ctx.save();
    ctx.translate(h.x,h.y);
    ctx.rotate(h.rot);
    const s=h.s*(0.7+0.3*a);
    ctx.scale(s,s);
    ctx.globalAlpha=a;
    ctx.fillStyle=h.col;
    ctx.fill(HEART);
    if(h.glow){
      ctx.scale(0.42,0.42);
      ctx.fillStyle='rgba(255,255,255,0.85)';
      ctx.fill(HEART);
    }
    ctx.restore();
  }
  ctx.globalAlpha=1;
}

function drawBall(){
  for(let i=ball.trail.length-1;i>=0;i--){
    const p=ball.trail[i];
    ctx.globalAlpha=0.15*(1-i/ball.trail.length);
    ctx.fillStyle='#ff9ebb';
    ctx.beginPath();ctx.arc(p.x,p.y,ball.r*0.85,0,TAU);ctx.fill();
  }
  ctx.globalAlpha=1;
  ctx.save();
  ctx.translate(ball.x,ball.y);
  ctx.rotate(ball.spin);
  ctx.fillStyle='#7c4a24';
  ctx.beginPath();ctx.arc(0,0,ball.r,0,TAU);ctx.fill();
  ctx.strokeStyle='#46280f';ctx.lineWidth=1.6;ctx.stroke();
  ctx.fillStyle='#5b3416';
  const sp=[[0.35,0.1,1.7],[-0.3,0.3,1.4],[0.05,-0.42,1.5],[-0.42,-0.12,1.2]];
  for(const d of sp){ctx.beginPath();ctx.arc(d[0]*ball.r,d[1]*ball.r,d[2],0,TAU);ctx.fill();}
  ctx.fillStyle='rgba(255,235,210,0.5)';
  ctx.beginPath();ctx.arc(-ball.r*0.32,-ball.r*0.35,ball.r*0.3,0,TAU);ctx.fill();
  ctx.restore();
}

function drawPaddle(){
  ctx.save();
  ctx.translate(paddle.x,paddle.cy);
  const k=paddle.squish;
  ctx.scale(1+0.15*k,1-0.25*k);
  drawBurger(ctx,0,0,paddle.w+4);
  ctx.restore();
}

function drawPops(){
  ctx.textAlign='center';ctx.textBaseline='middle';
  ctx.font='900 15px "Arial Black",Arial,sans-serif';
  for(const p of pops){
    ctx.globalAlpha=Math.max(0,Math.min(1,p.life*1.4));
    ctx.lineWidth=3;ctx.strokeStyle='rgba(30,8,12,0.8)';
    ctx.strokeText(p.txt,p.x,p.y);
    ctx.fillStyle=p.col;
    ctx.fillText(p.txt,p.x,p.y);
  }
  ctx.globalAlpha=1;
}

function drawFlashes(){
  for(const f of flashes){
    ctx.globalAlpha=Math.max(0,f.life)*0.7;
    ctx.strokeStyle='#ffd7e2';
    ctx.lineWidth=3*f.life+0.5;
    ctx.beginPath();ctx.arc(f.x,f.y,f.r,0,TAU);ctx.stroke();
  }
  ctx.globalAlpha=1;
}

function drawHUD(){
  ctx.fillStyle='rgba(10,4,3,0.35)';
  ctx.fillRect(0,0,W,HUD);
  ctx.strokeStyle='rgba(255,233,194,0.15)';ctx.lineWidth=2;
  ctx.beginPath();ctx.moveTo(0,HUD);ctx.lineTo(W,HUD);ctx.stroke();
  ctx.textBaseline='middle';
  ctx.font='900 21px "Arial Black",Arial,sans-serif';
  ctx.textAlign='left';
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('SCORE '+String(score).padStart(6,'0'),24,27);
  ctx.textAlign='center';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('LEVEL '+level,W/2,27);
  if(combo>=2){
    ctx.save();
    const cp=1+comboPop*0.4;
    ctx.translate(W-262,27);
    ctx.scale(cp,cp);
    ctx.font='900 19px "Arial Black",Arial,sans-serif';
    ctx.fillStyle='#ff9ebb';
    ctx.fillText('COMBO ×'+combo,0,0);
    ctx.restore();
  }
  for(let i=0;i<Math.min(lives,5);i++){
    drawBurger(ctx,W-26-i*38,28,30);
  }
}

function drawServeHint(){
  const a=0.55+0.45*Math.sin(elapsed*5);
  const x=Math.max(110,Math.min(W-110,paddle.x));
  ctx.save();
  ctx.globalAlpha=a;
  ctx.textAlign='center';ctx.textBaseline='middle';
  ctx.font='900 15px "Arial Black",Arial,sans-serif';
  ctx.lineWidth=4;ctx.strokeStyle='rgba(30,8,12,0.85)';
  ctx.strokeText('CLICK TO LAUNCH',x,paddle.cy-78);
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('CLICK TO LAUNCH',x,paddle.cy-78);
  ctx.restore();
}

function drawBanner(){
  const k=1-Math.min(1,bannerT/1.8);
  const sc=k<0.15?k/0.15:1;
  ctx.save();
  ctx.translate(W/2,H/2-20);
  ctx.scale(sc,sc);
  ctx.textAlign='center';ctx.textBaseline='middle';
  ctx.font='900 46px "Arial Black",Arial,sans-serif';
  ctx.lineWidth=8;ctx.strokeStyle='#4a0d1c';ctx.lineJoin='round';
  ctx.strokeText('LEVEL '+level,0,0);
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('LEVEL '+level,0,0);
  ctx.font='900 17px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('S P E E D   U P !',0,36);
  ctx.restore();
}

function drawAttractPanel(){
  ctx.fillStyle='rgba(15,6,5,0.4)';
  ctx.fillRect(0,0,W,H);
  const w=560,h=300,x=W/2-w/2,y=H/2-h/2;
  ctx.fillStyle='rgba(26,11,9,0.92)';
  rrect(ctx,x,y,w,h,16);ctx.fill();
  ctx.strokeStyle='#e5484d';ctx.lineWidth=3;
  rrect(ctx,x+4,y+4,w-8,h-8,12);ctx.stroke();
  ctx.strokeStyle='rgba(255,233,194,0.3)';ctx.lineWidth=1.5;
  rrect(ctx,x+9,y+9,w-18,h-18,9);ctx.stroke();

  drawBurger(ctx,W/2,y+66,120);

  ctx.textAlign='center';ctx.textBaseline='middle';
  ctx.font='900 40px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#7a1029';
  ctx.fillText('HEARTBREAKER',W/2+3,y+132+3);
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('HEARTBREAKER',W/2,y+132);

  ctx.font='700 13px "Trebuchet MS",Arial,sans-serif';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('S M A S H   B R I C K S   ·   S H O W E R   H E A R T S',W/2,y+164);

  ctx.font='700 12px "Trebuchet MS",Arial,sans-serif';
  ctx.fillStyle='rgba(255,233,194,0.75)';
  ctx.fillText('MOUSE / TOUCH / ← → MOVE   ·   CLICK OR SPACE TO LAUNCH',W/2,y+198);

  ctx.font='700 11px "Trebuchet MS",Arial,sans-serif';
  ctx.fillStyle='#ff9ebb';
  ctx.fillText('CHEF’S DEMO PLAYING — SIT BACK & WATCH',W/2,y+226);

  ctx.globalAlpha=0.45+0.55*(0.5+0.5*Math.sin(elapsed*4));
  ctx.font='900 17px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('CLICK OR PRESS ANY KEY TO START',W/2,y+266);
  ctx.globalAlpha=1;
}

function drawOverPanel(){
  ctx.fillStyle='rgba(15,6,5,0.5)';
  ctx.fillRect(0,0,W,H);
  const w=560,h=270,x=W/2-w/2,y=H/2-h/2;
  ctx.fillStyle='rgba(26,11,9,0.94)';
  rrect(ctx,x,y,w,h,16);ctx.fill();
  ctx.strokeStyle='#ff4d6d';ctx.lineWidth=3;
  rrect(ctx,x+4,y+4,w-8,h-8,12);ctx.stroke();

  ctx.textAlign='center';ctx.textBaseline='middle';
  ctx.font='900 44px "Arial Black",Arial,sans-serif';
  ctx.lineWidth=8;ctx.strokeStyle='#3d0a14';ctx.lineJoin='round';
  ctx.strokeText('GAME OVER',W/2+3,y+78);
  ctx.fillStyle='#ff4d6d';
  ctx.fillText('GAME OVER',W/2,y+78);

  ctx.font='900 22px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('SCORE  '+String(score).padStart(6,'0'),W/2,y+132);

  ctx.font='700 13px "Trebuchet MS",Arial,sans-serif';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('LEVEL '+level+'  ·  BEST COMBO ×'+bestCombo,W/2,y+162);

  ctx.globalAlpha=0.45+0.55*(0.5+0.5*Math.sin(elapsed*4));
  ctx.font='900 16px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('CLICK OR PRESS ANY KEY TO TRY AGAIN',W/2,y+206);
  ctx.globalAlpha=1;

  ctx.font='700 11px "Trebuchet MS",Arial,sans-serif';
  ctx.fillStyle='rgba(255,233,194,0.5)';
  ctx.fillText('back to the demo in '+Math.max(0,Math.ceil(overT))+'s',W/2,y+236);
}

function render(){
  ctx.save();
  if(shake>0)ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake);
  drawBackground();
  drawBricks();
  drawFlashes();
  drawHearts();
  drawBall();
  drawPaddle();
  drawPops();
  drawHUD();
  ctx.restore();

  if(bannerT>0)drawBanner();
  if(phase==='over')drawOverPanel();
  else if(mode==='demo')drawAttractPanel();
  else if(phase==='serve')drawServeHint();
}

/* ---------- main loop ---------- */
function frame(now){
  let dt=(now-last)/1000;last=now;
  if(dt>0.033)dt=0.033;
  update(dt);
  render();
  requestAnimationFrame(frame);
}

/* ---------- input ---------- */
function localX(e){
  const r=canvas.getBoundingClientRect();
  return (e.clientX-r.left)*W/r.width;
}
canvas.addEventListener('pointermove',e=>{mouseX=localX(e);});
canvas.addEventListener('pointerdown',e=>{
  sfx.ensure();
  mouseX=localX(e);
  if(mode==='demo'||phase==='over'){startPlay();return;}
  if(phase==='serve')launch();
});
window.addEventListener('keydown',e=>{
  const dirKey=['ArrowLeft','ArrowRight','a','A','d','D'].includes(e.key);
  if(e.repeat){if(dirKey)e.preventDefault();return;}
  sfx.ensure();
  if(mode==='demo'||phase==='over'){startPlay();e.preventDefault();return;}
  switch(e.key){
    case ' ':case 'ArrowUp':
      if(phase==='serve')launch();
      e.preventDefault();break;
    case 'ArrowLeft':case 'a':case 'A':keyL=true;e.preventDefault();break;
    case 'ArrowRight':case 'd':case 'D':keyR=true;e.preventDefault();break;
  }
});
window.addEventListener('keyup',e=>{
  if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A')keyL=false;
  if(e.key==='ArrowRight'||e.key==='d'||e.key==='D')keyR=false;
});
document.addEventListener('visibilitychange',()=>{last=performance.now();});

/* ---------- boot ---------- */
resize();
startDemo();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (27,203 tokens)
The user wants a Breakout game where:
1. The paddle is a hamburger
2. Every brick breaks into tons of little hearts

Let me plan this carefully:

**Core game mechanics:**
- Canvas-based breakout
- Paddle: draw a hamburger (bun top with sesame seeds, lettuce, patty, bun bottom)
- Bricks: colorful rows
- When a brick breaks: spawn tons of little heart particles that fly out with physics
- Ball bounces off walls, bricks, paddle
- Lives, score, maybe levels
- Self-playing demo mode behind "Click or press a key to start" message — the AI paddle follows the ball

**Visual style — I want this to feel authored:**
- Since it's a hamburger + hearts theme, I'll lean into a warm, playful "diner / retro arcade" aesthetic. Warm cream/red/mustard palette. Maybe a checkerboard diner background? Or dark warm background so hearts pop.
- Font: I can't load external fonts except allowed CDNs... The rules say no external files/fonts. I'll use a system stack but style it well — maybe use a bold condensed feel via letter-spacing and text-transform. Actually I can't use Google Fonts here since "no external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task" — the task allows none specifically. So system fonts only. I'll craft a chunky arcade look with heavy weights and letter-spacing.

**Signature moment:** the heart explosion. When a brick shatters, spawn 30-50 hearts with gravity, rotation, flutter (hearts should flutter like falling petals — sway side to side), fade out. Also screen shake and a little pop sound via WebAudio. Big chain reactions when multiple bricks break.

**Hamburger paddle details:**
- Top bun: golden-brown dome with sesame seeds (little white ovals)
- Lettuce: wavy green ruffle
- Tomato: red slice maybe
- Cheese: yellow dripping triangle edges
- Patty: brown rounded rect with grill marks
- Bottom bun
- The ball could be... a cherry? A meatball? A meatball is fun — a brown ball with texture. Or a tomato. Meatball fits the food theme! Ball = meatball.

**Ball:** meatball — brown sphere with darker speckles and a highlight.

**Bricks:** maybe make them look like food-related things? Could be fries (yellow), ketchup packets (red), lettuce (green)? Simpler: colored bricks in diner palette: mustard, ketchup red, lettuce green, cheese yellow, bun tan. Different rows = different colors and points. Maybe some bricks take 2 hits.

**Game structure:**
- Title screen overlay: "CLICK OR PRESS A KEY TO START" — behind it, the AI demo plays. So the game runs in demo mode from the start; overlay shows title + instructions. When user clicks/presses key, game resets to player mode.
- Actually the rule: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So on load, AI plays. Overlay text shown. On click/keydown → switch to player control, restart round.
- After player loses all lives in real mode → "game over, click to play again" and revert to... I'll show a game over panel; clicking restarts player mode. If they never click, AI keeps demoing.

**AI demo mode:** paddle tracks ball x with easing, some error so it's not perfect but good enough. When AI loses all lives, restart demo.

**Hearts particles:**
- Each brick break: ~35 hearts. With many bricks, could get heavy — cap total particles at ~600, recycle.
- Heart drawing: use canvas path for heart shape, small, random sizes 4-10px, colors: pinks/reds (rose, #ff5d8f, #ff2e63, #ff8fab, white-pink). Physics: initial velocity outward from brick center, gravity, drag; rotation; flutter via sine on x; fade with life. Some hearts "pop" outward, some drift down like petals.
- Also on paddle hit: a few hearts? Maybe small heart puff when ball bounces on the burger — cute. A few (5) tiny hearts.
- Combo: consecutive brick hits without touching paddle multiply score.

**Screen shake:** on brick break small shake, bigger for multi-brick chains.

**Sound:** WebAudio bleeps — bounce (short square), brick pop (rising ping), heart burst (chime arpeggio?), lose life (descending). Keep simple, resume context on first interaction. Need to be careful: AudioContext can only start after user gesture. In demo mode, sounds might not play — guard with try/catch and state. I'll create the context lazily on first user gesture; before that, silent (fine — demo is silent until interaction, which is actually good).

**Layout:**
- Full-page: warm dark background (deep brown/near-black with subtle texture? maybe radial vignette). Centered game canvas with a chunky border like an arcade cabinet / diner menu board. Header above: game title "HEARTBREAKER BURGER" or "BRICK & BURGER"... Let me think of a name: "HEARTBREAKER" with subtitle "a burger-powered brick breaker". Cute.
- Side or below: score, lives (shown as tiny burger icons), combo. HUD drawn on canvas or as DOM? I'll draw HUD in canvas top area or as DOM elements around canvas. DOM HUD is easier to style nicely. I'll do: canvas is the playfield; DOM header with title; HUD bar above canvas with SCORE / COMBO / LIVES (burger glyphs drawn as small inline canvases? Too much — use text "🍔"? No emoji... I avoid emoji as icons. I could draw tiny burger SVGs inline. Or draw lives inside the canvas as mini burgers — easy since I have a drawBurger function. Better: keep HUD inside canvas as part of the render, using the drawing code I already have. That keeps everything unified and lets me show mini burgers for lives.)

Decision: draw everything in canvas, including HUD at top strip inside the canvas. The DOM just has the page frame: title, canvas, footer hint. That's clean.

Canvas size: 1280x800 viewport target. Canvas logical 900x640? Let's do canvas 880 wide x 620 tall, centered, scales down responsively via CSS width:100% with max-width. Use devicePixelRatio scaling for crispness.

**Brick layout:** cols 12, rows 6, top offset ~70 (leaving HUD space), brick size ~ (playWidth - margins)/cols. Colors per row. Some 2-hit bricks (rows 0-1). 

**Ball physics:** standard; speed increases slightly per brick or over time. Paddle bounce angle based on hit offset. Cap speed.

**Mouse control:** move mouse over canvas → paddle x. Touch: touchmove. Keyboard: arrows/A-D. Click to launch ball (ball starts stuck to paddle). In demo, AI launches automatically.

**Game states:** 'demo' (AI), 'playing', 'gameover'(then back to playing on click), 'ready' (ball on paddle awaiting launch — but in playing). Keep it simple:
- state: 'attract' | 'serve' | 'play' | 'over'
- attract: AI controls paddle, auto-launches.
- On user click/key: switch to serve with fresh game (score 0, lives 3).
- serve: ball stuck on burger, "Click to launch" hint (in canvas).
- play.
- lives 0 → 'over': overlay "GAME OVER — Click to try again", and if no interaction for ~8s, return to attract? Simpler: clicking in over → attract? Hmm. After game over, click → restart in play mode (serve). That's fine. Maybe after 10s idle in over, go back to attract demo. Nice touch.

Overlay: The "Click or press a key to start" message. In attract state, draw a translucent panel with title, instructions, and pulsing "CLICK OR PRESS ANY KEY TO START". The demo game runs behind it — panel should be see-through enough (maybe just text with dark backing bar, not full dim). I'll render the overlay as: slight dim (rgba black 0.35), title big, small how-to lines, pulsing start prompt. The demo is visible around it.

**Diner background inside canvas:** deep warm brown (#2a1712?) with subtle grid or a big faint heart? Let's do a warm dark plum/brown with faint diagonal stripes or dot pattern — subtle. Vignette.

Let me also add a fun detail: hearts that reach the bottom "rebuild" nothing — they just vanish. Maybe hearts landing add tiny score sparkle? Keep simple.

Combo display: "x3" text near last brick break? Show combo multiplier in HUD; hearts burst count scales slightly with combo? Fun: combo increases heart count a bit. Cap for perf.

**Performance:** particle cap 700; use simple objects array; draw hearts as filled bezier path — fine. Precompute heart path per size? Just draw with scale transform each frame; 700 hearts with transforms is okay in Chrome.

Heart shape path function drawHeart(ctx,x,y,size,rot,color,alpha). Path: classic:
```
ctx.moveTo(0, s*0.3);
ctx.bezierCurveTo(-s, -s*0.6, -s*0.5? ...
```
Standard heart: with size s (half-width):
```
ctx.moveTo(0, s*0.35);
ctx.bezierCurveTo(s*0.5, -s*0.15, s*1.1, s*0.35? 
```
Let me use a known good one: for radius r:
```
ctx.moveTo(0, r*0.3);
ctx.bezierCurveTo(-r, -r*0.5, -r*0.4, -r*1.1, 0? 
```
I'll use this reliable version (centered-ish):
```
function heartPath(ctx, s){
  ctx.beginPath();
  ctx.moveTo(0, s*0.32);
  ctx.bezierCurveTo(-s*1.2, -s*0.48, -s*0.5, -s*1.25, 0, -s*0.5);
  ctx.bezierCurveTo(s*0.5, -s*1.25, s*1.2, -s*0.48, 0, s*0.32);
  ctx.closePath();
}
```
Hmm let me reason: a common heart: top dip at (0, -0.25s), lobes at (-0.5s,-0.6s) etc, bottom point (0, 0.5s). Let me define with size s meaning overall scale:
- Start at bottom tip (0, s).
- Curve up left: bezier cp1 (−1.1s, 0.4s), cp2 (−1.1s, −0.9s)? 

Simplest reliable heart (used often):
```
ctx.moveTo(0, s*0.25);
ctx.bezierCurveTo(0, 0, -s*0.5, 0, -s*0.5, s*0.25); // no...
```
I'll just carefully construct: Want two circles top and a V bottom. Parameterize: heart with half-width w, total height ~1.2w.
Path (centered at origin, heart upright, tip at bottom):
- Tip: (0, w*0.9)
- Left side: from tip curve to left lobe: bezier( (−w*1.1, w*0.25) cp? ...)

Honestly a well-known snippet:
```
ctx.moveTo(x, y + s*0.3);
ctx.bezierCurveTo(x, y + s*0.1, x - s*0.25, y - s*0.2, x - s*0.5, y - s*0.2);
ctx.bezierCurveTo(x - s*0.9, y - s*0.2, x - s*0.9, y + s*0.25, x - s*0.9, y + s*0.25)?? 
```
I recall the MDN heart? There's a classic:
```
ctx.bezierCurveTo(x + s/2, y - s/2, x, y - s/4, x, y); ...
```
Let me just derive cleanly with tip at bottom.

Define points (x relative, y relative, y down positive), heart of "radius" r:
- Bottom tip: (0, r)
- Left lobe center: (−r/2, −r/2), right lobe center: (r/2, −r/2), lobes radius r/2.
- Top dip point: (0, −r/4)? Actually dip should be around (0, −r*0.2)? With lobes of radius r/2 centered at (±r/2, −r/2), tops of lobes reach y = −r. Dip between lobes at x=0: lobe circles at distance... circle center (r/2, −r/2) radius r/2: at x=0, y = −r/2 − sqrt((r/2)² − (r/2)²)= −r/2. Hmm circle at x=0: (0−r/2)² + (y+r/2)² = (r/2)² → (r/2)²+(y+r/2)²=(r/2)² → y=−r/2 only tangent. So dip at (0,−r/2)? That seems deep-ish; visually fine.

Path with two beziers per side:
```
ctx.moveTo(0, r);                         // bottom tip
ctx.bezierCurveTo(-r*1.1, r*0.4, -r*0.95, -r*0.55, -r*0.5, -r*0.55); // left side up to left lobe top-ish
ctx.bezierCurveTo(-r*0.15, -r*0.55, -r*0.05, -r*0.35, 0, -r*0.2);   // into dip
ctx.bezierCurveTo(r*0.05, -r*0.35, r*0.15, -r*0.55, r*0.5, -r*0.55); // out of dip to right lobe
ctx.bezierCurveTo(r*0.95, -r*0.55, r*1.1, r*0.4, 0, r);              // right side down to tip
ctx.closePath();
```
This should give a decent heart. I'll tweak control points: for rounder lobes:
left lobe top arc: from (−0.5r, −0.55r) going to dip (0,−0.2r): cp1 (−0.5r+? ) eh. The above is symmetric and smooth enough. I'll go with it and mentally validate: bottom tip (0,r); bezier to (−0.5r,−0.55r) with cps (−1.1r,0.4r) and (−0.95r,−0.55r): gives fat left side. Then bezier to dip (0,−0.2r) with cps (−0.15r,−0.55r),(−0.05r,−0.35r): rounds the lobe into the dip. Symmetric right. Looks like a heart. 

**Burger paddle drawing:** width paddleW (~120), height ~34. Draw at (px, py):
- Bottom bun: rounded rect, tan #e8a952, bottom corners more rounded.
- Patty: dark brown #6b3e1e rounded rect with darker grill lines.
- Cheese: yellow #ffc93c, with drips (small triangles) — draw as a slightly wider rounded rect with zigzag bottom? Simpler: cheese slice rect + 3 drip triangles.
- Tomato: thin red band #e5484d.
- Lettuce: green wavy shape — draw with a series of arcs along the bottom edge of lettuce band (#7ed957), scalloped.
- Top bun: dome — arc/ellipse top half, tan with slightly darker edge, sesame seeds: small white/cream ellipses rotated randomly (fixed seed offsets).
- Outline: subtle darker stroke for cartoon look + drop shadow.

I'll write drawBurger(ctx, x, y, w, facing up?) — paddle always same orientation. Draw functions in local coordinates centered.

Lives icons: draw mini burger (scaled w=26) in HUD.

**Meatball ball:** circle radius 7, fill #7a4526, darker speckles (3-4 small dots), highlight arc white-ish at top-left. Slight rotation over time.

**Trail:** ball leaves subtle motion trail (few ghost circles) — adds life. Also heart sparkle trail? Small: every few frames spawn tiny fading circle. Keep light.

**Bricks visuals:** rounded rect, per-row color, slight top highlight, 1px darker border. 2-hit bricks show a crack (two dark strokes) after one hit. Colors (diner palette):
- Row colors: ketchup #e5484d, mustard #f2b705? cheese #ffc93c, lettuce #7ac74f, bun #e8a952, berry #d94f8e? Let's use 6 rows: #ff5d73 (ketchup), #ffb01f (mustard), #ffd23f? too close... rows: red, orange, yellow, green, cream/tan, pink? Points decrease downward: top row worth more.

Rows: 
0: '#ff4d6d' 70pts, hp2
1: '#ff754f'? hmm keep 5 colors × ... 12 cols × 6 rows = 72 bricks. Heart bursts of ~40 each → lots of particles; cap handles it. Maybe reduce rows to 5 and cols 11 to lower chaos? The user wants TONS of hearts — chaos is the point. Keep 12×6 but cap particles at 650 and scale burst size down when over budget. hp: rows 0-1 hp2, others hp1.

Points per brick: (rows-from-bottom+1)*10? Simple: row0=60,row1=50,...row5=10. Multiply by combo.

**Combo:** increments each brick broken before paddle touch; resets on paddle hit. Multiplier = min(combo, 9)? Score += pts * (1 + combo*0.25)? Keep display "COMBO ×N".

**Level progression:** when all bricks cleared → "LEVEL UP" banner, rebuild bricks, speed +. In demo mode too. Fine.

**Ball speed:** start 5.2 (at 60fps, per frame units), +0.15 per level, +small per brick up to cap 9. Use dt-normalized? I'll run fixed-step-ish using rAF with dt clamp; velocities in px/frame at 60fps scaled by dt*60/1000. Simpler: use dt in seconds and speeds in px/s: ball ~ 340 px/s start, cap 640. Paddle speed keyboard 560 px/s. AI: paddle lerp toward ball with max speed 420 px/s plus wobble.

**Collision:** ball vs bricks AABB with circle: find nearest point; reflect axis by penetration depth comparison. Standard approach: compute overlap on x and y after moving; reflect on smaller penetration axis. I'll do: for each brick (and only when ball moving toward), compute closest point, if dist < r: determine bounce axis by comparing |dx| vs |dy| of (ball − closest). Then push out. Break brick, spawn hearts.

Sub-stepping: at 640 px/s and 16ms frames → ~10px per frame, brick height ~20 → could tunnel slightly; use 2 substeps when speed high, or use dt capped and do continuous check for vertical: I'll do movement in 2 substeps if speed*dt > 8. Fine.

**Wall bounce:** left/right/top. Bottom: lose ball → hearts "poof" downward? Lose life; if lives>0 → serve state; else over.

Paddle collision: if ball moving down and intersects burger bounding box (with a bit of margin), bounce with angle: offset = (ballX − paddleX)/ (paddleW/2) clamped [−1,1]; angle = offset * 60°; speed preserved; vy upward. Spawn tiny heart puff + squish animation on burger (scaleY bounce). Cute: burger "chomps"? Squish is enough.

**Screen shake:** shakeT decays; offset canvas translate random within magnitude.

**HUD in canvas:** top strip height ~46: left "SCORE 000000", center "COMBO ×N" (visible when ≥2), right lives as mini burgers. Font: bold monospace? I'll use `'900 20px "Arial Black", Arial'` style with letterSpacing? Canvas letterSpacing supported in modern Chrome (ctx.letterSpacing). I can set it, guarded. Use font stack: `800 22px Futura, "Trebuchet MS", Arial, sans-serif` — Trebuchet is common. Actually for a chunky arcade feel, "Arial Black" is widely present. I'll use `'Arial Black', 'Arial', sans-serif` with weights.

Also DOM header: title with layered text-shadow for chunky retro look, subtitle. Colors: cream text on dark warm bg, red accent. A scalloped diner edge? I can add a repeating-radial-gradient scallop border at top of the page — nice authored touch: like an awning. Diner awning stripes at top of page: repeating-linear-gradient red/cream stripes with scalloped bottom via radial-gradient mask. Let me do a simple scalloped edge: a strip of repeating radial-gradient circles. That's a nice authored detail.

Page background: deep espresso #1d120d with subtle noise? Use radial vignette. Canvas frame: border 6px solid #3a2417, rounded, shadow, maybe a cream outer rim.

Responsive: canvas CSS width min(100% − 24px, 900px), height auto via aspect-ratio. Use aspect-ratio: 880/620? I'll set canvas element with width/height attributes = logical size ×dpr and CSS via aspect-ratio. Actually simpler: fixed logical 880×620; CSS: width: min(calc(100vw−28px), 880px); aspect-ratio: 880/620; height auto. Canvas internal resolution = 880*dpr etc. with dpr min(2). Since CSS scales, dpr times CSS px would be better: set canvas.width = cssWidth*dpr and scale ctx accordingly each resize. I'll implement resize handler: measure container width cw; canvas.style not needed (CSS handles); set canvas.width = cw*dpr, canvas.height = cw*620/880*dpr; ctx.setTransform(scale) mapping logical 880×620 → device. So all game logic uses logical coords 880×620. 

**Overlay drawing:** in attract state: dim rect rgba(20,10,8,0.45); big title "HEARTBREAKER" maybe with burger icon drawn; lines: "A BURGER-BOTTOM BRICK BUSTER", "MOUSE or ← → to move · CLICK to launch", pulsing "CLICK OR PRESS ANY KEY TO START" (alpha sine). Also small "DEMO — the chef is playing" tag. Cute.

Game over overlay similar: "GAME OVER", score, "CLICK TO TRY AGAIN". Also show "back to demo in Ns"? Just auto-revert after 9s.

Level clear banner: brief state 'banner' timer 1.6s showing "LEVEL N CLEAR!" while bricks rebuilt, then serve.

**Sounds (WebAudio):**
- ensureAudio() on first pointerdown/keydown.
- beep(freq, dur, type, vol, slideTo). bounce: 220 square short; wall: 180; brick: 500+combo*40 triangle 0.08; burst: play 3 quick notes (chime) — maybe a short noise? Keep: two-note "pop" up. life lost: descending saw. launch: quick up. level clear: little arpeggio. Master gain 0.25.
- Guard everything in try/catch.

**Code organization:** single script, clear sections, ~600-800 lines. Let me write it.

Key constants:
```
W=880,H=620; HUD_H=52; 
paddle: w=118,h=30? burger visual ~ w120 h34; paddle.y = H-46.
bricks: cols=12, rows=6, top=HUD_H+26, side=26, gap=6, brickH=22.
brickW = (W-2*side-(cols-1)*gap)/cols = (880-52-66)/12 = 762/12=63.5.
ball r=8.
```

Ball speeds: base 330 px/s + 26*level, cap 620. Paddle max influence angle 65°.

AI: target = ball.x predicted slightly; move with speed 400 but ease: paddle.x += clamp(target−x, ±speed*dt). Add sinus offset so it feels human: target += sin(t*1.7)*18. Also AI misses occasionally? For demo it should mostly succeed but it's okay if it loses a life rarely; then demo resets lives. I'll give AI lives infinite in demo (lives stay 3). On brick clear, next level.

Ball stuck to paddle in serve: ballX = paddle.x, y = paddleTop − r − 2. Launch on click/key(space) in playing mode; in demo auto-launch after 0.9s.

Input handling:
- pointermove on window → mouseX in logical coords (use canvas bounding rect scale).
- pointerdown: ensureAudio; if state attract → startGame(player) else if over → startGame(player) else if serve → launch.
- keydown: same start logic; arrows/A,D set keyDir; Space launch.
- keyup clears dir.
- touch: pointermove covers with touch if touch-action none? Add touchmove listener mapping to mouseX; also prevent scroll via touch-action:none on canvas.

Start game resets score, lives=3, level=1, rebuild bricks, serve.

Attract-mode init: also full reset with lives=99 (AI), auto flag.

Hearts spawn function:
```
function burst(x,y,count,colorHint){
 const n = Math.min(count, MAXP - hearts.length);
 for(i<n){
   const a = rand(0,TAU), sp = rand(60, 320);
   hearts.push({x,y,vx:cos(a)*sp, vy:sin(a)*sp - rand(40,160), rot:rand(0,TAU), vr:rand(-6,6), size:rand(3.5,9), life:1, decay:rand(0.55,0.95), hue: pick(pinks), sway:rand(2,7), swayF:rand(2,5), t:0, glow: rand()<0.15});
 }
}
```
Update: vy += 520*dt; vx *= drag? apply slight air drag 0.9^; x += vx*dt + sin(t*swayF)*sway*dt? Simpler flutter: x += (vx + sin(t*freq)*amp)*dt. life -= decay*dt*0.6. alpha = life. Remove when life<=0 or off bottom.

Also hearts shrink slightly with life? Keep size, fade alpha, slight scale (0.6+0.4*life).

Draw hearts with shadow? Shadow per heart is expensive for 600 — skip shadows; maybe glow hearts (few) get shadowBlur 8 — cap those. Actually even 600 simple path fills fine. Add ctx.globalAlpha per heart.

Colors array: ['#ff2e63','#ff5d8f','#ff8fab','#ffc2d4','#ff4d6d','#e6437a'].

Combo puffs on paddle bounce: burst(x, y, 6, small) with lower speeds — separate mini burst params (speed 40-140).

Brick break: burst(cx, cy, 26 + min(combo*3, 24) → up to ~50). Plus 6 white "spark" rects? Keep hearts only — that's the theme, plus a quick ring flash: draw expanding circle stroke for 0.2s. I'll add small flashes array.

Score popup text: floating "+120" small text at brick pos rising, fade — nice feedback. popups array.

**Lives icons:** draw mini burger at HUD right: for i<lives draw drawBurger small at (W−30−i*34, 26, 26). Cap display 5.

**drawBurger implementation:** 
```
function drawBurger(c, x, y, w){ // centered at x, y = center vertical
  const s = w/120; // scale from base 120
  c.save(); c.translate(x,y); c.scale(s,s);
  // base coords: width 120, heights:
  // bottom bun: y 6..16
  // patty: y -2..8
  // cheese drips over patty
  // tomato: y -7..-3
  // lettuce: y -12..-6 with scallops
  // top bun: dome from y -12 up to -26
  ...
}
```
Let me define concrete geometry (base width 120, so half=60):
- Bottom bun: rounded rect from (−50, 4) to (50, 16), r=8, fill #e9a13b, stroke #a86a1e lw2. Slight lighter top edge.
- Patty: rounded rect (−54, −4) to (54, 8) r=7 fill #7a4a21, stroke #4c2a10; grill marks: two dark lines.
- Cheese: shape spanning (−56,−8) to (56,−2) fill #ffc93c stroke #d69a1a; drips: triangles at x=−30,0,25 hanging to y+6..8. Draw as path.
- Tomato: rounded rect (−46,−12) to (46,−7) fill #e5484d stroke? maybe #b33.
- Lettuce: wavy band (−58,−16) to (58,−10): draw scalloped: path along top straight and bottom wavy arcs of radius 7 alternating. fill #7ed957? nicer #8bd450, stroke #4f8f2a.
- Top bun: path: start (−52,−12), arc/quad up to (0,−34) to (52,−12), close along bottom line. Use quadratic curves: moveTo(−52,−12); quadraticCurveTo(−54,−30, 0,−32)? Let me: dome via two quadratics: (−52,−12) → cp(−50,−32) → (0,−33) → cp(50,−32) → (52,−12); then lineTo close to (−52,−12). fill #f0b04a? #e8a13b with lighter top: add highlight ellipse. stroke #a86a1e.
- Sesame seeds: fixed offsets [(-34,-24),(-20,-29),(-6,-31),(8,-30),(22,-27),(34,-22),(-27,-20)? maybe y between −26 and −18] small ellipse 3.2×2 rotated, fill #ffe9c2, stroke none, slight shadow.
- Overall outline shadow: before drawing, draw same silhouette offset? Skip; use drop-shadow via ctx.shadowColor once at group start: shadowColor rgba(0,0,0,.35), shadowBlur 8, shadowOffsetY 4, then reset after first fill... Shadows apply per draw; simplest: wrap whole burger draws with shadow set only on bottom bun & top bun (the big shapes), others no shadow. Or draw an ellipse shadow beneath on canvas separately for the paddle. I'll set shadow on first shape then disable.

Also squish animation: when ball hits, squish=1 decays; scale(1+0.15*squish? , 1−0.25*squish) around bottom anchor. Apply via extra transform in drawPaddle: translate(x, y+h/2); scale(1+0.2*k, 1−0.3*k); translate(0, −h/2)... I'll handle with pivot at paddle bottom.

**Meatball draw:**
```
circle fill #7c4a24; stroke #4a2a10; speckles: dots at fixed rel offsets (#5b3416); highlight: white arc alpha .5 at (-r*0.35,-r*0.35) r*0.32.
```
Rotation: rotate speckles by ballSpin accumulating with vx.

**Trail:** store last 8 positions; draw circles alpha decreasing. Cheap.

**Background inside canvas:** fill #241311? Let's pick warm dark: #221210. Pattern: faint dots: draw once per frame? Precompute pattern via offscreen canvas: 32×32 with a subtle dot, createPattern, fill overlay alpha 0.5. Or simpler: radial vignette gradient (cached) + a faint huge heart watermark in center (alpha 0.04, cream color) — thematic. I'll do: bg fill, big heart watermark alpha .05, vignette gradient cached, plus faint horizontal scanline? Keep vignette + heart.

Also brick area backdrop? No.

**HUD:** strip at top: translucent dark bar rgba(0,0,0,0.25) from 0..HUD_H with 2px bottom border #3a2417. Text cream #ffe9c2. SCORE left, LEVEL center-left? Layout: "SCORE 012340" left; center "LEVEL 1"; right mini burgers. Combo shown as floating text near paddle? Show center-right "COMBO ×4" in pink when active. HUD height 52.

Font sizes: 20px Arial Black. Use textAlign left/center/right.

**Now overlay title art:** In attract overlay, draw the title text twice offset for chunky shadow: fillText '#ff2e63' offset (0,4) then cream on top? Classic: dark offset then main. Title "HEARTBREAKER" ~46px, subtitle "BURGER BREAKOUT · EVERY BRICK BLEEDS LOVE" 14px letterspaced. Start prompt 18px pulsing in mustard #ffc93c. Also small drawn burger at top of panel (drawBurger w=90). Cute!

**State machine & timers:**
```
let state='attract', overTimer, levelBannerT=0, serveTimer (demo auto launch).
```
Attract: lives=3 display? AI never dies realistically... if ball falls (rare), just relaunch without penalty (or decrement but refill). I'll make attract lose → instantly respawn ball, no lives lost.

play: lives 3; ball lost → lives−1; if >0 serve else over (overTimer=9 → back to attract).

Level clear (both modes): bannerT=1.8; during banner, ball frozen on paddle; then rebuild bricks? Rebuild immediately but pause ball. I'll: bricks cleared → state stays but levelUp flag: rebuildBricks(level+1), ball to serve position, serveTimer counts, banner shows.

Implementation detail: function levelClear(): level++, speed bump, buildBricks(), serve (state='serve'; in demo autoLaunchT=0.8). bannerT=1.6.

**Win/lose edge cases in demo:** same as play but infinite lives.

**Collision paddle:** treat burger as rect w=paddleW (=112), h=26 for physics, y = paddleY (top). Only when vy>0 and ball.y+r >= y and ball.y − r < y+h and |dx|<=w/2+r. Then reflect: rel = clamp((ball.x−px)/(w/2),−1,1); ang = rel*1.15 (rad ≈ 66°); speed=current speed; vx = sin(ang)*speed; vy = −cos(ang)*speed. Ensure min vy. ball.y = y − r − 0.5. combo reset 0 (but only if >0 show "lost"? fine). squish=1. puff hearts small. sound bounce.

**Bricks loop:** iterate rows/cols; skip dead. Circle-rect: nx = clamp(bx, brickX, brickX+bw), ny = clamp(by, brickY, brickY+bh); dx=bx−nx, dy=by−ny; if dx²+dy² ≤ r²: hit. Determine bounce: if |dx|>|dy| reflect vx else vy; if both zero (center inside) reflect vy. Push ball out along normal: if reflect x: ball.x = nx + (dx>0? r:−r)?? Standard: if dx>0 ball.x = brickRight + r? Actually nx is on brick edge; set ball pos to nx + sign(dx)*r... if dx==0 use previous axis. Simplify: penetration along chosen axis: if axis x: ball.x = (dx>0? brickX+bw+r : brickX−r); similarly y. Then hp−−; if 0 → break: hearts, score, popup, flash, maybe sound; else crack sound small + few hearts (8). Break combo++.

Only process one brick per substep? Allow multiple but typical one. Fine to continue loop (chain!) — chains look awesome with hearts. But double-reflect could jitter; acceptable.

**Speed increase:** each brick broken: speed = min(cap, speed+2.2).

**Substeps:** steps = speed*dt > 7 ? 2 : 1... At cap 620 and dt 0.016 → 10px. 2 substeps → 5px each, r=8, brick h 22 — fine.

Let me now also handle "no horizontal scrollbar": body overflow-x hidden; layout column centered; canvas max-width 100%.

Page structure:
```
<body>
 <div class="awning"></div> (scalloped stripes, maybe 3 stripes height ~18px + scallop via repeating radial-gradient at bottom)
 <header> h1 HEARTBREAKER <p class=sub>...
 <main><div class="cabinet"><canvas></canvas></div></main>
 <footer>hint line: MOUSE MOVE · CLICK / SPACE LAUNCH · ←→ STEER</footer>
```
Awning CSS:
```
.awning{height:26px; background: repeating-linear-gradient(90deg, #e5484d 0 46px, #ffe9c2 46px 92px);}
.awning::after? scallop: use mask? Simpler: separate div .scallop height 12px background: radial-gradient(circle at 23px -2px? ...
```
Scallop technique: 
```
.scallop{height:12px;background: radial-gradient(circle at 23px 0, #e5484d 0 11px, transparent 12px) repeat-x; background-size:46px 12px;}
```
But stripes alternate colors — scallops need matching alternating colors: do two backgrounds offset:
```
background:
 radial-gradient(circle 12px at 23px 0, #e5484d 98%, transparent) ... 
```
With repeating background-size 92px 12px containing two scallops: one red at x=23, one cream at x=69:
```
background-image:
 radial-gradient(circle at 23px -4px, #e5484d 11px, transparent 11.5px),
 radial-gradient(circle at 69px -4px, #ffe9c2 11px, transparent 11.5px);
background-size: 92px 14px; background-repeat: repeat-x;
```
Set strip height 14, circles centered slightly above so they hang down. Circle radius 11 at cy −4 → extends to y=7. Height 14 shows partial. Let me just: height 13; circle at (23, −2) r 11 → bottom at 9. Hmm scallop bottom should be the strip bottom. Use cy=0? circle center at top edge, r 12, strip height 12: scallop half-circles hanging: radial-gradient(circle 12px at 23px 0px, color 12px, transparent 12.5px). With background-size 92px 12px. Good: two scallops per 92px matching stripe width 46. Stripe div height 26 with stripes 46px; scallop div below it. The alignment: stripe period 92 for two-color pair — repeating-linear-gradient(90deg, red 0 46, cream 46 92) period 92. Scallop positions 23 and 69 align centers. 

Also add box-shadow to scallop? skip.

Header: 
```
h1 { font: 900 34px 'Arial Black'...; color:#ffe9c2; letter-spacing:4px; text-shadow: 0 3px 0 #7a1f2b? ...}
```
Add small heart bullet decorations? Use CSS pseudo with content "♥"? That's a text glyph, not emoji — acceptable as decorative glyph? It renders as text heart in most fonts (U+2665). It's a typographic character; I'll use it sparingly in the footer separator, or skip — I can draw hearts via tiny inline SVG. Keep footer simple text with "·" separators.

Title color scheme: cream text with red shadow offset: text-shadow: 3px 3px 0 #e5484d, 6px 6px 0 rgba(0,0,0,.35). Mustard accents for subtitle.

Footer hint in muted cream.

Vertical space: viewport 1280×800: awning 26+12=38, header ~80, canvas 880×620 → 620 + borders 12 = 632, footer 30, margins ~20 → total ≈ 800. Tight! 38+80+632+30+margins(24) = 804. Slightly over. Reduce: header margin small, h1 30px, canvas height fits: make logical canvas 880×600? 620→ 38+70+632+28+16=784 ok. Let me compute precisely later; I'll make body height 100vh flex column with canvas flex-shrink and max-height: calc(100vh − 170px) with aspect-ratio preserved via width auto? If I constrain by height, width shrinks — fine on short screens. CSS:
```
.cabinet{ ... }
canvas{ display:block; width:min(calc(100vw - 40px), 880px); max-height: calc(100vh - 190px); aspect-ratio: 880/620; height:auto; }
```
With max-height + aspect-ratio + width — browser resolves: if width sets height beyond max-height, max-height wins and aspect-ratio adjusts width? With width specified and max-height, aspect-ratio is ignored (width fixed) → distortion. Better: use width: min(calc(100vw−40px), 880px, calc((100vh − 190px) * 880 / 620)). Then height auto via aspect-ratio: 880/620 and height:auto. That keeps ratio. 

dpr resize: observe via ResizeObserver or on resize event recompute canvas.width from clientWidth. I'll do window resize + initial, reading canvas.clientWidth.

Mobile 375px: canvas ~335×236 — small but playable; touch controls move paddle. HUD text at 20px logical scales down — readable-ish. Acceptable. Also drag anywhere on canvas moves paddle (touch). Prevent default touch scrolling: touch-action:none on canvas.

**Pointer mapping:**
```
function toLogical(e){ const r=canvas.getBoundingClientRect(); return {x:(e.clientX−r.left)*880/r.width, y: ...} }
```
Use pointermove; for touch, pointermove fires while touching (with pointer events). Also add pointerdown for launch/start.

Keyboard: keydown: if attract/over → start; else if Space/ArrowUp → launch if serve; track left/right.

Also "press a key to start" — any key. Avoid F5 etc triggering weird — fine.

**Prevent scroll on Space:** e.preventDefault() for arrows/space when game focused (always, since page is the game).

**Now write the actual code.** I'll be meticulous about correctness:

Vars:
```
const W=880,H=620,HUD=52;
let canvas, ctx, dpr;
let state='attract';
let score=0, lives=3, level=1, combo=0, comboBest? not needed.
let paddle={x:W/2, y:H-46, w:116, h:26, speed:560, squish:0, targetX:null};
let ball={x,y,vx,vy,r:8, speed:330, stuck:true, spin:0, trail:[]};
let bricks=[]; // {x,y,w,h,hp,alive,row}
let hearts=[], pops=[], flashes=[];
let shake=0, bannerT=0, demoLaunchT=0, overT=0;
let mouseX=null, keyDir=0;
let tPrev=performance.now(), elapsed=0;
```

Brick build:
```
const COLS=12, ROWS=6, SIDE=26, BGAP=6, BH=22, BTOP=HUD+30;
bw = (W − SIDE*2 − (COLS−1)*BGAP)/COLS → (880−52−66)/12 = 63.5.
rowColors = ['#ff4d6d','#ff754f','#ffb01f','#ffd23f','#8bd450','#e8a952']; hmm yellow twice-ish; choose distinct: 
['#ff4d6d','#ff754f','#ffb01f','#ffd23f','#8bd450','#6fc7c4']? teal last? Diner palette maybe: red, orange, mustard #f2b70, cheese? Let me pick 6 clearly distinct warm colors with pink for hearts contrast:
row0 '#ff4d6d' (raspberry red) hp2 60
row1 '#ff7a4d' (coral) hp2 50
row2 '#ffb020' (mustard) 40
row3 '#ffd93d'?? too close to row2. Use '#ffe066'? still close. Alternate: '#a3d977' lettuce green for row3? Let's order top→bottom: red, coral/orange, mustard, green, cream-tan, pink:
row3 '#8bd450' (lettuce) 30
row4 '#e8a952' (bun tan) 20
row5 '#ff9ebb' (pink) 10
points = 60−row*10.
hp = row<2 ? 2 : 1.
```
2-hit bricks visual: draw slightly darker + a "glossy" star? Show hp2 with a subtle inner border / darker shade; after hit → crack lines. I'll tint: hp2 unbroken has small notch marks? Simpler: hp2 bricks have double border (inner stroke). After hit, hp1 with crack. Good enough.

**Popups:** {x,y,txt,life}. rise 40px/s, fade 0.9s. Draw 700? font 15px Arial Black cream with dark outline (strokeText).

**Flash ring:** {x,y,r,life} expanding stroke circle pink fading 0.25s.

**Heart drawing color variety with slight lighten for glow ones:** glow hearts get shadowColor pink shadowBlur 10 — but per-particle shadow state changes are costly; cap glows to 10% and skip if hearts.length>350.

Simplify: no shadow, but glow hearts drawn slightly larger with white core? I'll draw glow hearts with a second smaller white heart overlay alpha .5. Cheap and cute.

**Score formatting:** padStart(6,'0').

**Level banner:** during bannerT>0 draw centered text "LEVEL "+level with scale pop. Ball stuck anyway.

**Serve state:** show small pulsing "CLICK TO LAUNCH" above burger in play mode; demo shows nothing (auto-launch). 

**Attract overlay panel:** draw rounded rect panel centered, w 560 h ~300, fill rgba(24,12,10,0.82), border 2px #e5484d? Cream border. Contents:
- mini burger at top center (drawBurger w=110)
- title "HEARTBREAKER" 40px, cream w/ red offset shadow
- subtitle "A BURGER WITH A BOTTOM-BUN MISSION" 13px mustard letterspaced — hmm copy: "SMASH BRICKS. SHOWER HEARTS." good.
- control hints line 12px muted: "MOUSE / TOUCH / ← → MOVE · CLICK OR SPACE LAUNCH"
- pulsing prompt: "CLICK OR PRESS ANY KEY TO START" 17px pink pulsing alpha, maybe scale.
Also above panel a small "DEMO" ribbon: text "CHEF DEMO PLAYING" 11px.

The dim behind panel: rgba(20,8,6,0.35).

Panel entrance animation: slide/scale in over 0.4s using elapsed since state change? Nice: panelK animates 0→1 with easeOutBack. track stateTime.

**Game over panel:** similar: "GAME OVER" red big, "SCORE 012340" cream, "LEVEL reached", "CLICK OR PRESS ANY KEY TO TRY AGAIN" pulsing. overT countdown 9 → attract.

**startGame():**
```
score=0; lives=3; level=1; buildBricks(); resetBallServe(); state='serve'; combo=0; panelK=0;
```
Also demoStart(): lives=3 (display), same but state='serve' with demoLaunchT.

At boot: demoStart().

In update: if state==='attract': AI controls paddle & auto launch: demoLaunchT−=dt; when serve in attract and demoLaunchT<=0 → launchBall().

Actually unify: state 'serve' exists in both modes; mode = (state==='attract')? No — I used attract as state. Let me restructure: `mode` = 'demo'|'player'; `phase` = 'serve'|'play'|'over'|'banner'? Simpler single state var: 'attract' handles everything in demo (serve/play internal). Hmm.

Cleaner: 
- `mode`: 'demo' | 'play'
- `phase`: 'serve' | 'play' | 'over'
- overlay visible when mode==='demo' OR phase==='over'.
- start target: when user activates → mode='play', phase='serve', fresh scores.
- When phase==='over' and overT expires → mode='demo', resetGame for demo, phase='serve'.
- In demo, ball loss → phase='serve' directly (no life loss, keep lives=3 for display).

Input:
```
function userActivate(){ ensureAudio(); if(mode==='demo' || phase==='over'){ startPlay(); } else if(phase==='serve'){ launch(); } }
```
keydown: always userActivate()? If playing and pressing arrows, don't relaunch. So keydown: if(mode==='demo'||phase==='over') startPlay(); else { if Space && serve → launch; set dirs }. Also pressing key during serve (any key?) — only Space/click launches; arrows just move. Fine.

pointerdown: same activation logic (in serve → launch).

**AI update (mode demo, phase play/serve):**
```
const predict = ball.stuck? W/2 + sin(elapsed*0.8)*60 : ball.x + ball.vx*0.18;
target = clamp(predict, paddle.w/2+8, W−paddle.w/2−8) + sin(elapsed*1.3)*10;
paddle.x += clamp(target−paddle.x, ±420*dt);
```
Auto-launch: when serve: demoT counts to 0.7+rand.

**Player paddle update:** if keyDir → x += dir*speed*dt; else if mouseX != null → ease toward mouseX: x += (mx−x)*min(1, dt*20)? For snappy: direct with slight smoothing 1−exp(−dt*25). Clamp x.

mouseX updates on pointermove; store latest; smoothing lerp factor 1−0.001^dt? Use x += (mx−x)*(1−Math.exp(−dt*30)). Feels responsive.

**launch():**
```
const a = (-60..60 deg random slight)? Standard: angle = (−20 + rand*40) degrees from vertical: 
vy = −cos, vx = sin. speed = base.
ball.stuck=false; sound.
```
**Ball update:** if stuck: follow paddle. else substeps: move, wall bounce (x<r+? left wall at 0? playfield walls at 0..W, top at HUD). Top wall y=HUD. 

Bottom: y − r > H → lose ball: hearts? spawn small heart burst falling? Just sound + shake small; if demo → serve; else lives−−; lives>0? serve : over (overT=9).

**Paddle/ball also when ball below paddle but above bottom — no catch. fine.**

**Combo popup at paddle:** when combo≥3 and ball hits paddle, popup "COMBO ×N"? Show HUD combo text with scale pop when incremented (comboPop=1 decay).

**Sounds via helper:**
```
let AC=null;
function ensureAudio(){ if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); mg=AC.createGain(); mg.gain.value=0.22; mg.connect(AC.destination);}catch(e){} } if(AC&&AC.state==='suspended') AC.resume(); }
function tone(f, dur, type='square', vol=0.5, slide=0){ if(!AC) return; try{ const t=AC.currentTime; const o=AC.createOscillator(), g=AC.createGain(); o.type=type; o.frequency.setValueAtTime(f,t); if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,slide), t+dur); g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(0.001,t+dur); o.connect(g); g.connect(mg); o.start(t); o.stop(t+dur+0.02);}catch(e){} }
```
Sounds: 
- wall: tone(190,0.05,'square',0.25)
- paddle: tone(150,0.08,'square',0.4, 90)? A low thump: 140 slide 70.
- brick: tone(520+min(combo,10)*45, 0.09,'triangle',0.5, up? slide to *1.5)
- burst chime: two tones staggered via setTimeout? Use start times: tone2 with delay param. I'll add delay param: start at t+delay. burst: tone(660,0.12,'sine',0.35)+tone(990,0.14,'sine',0.3,0,0.05 delay). Keep simple: single higher ping + the brick tone covers it.
- lose: tone(200,0.4,'sawtooth',0.4,60)
- launch: tone(300,0.1,'square',0.3,520)
- level: arpeggio: 3 tones delays 0, .09, .18: 523,659,784.

Add `delay` param.

**Rendering order:** bg → vignette/watermark → flashes behind bricks? order: bg, bricks, popups, hearts, ball trail, ball, paddle, HUD, banners/overlays, then screen shake wraps all (translate before draw, after clear). Shake: ctx.translate(rx, ry) at start.

Let me write final code now. I'll be careful with function definitions and avoid errors. Also make sure no console errors: guard AudioContext, ResizeObserver optional (use window resize only + initial).

One more thing: dt clamp (max 0.033) to avoid tunneling on tab switch. elapsed accumulates.

Ball spin: ball.spin += ball.vx*dt*0.02.

Trail: push {x,y} every frame, keep 10; draw with alpha i/10*0.25, radius scaled.

Brick crack rendering: if brick.hp < brick.maxHp: draw crack: two polyline strokes dark alpha .5 across brick: precomputed per brick random? Just draw a zigzag from top to bottom at brick center with random offset stored at hit time (brick.crack = rand seed). Draw simple: line from (cx−8, y) to (cx+2, y+h*0.5) to (cx−4, y+h). Fine.

hp2 unbroken style: draw inner stroke 2px inset lighter (like glossy candy). Also maybe slightly taller? no.

Let me also add slight rounded highlight on bricks: white alpha 0.18 top inset bar (h 4). 

Watermark heart: use heartPath with s=170 at center (W/2, H/2+40), fill 'rgba(255,150,180,0.045)'.

Vignette: cached radial gradient (recreate on resize? gradient in logical coords — cache once since logical size fixed). Yes: logical coords fixed 880×620, so create gradients once.

HUD text baseline: use textBaseline='middle', y=26.

Lives mini burgers: for i in 0..lives−1 (cap 5): drawBurger(ctx, W−26−i*36, 26, 30). Also label "LIVES" small? skip label, icons are clear. Maybe tiny text left of them? Skip.

Demo mode: show lives as 3 normally.

**Level banner rendering:** if bannerT>0: bannerT−=dt; scale = 1+ (something). draw text "LEVEL "+level big center with pink/cream, plus "SPEED UP!" small. Actually show when new level starts (after rebuild). Fine.

Also first-serve hint in play: pulsing text above burger: "CLICK TO LAUNCH" alpha sine.

Edge: hearts spawn during HUD area fine.

**Total particles perf:** update loop O(n), fine.

Now — writing the whole file. Let me draft carefully.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HEARTBREAKER — Burger Breakout</title>
<style>
  :root{
    --cream:#ffe9c2; --red:#e5484d; --pink:#ff2e63; --mustard:#ffc93c; --deep:#20100c;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%}
  body{
    background:
      radial-gradient(1200px 700px at 50% -10%, #38211a 0%, transparent 60%),
      radial-gradient(900px 600px at 50% 115%, #2b1712 0%, transparent 60%),
      var(--deep);
    color:var(--cream);
    font-family:"Trebuchet MS","Verdana",Arial,sans-serif;
    display:flex; flex-direction:column; align-items:center;
    overflow-x:hidden;
    min-height:100vh;
  }
  .awning{ width:100%; height:26px; flex:none;
    background:repeating-linear-gradient(90deg,#e5484d 0 46px,#ffe9c2 46px 92px);
  }
  .scallop{ width:100%; height:12px; flex:none;
    background-image:
      radial-gradient(circle 12px at 23px 0px, #e5484d 11.5px, transparent 12px),
      radial-gradient(circle 12px at 69px 0px, #ffe9c2 11.5px, transparent 12px);
    background-size:92px 12px; background-repeat:repeat-x;
  }
```
Wait: radial-gradient(circle 12px at ...) syntax: `radial-gradient(circle 12px at 23px 0, color 0 11.5px, transparent 12px)` — the size 12px then color stops with px lengths. Correct syntax: `radial-gradient(12px 12px at 23px 0px, #e5484d 0 11px, transparent 12px)` using ellipse dims. I'll use that form: `radial-gradient(12px 12px at 23px 0px, ...)`.

Scallop shadow under? skip.

Header:
```
header{ text-align:center; padding:10px 12px 6px; flex:none;}
h1{ font-family:"Arial Black","Arial Bold",Arial,sans-serif; font-weight:900;
   font-size:clamp(22px,4vw,34px); letter-spacing:6px; color:var(--cream);
   text-shadow:2px 2px 0 var(--red), 4px 4px 0 rgba(0,0,0,.4);
}
h1 .heart{ color:var(--pink); }
```
Add inline SVG heart in title? Use drawn heart character? I'll add two small inline SVG hearts flanking title via markup — inline SVG allowed (no external). 
```
<svg class="tico" viewBox="0 0 24 22" width="22" height="20"><path d="M12 21 C5 15 1 10.5 1 6.5 C1 3 3.5 1 6.5 1 C9 1 11 2.5 12 4.5 C13 2.5 15 1 17.5 1 C20.5 1 23 3 23 6.5 C23 10.5 19 15 12 21 Z" fill="#ff2e63" stroke="#7a1029" stroke-width="1.5"/></svg>
```
Flank title: HEART <svg> BREAKER? Title: "HEARTBREAKER" with one heart replacing the A? Simplest: heart icon before title text, and after. ok.

Subtitle: `.sub{ font-size:12px; letter-spacing:3px; color:var(--mustard); margin-top:4px; }` copy: "BURGER PADDLE · BRICKS BURST INTO HEARTS".

Cabinet:
```
main{ flex:1 0 auto; display:flex; align-items:center; justify-content:center; width:100%; padding:6px 14px 10px; min-height:0;}
.cabinet{ border:6px solid #4a2c1a; outline:2px solid #2a1810? ; border-radius:14px; background:#160b08;
  box-shadow:0 14px 40px rgba(0,0,0,.55), inset 0 0 0 2px rgba(255,233,194,.12);
  line-height:0; max-width:100%;
}
canvas{ display:block; width:min(calc(100vw - 76px), 880px, calc((100vh - 220px) * 1.4194)); aspect-ratio:880/620; height:auto; border-radius:8px; touch-action:none; cursor:none? 
```
cursor: crosshair maybe. cursor:none hides pointer over canvas — but users need to aim... paddle follows mouse so hiding cursor is nice (paddle IS the cursor). But over overlays they need to click — cursor none still clickable. I'll use cursor:none on canvas for immersion. Hmm on desktop it's cool. Keep `cursor:none`.

Wait width min with 3 values: `min(A, B, C)` valid in CSS with multiple args. 880/620 = 1.41935. (100vh − 220px)*1.4194 ensures fits vertically. Let me compute vertical budget: awning 26 + scallop 12 + header ~ 66 (10+34+4+12+6) + main padding 16 + cabinet border 12 + footer ~34 → ~170-200. Use 100vh − 210px to be safe: canvas height ≤ that, width = h*1.4194.

At 1280×800: height budget = 590 → width 837 — slightly less than 880, fine. Hmm I'd like closer to 880 wide. Reduce overhead: header compact: h1 30px, paddings small → maybe 190. canvas h=610*? Let me set formula (100vh − 196px). 800−196=604 h → 857 w. Fine, still large.

Footer: `.foot{ flex:none; padding:6px 10px 12px; font-size:11px; letter-spacing:2.5px; color:rgba(255,233,194,.55); }`

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

```js
(()=>{ 
'use strict';
const W=880, H=620, HUD=52;
const TAU=Math.PI*2;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');

/* ---------- responsive backing store ---------- */
let viewW=W;
function resize(){
  const cssW=canvas.clientWidth||W;
  const dpr=Math.min(window.devicePixelRatio||1,2);
  canvas.width=Math.round(cssW*dpr);
  canvas.height=Math.round(cssW*(H/W)*dpr);
  const s=canvas.width/W;
  ctx.setTransform(s,0,0,s,0,0);
  ctx.textBaseline='middle';
}
window.addEventListener('resize',resize);
```
Note: since CSS uses aspect-ratio, clientWidth reflects final width. Initial call after layout — call resize() once at start; also ResizeObserver on canvas for robustness:
```
if(window.ResizeObserver){ new ResizeObserver(resize).observe(canvas); }
```
ResizeObserver callback with canvas — fires initially. Keep both (resize idempotent).

Set font smoothing not needed.

Game state:
```
let mode='demo';            // 'demo' | 'play'
let phase='serve';          // 'serve' | 'play' | 'over'
let phaseT=0;               // time in current phase
let score=0, lives=3, level=1, combo=0, comboPop=0;
let bricks=[], hearts=[], pops=[], flashes=[];
let shake=0, bannerT=0, overT=0, serveT=0;
let mouseX=null, keyL=false, keyR=false;
let elapsed=0, last=performance.now();
```
Paddle/ball:
```
const paddle={x:W/2, y:H-44, w:116, h:26, speed:600, squish:0, vx:0};
const ball={x:W/2,y:0,vx:0,vy:0,r:8,speed:330,spin:0,stuck:true,trail:[]};
```
paddle.y = top of burger physics box. Burger drawn centered at (x, y+h/2).

Bricks:
```
const COLS=12, ROWS=6, SIDE=26, GAP=6, BH=22, BTOP=HUD+30;
const BW=(W-SIDE*2-(COLS-1)*GAP)/COLS; // 63.5
const ROWS_COL=['#ff4d6d','#ff7a4d','#ffb020','#8bd450','#e8a952','#ff9ebb'];
function buildBricks(){
  bricks=[];
  for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
    bricks.push({x:SIDE+c*(BW+GAP), y:BTOP+r*(BH+GAP), w:BW, h:BH,
      row:r, hp:r<2?2:1, max:r<2?2:1, alive:true, crack:0});
  }
}
function brickPoints(r){ return (ROWS-r)*10; }
```
BTOP = 82; rows occupy 82 .. 82+6*28−6 = 82+162=244. Paddle at 576. Ball travel area 244→576: 332px. Good.

Serve:
```
function toServe(){
  phase='serve'; phaseT=0; serveT= mode==='demo' ? 0.7+Math.random()*0.6 : 0;
  ball.stuck=true; ball.trail.length=0; ball.speed=Math.min(620, 330+(level-1)*30);
  combo=0;
}
function launch(){
  ball.stuck=false;
  const a=(Math.random()*0.6-0.3);       // radians from vertical
  ball.vx=Math.sin(a)*ball.speed;
  ball.vy=-Math.cos(a)*ball.speed;
  sfx.launch();
}
```
Also ensure not perfectly vertical? a≥0.3 rad offset always gives vx. random in [−0.3,0.3] fine.

startPlay:
```
function startPlay(){
  mode='play'; score=0; lives=3; level=1; buildBricks(); toServe();
  sfx.uiStart();
}
function startDemo(){
  mode='demo'; score=0; lives=3; level=1; buildBricks(); toServe();
}
```
levelUp:
```
function levelUp(){
  level++; buildBricks(); bannerT=1.8; toServe();
  sfx.level();
}
```
Lose ball:
```
function loseBall(){
  sfx.lose(); shake=Math.max(shake,6);
  if(mode==='demo'){ toServe(); return; }
  lives--;
  if(lives>0){ toServe(); } else { phase='over'; phaseT=0; overT=9; }
}
```
Burst:
```
const HEART_COLS=['#ff2e63','#ff5d8f','#ff8fab','#ff4d6d','#ffc2d4','#e6437a'];
function burst(x,y,n,pow){ // pow 1 = brick, 0.4 = puff
  const budget=Math.max(0, 720-hearts.length);
  n=Math.min(n,budget);
  for(let i=0;i<n;i++){
    const a=Math.random()*TAU;
    const sp=(60+Math.random()*260)*pow;
    hearts.push({
      x,y,
      vx:Math.cos(a)*sp,
      vy:Math.sin(a)*sp-pow*(40+Math.random()*160),
      rot:Math.random()*TAU, vr:(Math.random()-0.5)*7,
      s:3.2+Math.random()*5.5,
      life:1, dk:0.62+Math.random()*0.5,
      sw:1.5+Math.random()*3.5, sf:2+Math.random()*4,
      t:Math.random()*TAU,
      col:HEART_COLS[(Math.random()*HEART_COLS.length)|0],
      glow:Math.random()<0.14
    });
  }
}
```
Popups:
```
function popup(x,y,txt,col){ pops.push({x,y,txt,col:col||'#ffe9c2',life:1}); }
```
Flash:
```
function flash(x,y){ flashes.push({x,y,r:6,life:1}); }
```
SFX object:
```
const sfx=(()=>{ 
 let AC=null, master=null;
 function ensure(){ 
   try{ if(!AC){ const C=window.AudioContext||window.webkitAudioContext; if(!C) return; AC=new C(); master=AC.createGain(); master.gain.value=0.22; master.connect(AC.destination);} if(AC.state==='suspended')AC.resume(); }catch(e){AC=null;}
 }
 function tone(f,d,type,vol,slide,delay){
   if(!AC)return;
   try{
     const t=AC.currentTime+(delay||0);
     const o=AC.createOscillator(),g=AC.createGain();
     o.type=type||'square'; o.frequency.setValueAtTime(f,t);
     if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,slide),t+d);
     g.gain.setValueAtTime(vol||0.4,t);
     g.gain.exponentialRampToValueAtTime(0.0008,t+d);
     o.connect(g).connect? o.connect(g); g.connect(master);
     o.start(t); o.stop(t+d+0.03);
   }catch(e){}
 }
 return {
   ensure,
   wall(){tone(190,0.05,'square',0.18);},
   paddle(){tone(140,0.09,'square',0.35,80);},
   brick(c){tone(460+Math.min(c,12)*40,0.09,'triangle',0.4, 900);},
   pop(){tone(660,0.1,'sine',0.3,990);tone(990,0.12,'sine',0.22,1320,0.06);},
   crack(){tone(300,0.06,'square',0.22,240);},
   launch(){tone(280,0.12,'square',0.28,540);},
   lose(){tone(220,0.5,'sawtooth',0.3,55);},
   level(){tone(523,0.12,'triangle',0.35);tone(659,0.12,'triangle',0.35,null,0.09);tone(784,0.16,'triangle',0.35,null,0.18);},
   uiStart(){tone(392,0.08,'square',0.3);tone(523,0.12,'square',0.3,null,0.08);}
 };
})();
```
Fix `o.connect(g).connect?` — just `o.connect(g); g.connect(master);`.

Update loop:
```
function update(dt){
  elapsed+=dt; phaseT+=dt;
  if(bannerT>0)bannerT-=dt;
  if(comboPop>0)comboPop-=dt*3;
  if(shake>0)shake=Math.max(0,shake-dt*30);
  paddle.squish=Math.max(0,paddle.squish-dt*5);

  // ---- paddle control
  if(mode==='demo'){
    const predict=ball.stuck? W/2 : ball.x+ball.vx*0.16;
    let target=predict+Math.sin(elapsed*1.4)*12;
    target=Math.max(paddle.w/2+6,Math.min(W-paddle.w/2-6,target));
    const dx=target-paddle.x;
    const mv=Math.min(Math.abs(dx),430*dt);
    paddle.x+=Math.sign(dx)*mv;
    if(phase==='serve'){ serveT-=dt; if(serveT<=0) launch(); }
  }else{
    let dir=(keyR?1:0)-(keyL?1:0);
    if(dir){ paddle.x+=dir*paddle.speed*dt; mouseX=paddle.x; } 
```
Hmm — if keyboard used, sync mouseX so mouse doesn't yank back. Actually better: only follow mouse when mouse recently moved. Track lastInput: if keys pressed set mouseX=paddle.x (so mouse easing holds). But then when they move mouse again it updates. Good.
```
    else if(mouseX!=null){ paddle.x+=(mouseX-paddle.x)*Math.min(1,dt*28); }
    paddle.x=Math.max(paddle.w/2+4,Math.min(W-paddle.w/2-4,paddle.x));
    if(phase==='serve' && ...) // player launches via input only
  }

  // ---- ball
  if(ball.stuck){
    ball.x=paddle.x; ball.y=paddle.y-ball.r-3;
  }else if(phase==='play'){
    const steps=ball.speed*dt>7?2:1;
    const sdt=dt/steps;
    for(let s=0;s<steps;s++){ moveBall(sdt); if(phase!=='play')break; }
    ball.spin+=ball.vx*dt*0.06;
    ball.trail.unshift({x:ball.x,y:ball.y});
    if(ball.trail.length>9)ball.trail.pop();
  }

  // particles etc always update
  updateHearts(dt); updatePops(dt); updateFlashes(dt);

  // over countdown
  if(phase==='over'){ overT-=dt; if(overT<=0) startDemo(); }
}
```
moveBall:
```
function moveBall(dt){
  ball.x+=ball.vx*dt; ball.y+=ball.vy*dt;
  // walls
  if(ball.x<ball.r){ ball.x=ball.r; ball.vx=Math.abs(ball.vx); sfx.wall(); }
  else if(ball.x>W-ball.r){ ball.x=W-ball.r; ball.vx=-Math.abs(ball.vx); sfx.wall(); }
  if(ball.y<HUD+ball.r){ ball.y=HUD+ball.r; ball.vy=Math.abs(ball.vy); sfx.wall(); }
  // bottom
  if(ball.y-ball.r>H){ loseBall(); return; }
  // paddle
  if(ball.vy>0){
    const px=paddle.x, py=paddle.y, pw=paddle.w, ph=paddle.h;
    if(ball.y+ball.r>=py && ball.y-ball.r<py+ph+6 && ball.x>px-pw/2-ball.r && ball.x<px+pw/2+ball.r){
      const rel=Math.max(-1,Math.min(1,(ball.x-px)/(pw/2)));
      const ang=rel*1.12;
      ball.vx=Math.sin(ang)*ball.speed;
      ball.vy=-Math.cos(ang)*ball.speed;
      ball.y=py-ball.r-0.5;
      paddle.squish=1;
      if(combo>0){ combo=0; }
      burst(ball.x,py,7,0.35);
      sfx.paddle();
    }
  }
  // bricks
  for(const b of bricks){
    if(!b.alive)continue;
    const nx=Math.max(b.x,Math.min(ball.x,b.x+b.w));
    const ny=Math.max(b.y,Math.min(ball.y,b.y+b.h));
    const dx=ball.x-nx, dy=ball.y-ny;
    if(dx*dx+dy*dy<=ball.r*ball.r){
      // resolve axis
      if(Math.abs(dx)>Math.abs(dy)){
        ball.vx=dx>0?Math.abs(ball.vx):-Math.abs(ball.vx);
        ball.x=nx+(dx>0?ball.r:-ball.r);
      }else{
        ball.vy=dy>0?Math.abs(ball.vy):-Math.abs(ball.vy);
        ball.y=ny+(dy>0?ball.r:-ball.r);
      }
      hitBrick(b);
      break; // one per substep keeps physics sane
    }
  }
}
```
Hmm the ball.x = nx ± r when dx could be 0 (inside): if dx==0 and dy==0 (ball center inside brick), nx=ball.x → dx=0; then branch |dx|>|dy| false → vy branch with dy=0 → dy>0 false → vy=−|vy| and ball.y = ny − r = ball.y − r — pushes up. OK.

Edge: dx>0?Math.abs → when hitting from right, dx>0 → vx positive (moving right). Correct.

Speed up per brick:
```
function hitBrick(b){
  b.hp--;
  ball.speed=Math.min(640, ball.speed+2.0);
  const sp=ball.speed;
  // renormalize velocity magnitude to new speed
  const m=Math.hypot(ball.vx,ball.vy)||1;
  ball.vx*=sp/m; ball.vy*=sp/m;
  if(b.hp<=0){
    b.alive=false;
    combo++;
    comboPop=1;
    const pts=brickPoints(b.row)*(1+Math.max(0,combo-1)*0.25)|0;
```
Careful with precedence: `(brickPoints * mult) | 0` — write explicitly: `const pts=Math.round(brickPoints(b.row)*(1+Math.max(0,combo-1)*0.25));`
```
    score+=pts;
    const cx=b.x+b.w/2, cy=b.y+b.h/2;
    burst(cx,cy, 26+Math.min(26,combo*4), 1);
    flash(cx,cy);
    popup(cx,cy-6,'+'+pts, combo>=4?'#ff9ebb':'#ffe9c2');
    shake=Math.max(shake, 3+Math.min(5,combo*0.7));
    sfx.brick(combo); sfx.pop();
    if(!bricks.some(k=>k.alive)) levelUp();
  }else{
    b.crack=1;
    burst(b.x+b.w/2,b.y+b.h/2,6,0.6);
    sfx.crack();
  }
}
```
levelUp inside hitBrick — fine (called during moveBall; sets phase via toServe → ball stuck; the substep loop breaks since phase!=='play'? toServe sets phase='serve' → break). Good.

Particles:
```
function updateHearts(dt){
  for(let i=hearts.length-1;i>=0;i--){
    const h=hearts[i];
    h.t+=dt;
    h.vy+=460*dt;
    h.vx*=Math.pow(0.9,dt*60)? // drag: h.vx -= h.vx*1.2*dt;
    h.vx-=h.vx*Math.min(0.9,1.4*dt);
    h.x+=(h.vx+Math.sin(h.t*h.sf)*h.sw*14)*dt;
    h.y+=h.vy*dt;
    h.rot+=h.vr*dt;
    h.life-=h.dk*dt*0.7;
    if(h.life<=0||h.y>H+20){ 
      // swap-remove
      hearts[i]=hearts[hearts.length-1]; hearts.pop();
    }
  }
}
```
Flutter term: sin(...)*sw*14 as velocity px/s — sw up to 5 → ±70 px/s sway. ok.

updatePops: life−=dt*1.1; y−=45*dt; remove ≤0.
updateFlashes: life−=dt*4; r+=140*dt; remove.

Rendering:

```
function render(){
  // clear with bg
  ctx.save();
  if(shake>0){ ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake); }
  drawBackground();
  drawBricks();
  drawFlashes();
  drawHearts();
  drawBall();
  drawPaddle();
  drawPops();
  drawHUD();
  ctx.restore();
  // overlays unshaken
  if(bannerT>0) drawBanner();
  if(phase==='over') drawOverPanel();
  else if(mode==='demo') drawAttractPanel();
  else if(phase==='serve') drawServeHint();
}
```
Wait attract panel shows during demo serve+play. Yes.

drawBackground:
```
let heartMarkPath=null; // cache path2d? Use Path2D for watermark & hearts? Path2D supported. I'll build heart as Path2D once at unit scale and use transforms.
const HEART=new Path2D();
HEART.moveTo(0,1);
HEART.bezierCurveTo(-1.15,0.35,-0.95,-0.6,-0.5,-0.6);
HEART.bezierCurveTo(-0.12,-0.6,-0.03,-0.35,0,-0.2);
HEART.bezierCurveTo(0.03,-0.35,0.12,-0.6,0.5,-0.6);
HEART.bezierCurveTo(0.95,-0.6,1.15,0.35,0,1);
HEART.closePath();
```
Unit heart: tip at (0,1), lobes top at −0.6, width ±~0.95. Scale by s (≈ half-height). Draw: save, translate, rotate, scale(s,s), fill(HEART). 

drawBackground:
```
function drawBackground(){
  ctx.fillStyle='#231210';
  ctx.fillRect(-10,-10,W+20,H+20);
  // watermark heart
  ctx.save();
  ctx.translate(W/2,H/2+30);
  ctx.scale(150,150);
  ctx.fillStyle='rgba(255,120,150,0.05)';
  ctx.fill(HEART);
  ctx.restore();
  // vignette
  const g=vignette||(vignette=ctx.createRadialGradient(W/2,H/2,200,W/2,H/2,620));
  g ... fill
}
let vignette=null;
```
Create once: addColorStop(0,'rgba(0,0,0,0)'), (1,'rgba(0,0,0,0.5)'). fillRect whole.

drawBricks:
```
function drawBricks(){
  for(const b of bricks){
    if(!b.alive)continue;
    const col=ROWS_COL[b.row];
    ctx.fillStyle=col;
    roundRect(b.x,b.y,b.w,b.h,5);
    ctx.fill();
    // top gloss
    ctx.fillStyle='rgba(255,255,255,0.22)';
    roundRect(b.x+3,b.y+2.5,b.w-6,4.5,2.5);
    ctx.fill();
    // outline
    ctx.strokeStyle='rgba(0,0,0,0.35)';
    ctx.lineWidth=1.5;
    roundRect(b.x+0.75,b.y+0.75,b.w-1.5,b.h-1.5,4);
    ctx.stroke();
    if(b.max===2){
      if(b.hp===b.max){ // inner ring shows toughness
        ctx.strokeStyle='rgba(255,255,255,0.5)';
        ctx.lineWidth=1.2;
        roundRect(b.x+4,b.y+4,b.w-8,b.h-8,3);
        ctx.stroke();
      }else{
        // crack
        ctx.strokeStyle='rgba(40,10,15,0.7)';
        ctx.lineWidth=1.6;
        ctx.beginPath();
        const cx=b.x+b.w*0.5;
        ctx.moveTo(cx-6,b.y+3);
        ctx.lineTo(cx+2,b.y+b.h*0.45);
        ctx.lineTo(cx-4,b.y+b.h-3);
        ctx.stroke();
      }
    }
  }
}
```
roundRect helper (ctx.roundRect exists in Chrome but write fallback):
```
function roundRect(x,y,w,h,r){
  ctx.beginPath();
  ctx.moveTo(x+r,y);
  ctx.arcTo(x+w,y,x+w,y+h,r);
  ctx.arcTo(x+w,y+h,x,y+h,r);
  ctx.arcTo(x,y+h,x,y,r);
  ctx.arcTo(x,y,x+w,y,r);
  ctx.closePath();
}
```
Or use ctx.roundRect if available (Chrome yes). I'll just use helper everywhere — safe.

drawHearts:
```
function drawHearts(){
  for(const h of hearts){
    const a=Math.max(0,Math.min(1,h.life));
    ctx.save();
    ctx.translate(h.x,h.y);
    ctx.rotate(h.rot);
    const s=h.s*(0.7+0.3*a);
    ctx.scale(s,s);
    ctx.globalAlpha=a;
    ctx.fillStyle=h.col;
    ctx.fill(HEART);
    if(h.glow){
      ctx.scale(0.45,0.45);
      ctx.fillStyle='rgba(255,255,255,0.85)';
      ctx.fill(HEART);
    }
    ctx.restore();
  }
  ctx.globalAlpha=1;
}
```
Note HEART spans y −0.6..1 → visual center offset ~0.2 below origin; fine.

drawBall (meatball):
```
function drawBall(){
  // trail
  for(let i=ball.trail.length-1;i>=0;i--){
    const p=ball.trail[i];
    ctx.globalAlpha=0.16*(1-i/ball.trail.length);
    ctx.fillStyle='#ff9ebb';
    ctx.beginPath();ctx.arc(p.x,p.y,ball.r*0.85,0,TAU);ctx.fill();
  }
  ctx.globalAlpha=1;
  ctx.save();
  ctx.translate(ball.x,ball.y);
  ctx.rotate(ball.spin);
  ctx.fillStyle='#7c4a24';
  ctx.beginPath();ctx.arc(0,0,ball.r,0,TAU);ctx.fill();
  ctx.strokeStyle='#46280f';ctx.lineWidth=1.6;ctx.stroke();
  // speckles
  ctx.fillStyle='#5b3416';
  [[0.35,0.1,1.7],[-0.3,0.3,1.4],[0.05,-0.42,1.5],[-0.42,-0.12,1.2]].forEach(d=>{
    ctx.beginPath();ctx.arc(d[0]*ball.r? wait coords
```
Speckles at scaled positions: arc(d[0]*ball.r? They should be within circle: positions like (2.8,0.9) px. Use d values multiplied by ball.r: arc(d[0]*ball.r, d[1]*ball.r, d[2],0,TAU). r 1.2-1.7 px. ok.
```
  // highlight
  ctx.fillStyle='rgba(255,235,210,0.5)';
  ctx.beginPath();ctx.arc(-ball.r*0.32,-ball.r*0.35,ball.r*0.3,0,TAU);ctx.fill();
  ctx.restore();
}
```
Trail color pink-ish? Trail of meatball should be brownish; pink reads as hearts theme. Use '#a9683f'? I'll use warm brown alpha — actually a subtle pink glow trail looks magic and matches theme. Keep pink low alpha.

drawPaddle: burger.
```
function drawPaddle(){
  const x=paddle.x, yTop=paddle.y, w=paddle.w;
  const k=paddle.squish;
  ctx.save();
  ctx.translate(x, yTop+paddle.h);      // pivot at bottom
  ctx.scale(1+0.16*k, 1-0.22*k);
  ctx.translate(0, -(paddle.h/2));      // drawBurger centered at (0,0) with width w
  drawBurger(ctx, 0, 0, w+6);
  ctx.restore();
}
```
Wait drawBurger(x,y,w) centered. translate(x, yTop+h) → bottom pivot. scale. then we need burger center at (0, −h/2) relative → after scale, translate(0, −paddle.h/2). Since scale applies to subsequent translate — translate(0,−13) in scaled space ≈ fine.

drawBurger implementation (base width 120 design units, scale s=w/120):
```
function drawBurger(c,cx,cy,w){
  const s=w/120;
  c.save();
  c.translate(cx,cy);
  c.scale(s,s);
  c.lineWidth=2.4;
  c.lineJoin='round';
  // shadow under
  c.fillStyle='rgba(0,0,0,0.35)';
  c.beginPath();c.ellipse(0,20,52,7,0,0,TAU);c.fill();
```
Hmm shadow ellipse below burger — for HUD minis also ok. But it's inside squish transform... fine.

Geometry (design units, center 0,0; total height ~ 56: from −34 top bun to +16 bottom bun + shadow):
```
  // bottom bun
  bun(−48,6,96,12, r bottom 8) → use roundRectV path:
  c.fillStyle='#e8a13b';
  c.strokeStyle='#9c6218';
  roundRectOn(c,-46,5,92,12,{tl:4,tr:4,bl:8,br:8}) — simpler: roundRect radius 6 all.
  c.fill();c.stroke();
  // patty
  c.fillStyle='#7a4a21'; c.strokeStyle='#4a2a10';
  rr(-54,-4,108,13,6.5); fill;stroke;
  // grill marks
  c.strokeStyle='rgba(40,20,8,0.55)'; c.lineWidth=1.8;
  lines at x=−28..: three short vertical-ish lines? Horizontal grill lines: moveTo(-30,-1) lineTo... let me: two horizontal lines y=0 and y=4 from −40..−24, −10..6, 20..36? Keep: 
  beginPath; moveTo(-36,1);lineTo(-18,1); moveTo(2,4);lineTo(24,4); stroke;
  // cheese with drips
  c.fillStyle='#ffc93c'; c.strokeStyle='#d69a1a'; c.lineWidth=2.4;
  path: start(-57,-6) → lineTo(57,-6) → lineTo(57,-2) → drips along bottom from 57 to −57: zigzag: 
   lineTo(44,-2) then drip down: lineTo(40,6) lineTo(36,-2)? dripping triangles: sequence of points along y=−2 with occasional V down to 5..7.
```
Let me define cheese bottom polyline with drips at specific x: from x=57 → 40 → (38, 6) → (34,−2) → 18 → (15,5) → 12 → −2 → (−6,7) → −12 → −26 → (−29,6) → −32 → −57. Path: moveTo(−57,−6); lineTo(57,−6); lineTo(57,−2); then the points right-to-left along bottom: (40,−2),(38,6),(34,−2),(18,−2),(15,5),(12,−2),(−2,−2),(−6,7),(−12,−2),(−26,−2),(−29,6),(−32,−2),(−57,−2); closePath (back to −57,−6). fill+stroke. Good.
```
  // tomato
  c.fillStyle='#e5484d'; c.strokeStyle='#a91f27';
  rr(-46,-13,92,6,3); fill;stroke;
  // lettuce (scalloped band above tomato)
  c.fillStyle='#8bd450'; c.strokeStyle='#4f8f2a';
  path: top edge straight y=−18 from −58..58; bottom scalloped: from (58,−12) arcs of radius ~8 bulging down to −9:
  beginPath();
  moveTo(-58,-18); lineTo(58,-18); lineTo(58,-12);
  then scallops leftward: for x=58; x>−58; x−=11.6: arcTo? use quadratic: quadraticCurveTo(x−5.8, −6, x−11.6, −12);
  closePath → line back to (−58,−18). fill;stroke;
```
10 scallops of 11.6 = 116. good.
```
  // top bun dome
  c.fillStyle='#f2ac3c'; c.strokeStyle='#9c6218';
  beginPath();
  moveTo(-52,-16);
  quadraticCurveTo(-54,-34, 0,-35);
  quadraticCurveTo(54,-34, 52,-16);
  closePath(); // closes straight line back along bottom
  fill;stroke;
  // top bun highlight
  c.fillStyle='rgba(255,240,200,0.35)';
  beginPath(); ellipse? small arc: ellipse(-20,-27,16,5, -0.3? ,0,TAU); fill;
  // sesame seeds
  c.fillStyle='#ffe9c2';
  const seeds=[[-36,-22,-0.5],[-22,-28,0.4],[-8,-31,0],[6,-30,-0.3],[20,-27,0.5],[33,-21,-0.4],[-15,-20,0.9],[14,-19,-0.7]];
  for(const sd of seeds){ c.save(); c.translate(sd[0],sd[1]); c.rotate(sd[2]); c.beginPath(); c.ellipse(0,0,3.4,2,0,0,TAU); c.fill(); c.restore(); }
  c.restore();
}
```
rr helper bound to a ctx param — I wrote roundRect as global using main ctx. Make a generic helper rrect(c,x,y,w,h,r). Use everywhere.

Paddle physics h=26 matches visual ~ (from −35+? at scale w/120 with w=122, s≈1.017; visual height 51 px incl shadow, but collision box h=26 top at paddle.y: ball rests at y−r−3 = burger center−16... visual top bun at cy−35*s ≈ −35.6 from center → paddle.y = cy−13 → top bun sticks above collision box by ~22px. That's okay (forgiving), but ball appears to sink "into" the bun before bouncing? Ball bounces when reaching paddle.y (top of collision box) — that's 22px below the visual top → ball will visually overlap the bun top when hitting center. Hmm. Options: set collision box to match visual better: paddle.h=30 and paddle.y=cy−? Let's define paddle.y as the visual TOP of burger: yTop = H−44−? Simplify: let physics top = visual top minus small grace: paddle.h=34, and bounce when ball bottom reaches yTop+6 (a bit inside, so it looks like ball lands ON the bun top which is dome shaped — acceptable and looks intentional). Ball rest position: ball.y = yTop + ... when stuck, place ball visually ON TOP of dome: ball.y = paddle.y + 2 − r? Dome apex at center. Stuck ball should sit on dome apex: cy − 35*s − r. cy = yTop + h/2? Let me define: paddle.y = top of burger visuals = paddleCenterY − 26 where burger visual half-height ≈26 (top bun −35.. bottom +16 → half range asymmetric). Ugh.

Cleaner: define paddle.cy (center of burger drawing). Visual spans cy−35.7*s? For w=122, s=1.0167: top = cy − 35.6, bottom = cy + 16.7 + shadow. Ball resting on dome: y = cy − 35.6 − 8 − 1.

Physics: treat collision surface as a horizontal line at ySurf = cy − 20 (dome mid-slope) for bounce, with x-extent w/2. Ball bounces when crossing while moving down: if(ball.y + r >= cy−20 && ball.y − r < cy + 12 && |dx| < w/2 + r). Then ball.y = cy − 20 − r. Resting stuck ball at dome apex though — when stuck, ball.y = cy − 35.6 − r − 2. Slight mismatch but fine: after launch it falls and bounces at ySurf (a bit lower) — looks like it lands on bun. But bounce point x near edges: dome lower there; ySurf constant −20 means near edges ball bounces higher than visual — minor, forgiving gameplay. OK.

Actually simpler: bounce line at cy−22 (close to dome top-ish −35? no −22 is 13px below apex). Meh, acceptable — burger is soft! Squish animation sells it. Ball slightly overlaps bun visually then bounces = looks like hitting a soft burger. Deliberately charming. Proceed:
```
const cy=H-30; // paddle center: bottom bun bottom at H−30+17=H−13, leaves 13px below
```
Let me set paddle.cy = H−34. Bottom of burger ≈ H−17; canvas bottom H → 17px margin. Good.
```
const paddle={x:W/2, cy:H-34, w:122, speed:600, squish:0, top: H-34-22 /*surface*/};
```
I'll compute surface as paddle.cy-22 in code.

drawPaddle:
```
ctx.save();
ctx.translate(paddle.x, paddle.cy);
const k=paddle.squish;
ctx.scale(1+0.15*k, 1-0.25*k);
drawBurger(ctx,0,0,paddle.w+4);
ctx.restore();
```
Squish pivot at center — acceptable (whole burger squashes). Better pivot bottom: translate(x, cy+16); scale; drawBurger at (0,0) draws around its own center — I'd need offset. Keep center pivot, looks fine.

HUD:
```
function drawHUD(){
  ctx.fillStyle='rgba(10,4,3,0.35)';
  ctx.fillRect(0,0,W,HUD);
  ctx.strokeStyle='rgba(255,233,194,0.15)';
  ctx.lineWidth=2;
  ctx.beginPath();ctx.moveTo(0,HUD);ctx.lineTo(W,HUD);ctx.stroke();
  ctx.textBaseline='middle';
  // score
  ctx.font='900 21px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#ffe9c2';
  ctx.textAlign='left';
  ctx.fillText('SCORE '+String(score).padStart(6,'0'),24,27);
  // level center
  ctx.textAlign='center';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('LEVEL '+level,W/2,27);
  // combo right of center? place before lives
  if(combo>1){
    const pop=1+Math.max(0,comboPop)*0.35;
    ctx.save();
    ctx.translate(W/2+150? ,27); ...
```
Combo position: center-right: x=W−230? Lives take right side ~ 5*36+30=210. Combo at x = W−250? Let me: lives icons from W−24 leftward. Combo text at W−300 center, scale pop, color '#ff9ebb', text '×'+combo. Only when combo≥2.
```
  // lives
  for(let i=0;i<Math.min(lives,5);i++){
    drawBurger(ctx, W-24-i*38, 28, 30);
  }
  if(lives>5){ draw '+n' } // skip; lives max 3 anyway
}
```
Mini burger at scale 30/120=0.25 — strokes 2.4 → 0.6px fine.

drawBanner:
```
function drawBanner(){
  const k=1-Math.min(1,bannerT/1.8); // 0→1
  const sc=Math.min(1, k*6); // quick pop-in
  ctx.save();
  ctx.translate(W/2,H/2);
  ctx.scale(sc? use easeOutBack
  ...
  ctx.textAlign='center';
  ctx.font='900 46px "Arial Black",Arial';
  outlined text: ctx.lineWidth=8; ctx.strokeStyle='#4a0d1c'; ctx.strokeText('LEVEL '+level,0,0);
  ctx.fillStyle='#ffe9c2'; ctx.fillText('LEVEL '+level,0,0);
  sub 'SPEED UP!' below in mustard 18px.
  ctx.restore();
}
```
Add slight rise: y offset.

drawServeHint (play mode):
```
if pulsing alpha = 0.6+0.4*sin(elapsed*5)
text 'CLICK TO LAUNCH' at (paddle.x, paddle.cy-70)? center: at ball above: (paddle.x, cy−60), 15px Arial Black cream w/ dark stroke.
```
drawAttractPanel:
```
function drawAttractPanel(){
  ctx.fillStyle='rgba(15,6,5,0.45)';
  ctx.fillRect(0,0,W,H);
  const w=560,h=330;
  const x=W/2-w/2, y=H/2-h/2;
  // panel
  ctx.fillStyle='rgba(26,11,9,0.92)';
  rrect(ctx,x,y,w,h,16); ctx.fill();
  ctx.strokeStyle='#e5484d'; ctx.lineWidth=3;
  rrect(ctx,x+4,y+4,w-8,h-8,12); ctx.stroke();
  ctx.strokeStyle='rgba(255,233,194,0.35)'; ctx.lineWidth=1.5;
  rrect(ctx,x+9,y+9,w-18,h-18,9); ctx.stroke();
  // burger
  drawBurger(ctx,W/2,y+74,120);
  // title
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.font='900 42px "Arial Black",Arial,sans-serif';
  ctx.fillStyle='#7a1029';
  ctx.fillText('HEARTBREAKER',W/2+3,y+140+3);
  ctx.fillStyle='#ffe9c2';
  ctx.fillText('HEARTBREAKER',W/2,y+140);
  // subtitle
  ctx.font='700 13px "Trebuchet MS",Arial';
  ctx.fillStyle='#ffc93c';
  ctx.fillText('S M A S H   B R I C K S   ·   S H O W E R   H E A R T S',W/2,y+172);
```
Hmm letter-spacing in canvas: ctx.letterSpacing='3px' works Chrome 99+. Use it guarded, else spaces. I'll set ctx.letterSpacing where supported:
```
if('letterSpacing' in ctx) ctx.letterSpacing='3px';
```
Set and reset. Safer: just write spaced string. I'll use letterSpacing property with try — plain assignment can't throw. Use it.

```
  // controls line
  ctx.font='700 12px ...'; fillStyle 'rgba(255,233,194,0.75)';
  'MOUSE / TOUCH / ← → MOVE · CLICK OR SPACE TO LAUNCH' at y+205;
```
Arrows as text '←' '→' are fine glyphs.
```
  // demo tag
  ctx.font='700 11px'; fillStyle '#ff9ebb';
  'CHEF’S DEMO — SIT BACK & WATCH' at y+232? 
  // pulsing prompt
  const p=0.55+0.45*Math.sin(elapsed*4);
  ctx.font='900 17px "Arial Black"';
  ctx.globalAlpha=0.45+0.55*p;
  ctx.fillStyle='#ff2e63'... on dark panel pink readable; add cream stroke? use fill '#ffd166'? Prompt should pop: mustard '#ffc93c'. 
  'CLICK OR PRESS ANY KEY TO START' at y+272;
  ctx.globalAlpha=1;
}
```
Panel height 330: y from 145..475. Positions: burger 74, title 140, sub 172, controls 205, demo 232, prompt 272 — within 330 (ends 475-? y+330=... y=145 → prompt at 417, panel bottom 475. ok breathing room.

drawOverPanel similar:
```
title 'GAME OVER' red '#ff4d6d' big w/ dark stroke; score cream 'SCORE 012340'; 'LEVEL n · BEST COMBO?' skip best; prompt pulsing 'CLICK OR PRESS ANY KEY TO TRY AGAIN'; small '(back to the demo in Ns)'.
```
Track bestCombo maybe — easy: bestCombo var updated on combo++; show on over panel. Nice touch. Add let bestCombo=0; reset in starts.

Serve hint only when mode play & phase serve.

Main loop:
```
function frame(now){
  let dt=(now-last)/1000; last=now;
  if(dt>0.033)dt=0.033;
  update(dt);
  render();
  requestAnimationFrame(frame);
}
```
Input listeners:
```
canvas.addEventListener('pointermove',e=>{
  const r=canvas.getBoundingClientRect();
  mouseX=(e.clientX-r.left)*W/r.width;
});
canvas.addEventListener('pointerdown',e=>{
  sfx.ensure();
  const r=canvas.getBoundingClientRect();
  mouseX=(e.clientX-r.left)*W/r.width;
  if(mode==='demo'||phase==='over'){ startPlay(); return; }
  if(phase==='serve'){ launch(); }
});
window.addEventListener('keydown',e=>{
  if(e.repeat){ if(['ArrowLeft','ArrowRight','a','d','A','D'].includes(e.key)) setDir(e,true); return; }
  sfx.ensure();
  if(mode==='demo'||phase==='over'){ startPlay(); e.preventDefault(); return; }
  switch(e.key){
    case ' ': case 'ArrowUp': if(phase==='serve') launch(); e.preventDefault(); break;
    case 'ArrowLeft': case 'a': case 'A': keyL=true; e.preventDefault(); break;
    case 'ArrowRight': case 'd': case 'D': keyR=true; e.preventDefault(); break;
  }
});
window.addEventListener('keyup',e=>{
  if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A')keyL=false;
  if(e.key==='ArrowRight'||e.key==='d'||e.key==='D')keyR=false;
});
```
Touch: pointer events cover touch when touch-action:none. pointermove for touch fires during drag. Good. Also prevent default on touchstart? touch-action none handles.

One catch: with cursor:none and overlay, mobile users tap — pointerdown starts game. good.

Also when phase over & overT running, pointerdown startPlay — good.

visibilitychange: reset last to avoid dt jump — dt clamped anyway.

Init:
```
resize();
startDemo();
requestAnimationFrame(frame);
```
Check heart Path2D scaling with non-uniform scale in ctx.scale(s,s) — uniform fine.

Potential visual bug: HUD drawn inside shaken transform — fine.

drawPops:
```
for(const p of pops){
  ctx.globalAlpha=Math.max(0,Math.min(1,p.life*1.4));
  ctx.font='900 15px "Arial Black",Arial';
  ctx.textAlign='center';
  ctx.lineWidth=3; ctx.strokeStyle='rgba(30,8,12,0.8)';
  ctx.strokeText(p.txt,p.x,p.y);
  ctx.fillStyle=p.col;
  ctx.fillText(p.txt,p.x,p.y);
}
ctx.globalAlpha=1;
```
pops y update in updatePops: p.y-=42*dt; p.life-=dt*1.1.

drawFlashes:
```
for(const f of flashes){
  ctx.globalAlpha=f.life*0.7;
  ctx.strokeStyle='#ffd7e2';
  ctx.lineWidth=3*f.life+0.5;
  ctx.beginPath();ctx.arc(f.x,f.y,f.r,0,TAU);ctx.stroke();
}
```
Now double-check paddle bounce uses cy & surface:
```
const surf=paddle.cy-22;
if(ball.vy>0 && ball.y+ball.r>=surf && ball.y-ball.r<paddle.cy+16 && Math.abs(ball.x-paddle.x)<=paddle.w/2+ball.r){
  ... ball.y=surf-ball.r-0.5;
}
```
Stuck ball rest: on dome apex: ball.y = paddle.cy - (35.5*(paddle.w+4)/120) - ball.r - 2. Compute burgerTop = paddle.cy - 35.5*s where s=(paddle.w+4)/120 ≈ 1.05 → 37.3. Store BURGER_TOP=36*s. fine compute inline.

Level speed: base 330+30*(level−1). ball.speed set in toServe.

AI prediction uses ball.vx*0.16 — lead. Add slight error so demo misses sometimes? It'll self-balance.

Demo serve: also randomize launch angle sign — yes random a range includes both.

Now hearts cap 720 vs perf ok.

Also: what if user clicks during banner (phase serve)? launch works — banner continues counting. fine.

What about ball speed normalization when paddle bounce sets angle — uses current ball.speed. ok.

One more juice: when brick hp2 first hit — small hearts already; ok.

Watermark & vignette caching: vignette gradient create once lazily (ctx exists). Since transform set via setTransform each resize, gradient in logical coords works.

Let me also add a subtle "heartbeat" pulse to watermark heart: scale 150*(1+0.02*sin(t*2))? cheap & thematic. Yes: scale factor pulse.

Font stacks: '"Arial Black","Arimo",Arial' fine.

letterSpacing: set ctx.letterSpacing='2px' globally after resize? It persists; reset per text as needed. I'll set for HUD '1px', titles '3px' and reset to '0px' after. Guard with 'letterSpacing' in ctx.

Text for arrows in controls: '← →' fine.

Double-check startPlay called from keydown during demo — startPlay resets everything, sets mode play, phase serve. Then prompt disappears, serve hint shows.

Over panel shows overT ceil: `Math.ceil(overT)`.

Edge: when phase over, ball keeps? Stop ball: in update, ball only moves when phase==='play'. Ball stuck false but not updated → frozen midair — draw it frozen; fine, overlay covers. Or hide ball? Leave visible frozen, panel covers center. ok, but hearts continue falling — nice.

Paddle in over: don't move? Keep responding, harmless. Actually looks alive — good.

In demo, if ball somehow lost repeatedly no problem.

Score popup color for big combos pink — done.

HUD combo pop scale: 
```
if(combo>=2){
  ctx.save();
  const cp=1+Math.max(0,comboPop)*0.4;
  ctx.translate(W-258,28);
  ctx.scale(cp,cp);
  ctx.font='900 19px "Arial Black"';
  ctx.textAlign='center';
  ctx.fillStyle='#ff9ebb';
  ctx.fillText('COMBO ×'+combo,0,0);
  ctx.restore();
}
```
Wait comboPop decays 3/s from 1 → visible ~0.33s.

Lives icons y=28 within HUD(52): burger mini height ≈ 52*0.25=13 + shadow. ok.

Check brick top vs HUD: BTOP=HUD+30=82. fine.

Ball speed 330 px/s crossing 880 in 2.6s — good pace. Cap 640.

Paddle keyboard speed 600 px/s.

Substeps: speed*dt>7 → 2. At 640*0.0167=10.7 → 2 substeps 5.3px each. r=8 — no tunneling through 22px bricks even 1 substep. But bricks gap 6, ball 16 — fine.

Potential issue: `bricks.some(k=>k.alive)` after each break — O(n) fine.

levelUp during iteration: hitBrick → levelUp → buildBricks replaces array while we're in moveBall loop `for(const b of bricks)` — we broke out right after hitBrick (break). Good — ensure break after hitBrick call. Yes I have break.

But careful: hitBrick may call levelUp→toServe→ball.stuck=true; moveBall continues? After hitBrick we break — good.

loseBall during moveBall (bottom) → phase changes to 'serve' or 'over' → return; outer loop checks phase!=='play' → break. Good.

Also after loseBall in for-substep loop: `if(phase!=='play')break;`.

Score popup at top rows may overlap HUD — fine.

Now the awning/scallop colors matching header bg? awning at very top of viewport, width 100%. 

Footer copy: "MOUSE MOVE · CLICK / SPACE LAUNCH · ← → STEER · EVERY BRICK = ~40 HEARTS". 

Also add tiny credit? skip.

Mobile: header clamps, canvas ~347px wide. HUD text 21px logical → scaled 0.39 → 8px rendered — tiny but acceptable. Could bump logical font sizes; fine.

Let me also ensure `resize()` reads clientWidth AFTER CSS applied — script at end of body, ok. ResizeObserver on canvas catches later changes.

One concern: canvas.width changes reset ctx state each resize — we reapply setTransform and set textBaseline. Gradients cached still valid (created from ctx, coordinates logical — gradients are objects independent of transform state at fill time? Canvas gradient coordinates are in current transform space at fill time. Since our transform maps logical→device consistently, cached gradient with logical coords works. Yes.)

Shadow ellipse under burger: uses c.ellipse — supported.

Now assemble everything meticulously. I'll also add `oncontextmenu` prevent? Not needed.

Also heart in DOM title: two inline SVGs flanking "HEARTBREAKER". Ensure vertical-align middle.

Write final HTML. Also double check no usage of localStorage etc. No external requests. One fenced code block. Let me carefully write, watching syntax errors (this is long).

Also don't forget: `phase` starts via startDemo() which sets mode/phase. phaseT unused mostly — drop or keep for panel anim. Panel appears instantly; fine without anim. I'll skip panel entrance animation to reduce bug surface... Actually small touch: skip.

comboPop update: comboPop-=dt*3 clamp 0.

bestCombo tracking: on combo++ → bestCombo=Math.max(bestCombo,combo). Show in over panel: 'LEVEL '+level+' · BEST COMBO ×'+bestCombo. Reset in startPlay.

Ensure `sfx.pop` delays param signature: tone(f,d,type,vol,slide,delay).

In tone, exponentialRamp requires positive start value — vol>0 given. frequency slide target positive. ok.

master gain connect — if ensure() fails AC null → all tone guard `if(!AC)return`.

Also iOS: AudioContext resume on gesture — ensure called in pointerdown/keydown handlers. good.

drawServeHint uses text at paddle.x could overflow right edge if paddle at edge: clamp x between 90..W−90.

Also attract panel covers center — demo continues behind. Dim 0.45 might make demo too dark? It's a panel with its own bg; dim is global. Use 0.35 dim. The panel itself 0.92 opaque center. Fine — demo visible around panel edges and through dim. Since panel is big (560×330 of 880×620), plenty of action visible around.

Actually maybe smaller panel h=300 to show more: recompute lines: burger y+64, title y+126, sub y+158, controls y+192, demo tag y+220, prompt y+258, h=290. Let me set h=300, y=H/2−150=160. Entries: burger cy=y+66=226; title y+130; sub y+164; controls y+198; tag y+226; prompt y+266. ok.

Burger drawn w=120 at y+66 → its top at 226−37=189 ≥ panel top 160+? panel top y=160; burger top 189 fine below panel top.

Title y+130=290, panel center-ish. good.

drawOver panel: h=270,w=560: title y+92; score y+140; stats y+170; prompt y+214; back note y+244.

Now writing final code — I'll be thorough and double check braces.

Also add heart flutter trail when ball moves fast? no, enough.

Let me also handle rare NaN: ball.vx zero at exact vertical bounce on paddle rel=0 → vx=0,vy=−speed — vertical bouncing forever off walls? vx=0 only until wall? vertical up hits top → vy flips down → back to paddle center... paddle moves though so rel won't stay 0. Add tiny jitter on paddle bounce: rel += (Math.random()−0.5)*0.04. ok.

Wall bounce sfx spam ok.

Also demo paddle x clamp.

For the "first paint" requirement: everything renders immediately — demo running, overlay visible. 

Edge: `String(score).padStart(6,'0')` — padStart supported.

Write the code now. Also careful: in drawBurger I use helper `rr(c,...)`.

Cheese path numbers — let me write concrete:
```
c.beginPath();
c.moveTo(-57,-7);
c.lineTo(57,-7);
c.lineTo(57,-2);
c.lineTo(40,-2); c.lineTo(38,7); c.lineTo(34,-2);
c.lineTo(18,-2); c.lineTo(15,6); c.lineTo(12,-2);
c.lineTo(-2,-2); c.lineTo(-6,8); c.lineTo(-12,-2);
c.lineTo(-26,-2); c.lineTo(-29,7); c.lineTo(-32,-2);
c.lineTo(-57,-2);
c.closePath();
```
Lettuce:
```
c.beginPath();
c.moveTo(-58,-19);
c.lineTo(58,-19);
c.lineTo(58,-13);
let x=58;
while(x>-58+0.01){
  c.quadraticCurveTo(x-5.8,-6,x-11.6,-13);
  x-=11.6;
}
c.closePath();
```
58/11.6=5 exactly → ends at −58. 

Top bun bottom line at −16; tomato at −13..−7 (rr(-46,-13,92,6,3)); cheese top −7.. overlaps tomato bottom −7. Lettuce bottom scallop dips to −6+? curve control −6 → dips ~ −9.5 visually between −13. Tomato −13..−7: lettuce band top −19, scallop bottom ~−13..−9: overlaps tomato top. Good stack.

Patty −4..9 (rr(-54,-4,108,13,6.5)). Cheese drips reach 8 → over patty. Bottom bun 5..17 (rr(-46,5,92,12,6)). Drips overlap bun top — fine (cheese melted over).

Grill marks inside patty at y≈0 and 4.

Burger total: top −35.. bottom 17 → height 52 design units → *s(w/120). With w=126 → s=1.05: top ≈ −36.8, bottom 17.9. cy=H−34 → bottom at H−16. Surface cy−22=H−56.

Ball stuck rest: cy − 36.8 − r −2 → paddle.cy−46.8. Serve ball visible above dome apex. good.

drawServeHint at paddle.cy−75.

HUD: mini burger lives drawBurger w=30 → s=0.25 → top −8.8 bottom 4.2 at y=27: spans 18..31 within HUD 52. good.

Now finalize attract panel text with apostrophe "CHEF'S DEMO" — escape in JS string with different quote or unicode '’'. Use 'CHEF’S DEMO PLAYING — SIT BACK & WATCH'.

Also the demo tag color pink '#ff9ebb'.

I think code plan complete. Write it. Careful bracket matching. I'll write in one pass but mentally compile.

Also add `image-rendering` nothing needed.

One more: prevent arrow keys scrolling page: preventDefault done for arrows/space. Page likely no scroll anyway (overflow-x hidden; vertical may overflow slightly on short screens — flex layout with fixed header keeps it fit; body overflow-y default visible; fine).

Let me now also double check `pops` update loop & arrays swap-remove consistent.

Alright, compose final file (~500 lines). Also include `<meta name="viewport">`.

During render of attract panel + banner simultaneously? banner only after demo level ups — banner draws over panel; fine.

OverT note text: `auto-demo in ${Math.ceil(overT)}s`.

Final check on ctx.letterSpacing assignments: I'll only use spaced strings instead to avoid state bugs. Subtitle uses spaced characters manually. Controls line normal.

Deliver.