Models 44

DeepSeek V4.1 Flash 1017

by DeepSeekReasoningClean
Open in new window

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

How it went

Total time
1 min 50 s
Cost
$0.017
Output tokens
31,977
Spent thinking
23,647 tokens
Speed
293 tok/s
Console errors
0
First token after
612 ms
First visible text after
1 min 27 s
Prompt tokens
385
Code size
20.7 KB
Finish reason
stop
Served by
StepFun
Model id
deepseek/deepseek-v4.1-flash-1017
Reasoning sent
{"effort":"low"}
Max output
64,000 tokens
Generated
Nov 11, 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 · 765 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Burger Breakout</title>
<style>
  *{box-sizing:border-box;}
  html,body{
    margin:0;height:100%;overflow:hidden;background:#0b040a;
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    -webkit-tap-highlight-color:transparent;
  }
  #wrap{position:fixed;inset:0;}
  canvas{display:block;width:100%;height:100%;touch-action:none;}
  #overlay{
    position:fixed;inset:0;z-index:10;
    display:flex;flex-direction:column;align-items:center;justify-content:center;
    gap:12px;text-align:center;padding:24px;cursor:pointer;
    background:radial-gradient(ellipse at 50% 45%, rgba(20,4,16,0.10) 0%, rgba(14,3,12,0.55) 65%, rgba(8,2,8,0.82) 100%);
    transition:opacity .28s ease;
  }
  #overlay.hidden{opacity:0;pointer-events:none;}
  #ov-title{
    margin:0;font-size:clamp(26px,6.2vw,60px);line-height:1.05;letter-spacing:.01em;
    font-weight:900;
    background:linear-gradient(180deg,#fff3c4 0%,#ffc24d 42%,#ff5c93 100%);
    -webkit-background-clip:text;background-clip:text;color:transparent;
    filter:drop-shadow(0 4px 26px rgba(255,80,140,.55));
  }
  #ov-sub{
    margin:0;color:#ffd0e2;font-size:clamp(13px,2.3vw,18px);font-weight:600;
    text-shadow:0 2px 12px rgba(0,0,0,.8);max-width:34ch;
  }
  #ov-hint{
    margin-top:16px;font-size:clamp(13px,2.4vw,19px);font-weight:800;color:#fff;
    background:rgba(255,70,140,.22);
    border:1px solid rgba(255,130,180,.65);
    padding:11px 22px;border-radius:999px;
    box-shadow:0 0 30px rgba(255,70,140,.35), inset 0 0 18px rgba(255,120,170,.18);
    animation:pulse 1.7s ease-in-out infinite;
  }
  @keyframes pulse{
    0%,100%{transform:scale(1);box-shadow:0 0 26px rgba(255,70,140,.3), inset 0 0 16px rgba(255,120,170,.15);}
    50%{transform:scale(1.045);box-shadow:0 0 44px rgba(255,70,140,.55), inset 0 0 22px rgba(255,120,170,.28);}
  }
</style>
</head>
<body>
<div id="wrap"><canvas id="game"></canvas></div>
<div id="overlay">
  <h1 id="ov-title">🍔 BURGER BREAKOUT 💖</h1>
  <p id="ov-sub">Smash the bricks — every single one bursts into a storm of little hearts.</p>
  <div id="ov-hint">Click or press any key to start</div>
</div>
<script>
(function(){
'use strict';

var canvas   = document.getElementById('game');
var ctx      = canvas.getContext('2d', { alpha:false });
var overlay  = document.getElementById('overlay');
var ovTitle  = document.getElementById('ov-title');
var ovSub    = document.getElementById('ov-sub');
var ovHint   = document.getElementById('ov-hint');

var clamp = function(v,a,b){ return v<a?a:(v>b?b:v); };

var W = 1280, H = 800, DPR = 1;

var ROW_COLORS = ['#ff4d8d','#ff8a3d','#ffd23f','#5fd97a','#4fc3f7','#b388ff'];
var HEART_COLORS = ['#ff2d6f','#ff5c93','#ff8ab5','#ff3d7f','#ffa8c9','#ff1e5a','#ffe0ec','#ff6fa5'];

/* ---------- heart path (unit: x -1..1, y -0.4..0.95) ---------- */
var heartPath = new Path2D();
heartPath.moveTo(0, 0.30);
heartPath.bezierCurveTo( 0.50, -0.42,  1.00, 0.26, 0, 0.95);
heartPath.bezierCurveTo(-1.00,  0.26, -0.50,-0.42, 0, 0.30);
heartPath.closePath();

/* ---------- state ---------- */
var cfg = {};

var state = {
  mode:'demo',       /* demo | play | over */
  score:0,
  lives:3,
  level:1,
  shake:0
};

var paddle = { x:0, w:120, h:32, y:0 };
var ball   = { x:0, y:0, vx:0, vy:0, r:9, speed:480, stuck:false, stuckT:0 };

var bricks     = [];
var hearts     = [];
var bgHearts   = [];
var keys       = { left:false, right:false };
var pointerX   = null;
var pointerOn  = false;
var tGlobal    = 0;

/* ---------- metrics ---------- */
function computeMetrics(){
  cfg.side     = Math.max(12, W*0.035);
  cfg.cols     = Math.max(5, Math.min(11, Math.floor(W/105)));
  cfg.rows     = 6;
  cfg.gap      = Math.max(5, Math.min(12, W*0.008));
  cfg.brickW   = (W - cfg.side*2 - cfg.gap*(cfg.cols-1)) / cfg.cols;
  cfg.brickH   = Math.max(15, Math.min(30, cfg.brickW*0.42));
  cfg.fieldTop = Math.max(52, Math.min(H*0.13, 100));
  cfg.paddleW  = Math.max(84, Math.min(W*0.155, 168));
  cfg.paddleH  = Math.max(20, cfg.paddleW*0.26);
  cfg.paddleY  = H - Math.max(52, H*0.10);
  cfg.ballR    = Math.max(7, Math.min(12, W*0.011));
  cfg.baseSpeed= Math.max(300, Math.min(H*1.05, H*0.62));
}

function baseSpeed(){
  return Math.min(cfg.baseSpeed * (1 + (state.level-1)*0.09), cfg.baseSpeed*1.55);
}

/* ---------- level ---------- */
function buildBricks(){
  bricks = [];
  var s = cfg.side, g = cfg.gap, bw = cfg.brickW, bh = cfg.brickH;
  for (var r=0;r<cfg.rows;r++){
    for (var c=0;c<cfg.cols;c++){
      bricks.push({
        x: s + c*(bw+g),
        y: cfg.fieldTop + r*(bh+g),
        w: bw, h: bh,
        color: ROW_COLORS[r % ROW_COLORS.length],
        alive: true
      });
    }
  }
}

/* ---------- ball ---------- */
function resetBall(stuck){
  ball.speed = baseSpeed();
  ball.x = paddle.x;
  ball.y = paddle.y - ball.r - 3;
  var ang = -Math.PI/2 + (Math.random()-0.5)*0.6;
  ball.vx = Math.cos(ang)*ball.speed;
  ball.vy = Math.sin(ang)*ball.speed;
  ball.stuck = !!stuck;
  ball.stuckT = 0.85;
}

/* ---------- flow ---------- */
function startGame(){
  state.mode  = 'play';
  state.score = 0;
  state.lives = 3;
  state.level = 1;
  state.shake = 0;
  hearts.length = 0;
  paddle.x = W/2;
  buildBricks();
  resetBall(true);
  overlay.classList.add('hidden');
}

function gameOver(){
  state.mode = 'over';
  ovTitle.textContent = 'GAME OVER 🍔';
  ovSub.textContent   = 'You smashed your way to ' + state.score + ' points.';
  ovHint.textContent  = 'Click or press any key to play again';
  overlay.classList.remove('hidden');
}

/* ---------- particles ---------- */
function makeHeart(x, y, s){
  var a  = Math.random()*Math.PI*2;
  var sp = (55 + Math.random()*300) * s;
  return {
    x:x, y:y,
    vx: Math.cos(a)*sp,
    vy: Math.sin(a)*sp - 90*s,
    size: (2.4 + Math.random()*6.6) * s,
    rot: (Math.random()-0.5)*1.1,
    vrot: (Math.random()-0.5)*9,
    life: 0,
    max: 0.65 + Math.random()*0.9,
    g: 170 + Math.random()*270,
    color: HEART_COLORS[(Math.random()*HEART_COLORS.length)|0]
  };
}

function burst(x, y, s, n, spread){
  for (var i=0;i<n;i++){
    hearts.push(makeHeart(
      x + (Math.random()-0.5)*(spread||0),
      y + (Math.random()-0.5)*(spread||0),
      s
    ));
  }
}

function updateHearts(dt){
  var drag = Math.pow(0.45, dt);
  for (var i=hearts.length-1;i>=0;i--){
    var p = hearts[i];
    p.life += dt;
    if (p.life >= p.max || p.y > H + 60){ hearts.splice(i,1); continue; }
    p.vy += p.g*dt;
    p.vx *= drag;
    p.x  += p.vx*dt;
    p.y  += p.vy*dt;
    p.rot += p.vrot*dt;
  }
  if (hearts.length > 900) hearts.splice(0, hearts.length-900);
}

function initBgHearts(){
  bgHearts = [];
  for (var i=0;i<22;i++){
    bgHearts.push({
      x: Math.random()*W,
      y: Math.random()*H,
      s: 6 + Math.random()*24,
      a: 0.035 + Math.random()*0.07,
      rot: (Math.random()-0.5)*1.4,
      vr: (Math.random()-0.5)*0.35,
      vy: -8 - Math.random()*24
    });
  }
}

function updateBgHearts(dt){
  for (var i=0;i<bgHearts.length;i++){
    var b = bgHearts[i];
    b.y += b.vy*dt;
    b.rot += b.vr*dt;
    if (b.y < -60){ b.y = H + 60; b.x = Math.random()*W; }
  }
}

/* ---------- AI ---------- */
function aiTarget(){
  if (ball.stuck || ball.vy <= 0){
    return W*0.5 + (ball.x - W*0.5)*0.32;
  }
  var targetY = paddle.y - ball.r;
  var t = (targetY - ball.y) / ball.vy;
  if (!isFinite(t) || t < 0) t = 0;
  var px = ball.x + ball.vx * t;
  var lo = ball.r, hi = W - ball.r, span = hi - lo;
  if (span > 1){
    var k = (px - lo) / span;
    k = ((k % 2) + 2) % 2;
    if (k > 1) k = 2 - k;
    px = lo + k*span;
  }
  var err = Math.min(34, W*0.022);
  return px + Math.sin(tGlobal*2.3)*err;
}

/* ---------- collisions ---------- */
function breakBrick(b){
  b.alive = false;
  state.score += 10;
  state.shake = Math.min(15, state.shake + 6);
  burst(b.x + b.w/2, b.y + b.h/2, 1, 26 + ((Math.random()*14)|0), b.w*0.9);
}

function collideBricks(){
  var r = ball.r;
  for (var i=0;i<bricks.length;i++){
    var b = bricks[i];
    if (!b.alive) continue;
    if (ball.x + r < b.x || ball.x - r > b.x + b.w ||
        ball.y + r < b.y || ball.y - r > b.y + b.h) continue;

    var penL = (ball.x + r) - b.x;
    var penR = (b.x + b.w) - (ball.x - r);
    var penT = (ball.y + r) - b.y;
    var penB = (b.y + b.h) - (ball.y - r);
    var m = Math.min(penL, penR, penT, penB);

    if (m === penL){ ball.x = b.x - r;        ball.vx = -Math.abs(ball.vx); }
    else if (m === penR){ ball.x = b.x + b.w + r; ball.vx =  Math.abs(ball.vx); }
    else if (m === penT){ ball.y = b.y - r;        ball.vy = -Math.abs(ball.vy); }
    else { ball.y = b.y + b.h + r;                 ball.vy =  Math.abs(ball.vy); }

    breakBrick(b);
    return;
  }
}

function collidePaddle(){
  if (ball.vy <= 0) return;
  var pw = paddle.w, ph = paddle.h;
  var px = paddle.x - pw/2, py = paddle.y;
  if (ball.y + ball.r < py) return;
  if (ball.y - ball.r > py + ph) return;
  if (ball.x + ball.r < px || ball.x - ball.r > px + pw) return;

  ball.y = py - ball.r;
  var off = clamp((ball.x - paddle.x) / (pw/2), -1, 1);
  var ang = off * 1.02;
  ball.vx =  Math.sin(ang) * ball.speed;
  ball.vy = -Math.cos(ang) * ball.speed;

  burst(ball.x, py + 2, 0.55, 10, pw*0.28);
  state.shake = Math.min(8, state.shake + 2.5);
}

function onBallLost(){
  if (state.mode === 'demo'){
    burst(paddle.x, paddle.y, 0.7, 14, 60);
    resetBall(false);
    return;
  }
  state.lives--;
  state.shake = 16;
  if (state.lives <= 0) gameOver();
  else resetBall(true);
}

/* ---------- ball stepping ---------- */
function stepBall(dt){
  var sp = Math.hypot(ball.vx, ball.vy) || 1;
  var steps = clamp(Math.ceil(sp*dt / (ball.r*0.75)), 1, 12);
  var sdt = dt/steps;

  for (var i=0;i<steps;i++){
    ball.x += ball.vx*sdt;
    ball.y += ball.vy*sdt;

    var r = ball.r;
    if (ball.x - r < 0){ ball.x = r; ball.vx = Math.abs(ball.vx); }
    else if (ball.x + r > W){ ball.x = W - r; ball.vx = -Math.abs(ball.vx); }
    if (ball.y - r < 0){ ball.y = r; ball.vy = Math.abs(ball.vy); }

    if (ball.y - r > H){ onBallLost(); return; }

    collideBricks();
    collidePaddle();

    if (ball.stuck) return;
  }

  /* keep speed constant */
  var s2 = Math.hypot(ball.vx, ball.vy);
  if (s2 > 0.0001){
    ball.speed = ball.stuck ? ball.speed : ball.speed;
    ball.vx = ball.vx/s2 * ball.speed;
    ball.vy = ball.vy/s2 * ball.speed;
  }
}

/* ---------- update ---------- */
function update(dt){
  tGlobal += dt;
  if (state.shake > 0){
    state.shake *= Math.pow(0.015, dt);
    if (state.shake < 0.25) state.shake = 0;
  }

  updateBgHearts(dt);

  /* paddle control */
  if (state.mode === 'demo'){
    var tx = aiTarget();
    var maxV = Math.max(W*0.95, 620);
    var d = tx - paddle.x;
    paddle.x += clamp(d, -maxV*dt, maxV*dt);
  } else if (state.mode === 'play'){
    if (keys.left || keys.right){
      var sp = Math.max(W*0.95, 560);
      paddle.x += ((keys.right?1:0) - (keys.left?1:0)) * sp * dt;
    } else if (pointerOn && pointerX !== null){
      paddle.x += (pointerX - paddle.x) * Math.min(1, dt*22);
    }
  }
  paddle.x = clamp(paddle.x, paddle.w/2, W - paddle.w/2);

  /* ball */
  if (ball.stuck){
    ball.x = paddle.x;
    ball.y = paddle.y - ball.r - 3;
    ball.stuckT -= dt;
    if (ball.stuckT <= 0) ball.stuck = false;
  } else {
    stepBall(dt);
  }

  updateHearts(dt);

  /* level cleared */
  var alive = false;
  for (var i=0;i<bricks.length;i++){ if (bricks[i].alive){ alive = true; break; } }
  if (!alive){
    state.level++;
    state.shake = 12;
    burst(W/2, H*0.42, 1.7, 130, Math.min(W,H)*0.5);
    buildBricks();
    resetBall(true);
  }
}

/* ---------- drawing ---------- */
function drawBackground(){
  var g = ctx.createRadialGradient(W*0.5, H*0.12, 30, W*0.5, H*0.62, Math.max(W,H)*0.95);
  g.addColorStop(0,   '#3d1433');
  g.addColorStop(0.42,'#1f0a1d');
  g.addColorStop(1,   '#0a0309');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);

  for (var i=0;i<bgHearts.length;i++){
    var b = bgHearts[i];
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.rotate(b.rot);
    ctx.scale(b.s, b.s);
    ctx.translate(0, -0.275);
    ctx.globalAlpha = b.a;
    ctx.fillStyle = '#ff6fa5';
    ctx.fill(heartPath);
    ctx.restore();
  }
  ctx.globalAlpha = 1;
}

function drawBricks(){
  for (var i=0;i<bricks.length;i++){
    var b = bricks[i];
    if (!b.alive) continue;
    var rad = Math.min(7, b.h*0.32);

    ctx.fillStyle = b.color;
    ctx.beginPath();
    ctx.roundRect(b.x, b.y, b.w, b.h, rad);
    ctx.fill();

    /* top gloss */
    ctx.fillStyle = 'rgba(255,255,255,0.38)';
    ctx.beginPath();
    ctx.roundRect(b.x + 2, b.y + 2, b.w - 4, Math.max(2, b.h*0.26), rad*0.6);
    ctx.fill();

    /* bottom shade */
    ctx.fillStyle = 'rgba(0,0,0,0.22)';
    ctx.beginPath();
    ctx.roundRect(b.x + 2, b.y + b.h - Math.max(3, b.h*0.30), b.w - 4, Math.max(2, b.h*0.26), rad*0.6);
    ctx.fill();

    /* outline */
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, rad);
    ctx.stroke();
  }
}

function drawPaddle(){
  var w = paddle.w, h = paddle.h;
  var x = paddle.x - w/2, y = paddle.y;

  /* soft drop shadow */
  ctx.save();
  ctx.shadowColor = 'rgba(0,0,0,0.6)';
  ctx.shadowBlur = 16;
  ctx.shadowOffsetY = 7;
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.beginPath();
  ctx.roundRect(x, y + h*0.3, w, h*0.7, h*0.3);
  ctx.fill();
  ctx.restore();

  /* bottom bun */
  ctx.fillStyle = '#d89442';
  ctx.beginPath();
  ctx.roundRect(x, y + h*0.74, w, h*0.26, [3,3,h*0.13,h*0.13]);
  ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.18)';
  ctx.beginPath();
  ctx.roundRect(x + w*0.06, y + h*0.79, w*0.88, h*0.06, h*0.03);
  ctx.fill();

  /* patty */
  ctx.fillStyle = '#5a2e1a';
  ctx.beginPath();
  ctx.roundRect(x - w*0.012, y + h*0.53, w*1.024, h*0.25, h*0.12);
  ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.09)';
  ctx.beginPath();
  ctx.roundRect(x + w*0.05, y + h*0.565, w*0.9, h*0.05, h*0.025);
  ctx.fill();

  /* cheese with drips */
  ctx.fillStyle = '#ffc633';
  ctx.beginPath();
  ctx.roundRect(x - w*0.014, y + h*0.44, w*1.028, h*0.115, h*0.05);
  for (var i=0;i<3;i++){
    var dx = x + w*(0.24 + 0.26*i);
    var dy = y + h*0.545;
    var rw = w*0.048;
    ctx.moveTo(dx + rw, dy);
    ctx.arc(dx, dy, rw, 0, Math.PI);
  }
  ctx.fill();

  /* lettuce */
  var lx0 = x - w*0.022, lx1 = x + w*1.022;
  var lyMid = y + h*0.39, lyBot = y + h*0.49;
  var bumps = 7, seg = (lx1 - lx0)/bumps;
  ctx.fillStyle = '#5fc93f';
  ctx.beginPath();
  ctx.moveTo(lx0, lyBot);
  ctx.lineTo(lx0, lyMid);
  for (var j=0;j<bumps;j++){
    var xa = lx0 + seg*j, xb = xa + seg;
    ctx.quadraticCurveTo((xa+xb)/2, lyMid - h*0.10, xb, lyMid);
  }
  ctx.lineTo(lx1, lyBot);
  ctx.closePath();
  ctx.fill();

  /* top bun */
  var bg = ctx.createLinearGradient(0, y, 0, y + h*0.36);
  bg.addColorStop(0,    '#ffe4ac');
  bg.addColorStop(0.55, '#f2bb66');
  bg.addColorStop(1,    '#d68f3a');
  ctx.fillStyle = bg;
  ctx.beginPath();
  ctx.moveTo(x + w*0.02, y + h*0.36);
  ctx.bezierCurveTo(x + w*0.02, y + h*0.05, x + w*0.22, y - h*0.01, x + w*0.50, y - h*0.01);
  ctx.bezierCurveTo(x + w*0.78, y - h*0.01, x + w*0.98, y + h*0.05, x + w*0.98, y + h*0.36);
  ctx.closePath();
  ctx.fill();

  /* sesame seeds */
  var seeds = [
    [0.20,0.16,-0.55],[0.36,0.09, 0.28],[0.52,0.13,-0.20],
    [0.68,0.09, 0.50],[0.84,0.17,-0.42],[0.30,0.27, 0.20],
    [0.62,0.28, 0.38]
  ];
  ctx.fillStyle = 'rgba(255,246,214,0.97)';
  for (var s=0;s<seeds.length;s++){
    ctx.save();
    ctx.translate(x + w*seeds[s][0], y + h*seeds[s][1]);
    ctx.rotate(seeds[s][2]);
    ctx.beginPath();
    ctx.ellipse(0, 0, w*0.021, w*0.011, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.restore();
  }
}

function drawBall(){
  var r = ball.r;
  var g = ctx.createRadialGradient(ball.x - r*0.35, ball.y - r*0.42, r*0.12,
                                   ball.x, ball.y, r*1.18);
  g.addColorStop(0,   '#ffb1b1');
  g.addColorStop(0.45,'#f42a3f');
  g.addColorStop(1,   '#8b0c1d');

  ctx.save();
  ctx.shadowColor = 'rgba(255,60,95,0.85)';
  ctx.shadowBlur = 20;
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, r, 0, Math.PI*2);
  ctx.fill();
  ctx.restore();

  /* glossy highlight */
  ctx.fillStyle = 'rgba(255,255,255,0.78)';
  ctx.beginPath();
  ctx.ellipse(ball.x - r*0.34, ball.y - r*0.40, r*0.27, r*0.19, -0.6, 0, Math.PI*2);
  ctx.fill();

  /* little green stem */
  ctx.strokeStyle = '#45a52c';
  ctx.lineWidth = Math.max(1.6, r*0.22);
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(ball.x, ball.y - r*0.86);
  ctx.quadraticCurveTo(ball.x + r*0.55, ball.y - r*1.55, ball.x + r*1.05, ball.y - r*1.32);
  ctx.stroke();
}

function drawHearts(sx, sy){
  var base = DPR;
  for (var i=0;i<hearts.length;i++){
    var p = hearts[i];
    var t = p.life / p.max;
    var a = t < 0.6 ? 1 : 1 - (t - 0.6) / 0.4;
    if (a <= 0) continue;
    var s = p.size * (1 - t*0.35);
    var c = Math.cos(p.rot)*s, sn = Math.sin(p.rot)*s;

    var ox = p.x + sx + 0.275*s*sn;
    var oy = p.y + sy - 0.275*s*c;

    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.setTransform(c*base, sn*base, -sn*base, c*base, ox*base, oy*base);
    ctx.fill(heartPath);
  }
  ctx.globalAlpha = 1;
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}

function drawHUD(){
  var pad   = Math.max(12, Math.min(26, W*0.022));
  var small = Math.round(Math.max(10, Math.min(15, W*0.012)));
  var big   = Math.round(Math.max(17, Math.min(26, W*0.021)));
  var FONT  = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';

  /* score */
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.font = '800 ' + small + 'px ' + FONT;
  ctx.fillStyle = 'rgba(255,150,195,0.72)';
  ctx.fillText('SCORE', pad, pad);
  ctx.font = '900 ' + big + 'px ' + FONT;
  ctx.fillStyle = '#ffffff';
  ctx.fillText(('0000' + state.score).slice(-4), pad, pad + small + 2);

  /* level */
  ctx.textAlign = 'center';
  ctx.font = '800 ' + small + 'px ' + FONT;
  ctx.fillStyle = 'rgba(255,195,225,0.6)';
  ctx.fillText('LEVEL ' + state.level, W/2, pad + 4);

  /* lives as hearts */
  var hs = Math.max(6.5, Math.min(11, W*0.0085));
  for (var i=0;i<state.lives;i++){
    var hx = W - pad - hs*1.7 - i*(hs*3.1);
    var hy = pad + hs*1.7;
    ctx.save();
    ctx.translate(hx, hy);
    ctx.scale(hs, hs);
    ctx.translate(0, -0.275);
    ctx.fillStyle = '#ff3d7f';
    ctx.fill(heartPath);
    ctx.restore();
  }
}

/* ---------- render ---------- */
function render(){
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  drawBackground();

  var sx = 0, sy = 0;
  if (state.shake > 0.05){
    sx = (Math.random()-0.5) * state.shake;
    sy = (Math.random()-0.5) * state.shake;
  }

  ctx.save();
  ctx.translate(sx, sy);
  drawBricks();
  drawPaddle();
  drawBall();
  ctx.restore();

  drawHearts(sx, sy);

  drawHUD();
}

/* ---------- resize ---------- */
function resize(){
  W = Math.max(300, window.innerWidth);
  H = Math.max(320, window.innerHeight);
  DPR = Math.min(window.devicePixelRatio || 1, 2);

  canvas.width  = Math.round(W*DPR);
  canvas.height = Math.round(H*DPR);
  canvas.style.width  = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);

  computeMetrics();

  paddle.w = cfg.paddleW;
  paddle.h = cfg.paddleH;
  paddle.y = cfg.paddleY;
  ball.r   = cfg.ballR;

  if (!paddle.x || !isFinite(paddle.x)) paddle.x = W/2;
  paddle.x = clamp(paddle.x, paddle.w/2, W - paddle.w/2);

  buildBricks();

  if (state.mode === 'demo'){
    resetBall(false);
  } else {
    resetBall(true);
  }

  initBgHearts();
}

/* ---------- loop ---------- */
var last = 0;
function frame(now){
  if (!last) last = now;
  var dt = (now - last) / 1000;
  last = now;
  if (dt > 1/30) dt = 1/30;
  if (dt < 0) dt = 0;

  update(dt);
  render();

  requestAnimationFrame(frame);
}

/* ---------- input ---------- */
function updatePointer(e){
  var r = canvas.getBoundingClientRect();
  pointerX = e.clientX - r.left;
  pointerOn = true;
}

function handleStart(){
  if (state.mode !== 'play') startGame();
}

window.addEventListener('pointerdown', function(e){
  updatePointer(e);
  handleStart();
}, { passive:true });

window.addEventListener('pointermove', function(e){
  updatePointer(e);
}, { passive:true });

window.addEventListener('keydown', function(e){
  var k = e.key;
  if (k === 'ArrowLeft'  || k === 'a' || k === 'A') keys.left  = true;
  if (k === 'ArrowRight' || k === 'd' || k === 'D') keys.right = true;
  if (k === ' ' || k === 'ArrowLeft' || k === 'ArrowRight' ||
      k === 'ArrowUp' || k === 'ArrowDown') e.preventDefault();
  handleStart();
}, { passive:false });

window.addEventListener('keyup', function(e){
  var k = e.key;
  if (k === 'ArrowLeft'  || k === 'a' || k === 'A') keys.left  = false;
  if (k === 'ArrowRight' || k === 'd' || k === 'D') keys.right = false;
});

window.addEventListener('resize', resize);

/* ---------- go ---------- */
paddle.x = 0;
state.mode = 'demo';
resize();
requestAnimationFrame(frame);

})();
</script>
</body>
</html>
Reasoning trace (23,647 tokens)
Let me create a polished Breakout game with a hamburger paddle and heart particle explosions.

Key features:
- Canvas-based
- Hamburger paddle drawn with canvas (bun top with sesame seeds, lettuce, patty, bun bottom)
- Bricks in colorful rows
- On brick break: explode into lots of little hearts (particle system with heart shapes)
- Demo mode AI plays automatically behind "Click or press a key to start" overlay
- Responsive, 1280x800

Let me write it.

Canvas sizing: fill the window with a fixed logical resolution scaled. Let's use a logical size of 1280x800 with CSS scaling to fit, using devicePixelRatio. Actually simpler: set canvas width/height to container size in CSS pixels * dpr, and design the game in logical units matching CSS pixels. Game logic uses canvas CSS pixel dimensions.

Let me make a resize handler that sets gameWidth/gameHeight and rescales brick layout.

Simpler: use a fixed logical world of 1280x800 and letterbox with CSS. Hmm but on 375px wide, letterboxing gives tiny game. Better: responsive world.

Let's do responsive: on resize, compute width = container width, height = container height. Rebuild bricks preserving... just rebuild at start. During play, rescale positions proportionally. Eh, keep it simple: on resize, scale all entity positions by ratio. Fine.

Actually let's just handle resize by restarting brick layout and keeping ball. Meh. I'll do proportional scaling of everything — it's a few lines.

Let me design:

Paddle (hamburger): width ~120, height ~34. Drawn as:
- bottom bun: rounded rect, tan/golden
- patty: dark brown rounded rect
- lettuce: green wavy edge
- cheese: yellow with drips
- top bun: dome shape with sesame seeds

Ball: could be a cherry tomato or a meatball. Let's make it a little sesame-seed? No—make it a glossy red cherry tomato. Or a small patty. I'll do a glossy red cherry tomato with a green stem. Cute.

Bricks: colorful, with a slight 3D look. Rows of different colors.

Hearts: particle system drawing heart shapes with bezier/parametric path, various colors (pink, red, magenta, coral), rotating, fading, gravity, some sparkles.

Also add a small screen shake and heart burst count.

Power-ups? Keep it simple but maybe add a "wide paddle" occasionally. Skip for simplicity — actually a bit of extra polish: every brick has small chance to drop a heart that gives extra life? Nah. Keep it focused.

Game logic:
- Ball collides with paddle: reflect with angle depending on hit offset.
- Ball collides with bricks: AABB, determine axis.
- Demo mode: AI moves paddle toward predicted ball x. Simple: move toward ball.x with speed limit, plus small error.

Start state: demo mode active, overlay "Click or press a key to start". On click/keypress: hide overlay, reset game to a fresh state with full lives, player controls paddle with mouse/touch/arrow keys.

When player loses all lives: game over, show "Game Over — click to play again" and go back to demo... Let's show overlay again with restart.

Let's also show a hearts counter / score.

Let me write the heart drawing function:

```js
function heartPath(ctx, x, y, size, rot) {
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rot);
  ctx.scale(size, size);
  ctx.beginPath();
  ctx.moveTo(0, 0.3);
  ctx.bezierCurveTo(-0.5, -0.3, -1, 0.15, 0, 1); // hmm
  ...
}
```

Standard heart via bezier: 
```
ctx.beginPath();
ctx.moveTo(0, 0.35);
ctx.bezierCurveTo(0, 0, -0.5, -0.1, -0.5, 0.3);  
```
Let me use a known good heart path with size unit:

```js
function heartShape(ctx, s) {
  ctx.beginPath();
  ctx.moveTo(0, s * 0.3);
  ctx.bezierCurveTo(s * 0.5, -s * 0.4, s * 1.0, s * 0.25, 0, s * 0.95);
  ctx.bezierCurveTo(-s * 1.0, s * 0.25, -s * 0.5, -s * 0.4, 0, s * 0.3);
  ctx.closePath();
}
```
Hmm, that gives a heart roughly spanning -s..s in x and -0.4s..0.95s in y. Good enough. Let me test mentally: start at top middle notch (0, 0.3s). Curve to right lobe out to (0.5s,-0.4s) control and (1.0s, 0.25s) end — that end is the rightmost bottom-ish point. Then curve back to (0, 0.95s) bottom tip. Symmetric left. That's a decent heart. Actually the classic heart: top notch at y=0, lobes at top, sides bulge, bottom tip. Here the "widest" points are at (1.0s, 0.25s) and (-1.0s, 0.25s), bottom tip at (0, 0.95s). Top notch at (0, 0.3s) which is below the control points y=-0.4s, so lobes go up above the notch. Good.

Particle: {x, y, vx, vy, rot, vrot, size, life, maxLife, color, hue}.

On brick destroy: spawn ~28 hearts. "tons of little hearts" — yes, lots. Maybe scale with brick size.

Cap total particles at ~1200 for perf.

Let's also emit small heart burst when ball hits paddle? Small, like 6.

Colors: palette ['#ff4d8d','#ff7ab8','#ff2d55','#ff8fa3','#ff5ec4','#ffd1dc'].

Rendering order: background gradient, bricks, paddle, ball, particles, HUD.

Background: dark warm gradient with subtle vignette, maybe a soft glow. Add a slight "restaurant" vibe? Keep it dark so hearts pop.

Let me write code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Burger Breakout</title>
<style>
  html,body { margin:0; height:100%; overflow:hidden; background:#150a14; }
  #wrap { position:fixed; inset:0; }
  canvas { display:block; width:100%; height:100%; touch-action:none; cursor:none; }
  #overlay {...}
</style>
```

Overlay: centered card with title "🍔 BURGER BREAKOUT" and text "Click or press a key to start". Use pointer-events none? No — we need click. Put overlay with pointer-events:auto and clicking anywhere starts. Actually clicking on canvas too. Add a global listener on window for pointerdown and keydown.

Let's do: overlay div, position fixed inset 0, flex center, background rgba(0,0,0,0.45), backdrop-filter blur. When hidden, display:none.

HUD drawn on canvas: score top-left, lives (little hearts) top-right, level maybe.

Let me now write game code carefully.

```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1;

function resize() {
  const r = canvas.getBoundingClientRect();
  W = Math.max(280, r.width);
  H = Math.max(300, r.height);
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = Math.round(W * DPR);
  canvas.height = Math.round(H * DPR);
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  layout();
}
```

Hmm resize triggers layout which rebuilds bricks. On first load fine. On resize during play, I'll rescale. Let's just rebuild bricks on resize but preserve brick "alive" states? Complexity. I'll store bricks in grid coordinates with relative sizing computed at draw... 

Alternative: use a fixed logical world size (e.g., 960x600) and scale to fit with letterbox. On narrow screens the game gets small but stays playable and consistent. With 375px width, a 960x600 world scaled to 375 wide → 234 tall. That's small but visible. Hmm, not great.

Better: adaptive world with logical sizing derived from viewport. Let's just rebuild the level on resize and keep score/lives. Resizes are rare. That's fine. Actually rebuild resets bricks which could feel odd but acceptable.

Let's define layout():
- paddleY = H - 60 (or H*0.9)
- paddleWidth = clamp(W*0.14, 80, 160)... on 375px that's 52→ clamp min 80. ok.
- brick area: top margin H*0.12, left/right padding.
- cols = clamp(floor(W / 92), 5, 11)
- rows = 7? Let's do rows = 6 on wide, fewer on narrow? Keep 6.

Brick width = (W - 2*pad - (cols-1)*gap) / cols.
Brick height = clamp(brickWidth*0.42, 16, 30).

Let's write it.

Ball: radius clamp(W*0.012, 7, 11). Speed base ~ H*0.55 per second? Let's use per-second velocities and dt.

Ball speed: start 420 px/s at H=800. Scale with H: speed = H*0.55. Cap at H*0.95.

Use dt clamped to 1/30.

Collision: for ball vs brick, simple AABB with previous position to determine axis. I'll do: find closest point on brick rect to ball center, if distance < r then resolve.

Simpler approach that works well: iterate bricks, for each overlapping, compute overlap on x and y, push out along the smaller overlap and flip that velocity component. Break brick.

Multiple bricks per frame — handle by breaking at most one per frame? Usually fine to break all overlapping but flipping twice could be wrong. I'll break the first overlapping found and reflect; then break loop. Actually with a fast ball, it might tunnel through bricks. Use substeps: move ball in small steps (e.g., 4 substeps per frame) and check collisions each. Good.

Let me implement update with substeps: steps = ceil(speed*dt / (r*0.8)), capped at 8.

Collision with walls: left/right/top.

Paddle: rect at paddleX (center) width pw, height ph, top at paddleY.

Ball-paddle: if ball bottom > paddleTop and ball within paddle x-range and vy>0 → set vy = -abs, vx based on offset: offset = (ball.x - paddleCenter) / (pw/2), clamp -1..1; angle = offset * 60deg from vertical; speed stays constant (slightly increase). Also add paddle velocity influence.

AI (demo): target = ball.x + ball.vx * 0.12 (lead). Move paddle toward target with max speed. Add jitter. When ball moving down and far, center. Actually a decent AI: predict where ball crosses paddleY by simulating wall bounces. Let's do simple prediction with reflections.

```js
function predictX(){
  if (ball.vy <= 0) return W/2;
  let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy;
  let guard = 0;
  while (y < paddleY && guard++ < 20) {
    const t = (paddleY - y) / vy;
    x += vx * t;
    y = paddleY;
    // reflect
    while (x < r || x > W - r) {
      if (x < r) { x = 2*r - x; vx = Math.abs(vx); }
      if (x > W - r) { x = 2*(W-r) - x; vx = -Math.abs(vx); }
    }
  }
  return x;
}
```
Hmm the loop with y=paddleY ends immediately. Let me restructure: compute trajectory properly.

```js
function predictX(){
  if (ball.vy <= 0) return W*0.5;
  let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy;
  let t = (paddleY - y) / vy;  // time to reach paddle
  let px = x + vx * t;
  // fold px into [r, W-r] with reflections
  const lo = ballR, hi = W - ballR;
  const span = hi - lo;
  if (span <= 0) return W/2;
  let k = (px - lo) / span;
  k = k - Math.floor(k/2)*2; // 0..2
  if (k > 1) k = 2 - k;
  return lo + k * span;
}
```
That's a nice triangular fold. 

Then AI paddle target = predicted x with small error, plus move with maxSpeed = W*1.1 per sec. Add slight lag for realism so it sometimes misses? For a demo we want it to look good and keep playing. Let's make it near-perfect with tiny error.

Also demo: ball shouldn't be lost. If ball goes below, in demo mode just respawn (no life loss). In play mode, lose a life.

Actually to keep demo running forever, let demo mode never lose. When ball.y > H + 30 in demo: reset ball.

Player mode: lose life, reset ball on paddle briefly.

Let's implement states: 'demo' | 'playing' | 'ready' | 'over'.

Simplify: `mode` = 'demo' or 'play'. And `serve` boolean: ball attached to paddle waiting for launch (auto-launch after 0.8s or on click).

Hmm, keep it simple: on losing a life, reset ball above paddle with vy negative after a short delay.

Lives = 3.

Let's just write it.

Score display: use canvas text with a nice font. Canvas fonts: use system font stack, e.g. `700 22px system-ui, -apple-system, "Segoe UI", sans-serif`. Emojis for lives: draw hearts with the heart path instead.

Let's write the whole thing.

```js
// ---------- setup
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('ov-title');
const overlayText = document.getElementById('ov-text');

let W=1280,H=800,DPR=1;

const state = {
  mode:'demo',
  score:0,
  lives:3,
  paused:false,
  shake:0,
};

let paddle = {x:0, w:130, h:34, y:0, vx:0, targetX:0};
let ball = {x:0,y:0,vx:0,vy:0,r:9};
let bricks = [];
let particles = [];
let time = 0;
let serveTimer = 0;
```

Brick color palette per row, from top: 
row0: '#ff4d8d' (hot pink)
row1: '#ff7a45' (orange)
row2: '#ffd166' (yellow)
row3: '#7ddf7d' (green)
row4: '#4fc3f7' (blue)
row5: '#b388ff' (purple)

With darker shade for 3D bottom edge.

Brick draw: rounded rect fill with color, top highlight lighter, bottom darker border. Add subtle inner shine.

Let's write helper roundRect (canvas has ctx.roundRect in modern Chrome — yes, Chrome 99+). Use it.

Now the hamburger paddle draw:

```js
function drawBurger(cx, cy, w, h) {
  // cy = center y
  const x = cx - w/2;
  const y = cy - h/2;
  const r = h*0.28;
  
  // shadow
  ctx.save();
  ctx.shadowColor='rgba(0,0,0,0.45)'; ctx.shadowBlur=18; ctx.shadowOffsetY=8;
  // bottom bun
  ctx.fillStyle='#d98b3a';
  ctx.beginPath();
  ctx.roundRect(x, y+h*0.62, w, h*0.38, [0,0,h*0.19,h*0.19]); 
  ctx.fill();
  ctx.restore();
  
  // patty
  ctx.fillStyle='#5a2d1a';
  ctx.beginPath();
  ctx.roundRect(x+w*0.02, y+h*0.44, w*0.96, h*0.26, h*0.13);
  ctx.fill();
  // patty highlight
  ctx.fillStyle='rgba(255,255,255,0.10)';
  ctx.beginPath(); ctx.roundRect(x+w*0.06, y+h*0.47, w*0.88, h*0.07, h*0.035); ctx.fill();
  
  // cheese
  ctx.fillStyle='#ffc63d';
  ctx.beginPath();
  ctx.moveTo(x+w*0.02, y+h*0.44);
  ctx.lineTo(x+w*0.98, y+h*0.44);
  ctx.lineTo(x+w*0.98, y+h*0.50);
  // drips
  ... 
  ```
  
Might be overcomplicating. Let's do simpler layered rounded rects:

From bottom to top:
1. Bottom bun: golden `#e0a04a`, rounded bottom corners, height h*0.30, at y+h*0.70.
2. Patty: `#5c2f1c`, height h*0.22, at y+h*0.50.
3. Cheese: `#ffcc33`, height h*0.10 at y+h*0.42, slightly wider with little triangular drips.
4. Lettuce: `#68c94a` wavy band height h*0.12 at y+h*0.32.
5. Top bun: `#f0b45a` dome — rounded rect with big top radius, height h*0.42 at y.

Total: top bun occupies 0..0.42, lettuce 0.30..0.42 (overlapping), cheese 0.42..0.52, patty 0.50..0.72, bottom bun 0.70..1.

Hmm ordering matters: draw bottom bun first, then patty, then cheese, then lettuce, then top bun. Since top bun is drawn last on top, it covers lettuce top. Good.

Sesame seeds on top bun: small ellipses, rotated, cream color.

Let's define h = 34 (paddle height), w = 130. So:
- bottom bun: y+0.68h to y+h, radius bottom.
- patty: y+0.48h, height 0.24h
- cheese: y+0.40h height 0.12h, width w*1.04 centered (slightly overhang)
- lettuce: wavy, y+0.28h .. y+0.44h, width w*1.02
- top bun: y .. y+0.42h, radius top h*0.34

Lettuce wavy: draw path with sine bumps.

```js
ctx.beginPath();
const lw = w*1.02, lx = x - w*0.01, ly = y + h*0.30, lh = h*0.16;
ctx.moveTo(lx, ly);
const bumps = 7;
for (let i=0;i<bumps;i++){
  const x0 = lx + lw*i/bumps;
  const x1 = lx + lw*(i+0.5)/bumps;
  const x2 = lx + lw*(i+1)/bumps;
  ctx.quadraticCurveTo(x1, ly+lh*1.1, x2, ly);
}
ctx.lineTo(lx+lw, ly+lh*0.6);
ctx.lineTo(lx, ly+lh*0.6);
ctx.closePath();
```
Hmm the wave on top and flat bottom. Fine, add fill green.

Actually simpler: draw the lettuce as a rounded rect with wavy top. Let's do:
```
ctx.beginPath();
ctx.moveTo(lx, ly+lh);
ctx.lineTo(lx, ly+2);
for i in 0..bumps: quadratic curves along top with bumps upward
ctx.lineTo(lx+lw, ly+lh);
close
```
Bumps upward means control points above ly. Let's do control at ly - lh*0.5.

Fine.

Cheese with drips: draw rounded rect then two small triangles/quads hanging down. Or just rounded rect with radius. Keep simple: rounded rect plus a couple of small semicircles at the bottom edge.

I'll keep it reasonably simple but cute.

Now the ball: cherry tomato — red radial gradient circle with highlight, and a small green leaf/stem on top. Rotate the stem? Keep static.

Actually a sesame seed would be weird. Cherry tomato it is. Or a meatball (brown) — less visible on dark bg. Cherry tomato red pops nicely.

Particles: hearts.

```js
function spawnHearts(x, y, count, spread, colorSet) {
  for (let i=0;i<count;i++){
    const a = Math.random()*Math.PI*2;
    const sp = 60 + Math.random()*260;
    particles.push({
      x, y,
      vx: Math.cos(a)*sp,
      vy: Math.sin(a)*sp - 60,
      size: 3 + Math.random()*7,
      rot: (Math.random()-0.5)*1.2,
      vrot: (Math.random()-0.5)*7,
      life: 0,
      max: 0.7 + Math.random()*0.9,
      color: colors[(Math.random()*colors.length)|0],
      grav: 220 + Math.random()*180,
      drag: 0.99,
    });
  }
}
```

Update: p.vy += grav*dt; p.x += vx*dt; p.y += vy*dt; vx *= drag^ (dt*60)? simpler: vx *= Math.pow(0.985, dt*60). rotation.

Life normalized t = p.life/p.max; alpha = 1 - t^2 maybe; size scales down near end.

Cap particles: if particles.length > 1600, splice from front.

For "tons of little hearts", each brick spawns 30-ish. With 6 rows × 11 cols = 66 bricks, could be a lot. Cap fine.

Also add heart burst on paddle hit (8) and on wall hit (2)? Maybe just paddle.

Screen shake: state.shake = 6 on brick break; decay.

Let's write the main loop.

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

update:
- update paddle target from AI or input
- move paddle
- move ball with substeps
- particles

Input: mouse move / touch move over canvas → paddle.x = clientX (relative). Keyboard arrows → paddle.left/right booleans.

Player input: pointermove on window? On canvas. Let's use pointermove on the wrap element.

Actually in play mode, use pointer position directly: paddle.x = pointerX. Plus arrow keys apply velocity.

Let me track `pointerX` (null if not set).

paddle update:
```js
if (state.mode==='demo') {
  const target = predictX();
  const maxV = W*1.15;
  const dx = target - paddle.x;
  paddle.x += Math.max(-maxV*dt, Math.min(maxV*dt, dx));
} else {
  let target = paddle.x;
  if (keys.left) target -= W*1.2*dt... 
}
```
Hmm, better: keep paddle.x as authoritative and move toward target.

```js
let desired = paddle.x;
if (pointerX !== null) desired = pointerX;
if (keys.left) desired -= W*0.9*dt;
if (keys.right) desired += W*0.9*dt;
```
This is a bit off since pointerX overrides. Let's do:

if keys pressed → desired = paddle.x ± speed*dt (keyboard wins)
else if pointerX !== null → desired = pointerX

Then paddle.x += (desired - paddle.x) * min(1, dt*22) for smoothing... but with pointer it feels laggy. Use direct for pointer, smoothing for keyboard. Fine: paddle.x = lerp(paddle.x, desired, 0.35) each frame — with 60fps that's quite snappy. Actually let's just set paddle.x = desired when pointer, and move with speed when keyboard.

I'll do:
```js
if (keys.l || keys.r) {
  const sp = W*1.3;
  paddle.x += (keys.r?1:0 - (keys.l?1:0)) * sp * dt;  // careful
} else if (pointerActive) {
  paddle.x = pointerX;
}
```
Good.

Clamp paddle.x to [w/2, W-w/2].

Ball physics:
```js
const speed = Math.hypot(ball.vx, ball.vy);
```
Base speed stored, increases slightly.

Let's store ball.speed and compute direction angle. Simpler: keep vx,vy and after any reflection, renormalize to target speed.

targetSpeed = H*0.62 initially, +score-based increase, capped at H*1.0.

Let me just do: `const SPEED = () => Math.min(H*0.60 + levelProgress, H*0.95)`.

Simplify: ball.speed = H*0.62, increases by 12 px/s per brick hit? No, keep constant per life, with slight increase per brick hit capped. Fine.

Substeps:
```js
const dist = ball.speed * dt;
const steps = Math.max(1, Math.min(10, Math.ceil(dist / (ball.r*0.8))));
const sdt = dt / steps;
for (let i=0;i<steps;i++){
  ball.x += ball.vx*sdt;
  ball.y += ball.vy*sdt;
  collideWalls();
  collideBricks();
  collidePaddle();
}
```

Normalize velocity after collisions to ball.speed.

collideWalls: 
- x - r < 0 → x = r, vx = |vx|, hearts small
- x + r > W → x = W-r, vx = -|vx|
- y - r < 0 → y = r, vy = |vy|
- y - r > H → lost

collideBricks: for each alive brick, AABB with circle:
```js
const cx = Math.max(b.x, Math.min(ball.x, b.x+b.w));
const cy = Math.max(b.y, Math.min(ball.y, b.y+b.h));
const dx = ball.x-cx, dy = ball.y-cy;
if (dx*dx+dy*dy < r*r) { hit }
```
But ball center may be inside the brick → closest point = center → dx=dy=0, ambiguous. Use previous position to determine which side. I'll store ball.px, ball.py before the substep and use them to determine reflection axis:

```js
const overlapX = ... 
```
Simplest robust: compute the ball's penetration depths:
```js
const dxLeft = (ball.x + r) - b.x;
const dxRight = (b.x + b.w) - (ball.x - r);
const dyTop = (ball.y + r) - b.y;
const dyBot = (b.y + b.h) - (ball.y - r);
const minPen = Math.min(dxLeft, dxRight, dyTop, dyBot);
if (minPen === dxLeft) { ball.x = b.x - r; ball.vx = -Math.abs(ball.vx); }
else if (minPen === dxRight) { ball.x = b.x+b.w + r; ball.vx = Math.abs(ball.vx); }
else if (minPen === dyTop) { ball.y = b.y - r; ball.vy = -Math.abs(ball.vy); }
else { ball.y = b.y+b.h + r; ball.vy = Math.abs(ball.vy); }
```
This works when the ball overlaps the brick rect (AABB test). So first test AABB overlap: ball.x + r > b.x && ball.x - r < b.x+b.w && ball.y + r > b.y && ball.y - r < b.y+b.h. That's a loose test (corners) but fine.

Actually that's a "circle bounding box" which over-detects at corners, but for breakout it's fine and feels good.

Break the brick, spawn hearts, score += 10, shake.

Only one brick per substep — break out of loop after first hit. Good.

collidePaddle: if ball.vy > 0 and ball.y + r >= paddle.y (paddle.y is top) and ball.y - r <= paddle.y + paddle.h and ball.x within [paddle.x - w/2 - r, paddle.x + w/2 + r]:
- ball.y = paddle.y - r
- offset = (ball.x - paddle.x)/(w/2) clamped
- angle = offset * 1.05 rad (60°)
- ball.vx = sin(angle)*speed; ball.vy = -cos(angle)*speed
- add a bit of paddle vx influence
- spawn small hearts (8)
- ensure min |vy| so ball doesn't go horizontal.

Paddle y: ball.y is top of paddle.

Lose ball: if ball.y - r > H → if demo: reset; if play: lives--, if 0 → gameover else reset with serve delay.

Reset ball: ball.x = paddle.x, ball.y = paddle.y - r - 30, vx = ±random, vy = -speed. Actually set a serve where ball sticks to paddle. Let's do simple: place on paddle and launch with a slight delay using `serveTimer`.

Implement: `serving = true; serveTimer = 0.9;` During serving, ball follows paddle position, and no physics. After timer, launch. In demo, launch immediately-ish.

Simplify further: on reset, just place the ball above the paddle moving up with a random-ish angle. No serve. In demo mode that's fine. In play mode, a small serve delay is nicer. Let's include the serve with a brief hold.

I'll do: `ball.stuck = true; ball.stuckTimer = 0.8;` while stuck, ball.x = paddle.x, ball.y = paddle.y - ball.r - 2. Then launch.

Now for lives display: draw 3 hearts.

Now overlay / mode transitions:

- Initially mode='demo'. Overlay shown with "Click or press a key to start".
- On start(): mode='play', reset all, hide overlay.
- On game over: mode='over', show overlay with "Game Over" and "Score: X — Click to play again". Clicking → start().
- Could also return to demo. Let's go: after game over, clicking restarts play. But the screenshot is at 3s and no interaction, so demo will be running. Good.

Demo mode should look great: AI keeps playing, hearts flying.

Make sure demo has a full brick layout at start.

Now, "every brick you break explodes into tons of little hearts" — make it dramatic: ~34 hearts per brick.

Perf: 66 bricks × 34 = 2244 hearts if all broken at once. With cap 1500 it's fine.

Let's also make hearts have slight color variation per brick color. Use brick color family + pink set. Let's just use a global heart palette plus the brick color mixed in. I'll build the palette from brick color: lighten/darken variants. Simpler: pick from ['#ff3d71','#ff6b9d','#ff9ec4','#ff4d94','#ffb3d1','#e8336b'] with occasional white sparkle.

Let's include a small sparkle particle (tiny circle) for variety — maybe 20% of particles are tiny dots. Nah, keep hearts only but vary size.

Let me also add a subtle radial glow behind each heart? No, perf.

Rendering hearts: use a path function and fill. ~1000 fills of bezier paths at 60fps is OK in Chrome.

Optimization: precompute heart path with Path2D at unit scale, then use ctx.setTransform / translate+scale and fill. Path2D is nice.

```js
const heartPath = new Path2D();
// unit heart centered-ish, size 1 => extends x -1..1, y -0.4..0.95
heartPath.moveTo(0, 0.3);
heartPath.bezierCurveTo(0.5, -0.4, 1.0, 0.25, 0, 0.95);
heartPath.bezierCurveTo(-1.0, 0.25, -0.5, -0.4, 0, 0.3);
heartPath.closePath();
```

Then draw: ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.scale(s,s); ctx.translate(0,-0.3); ctx.fillStyle=...; ctx.globalAlpha=a; ctx.fill(heartPath); ctx.restore();

Hmm the translate(0,-0.3) recenters vertically roughly. Fine.

Using save/restore per particle ~1000 times/frame is acceptable but let's use setTransform directly for speed:

```js
ctx.setTransform(DPR*..., ...)
```
That complicates. Just use save/restore; it's fine for ~800 particles.

Actually, I can avoid globalAlpha changes with fillStyle rgba strings — also string building per particle. Just use globalAlpha.

Optimization: group by nothing. Fine.

Now let's write the render:

```js
function render(){
  ctx.setTransform(DPR,0,0,DPR,0,0);
  // shake
  let sx=0, sy=0;
  if (state.shake>0.1){ sx=(Math.random()-0.5)*state.shake; sy=(Math.random()-0.5)*state.shake; }
  ctx.clearRect(0,0,W,H);
  // bg
  ...
  ctx.save();
  ctx.translate(sx,sy);
  drawBricks(); drawPaddle(); drawBall(); drawParticles();
  ctx.restore();
  drawHUD();
}
```

Background: I'll draw a gradient once into an offscreen? Just draw a linear gradient each frame — cheap enough.

Background: radial gradient from #2a1226 center-top to #120810. Plus some subtle vignette. Add faint floating hearts in the background for ambiance? That'd be nice but adds particles. Let's add a few slow-drifting background hearts — 18 of them, low alpha. Nice touch.

OK, let's write the code fully now.

```js
// ============ state ============
const HEART_COLORS = ['#ff2d6f','#ff5c93','#ff8ab5','#ff3d7f','#ffa8c9','#ff1e5a','#ffd9e6'];
```

Levels: after clearing all bricks, refill with a new pattern and increase ball speed. Nice touch — add row offset.

Let's write `buildBricks(pattern)`:

```js
const COLS_ROWS = ...;
function layout(){
  // called on resize
}
```

Let me structure:

```js
let cfg = {}; // computed metrics

function computeMetrics(){
  cfg.padTop = Math.max(56, H*0.10);      // top of brick field
  cfg.side = Math.max(14, W*0.035);
  cfg.cols = Math.max(5, Math.min(11, Math.floor(W/105)));
  cfg.rows = 6;
  cfg.gap = Math.max(6, W*0.008);
  cfg.brickW = (W - cfg.side*2 - cfg.gap*(cfg.cols-1)) / cfg.cols;
  cfg.brickH = Math.max(16, Math.min(30, cfg.brickW*0.40));
  cfg.paddleW = Math.max(84, Math.min(W*0.16, 170));
  cfg.paddleH = cfg.paddleW * 0.26;
  cfg.paddleY = H - Math.max(52, H*0.09);
  cfg.ballR = Math.max(7, Math.min(12, W*0.011));
}
```

Hmm on 375 width: paddleW = max(84, min(60,170)) = 84. Width ratio 84/375 = 22% — fine. paddleH = 84*0.26 = 22.

On 1280: paddleW = min(204.8,170)=170. paddleH=44. Good.

brickH on 1280: cols = floor(1280/105)=12 → min 11. side = 44.8. gap = 10.24. brickW = (1280-89.6-102.4)/11 = 1088/11 = 98.9. brickH = min(30, 39.6)=30. Good.

On 375: cols = floor(3.57)=3 → max 5. side=13.1. gap=6. brickW = (375-26.2-24)/5 = 324.8/5 = 65. brickH = 26. Good.

Brick top: padTop = max(56, 64) on 800. rows 6, brickH 30, gap 10 → total height = 6*30+5*10 = 230. Ends at 64+230 = 294. Plenty of room.

On 375x800 (portrait)... wait, viewport 375 wide but height maybe 700. H=700. padTop = 70. rows 6*26+5*6=186 → ends 256. paddleY = 700-63 = 637. Fine.

Now build bricks:

```js
function buildBricks(){
  bricks = [];
  for (let r=0;r<cfg.rows;r++){
    for (let c=0;c<cfg.cols;c++){
      // pattern: skip some for shape
      bricks.push({
        x: cfg.side + c*(cfg.brickW+cfg.gap),
        y: cfg.padTop + r*(cfg.brickH+cfg.gap),
        w: cfg.brickW, h: cfg.brickH,
        color: ROW_COLORS[r % ROW_COLORS.length],
        alive: true,
        hp: 1,
      });
    }
  }
}
```

Add a "hp 2" for top row? Maybe keep hp 1 for simplicity but top row tinted differently. Let's keep all hp 1 for max heart explosions.

Level: after all cleared, rebuild with +1 speed and maybe a pattern. I'll add a `level` counter and shift.

Alright, let's write everything out now.

Also HUD: score at top-left, level top-center, lives (hearts) top-right.

Font: `bold 20px system-ui`. Use ctx.font with fallbacks.

Let me write out the full code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Burger Breakout</title>
<style>
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden;background:#0d060c;
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
  #wrap{position:fixed;inset:0;}
  canvas{display:block;width:100%;height:100%;touch-action:none;}
  #overlay{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
    gap:10px;text-align:center;padding:24px;cursor:pointer;
    background:radial-gradient(circle at 50% 45%, rgba(60,10,40,.55), rgba(8,3,8,.88));
    backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
    transition:opacity .25s ease;z-index:10;}
  #overlay.hidden{opacity:0;pointer-events:none;}
  h1{margin:0;font-size:clamp(26px,6vw,58px);letter-spacing:.02em;
    background:linear-gradient(180deg,#ffe6a8,#ffb347 45%,#ff5c93);
    -webkit-background-clip:text;background-clip:text;color:transparent;
    text-shadow:0 0 40px rgba(255,90,150,.35);font-weight:900;}
  p{margin:0;color:#ffd7e6;font-size:clamp(13px,2.4vw,18px);opacity:.95;font-weight:600}
  .hint{margin-top:14px;font-size:clamp(14px,2.6vw,20px);color:#fff;
    background:rgba(255,90,150,.18);border:1px solid rgba(255,120,170,.5);
    padding:10px 20px;border-radius:999px;animation:pulse 1.6s ease-in-out infinite;}
  @keyframes pulse{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.05);opacity:1}}
</style>
</head>
<body>
<div id="wrap"><canvas id="game"></canvas></div>
<div id="overlay">
  <h1>🍔 BURGER BREAKOUT 💖</h1>
  <p>Smash the bricks — every one bursts into a storm of hearts.</p>
  <div class="hint" id="hint">Click or press any key to start</div>
</div>
<script> ... </script>
</body>
</html>
```

Note: emoji in the h1 might render inconsistently but fine.

Now the JS.

```js
(function(){
'use strict';

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', {alpha:false});
const overlay = document.getElementById('overlay');
const hint = document.getElementById('hint');

let W = 1280, H = 800, DPR = 1;

const ROW_COLORS = ['#ff4d8d','#ff8a3d','#ffd23f','#5fd97a','#4fc3f7','#b388ff'];
const HEART_COLORS = ['#ff2d6f','#ff5c93','#ff8ab5','#ff3d7f','#ffa8c9','#ff1e5a','#ffe0ec'];

const cfg = {};
const state = {
  mode:'demo',      // demo | play | over
  score:0,
  lives:3,
  level:1,
  shake:0,
  paused:false
};

const paddle = {x:0, w:120, h:32, y:0};
const ball = {x:0,y:0,vx:0,vy:0,r:9,speed:400, stuck:false, stuckT:0};
let bricks = [];
let hearts = [];
let bgHearts = [];
const keys = {left:false, right:false};
let pointerX = null;
let pointerActive = false;
let tGlobal = 0;
```

Wait: I should keep pointer input only when mode is play.

Let me write functions.

```js
function computeMetrics(){
  cfg.side = Math.max(12, W*0.035);
  cfg.cols = Math.max(5, Math.min(11, Math.floor(W/105)));
  cfg.rows = 6;
  cfg.gap = Math.max(5, Math.min(12, W*0.008));
  cfg.brickW = (W - cfg.side*2 - cfg.gap*(cfg.cols-1)) / cfg.cols;
  cfg.brickH = Math.max(15, Math.min(30, cfg.brickW*0.42));
  cfg.fieldTop = Math.max(52, Math.min(H*0.13, 96));
  cfg.paddleW = Math.max(84, Math.min(W*0.155, 168));
  cfg.paddleH = Math.max(20, cfg.paddleW*0.26);
  cfg.paddleY = H - Math.max(54, H*0.10);
  cfg.ballR = Math.max(7, Math.min(12, W*0.011));
  cfg.baseSpeed = Math.min(H*1.05, Math.max(H*0.62, 300));
}
```

Hmm baseSpeed for H=800 → 496 px/s. Across an 800px tall field that's 1.6s to cross. Reasonable.

For mobile H=700 → 434.

Ball radius 9 at 1280 → 12 (min(14.08,12)) = 12. Good.

Now layout: 

```js
function layout(){
  computeMetrics();
  paddle.w = cfg.paddleW;
  paddle.h = cfg.paddleH;
  paddle.y = cfg.paddleY;
  ball.r = cfg.ballR;
  paddle.x = clamp(paddle.x || W/2, paddle.w/2, W - paddle.w/2);
  if (!paddle.x) paddle.x = W/2;
  buildBricks();
  // reposition ball if outside
  ...
}
```

Careful with paddle.x initialization. Set paddle.x = W/2 at start.

On resize, rebuilding bricks resets progress. Acceptable. But it also resets the demo. Fine.

Actually a resize during play resetting bricks is annoying but whatever — browser window resize is rare in the eval (screenshot at fixed size).

Now `buildBricks()`:

```js
function buildBricks(){
  bricks = [];
  const {side, gap, brickW, brickH, rows, cols, fieldTop} = cfg;
  for (let r=0;r<rows;r++){
    for (let c=0;c<cols;c++){
      bricks.push({
        x: side + c*(brickW+gap),
        y: fieldTop + r*(brickH+gap),
        w: brickW, h: brickH,
        color: ROW_COLORS[r % ROW_COLORS.length],
        alive: true
      });
    }
  }
}
```

Ball reset:

```js
function resetBall(stuck){
  ball.x = paddle.x;
  ball.y = paddle.y - ball.r - 3;
  ball.speed = baseBallSpeed();
  const ang = -Math.PI/2 + (Math.random()-0.5)*0.5;
  ball.vx = Math.cos(ang)*ball.speed;
  ball.vy = Math.sin(ang)*ball.speed;
  ball.stuck = stuck;
  ball.stuckT = 0.7;
}
```

Wait ang = -PI/2 means straight up. cos(-PI/2)=0, sin=-1 → vy negative. Good.

baseBallSpeed: cfg.baseSpeed * (1 + (level-1)*0.08) capped.

Start game:

```js
function startGame(){
  state.mode = 'play';
  state.score = 0;
  state.lives = 3;
  state.level = 1;
  hearts.length = 0;
  paddle.x = W/2;
  buildBricks();
  resetBall(true);
  overlay.classList.add('hidden');
}
```

Demo init:
```js
function initDemo(){
  state.mode = 'demo';
  paddle.x = W/2;
  buildBricks();
  resetBall(false);
}
```

Hmm resetBall(true) in demo would make it stuck. Use false and immediately launch. But then it starts at paddle position moving up — fine.

Actually in demo let's use a short stuck so we can see the ball. Nah, just launch.

Now update:

```js
function update(dt){
  tGlobal += dt;
  state.shake *= Math.pow(0.0025, dt); // fast decay
  if (state.shake < 0.2) state.shake = 0;
  
  updateBgHearts(dt);
  
  // paddle control
  if (state.mode === 'demo'){
    const tx = aiTarget();
    const maxV = Math.max(W*0.9, 600);
    const d = tx - paddle.x;
    paddle.x += clamp(d, -maxV*dt, maxV*dt);
  } else {
    if (keys.left || keys.right){
      const sp = Math.max(W*0.9, 520);
      paddle.x += ((keys.right?1:0) - (keys.left?1:0)) * sp * dt;
    } else if (pointerActive && pointerX !== null){
      paddle.x += (pointerX - paddle.x) * Math.min(1, dt*20);
    }
  }
  paddle.x = clamp(paddle.x, paddle.w/2, W - paddle.w/2);
  
  // ball
  if (ball.stuck){
    ball.x = paddle.x;
    ball.y = paddle.y - ball.r - 3;
    ball.stuckT -= dt;
    if (ball.stuckT <= 0 || state.mode === 'demo') ball.stuck = false;
  } else {
    stepBall(dt);
  }
  
  updateHearts(dt);
  
  // level clear
  if (bricks.every(b => !b.alive)){
    state.level++;
    buildBricks();
    resetBall(true);
    for (let i=0;i<120;i++) hearts.push(makeHeart(W/2, H/2, 1.6));
  }
}
```

Hmm ball.stuck with mode demo: sets stuck false immediately. Good.

Wait: in demo, if the AI misses and the ball falls, we call resetBall. Let me handle the loss in stepBall.

stepBall(dt):
```js
function stepBall(dt){
  const dx = ball.vx*dt, dy = ball.vy*dt;
  const dist = Math.hypot(dx,dy);
  const steps = Math.max(1, Math.min(12, Math.ceil(dist / (ball.r*0.7))));
  const sdt = dt/steps;
  for (let i=0;i<steps;i++){
    ball.x += ball.vx*sdt;
    ball.y += ball.vy*sdt;
    if (collideWalls()) break;
    collideBricks();
    collidePaddle();
  }
  // normalize
  const sp = Math.hypot(ball.vx, ball.vy) || 1;
  ...
}
```

Actually let's not renormalize every frame; instead keep speed constant by normalizing after collisions. Simply normalize at the end of stepBall to ball.speed:

```js
const sp = Math.hypot(ball.vx, ball.vy);
if (sp > 0.001){ ball.vx = ball.vx/sp*ball.speed; ball.vy = ball.vy/sp*ball.speed; }
```
This is fine and keeps the speed constant.

collideWalls returns true if the ball was lost.

```js
function collideWalls(){
  const r = ball.r;
  if (ball.x - r < 0){ ball.x = r; ball.vx = Math.abs(ball.vx); }
  else if (ball.x + r > W){ ball.x = W - r; ball.vx = -Math.abs(ball.vx); }
  if (ball.y - r < 0){ ball.y = r; ball.vy = Math.abs(ball.vy); }
  if (ball.y - r > H){
    onBallLost();
    return true;
  }
  return false;
}
```

Brick collision:

```js
function collideBricks(){
  const r = ball.r;
  for (let i=0;i<bricks.length;i++){
    const b = bricks[i];
    if (!b.alive) continue;
    if (ball.x + r < b.x || ball.x - r > b.x + b.w || ball.y + r < b.y || ball.y - r > b.y + b.h) continue;
    
    // resolve
    const penL = (ball.x + r) - b.x;
    const penR = (b.x + b.w) - (ball.x - r);
    const penT = (ball.y + r) - b.y;
    const penB = (b.y + b.h) - (ball.y - r);
    const m = Math.min(penL, penR, penT, penB);
    if (m === penL){ ball.x = b.x - r; ball.vx = -Math.abs(ball.vx); }
    else if (m === penR){ ball.x = b.x + b.w + r; ball.vx = Math.abs(ball.vx); }
    else if (m === penT){ ball.y = b.y - r; ball.vy = -Math.abs(ball.vy); }
    else { ball.y = b.y + b.h + r; ball.vy = Math.abs(ball.vy); }
    
    breakBrick(b);
    return;
  }
}
```

breakBrick(b):
```js
function breakBrick(b){
  b.alive = false;
  state.score += 10;
  state.shake = Math.min(14, state.shake + 5);
  const n = 26 + Math.floor(Math.random()*14);
  const cx = b.x + b.w/2, cy = b.y + b.h/2;
  for (let i=0;i<n;i++) hearts.push(makeHeart(cx + (Math.random()-0.5)*b.w, cy + (Math.random()-0.5)*b.h, 1));
  // little sparkle burst of tiny hearts
}
```

makeHeart(x,y,scale):

```js
function makeHeart(x, y, s){
  const a = Math.random()*Math.PI*2;
  const sp = (60 + Math.random()*300) * s;
  return {
    x, y,
    vx: Math.cos(a)*sp,
    vy: Math.sin(a)*sp - 90,
    size: (2.5 + Math.random()*7) * s,
    rot: (Math.random()-0.5)*1.0,
    vrot: (Math.random()-0.5)*8,
    life: 0,
    max: 0.65 + Math.random()*0.85,
    g: 180 + Math.random()*260,
    color: HEART_COLORS[(Math.random()*HEART_COLORS.length)|0]
  };
}
```

Update hearts:
```js
function updateHearts(dt){
  for (let i=hearts.length-1;i>=0;i--){
    const p = hearts[i];
    p.life += dt;
    if (p.life >= p.max){ hearts.splice(i,1); continue; }
    p.vy += p.g*dt;
    p.vx *= Math.pow(0.35, dt);   // strong horizontal drag
    p.x += p.vx*dt;
    p.y += p.vy*dt;
    p.rot += p.vrot*dt;
  }
  // cap
  if (hearts.length > 1400) hearts.splice(0, hearts.length-1400);
}
```

Hmm pow(0.35, dt) at dt=1/60 → 0.35^0.0167 = e^(ln0.35*0.0167) = e^(-0.0175) = 0.9826. Over 1s that's 0.35. OK reasonable drag. Actually that's a lot; hearts would stop horizontally fast. Let's use 0.18 → e^(-0.0285)=0.972 per frame → over 1 sec 0.18. Hmm, that's quite draggy. Let's use 0.5 → 0.9885/frame. Fine, use 0.45.

Wait, but vy isn't dragged, only gravity. So hearts fall. Good.

Draw hearts:
```js
function drawHearts(){
  for (let i=0;i<hearts.length;i++){
    const p = hearts[i];
    const t = p.life/p.max;
    const a = t < 0.6 ? 1 : 1 - (t-0.6)/0.4;
    const s = p.size * (1 - t*0.35);
    ctx.globalAlpha = Math.max(0, a);
    ctx.save();
    ctx.translate(p.x, p.y);
    ctx.rotate(p.rot);
    ctx.scale(s, s);
    ctx.translate(0, -0.3);
    ctx.fillStyle = p.color;
    ctx.fill(heartPath);
    ctx.restore();
  }
  ctx.globalAlpha = 1;
}
```

Hmm, `ctx.translate(0,-0.3)` after scaling means 0.3*s pixels. Good—centers the heart roughly.

Actually the heart spans y from -0.4 to 0.95, center ≈ 0.275. So translate(0,-0.275) centers it. Good.

Ball lost:

```js
function onBallLost(){
  if (state.mode === 'demo'){
    resetBall(false);
    ball.y = paddle.y - 200; // hmm
    return;
  }
  state.lives--;
  state.shake = 16;
  if (state.lives <= 0){
    gameOver();
  } else {
    resetBall(true);
  }
}
```

Careful: resetBall sets ball.y = paddle.y - r - 3 and stuck=true. Good.

For demo, resetBall(false) puts ball at paddle position moving up. Fine.

gameOver:
```js
function gameOver(){
  state.mode = 'over';
  overlay.classList.remove('hidden');
  titleEl.innerHTML = 'GAME OVER';
  hintEl.textContent = 'Click or press any key to play again';
  ...
}
```

Need references to title and subtitle elements. Let me give them ids.

Now AI target:

```js
function aiTarget(){
  if (ball.stuck || ball.vy >= 0){
    // ball moving down or stuck: predict landing
  }
  ...
}
```

Let's write:
```js
function aiTarget(){
  const r = ball.r;
  if (ball.vy <= 0){
    // ball going up: drift toward center or toward ball's x
    return W*0.5 + (ball.x - W*0.5)*0.35;
  }
  // predict
  let px = ball.x, py = ball.y, vx = ball.vx, vy = ball.vy;
  let guard = 0;
  while (guard++ < 24){
    const t = (paddle.y - r - py) / vy;
    if (t <= 0) break;
    px += vx*t;
    py = paddle.y - r;
    // reflect off side walls
    let bounced = false;
    if (px < r){ px = 2*r - px; vx = Math.abs(vx); bounced = true; }
    else if (px > W - r){ px = 2*(W-r) - px; vx = -Math.abs(vx); bounced = true; }
    if (!bounced) break;
  }
  // small error for realism
  return px + Math.sin(tGlobal*1.7)*10;
}
```

Hmm, when px reflects, we then need to check again (could be out of bounds again if W small). The while loop handles it with the guard.

Wait, after updating py = paddle.y - r we break unless bounced. If bounced, we loop again and compute t = (paddle.y - r - py)/vy = 0 → break. Oops. Need to continue with the same py and the new trajectory. Let me restructure:

```js
function aiTarget(){
  const r = ball.r;
  const targetY = paddle.y - r;
  if (ball.vy <= 0) return W*0.5 + (ball.x - W*0.5)*0.3;
  let px = ball.x, vx = ball.vx;
  let t = (targetY - ball.y) / ball.vy;
  if (t < 0) t = 0;
  px = ball.x + vx * t;
  const lo = r, hi = W - r, span = hi - lo;
  if (span > 1){
    let k = (px - lo) / span;
    k = ((k % 2) + 2) % 2;
    if (k > 1) k = 2 - k;
    px = lo + k*span;
  }
  return px + Math.sin(tGlobal*2.3)*8;
}
```
This uses the triangular fold which is exact for reflections. 

Edge case: t is huge if vy is tiny... vy>0 so fine.

Also if the ball is already below paddle.y (shouldn't happen), t<0 → 0.

Good.

Now, the AI is essentially perfect. Add a small error that grows: `+ Math.sin(tGlobal*2.3)*Math.min(40, W*0.02)`. On 1280 that's 25px. The paddle is 168 wide, so a 25px error still hits. Good, keeps the demo alive.

Ball-paddle collision:

```js
function collidePaddle(){
  if (ball.vy <= 0) return;
  const pw = paddle.w, ph = paddle.h;
  const px = paddle.x - pw/2, py = paddle.y;
  if (ball.y + ball.r < py) return;
  if (ball.y - ball.r > py + ph) return;
  if (ball.x + ball.r < px || ball.x - ball.r > px + pw) return;
  
  ball.y = py - ball.r;
  const off = clamp((ball.x - paddle.x) / (pw/2), -1, 1);
  const ang = off * 1.0;           // max ~57deg
  const sp = ball.speed;
  ball.vx = Math.sin(ang) * sp;
  ball.vy = -Math.cos(ang) * sp;
  // ensure not too horizontal
  ...
  // hearts
  for (let i=0;i<10;i++) hearts.push(makeHeart(ball.x, py, 0.55));
}
```

Hmm ball.y = py - ball.r then vy set to -cos... but if the ball hits the side of the paddle it'll teleport on top. Acceptable for breakout.

Wait, `ball.y + ball.r < py` means the ball is fully above the paddle → return. Good.

Also we need to make sure the ball only bounces once: after setting vy negative, the next substep check `ball.vy <= 0` returns early. Good.

Now the drawing.

drawBackground:
```js
function drawBackground(){
  const g = ctx.createRadialGradient(W*0.5, H*0.15, 20, W*0.5, H*0.6, Math.max(W,H)*0.9);
  g.addColorStop(0, '#3a1330');
  g.addColorStop(0.45, '#1e0a1c');
  g.addColorStop(1, '#0b040a');
  ctx.fillStyle = g;
  ctx.fillRect(0,0,W,H);
  
  // bg hearts
  for (const h of bgHearts){ ... }
}
```

bgHearts: array of {x,y,s,a,rot,vr,vy}. Slow drift upward. Draw with low alpha.

Let me create 22 of them at init.

```js
function initBgHearts(){
  bgHearts = [];
  for (let i=0;i<24;i++){
    bgHearts.push({
      x: Math.random()*W, y: Math.random()*H,
      s: 6 + Math.random()*22,
      a: 0.04 + Math.random()*0.07,
      rot: (Math.random()-0.5)*1.2,
      vr: (Math.random()-0.5)*0.3,
      vy: -8 - Math.random()*22
    });
  }
}
```

Update: y += vy*dt; if y < -40, y = H+40, x = random.

Good, subtle ambiance.

drawBricks:

```js
function drawBricks(){
  for (const b of bricks){
    if (!b.alive) continue;
    const r = Math.min(6, b.h*0.3);
    // body
    ctx.fillStyle = b.color;
    ctx.beginPath();
    ctx.roundRect(b.x, b.y, b.w, b.h, r);
    ctx.fill();
    // top highlight
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.beginPath();
    ctx.roundRect(b.x + 2, b.y + 2, b.w - 4, Math.max(2, b.h*0.28), r*0.6);
    ctx.fill();
    // bottom shadow
    ctx.fillStyle = 'rgba(0,0,0,0.22)';
    ctx.beginPath();
    ctx.roundRect(b.x + 2, b.y + b.h - Math.max(3, b.h*0.3), b.w - 4, Math.max(2, b.h*0.28), r*0.6);
    ctx.fill();
  }
}
```

Maybe add a glow: shadowBlur on each brick is expensive. Skip, or do a subtle global glow via a radial gradient overlay. Let's skip.

Actually, to make bricks pop, add a soft outer glow using shadowColor/shadowBlur once per row? Eh. Let's add `ctx.shadowColor = b.color; ctx.shadowBlur = 12;` before fill. That's 66 shadow fills per frame — could be slow-ish but probably OK at 66. Hmm, shadowBlur is genuinely slow. Skip it. Instead, draw the brick with a lighter border.

Let's do: fill with color, then a subtle inner top highlight, and a 1px darker outline. Good enough.

drawPaddle — the burger.

```js
function drawPaddle(){
  const w = paddle.w, h = paddle.h;
  const x = paddle.x - w/2, y = paddle.y;
  
  ctx.save();
  // drop shadow
  ctx.shadowColor = 'rgba(0,0,0,0.5)';
  ctx.shadowBlur = 20;
  ctx.shadowOffsetY = 8;
  ctx.fillStyle = '#c8802f';
  ctx.beginPath();
  ctx.roundRect(x, y + h*0.66, w, h*0.34, [6,6,h*0.17,h*0.17]);
  ctx.fill();
  ctx.restore();
  ...
}
```

Wait, the shadow applies to the bottom bun only. Fine.

Then bottom bun (drawn again without shadow? It's already drawn).

Actually let's restructure: draw a single shadowed silhouette first (rounded rect of the whole burger), then the layers on top without shadow. 

```js
// shadow silhouette
ctx.save();
ctx.shadowColor='rgba(0,0,0,0.55)'; ctx.shadowBlur=18; ctx.shadowOffsetY=7;
ctx.fillStyle='#000';
ctx.beginPath(); ctx.roundRect(x, y, w, h, h*0.35); ctx.fill();
ctx.restore();
```
That gives a nice drop shadow behind the burger. Then draw layers on top.

Layers:

```js
// bottom bun
ctx.fillStyle = '#dc9a45';
ctx.beginPath(); ctx.roundRect(x, y+h*0.64, w, h*0.36, [2,2,h*0.18,h*0.18]); ctx.fill();
// bottom bun highlight
ctx.fillStyle='rgba(255,255,255,0.16)';
ctx.beginPath(); ctx.roundRect(x+w*0.06, y+h*0.68, w*0.88, h*0.08, h*0.04); ctx.fill();

// patty
ctx.fillStyle = '#5b2f1b';
ctx.beginPath(); ctx.roundRect(x-w*0.015, y+h*0.46, w*1.03, h*0.26, h*0.13); ctx.fill();
// patty char highlight
ctx.fillStyle='rgba(255,255,255,0.10)';
ctx.beginPath(); ctx.roundRect(x+w*0.04, y+h*0.49, w*0.92, h*0.06, h*0.03); ctx.fill();

// cheese
ctx.fillStyle = '#ffc633';
ctx.beginPath();
ctx.moveTo(x - w*0.01, y+h*0.40);
ctx.lineTo(x + w*1.01, y+h*0.40);
ctx.lineTo(x + w*0.99, y+h*0.50);
// drips
for (let i=0;i<4;i++){ ... }
```
Simplify: cheese as a rounded rect with slight overhang + 3 drip semicircles.

Let me do:
```js
ctx.fillStyle = '#ffc633';
ctx.beginPath();
ctx.roundRect(x - w*0.012, y+h*0.385, w*1.024, h*0.12, h*0.05);
ctx.fill();
// drips
ctx.beginPath();
for (let i=0;i<3;i++){
  const dx = x + w*(0.22 + 0.28*i);
  const dy = y + h*0.485;
  const rw = w*0.05;
  ctx.moveTo(dx - rw, dy);
  ctx.arc(dx, dy, rw, Math.PI, 0, true);   // hmm
  ...
}
```
Simpler: `ctx.arc(dx, dy, rw, 0, Math.PI)` gives a downward semicircle. Then fill each individually. Let's just fill after building the path:

```js
ctx.beginPath();
ctx.roundRect(...);
for (let i=0;i<3;i++){
  const dx = x + w*(0.25 + 0.25*i);
  const dy = y + h*0.495;
  const rw = w*0.045;
  ctx.moveTo(dx + rw, dy);
  ctx.arc(dx, dy, rw, 0, Math.PI);
}
ctx.fill();
```
arc from 0 to PI going clockwise (default counterclockwise=false → clockwise in screen coords which is downward). Yes: angle 0 is right, PI/2 is down (since y is down), PI is left. So it draws the bottom semicircle. 

Then lettuce:
```js
ctx.fillStyle = '#5fc93f';
ctx.beginPath();
const lx0 = x - w*0.02, lx1 = x + w*1.02;
const ly = y + h*0.30, lh = h*0.13;
ctx.moveTo(lx0, ly + lh);
ctx.lineTo(lx0, ly + lh*0.4);
const bumps = 6;
for (let i=0;i<bumps;i++){
  const xa = lx0 + (lx1-lx0)*i/bumps;
  const xb = lx0 + (lx1-lx0)*(i+1)/bumps;
  ctx.quadraticCurveTo((xa+xb)/2, ly - lh*0.55, xb, ly + lh*0.4);
}
ctx.lineTo(lx1, ly + lh);
ctx.closePath();
ctx.fill();
```

Hmm the wave goes above ly. Fine — but the top bun drawn after will cover the top part. Let's make the top bun start at y and extend to y+h*0.42, so the lettuce peaks at ly - 0.55*lh = y+0.30h - 0.0715h = y+0.228h would be hidden behind the top bun. That's fine, it looks like lettuce tucked under the bun. Actually the visible lettuce would be the part below y+0.42h. lh=0.13h so lettuce spans y+0.30h to y+0.43h. Hmm, the top bun covers up to 0.42h. So only 0.01h of lettuce visible. Not good.

Let me adjust: top bun from y to y+0.36h. Lettuce band from y+0.30h to y+0.46h. Cheese at 0.44h-0.56h. Patty 0.54h-0.76h. Bottom bun 0.74h-1.0h.

Total height: h. Let's set:
- top bun: y .. y+0.38h, radius top = 0.38h (dome-ish). 
- lettuce: peaks at y+0.32h, base at y+0.48h.
- cheese: y+0.46h .. y+0.56h
- patty: y+0.54h .. y+0.78h
- bottom bun: y+0.76h .. y+1.0h

Draw order: shadow, bottom bun, patty, cheese, lettuce, top bun.

Bottom bun height 0.24h with h=44 → 10.5px. Patty 0.24h = 10.5px. Top bun 0.38h = 16.7px. OK proportions work for a burger.

Hmm, the paddle height 44 and width 170 — a burger that's 170x44 is wide and flat. That's fine, it's a paddle.

Sesame seeds on the top bun: 
```js
ctx.fillStyle = 'rgba(255,240,205,0.95)';
const seeds = [[0.20,0.16,-0.5],[0.38,0.10,0.3],[0.56,0.14,-0.2],[0.74,0.20,0.5],[0.86,0.28,-0.4],[0.30,0.28,0.2],[0.62,0.30,0.35]];
for (const [fx,fy,rot] of seeds){
  ctx.save();
  ctx.translate(x + w*fx, y + h*fy);
  ctx.rotate(rot);
  ctx.beginPath();
  ctx.ellipse(0,0, w*0.022, w*0.012, 0, 0, Math.PI*2);
  ctx.fill();
  ctx.restore();
}
```
Ellipse radii: w*0.022 for a 170px paddle = 3.7px. Times 2 = 7.4px wide seed. Reasonable.

Top bun dome: use roundRect with radius [0.38h, 0.38h, 0.1h, 0.1h]? Actually for a dome, use a path: 
```js
ctx.beginPath();
ctx.moveTo(x + w*0.02, y + h*0.38);
ctx.bezierCurveTo(x + w*0.02, y - h*0.10, x + w*0.98, y - h*0.10, x + w*0.98, y + h*0.38);
ctx.closePath();
```
That gives a nice dome. But h*0.10 above y goes out of the paddle bounds a bit. That's fine visually (the burger's top bun bulges up). But collision uses paddle.y as top. Slight mismatch is fine.

Actually let's keep the top of the dome at y+0.02h to avoid too much overhang:
```js
ctx.moveTo(x + w*0.02, y + h*0.40);
ctx.bezierCurveTo(x + w*0.03, y + h*0.02, x + w*0.30, y - h*0.02, x + w*0.50, y - h*0.02);
ctx.bezierCurveTo(x + w*0.70, y - h*0.02, x + w*0.97, y + h*0.02, x + w*0.98, y + h*0.40);
ctx.closePath();
```
Hmm, y - h*0.02 is slightly above. Fine.

Then the bottom of the dome is a straight line at y+0.40h. But we need it to close properly — closePath connects back to the start point. Good.

Add gradient to the bun for depth:
```js
const bg = ctx.createLinearGradient(0, y, 0, y+h*0.42);
bg.addColorStop(0, '#f7c877');
bg.addColorStop(1, '#dd9a3f');
ctx.fillStyle = bg;
```

Nice.

OK. Now drawBall — cherry tomato:

```js
function drawBall(){
  const r = ball.r;
  const g = ctx.createRadialGradient(ball.x - r*0.35, ball.y - r*0.4, r*0.1, ball.x, ball.y, r*1.15);
  g.addColorStop(0, '#ff8a8a');
  g.addColorStop(0.5, '#f02b3d');
  g.addColorStop(1, '#8e0d1e');
  ctx.save();
  ctx.shadowColor = 'rgba(255,60,90,0.8)';
  ctx.shadowBlur = 18;
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(ball.x, ball.y, r, 0, Math.PI*2); ctx.fill();
  ctx.restore();
  // highlight
  ctx.fillStyle = 'rgba(255,255,255,0.75)';
  ctx.beginPath(); ctx.ellipse(ball.x - r*0.35, ball.y - r*0.4, r*0.28, r*0.2, -0.6, 0, Math.PI*2); ctx.fill();
  // stem
  ctx.strokeStyle = '#43a02c';
  ctx.lineWidth = Math.max(1.5, r*0.2);
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(ball.x, ball.y - r*0.85);
  ctx.quadraticCurveTo(ball.x + r*0.5, ball.y - r*1.5, ball.x + r*1.0, ball.y - r*1.35);
  ctx.stroke();
}
```

Hmm, the stem only makes sense if the ball doesn't rotate. It's fine.

Actually a glowing red ball on a dark background looks great. Keep it.

Now HUD:

```js
function drawHUD(){
  ctx.save();
  ctx.font = '800 ' + Math.round(Math.min(24, Math.max(15, W*0.019))) + 'px system-ui, -apple-system, "Segoe UI", sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillStyle = 'rgba(255,255,255,0.92)';
  ctx.textAlign = 'left';
  ctx.fillText(String(state.score).padStart(4,'0'), 20, 16);
  ctx.font = ...smaller;
  ctx.fillStyle = 'rgba(255,190,215,0.65)';
  ctx.fillText('SCORE', 20, 16 + 26);  // hmm order
  ...
}
```

Let's do: "SCORE" label small above, number below. Or number big at top-left with a small "SCORE" label to its left. Simple:

top-left: "SCORE 0010" in one line? Let's do:
- Left: score number big, with "SCORE" tiny above.
- Right: hearts for lives.
- Center: "LEVEL 2" small.

Let me write:

```js
const pad = Math.max(14, Math.min(28, W*0.022));
const fs = Math.round(Math.max(12, Math.min(18, W*0.014)));
const bigFs = Math.round(Math.max(18, Math.min(28, W*0.023)));

ctx.textAlign='left'; ctx.textBaseline='top';
ctx.font = `700 ${fs}px ...`;
ctx.fillStyle='rgba(255,160,200,0.7)';
ctx.fillText('SCORE', pad, pad);
ctx.font = `900 ${bigFs}px ...`;
ctx.fillStyle='#fff';
ctx.fillText(pad4(state.score), pad, pad + fs + 2);
```

Hmm textBaseline top with the label above. Fine.

Lives: draw hearts at top-right:
```js
for (let i=0;i<Math.max(state.lives,0);i++){
  const hx = W - pad - 12 - i*26;
  const hy = pad + 8;
  drawHeartIcon(hx, hy, 9, '#ff3d7f');
}
```
Use ctx.save/translate/scale/fill(heartPath).

Level: center top.

Alright. Also in demo mode show a subtle "DEMO" badge? The overlay covers that. Actually the overlay is on top with a message. Let's make the overlay light enough to see the game behind (the gradient goes to 0.88 alpha at the edges — quite dark). Let's reduce to make the game visible: `radial-gradient(circle at 50% 45%, rgba(60,10,40,.25), rgba(8,3,8,.8))`. Hmm, we want the game visible in the screenshot. Let's use a lighter overlay: rgba(10,3,10,0.55) at edges, 0.15 in center. And the text has a strong shadow.

Actually the screenshot is taken at 3s without interaction, so the demo will be behind the overlay. It's important that the game is clearly visible. Let's make the overlay quite transparent: center 0.05, edges 0.62.

Also the overlay's backdrop-filter blur would blur the game. Remove it or make it small. Let's drop the blur.

OK. And put the title near the top maybe, so the center shows the game? No—centered is fine, but then the text covers bricks. Let's put the text lower-center-ish, or just center. Hmm.

Compromise: overlay content positioned in the vertical center but the gradient light. The bricks are in the upper area (y 64-294 of 800). The center of the screen is y=400, which is empty playfield. So centered text is fine!

Let me set the overlay content to be centered with the title and the hint. It'll overlay the empty middle area. 

Also add `pointer-events` and click handling.

Now input handlers:

```js
function startOrRestart(){
  if (state.mode === 'over' || state.mode === 'demo') startGame();
}

window.addEventListener('pointerdown', (e)=>{ 
  if (state.mode !== 'play') { startGame(); }
  updatePointer(e);
});
window.addEventListener('pointermove', (e)=>{ updatePointer(e); });
window.addEventListener('keydown', (e)=>{
  if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') keys.left = true;
  if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') keys.right = true;
  if (state.mode !== 'play'){ startGame(); }
  if (e.key === ' ' || e.key.startsWith('Arrow')) e.preventDefault();
});
window.addEventListener('keyup', ...);
```

Careful: preventDefault on space to avoid scrolling. Body has overflow hidden anyway.

updatePointer(e): pointerActive = true; pointerX = e.clientX (relative to canvas rect).

```js
function updatePointer(e){
  const r = canvas.getBoundingClientRect();
  pointerX = e.clientX - r.left;
  pointerActive = true;
}
```

But on mobile, touching triggers pointerdown→startGame then pointermove. Fine.

Note: The overlay covers the canvas, so pointerdown on the overlay... The window listener catches it regardless. Good. And after startGame, overlay gets class 'hidden' with pointer-events:none.

Now let's assemble. Also, on `startGame` from the game-over state, reset everything.

Let me handle the overlay text updates for game over.

```js
const overlayTitle = document.getElementById('ov-title');
const overlaySub = document.getElementById('ov-sub');
const overlayHint = document.getElementById('ov-hint');
```

startGame():
```js
function startGame(){
  state.mode = 'play';
  state.score = 0;
  state.lives = 3;
  state.level = 1;
  state.shake = 0;
  hearts.length = 0;
  paddle.x = W/2;
  buildBricks();
  resetBall(true);
  overlay.classList.add('hidden');
}
```

And resetBall(true) with stuckT = 0.9.

gameOver():
```js
function gameOver(){
  state.mode = 'over';
  overlayTitle.textContent = 'GAME OVER 🍔';
  overlaySub.textContent = 'You scored ' + state.score + ' points.';
  overlayHint.textContent = 'Click or press any key to play again';
  overlay.classList.remove('hidden');
}
```

And initDemo sets overlayTitle back to the default... Not needed.

Now `resize` and initial setup:

```js
function resize(){
  const rect = canvas.parentElement.getBoundingClientRect();
  W = Math.max(300, rect.width);
  H = Math.max(320, rect.height);
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = Math.round(W*DPR);
  canvas.height = Math.round(H*DPR);
  ctx.setTransform(DPR,0,0,DPR,0,0);
  computeMetrics();
  paddle.w = cfg.paddleW; paddle.h = cfg.paddleH; paddle.y = cfg.paddleY; ball.r = cfg.ballR;
  if (!paddle.x) paddle.x = W/2;
  paddle.x = clamp(paddle.x, paddle.w/2, W - paddle.w/2);
  buildBricks();
  ball.speed = baseSpeed();
  if (state.mode === 'demo') resetBall(false);
  else { ball.stuck = true; ball.stuckT = 0.9; }
  initBgHearts();
}
```

Hmm, on every resize rebuild bricks — acceptable.

Wait, but the canvas parent is #wrap which is fixed inset:0. Use window.innerWidth/innerHeight instead. Simpler and reliable:

```js
W = window.innerWidth; H = window.innerHeight;
```

Yes.

Wait — but the demo needs to look right at 1280x800 which is the iframe viewport. Fine.

Now the loop and initial call.

```js
resize();
window.addEventListener('resize', resize);
initDemo? 
```

Actually resize() does the demo init. But it also needs to set mode. Let's set state.mode='demo' initially and call resize() then start the loop.

But resetBall(false) requires paddle.y which is set. OK.

One more thing: in resize I call buildBricks() which resets brick state. On first load that's the demo. Fine.

Let's now handle the case where the ball gets stuck in a horizontal loop. After paddle bounce, ensure |vy| >= 0.35*speed. Since ang max is 1.0 rad, cos(1.0)=0.54, so vy magnitude >= 0.54*speed. Good, no issue.

But wall bounces could make it near-horizontal only if it were already near-horizontal. It can't be.

Hmm, actually after brick collisions vy is only flipped, so the angle stays. Fine.

Let's also add a max time guard... no.

Now, one concern: `ctx.roundRect` support — Chrome 99+. Fine.

Let me also double check `ctx.roundRect(x,y,w,h,[r1,r2,r3,r4])` — the radii array order is [top-left, top-right, bottom-right, bottom-left]. Yes.

Negative widths? No.

Now write drawHearts efficiently. Actually, I realize setting globalAlpha + save/restore 1000 times per frame might drop frames. Let's optimize: instead of save/restore, use setTransform:

```js
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
```
then for each particle:
```js
const c = Math.cos(p.rot)*s, sn = Math.sin(p.rot)*s;
ctx.setTransform(c*DPR, sn*DPR, -sn*DPR, c*DPR, p.x*DPR, (p.y - 0.275*s)*DPR);
```
Hmm, need to account for the translate(0,-0.275) in local space: after scale+rotate, translating by (0,-0.275) in local coords = rotate that vector. So the offset is (sin(rot)*0.275*s, -cos(rot)*0.275*s)... let me just do the simpler save/restore. Chrome handles ~1000 transforms per frame OK at 60fps. I'll cap particles at 900 to be safe.

Actually let's use setTransform properly — it's not hard:

Point p in heart-local coords (unit scale) maps to world:
world = center + R(rot) * S(s) * (local + (0,-0.275))
So origin offset = R(rot)*S(s)*(0,-0.275) = s*(-0.275)*(-sin? ) let's compute:
R(rot) = [cos, -sin; sin, cos]. Applied to (0, -0.275*s): x = -(-sin*0.275*s)? 
R * v where v = (0, -0.275*s):
x = cos*0 - sin*(-0.275s) = 0.275*s*sin
y = sin*0 + cos*(-0.275s) = -0.275*s*cos

world.x = p.x + 0.275*s*sin(rot)
world.y = p.y - 0.275*s*cos(rot)

Transform matrix: a = s*cos, b = s*sin, c = -s*sin, d = s*cos, e = world.x, f = world.y.

With DPR: ctx.setTransform(a*DPR, b*DPR, c*DPR, d*DPR, e*DPR, f*DPR).

That's clean and fast. Then after the loop, reset transform.

But careful: other drawing code uses ctx.save/restore with setTransform... The main render sets transform at the start. Let's reset with ctx.setTransform(DPR,0,0,DPR,0,0) after drawing hearts.

And the shake translation would be lost — apply shake as an offset to world x/y: e + shakeX, f + shakeY. I'll pass shake into the loop.

Hmm, simpler: apply shake only to the drawn elements by offsetting. Let me just incorporate shakeX/shakeY into the heart transform too.

Actually, simplest overall approach: do all drawing with save/restore + translate for shake, and use setTransform for hearts which ignores shake... The shake would not affect hearts. Minor. Let's include the shake offsets.

I'll structure render():

```js
function render(){
  const sx = state.shake ? (Math.random()-0.5)*state.shake : 0;
  const sy = state.shake ? (Math.random()-0.5)*state.shake : 0;
  ctx.setTransform(DPR,0,0,DPR,0,0);
  // bg (no shake)
  drawBackground();
  
  const base = new DOMMatrix(); // meh
```

Let's just use ctx.save(); ctx.translate(sx,sy); for the normal drawing, and for hearts add sx,sy manually.

OK.

Let me write drawHearts(sx, sy).

Now let's finalize and write the code.

One more consideration: first paint. At t=0 the ball is launched upward from the paddle, and the AI moves the paddle. Within 3 seconds the ball will hit bricks and hearts will fly. Good. Actually with speed ~496px/s and the field being ~500px tall from paddle to bricks, it takes ~1s to reach the bricks. Good.

Let me also make sure the demo has some hearts visible at 3s. Yes.

Let me also seed a few hearts at the start for immediate visual interest? Not necessary.

Actually, to guarantee a good screenshot, let me pre-break a couple of bricks at init in demo mode with heart bursts? That feels hacky. The ball will hit within 1s. Fine.

Hmm — actually let me add a small "sparkle" at load: no.

Write the code.

Also `pad4` function for score.

Let me write everything now carefully.

```js
(function(){
'use strict';

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d', { alpha: false });
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ov-title');
const ovSub = document.getElementById('ov-sub');
const ovHint = document.getElementById('ov-hint');

const clamp = (v,a,b)=> v<a?a:(v>b?b:v);

let W = 1280, H = 800, DPR = 1;

const ROW_COLORS = ['#ff4d8d','#ff8a3d','#ffd23f','#5fd97a','#4fc3f7','#b388ff'];
const ROW_DARK   = ['#c62b64','#c25f1a','#c99a10','#2f9c4a','#1f8fc4','#7a55cc'];
const HEART_COLORS = ['#ff2d6f','#ff5c93','#ff8ab5','#ff3d7f','#ffa8c9','#ff1e5a','#ffe0ec','#ff6fa5'];
```

heartPath as Path2D.

Let me write it all out now.

Also handle `visibilitychange`? Not needed.

Final code:

I'll write the whole script now in order:

1. constants & helpers
2. state variables
3. Path2D heart
4. resize/metrics
5. build bricks
6. resetBall / startGame / gameOver
7. AI
8. update
9. collisions
10. particles
11. render functions
12. loop
13. input

Let me write.

For the level-clear heart burst I use makeHeart(W/2,H/2,1.6) — that gives size up to 7*1.6=11. Fine.

One thing: `hearts.splice(0, hearts.length-900)` inside update — careful if length < 900 → negative → splice(0, negative) = splice(0,0). Fine.

Let me write final code.

For the demo, I also want the paddle to not care about pointer. In demo, pointerX updates won't affect since mode is demo. Good.

Writing:

```js
function update(dt){
  tGlobal += dt;
  if (state.shake > 0) state.shake = Math.max(0, state.shake - dt*60);  // decay
```
Hmm, decay per frame: shake -= dt*60 → at 60fps that's 1 per frame. Start at 14 → 14 frames. Too fast. Use shake *= Math.pow(0.02, dt) → after 1s, 2% remains. Let's do that.

Now writing the final.

Also I should double check: `ctx.roundRect` with radii array of 4 numbers, works.

For the paddle's bottom bun I use roundRect with [2,2,r,r].

Let me write out drawPaddle fully:

```js
function drawPaddle(){
  const w = paddle.w, h = paddle.h;
  const x = paddle.x - w/2, y = paddle.y;

  // drop shadow
  ctx.save();
  ctx.shadowColor = 'rgba(0,0,0,0.55)';
  ctx.shadowBlur = 16;
  ctx.shadowOffsetY = 6;
  ctx.fillStyle = 'rgba(0,0,0,0.9)';
  ctx.beginPath();
  ctx.roundRect(x, y + h*0.25, w, h*0.75, h*0.3);
  ctx.fill();
  ctx.restore();

  // bottom bun
  ctx.fillStyle = '#d99442';
  ctx.beginPath();
  ctx.roundRect(x, y + h*0.72, w, h*0.28, [3,3,h*0.14,h*0.14]);
  ctx.fill();

  // patty
  ctx.fillStyle = '#5a2e1a';
  ctx.beginPath();
  ctx.roundRect(x - w*0.01, y + h*0.52, w*1.02, h*0.24, h*0.12);
  ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.08)';
  ctx.beginPath();
  ctx.roundRect(x + w*0.05, y + h*0.555, w*0.9, h*0.05, h*0.025);
  ctx.fill();

  // cheese
  ctx.fillStyle = '#ffc633';
  ctx.beginPath();
  ctx.roundRect(x - w*0.012, y + h*0.44, w*1.024, h*0.12, h*0.05);
  for (let i=0;i<3;i++){
    const dx = x + w*(0.25 + 0.25*i), dy = y + h*0.55, rw = w*0.045;
    ctx.moveTo(dx + rw, dy);
    ctx.arc(dx, dy, rw, 0, Math.PI);
  }
  ctx.fill();
```
Careful: roundRect adds a subpath; then moveTo + arc adds more subpaths; fill uses nonzero. Should be fine.

Wait: after roundRect, the current point is wherever. moveTo starts a new subpath. Good.

```js
  // lettuce
  const lx0 = x - w*0.02, lx1 = x + w*1.02;
  const lyTop = y + h*0.30, lyBot = y + h*0.48;
  ctx.fillStyle = '#5fc93f';
  ctx.beginPath();
  ctx.moveTo(lx0, lyBot);
  ctx.lineTo(lx0, lyTop + (lyBot-lyTop)*0.5);
  const bumps = 7, seg = (lx1-lx0)/bumps;
  for (let i=0;i<bumps;i++){
    const xa = lx0 + seg*i, xb = xa + seg;
    ctx.quadraticCurveTo((xa+xb)/2, lyTop - (lyBot-lyTop)*0.7, xb, lyTop + (lyBot-lyTop)*0.5);
  }
  ctx.lineTo(lx1, lyBot);
  ctx.closePath();
  ctx.fill();
```
Hmm: after the last quadratic, the point is at (lx1, mid). Then lineTo(lx1, lyBot), then closePath back to (lx0, lyBot). Good.

The lettuce peaks go above lyTop by 0.7*(0.18h)=0.126h above y+0.30h → y+0.174h. Top bun covers up to y+0.40h, so lettuce peaks are hidden. Then the visible lettuce is from y+0.40h to y+0.48h. That's 0.08h ≈ 3.5px at h=44. Thin but visible.

Hmm, let me reorder: draw the top bun FIRST, then lettuce below it so the lettuce overlaps the bun's bottom edge? No, that looks odd.

Better: make the top bun end at y+0.34h, and lettuce band from y+0.30h to y+0.48h with peaks up to y+0.24h (hidden). Visible lettuce = 0.34h→0.48h = 0.14h ≈ 6px. Better.

Let's define:
- top bun: y .. y+0.35h (dome from 0.02h to 0.35h, closing flat at 0.35h)
- lettuce: 0.30h .. 0.48h, peaks to 0.22h
- cheese: 0.44h .. 0.56h
- patty: 0.54h .. 0.78h
- bottom bun: 0.76h .. 1.0h

Good.

Top bun dome path:
```js
const grad = ctx.createLinearGradient(0, y, 0, y+h*0.35);
grad.addColorStop(0, '#ffe0a3');
grad.addColorStop(0.55, '#f0b862');
grad.addColorStop(1, '#d68f3a');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(x + w*0.02, y + h*0.35);
ctx.bezierCurveTo(x + w*0.02, y + h*0.02, x + w*0.22, y + h*0.0, x + w*0.5, y + h*0.0);
ctx.bezierCurveTo(x + w*0.78, y + h*0.0, x + w*0.98, y + h*0.02, x + w*0.98, y + h*0.35);
ctx.closePath();
ctx.fill();
```

Sesame seeds within the dome region (y 0.06h..0.26h, x 0.15w..0.85w).

Good.

Now let's finalize and write out.

One more: the paddle's collision top is paddle.y. The visual burger's top is at paddle.y (dome). Good match.

Ball resting position: paddle.y - r - 3.

OK writing now.

I also want the "lives" hearts to look nice: use the heartPath at scale ~8.

drawHUD:

```js
function drawHUD(){
  const pad = Math.max(12, Math.min(26, W*0.022));
  const small = Math.round(Math.max(10, Math.min(15, W*0.012)));
  const big = Math.round(Math.max(17, Math.min(26, W*0.021)));

  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.font = `800 ${small}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`;
  ctx.fillStyle = 'rgba(255,150,195,0.75)';
  ctx.fillText('SCORE', pad, pad);
  ctx.font = `900 ${big}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`;
  ctx.fillStyle = '#ffffff';
  ctx.fillText(String(state.score).padStart(4,'0'), pad, pad + small + 2);

  // level center
  ctx.textAlign = 'center';
  ctx.font = `800 ${small}px ...`;
  ctx.fillStyle = 'rgba(255,190,220,0.6)';
  ctx.fillText('LEVEL ' + state.level, W/2, pad + 4);

  // lives right
  const hs = Math.max(7, Math.min(11, W*0.009));
  for (let i=0;i<state.lives;i++){
    const hx = W - pad - hs*1.6 - i*(hs*3.0);
    const hy = pad + hs*1.6;
    ctx.save();
    ctx.translate(hx, hy);
    ctx.scale(hs, hs);
    ctx.translate(0,-0.275);
    ctx.fillStyle = '#ff3d7f';
    ctx.fill(heartPath);
    ctx.restore();
  }
}
```

Note heartPath unit spans x -1..1 (so width 2 units) and y -0.4..0.95. Scale hs gives width 2*hs. Spacing 3*hs is fine.

Good.

Now writing the whole thing. Let me be careful with the `overlay` z-index and pointer events.

Also make sure the ball doesn't get stuck between bricks.

Writing final answer.

Oh wait, one thing about `ctx.getContext('2d', {alpha:false})` — the canvas is opaque; but our background fills everything so fine.

Also for the initial paint before the first render... fine.

Let me write it out.

For the demo restart when the ball is lost — in demo it just resets. Good, infinite demo.

Final check on `startGame()` called from a pointerdown on the overlay: the same event also calls updatePointer. Fine.

Also keyboard: keydown anywhere starts the game. Good.

Let me write.

I'll be careful with the `hearts` variable name vs `bgHearts`.

Writing now.