Hamburger breakoutOne-line
GLM 5.3
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 4 s
- Cost
- $0.177
- Output tokens
- 40,100
- Spent thinking
- 30,809 tokens
- Speed
- 164.4 tok/s
- Console errors
- 0
- First token after
- 504 ms
- First visible text after
- 3 min 27 s
- Prompt tokens
- 357
- Code size
- 25.9 KB
- Finish reason
- stop
- Served by
- Parasail
- Model id
z-ai/glm-5.3- Reasoning sent
{"effort":"high"}- Max output
- 943,718 tokens
- Generated
- Nov 12, 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 · 731 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heartbreak Diner — Breakout</title>
<style>
:root{
--ink:#54382c;
--paper:#fff9f0;
--accent:#e8455a;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
min-height:100vh;
display:flex;align-items:center;justify-content:center;
padding:22px 14px;
font-family:'Chalkboard SE','Comic Sans MS','Segoe Print','Marker Felt',cursive;
color:var(--ink);
background-color:#f6e9d6;
background-image:
radial-gradient(#eddcc2 14%, transparent 15%),
radial-gradient(#eddcc2 14%, transparent 15%);
background-size:36px 36px;
background-position:0 0,18px 18px;
-webkit-font-smoothing:antialiased;
user-select:none;
-webkit-user-select:none;
overflow-x:hidden;
}
.stage{width:min(960px,100%)}
.card{
background:var(--paper);
border:3px solid var(--ink);
border-radius:20px;
box-shadow:0 10px 0 rgba(84,56,44,.16);
overflow:hidden;
}
.checker{
height:16px;
background:repeating-conic-gradient(var(--accent) 0 25%, #fff3e4 0 50%);
background-size:18px 18px;
border-bottom:3px solid var(--ink);
}
header.top{
display:flex;align-items:center;justify-content:space-between;
gap:12px;padding:14px 18px 12px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:34px;height:34px;fill:var(--accent);flex:none;filter:drop-shadow(0 2px 0 rgba(84,56,44,.15))}
h1{font-size:clamp(22px,3.6vw,34px);line-height:1;letter-spacing:.5px}
h1 em{font-style:normal;color:var(--accent)}
.tag{font-size:13px;opacity:.62;margin-top:4px}
.hud{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.chip{
background:#fff;border:2px solid rgba(84,56,44,.25);border-radius:999px;
padding:6px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;
display:flex;align-items:center;gap:7px;
}
.chip b{font-size:17px;color:var(--accent);min-width:2ch;text-align:right}
.lives{
display:flex;align-items:center;gap:3px;
background:#fff;border:2px solid rgba(84,56,44,.25);
border-radius:999px;padding:7px 12px;
}
.lives svg{width:20px;height:20px;fill:var(--accent);transition:transform .25s,opacity .25s,fill .25s}
.lives svg.lost{fill:#e9d6c6;opacity:.55;transform:scale(.85)}
.frame{position:relative;border-top:3px solid rgba(84,56,44,.15)}
canvas{display:block;width:100%;height:auto;touch-action:none}
.overlay{
position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;
padding-bottom:9%;pointer-events:none;
}
#ovOver{align-items:center;padding-bottom:0}
.pill{
background:var(--paper);border:3px solid var(--ink);border-radius:999px;
padding:12px 26px;text-align:center;font-size:clamp(15px,2.2vw,19px);
box-shadow:0 6px 0 rgba(84,56,44,.18);
animation:bob 1.5s ease-in-out infinite;
}
.pill small{display:block;font-size:12px;opacity:.6;margin-top:3px}
@keyframes bob{50%{transform:translateY(-8px)}}
.card-over{
background:var(--paper);border:3px solid var(--ink);border-radius:18px;
padding:26px 44px;text-align:center;box-shadow:0 8px 0 rgba(84,56,44,.18);
animation:pop-in .3s cubic-bezier(.2,1.6,.4,1) both;
}
.card-over h2{font-size:clamp(22px,3.4vw,30px);color:var(--accent);margin-bottom:8px}
.card-over p{font-size:15px;margin-bottom:6px}
.card-over b{color:var(--accent);font-size:19px}
.card-over .again{opacity:.6;font-size:13px;margin-top:10px}
@keyframes pop-in{from{transform:scale(.6);opacity:0}}
.banner{
position:absolute;top:26%;left:50%;transform:translateX(-50%);
background:var(--paper);border:3px solid var(--ink);border-radius:16px;
padding:16px 34px;text-align:center;pointer-events:none;
box-shadow:0 8px 0 rgba(84,56,44,.18);
animation:pop-in .3s cubic-bezier(.2,1.6,.4,1) both;
max-width:88%;
}
.banner h2{color:var(--accent);font-size:clamp(20px,3vw,28px)}
.banner p{font-size:14px;opacity:.65;margin-top:4px}
.hidden{display:none!important}
.hint{text-align:center;font-size:13px;opacity:.55;margin-top:12px}
</style>
</head>
<body>
<main class="stage">
<div class="card">
<div class="checker"></div>
<header class="top">
<div class="brand">
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<div>
<h1>Heartbreak <em>Diner</em></h1>
<p class="tag">breakout, served with extra love</p>
</div>
</div>
<div class="hud">
<div class="chip">Score <b id="score">0</b></div>
<div class="chip">Level <b id="level">1</b></div>
<div class="lives" id="lives" aria-label="lives">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
</div>
</div>
</header>
<div class="frame">
<canvas id="game" width="960" height="600"></canvas>
<div class="overlay" id="ovDemo">
<div class="pill">Click or press a key to start<small>demo · the burger plays itself</small></div>
</div>
<div class="overlay hidden" id="ovOver">
<div class="card-over">
<h2>Out of love</h2>
<p>Final score <b id="finalScore">0</b></p>
<p class="again">Click anywhere to play again</p>
</div>
</div>
<div class="banner hidden" id="ovClear">
<h2 id="clearTitle">Level clear!</h2>
<p>+500 sweetheart bonus</p>
</div>
</div>
</div>
<p class="hint">Move with the mouse or arrow keys · every brick bursts into hearts</p>
</main>
<script>
'use strict';
const W=960,H=600;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const scoreEl=document.getElementById('score');
const levelEl=document.getElementById('level');
const livesEl=document.getElementById('lives');
const ovDemo=document.getElementById('ovDemo');
const ovOver=document.getElementById('ovOver');
const ovClear=document.getElementById('ovClear');
const clearTitle=document.getElementById('clearTitle');
const finalScoreEl=document.getElementById('finalScore');
const rand=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const pick=a=>a[(Math.random()*a.length)|0];
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y);this.arcTo(x+w,y,x+w,y+h,r);this.arcTo(x+w,y+h,x,y+h,r);
this.arcTo(x,y+h,x,y,r);this.arcTo(x,y,x+w,y,r);this.closePath();return this;
};
}
function fit(){
const dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=W*dpr;canvas.height=H*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
fit();
window.addEventListener('resize',fit);
/* ---------- entities ---------- */
const state={mode:'demo',score:0,lives:3,level:1,combo:0,time:0,shake:0,flash:0};
const paddle={x:W/2,y:H-64,w:124,h:24,squash:0};
const ball={x:W/2,y:0,vx:0,vy:0,r:11,speed:340,stuck:true,launchT:0.9,trail:[]};
const bricks=[],hearts=[],rings=[],pops=[],ambient=[];
const keys={left:false,right:false};
let clearT=0,prevMode='demo',overFrom='demo',overT=0;
const PINKS=['#ff5d7e','#ff8da1','#f43b5c','#ffb3c1','#e8455a','#ffd1da'];
const GOLDS=['#f6c14f','#ffd97a','#f0a63a','#ffeba8','#e8901f'];
const ROWCOLORS=['#ff8da1','#ffb27a','#ffd971','#9ad98c','#84c7ec','#c9a6e3'];
const HEART_MASK=[
'0011100011100',
'0111111111110',
'1111111111111',
'1111111111111',
'0111111111110',
'0011111111100',
'0001111111000',
'0000111110000',
'0000011100000',
'0000001000000'
];
for(let i=0;i<14;i++){
ambient.push({x:rand(0,W),y:rand(0,H),s:rand(7,20),v:rand(8,22),sway:rand(0,6.28),a:rand(.05,.11)});
}
function mix(a,b,t){
const A=parseInt(a.slice(1),16),B=parseInt(b.slice(1),16);
const ch=s=>Math.round(((A>>s)&255)+(((B>>s)&255)-((A>>s)&255))*t);
return 'rgb('+ch(16)+','+ch(8)+','+ch(0)+')';
}
/* ---------- level building ---------- */
function buildLevel(n){
bricks.length=0;
const pat=(n-1)%4;
let cols,rows,test;
if(pat===0){cols=12;rows=6;test=()=>true;}
else if(pat===1){cols=13;rows=10;test=(r,c)=>HEART_MASK[r][c]==='1';}
else if(pat===2){cols=12;rows=8;test=(r,c)=>(r+c)%2===0;}
else{cols=13;rows=9;test=(r,c)=>Math.abs(c-6)<=Math.min(r,8-r);}
const gap=8,side=34,top=64,bh=26;
const bw=(W-side*2-(cols-1)*gap)/cols;
for(let r=0;r<rows;r++)for(let c=0;c<cols;c++){
if(!test(r,c))continue;
const gold=Math.random()<0.09;
let color=gold?'#f4b64a':(pat===1?mix('#ff8da1','#e23156',rows>1?r/(rows-1):0):ROWCOLORS[r%ROWCOLORS.length]);
bricks.push({x:side+c*(bw+gap),y:top+r*(bh+gap),w:bw,h:bh,hp:gold?2:1,gold,cracked:false,color,t:-(r*0.05+c*0.02)});
}
}
function resetBall(){
ball.stuck=true;ball.launchT=0.9;ball.trail=[];
ball.speed=Math.min(340+(state.level-1)*15,500);
ball.x=paddle.x;ball.y=paddle.y-ball.r-1;
}
function launch(){
ball.stuck=false;
const a=rand(-0.35,0.35);
ball.vx=Math.sin(a)*ball.speed;
ball.vy=-Math.cos(a)*ball.speed;
}
/* ---------- hearts & effects ---------- */
function burstHearts(x,y,n,kind){
const cols=kind==='gold'?GOLDS:PINKS;
for(let i=0;i<n;i++){
hearts.push({
x:x+rand(-8,8),y:y+rand(-6,6),
vx:rand(-175,175),vy:rand(-300,-60),g:560,
rot:rand(0,6.28),vr:rand(-9,9),s:rand(6,14),
life:0,max:rand(.9,1.7),color:pick(cols),sway:rand(0,6.28)
});
}
}
/* ---------- sound (tiny synth) ---------- */
let actx=null;
function ensureAudio(){
try{
if(!actx)actx=new (window.AudioContext||window.webkitAudioContext)();
if(actx&&actx.state==='suspended')actx.resume();
}catch(e){}
}
function tone(freq,dur,type,vol,slide,delay){
try{
if(!actx||actx.state!=='running')return;
const t=actx.currentTime+(delay||0);
const o=actx.createOscillator(),g=actx.createGain();
o.type=type;o.frequency.setValueAtTime(freq,t);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,t+dur);
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(.0001,t+dur);
o.connect(g);g.connect(actx.destination);
o.start(t);o.stop(t+dur+.05);
}catch(e){}
}
function sfx(n){
if(!actx||actx.state!=='running')return;
if(n==='wall')tone(300,.05,'sine',.06,260);
else if(n==='paddle')tone(170,.09,'sine',.18,320);
else if(n==='pop')tone(rand(500,760),.11,'triangle',.16,rand(900,1150));
else if(n==='tick')tone(380,.06,'square',.07,300);
else if(n==='gold'){tone(700,.14,'triangle',.16,1400);tone(1050,.18,'sine',.09,1600);}
else if(n==='lose')tone(300,.4,'sawtooth',.13,70);
else if(n==='clear'){tone(523,.12,'triangle',.15);tone(659,.12,'triangle',.15,0,.12);tone(784,.12,'triangle',.15,0,.24);tone(1047,.25,'triangle',.17,0,.36);}
}
/* ---------- game flow ---------- */
function updateHUD(){
scoreEl.textContent=state.score.toLocaleString();
levelEl.textContent=state.level;
livesEl.querySelectorAll('svg').forEach((el,i)=>el.classList.toggle('lost',i>=state.lives));
}
function startGame(){
ovDemo.classList.add('hidden');ovOver.classList.add('hidden');ovClear.classList.add('hidden');
state.mode='playing';state.score=0;state.lives=3;state.level=1;state.combo=0;
hearts.length=0;pops.length=0;rings.length=0;
buildLevel(1);resetBall();updateHUD();
}
function toDemo(){
ovOver.classList.add('hidden');
state.mode='demo';state.score=0;state.lives=3;state.level=1;state.combo=0;
hearts.length=0;pops.length=0;
buildLevel(1);resetBall();updateHUD();
ovDemo.classList.remove('hidden');
}
function gameOver(){
overFrom=state.mode;state.mode='over';overT=3.2;
finalScoreEl.textContent=state.score.toLocaleString();
ovOver.classList.remove('hidden');
}
function levelClear(){
prevMode=state.mode;state.mode='clear';clearT=1.9;
state.score+=500;updateHUD();
clearTitle.textContent='Level '+state.level+' clear!';
ovClear.classList.remove('hidden');
for(let i=0;i<55;i++){
hearts.push({
x:rand(20,W-20),y:rand(-260,-10),
vx:rand(-40,40),vy:rand(50,130),g:70,
rot:rand(0,6.28),vr:rand(-3,3),s:rand(8,16),
life:0,max:rand(3,4.5),color:pick(PINKS),sway:rand(0,6.28)
});
}
pops.push({x:W/2,y:H/2-40,life:0,max:1.2,text:'+500'});
sfx('clear');
}
function loseLife(){
state.lives--;state.combo=0;state.shake=11;state.flash=.6;
updateHUD();sfx('lose');
burstHearts(ball.x,H-14,10,'pink');
if(state.lives<=0)gameOver();else resetBall();
}
/* ---------- physics ---------- */
function predictX(){
if(ball.vy<=0)return ball.x;
const t=(paddle.y-ball.r-ball.y)/ball.vy;
let px=ball.x+ball.vx*t;
const lo=ball.r,span=W-2*ball.r;
let m=(px-lo)%(2*span);if(m<0)m+=2*span;
return lo+(m>span?2*span-m:m);
}
function bouncePaddle(){
const rel=clamp((ball.x-paddle.x)/(paddle.w/2),-1,1);
const ang=rel*1.15;
ball.speed=Math.min(ball.speed+5,620);
ball.vx=Math.sin(ang)*ball.speed;
ball.vy=-Math.cos(ang)*ball.speed;
ball.y=paddle.y-ball.r-.5;
state.combo=0;
paddle.squash=1;
for(let i=0;i<3;i++){
hearts.push({x:ball.x+rand(-8,8),y:ball.y,vx:rand(-70,70),vy:rand(-170,-60),g:560,rot:rand(0,6.28),vr:rand(-6,6),s:rand(4,6),life:0,max:rand(.5,.8),color:pick(PINKS),sway:rand(0,6.28)});
}
sfx('paddle');
}
function damage(i){
const b=bricks[i];
const cx=b.x+b.w/2,cy=b.y+b.h/2;
b.hp--;
if(b.hp<=0){
bricks.splice(i,1);
state.combo=Math.min(state.combo+1,9);
const pts=(b.gold?200:50)*state.combo;
state.score+=pts;
burstHearts(cx,cy,b.gold?36:24,b.gold?'gold':'pink');
rings.push({x:cx,y:cy,r:8,v:300,a:.55});
pops.push({x:cx,y:cy-6,life:0,max:.85,text:'+'+pts+(state.combo>2?' x'+state.combo:'')});
state.shake=Math.min(state.shake+(b.gold?8:4.5),13);
updateHUD();sfx(b.gold?'gold':'pop');
if(bricks.length===0)levelClear();
}else{
b.cracked=true;
state.score+=25;updateHUD();
burstHearts(cx,cy,6,'pink');
state.shake=Math.min(state.shake+2,13);
sfx('tick');
}
}
function hitBricks(){
for(let i=0;i<bricks.length;i++){
const b=bricks[i];
if(b.t<0.15)continue;
const cx=clamp(ball.x,b.x,b.x+b.w),cy=clamp(ball.y,b.y,b.y+b.h);
const dx=ball.x-cx,dy=ball.y-cy;
if(dx*dx+dy*dy<=ball.r*ball.r){
if(Math.abs(dx)>Math.abs(dy))ball.vx=dx>0?Math.abs(ball.vx):-Math.abs(ball.vx);
else ball.vy=dy>=0?Math.abs(ball.vy):-Math.abs(ball.vy);
damage(i);
return true;
}
}
return false;
}
function stepBall(dt){
if(ball.stuck){
ball.x=paddle.x;ball.y=paddle.y-ball.r-1;
ball.launchT-=dt;
if(ball.launchT<=0)launch();
return;
}
const sp=Math.hypot(ball.vx,ball.vy);
const steps=Math.max(1,Math.ceil(sp*dt/8));
const sdt=dt/steps;
for(let s=0;s<steps;s++){
ball.x+=ball.vx*sdt;ball.y+=ball.vy*sdt;
if(ball.x<ball.r){ball.x=ball.r;ball.vx=Math.abs(ball.vx);sfx('wall');}
else if(ball.x>W-ball.r){ball.x=W-ball.r;ball.vx=-Math.abs(ball.vx);sfx('wall');}
if(ball.y<ball.r){ball.y=ball.r;ball.vy=Math.abs(ball.vy);sfx('wall');}
if(ball.vy>0&&ball.y+ball.r>=paddle.y&&ball.y-ball.r<paddle.y+paddle.h+6&&Math.abs(ball.x-paddle.x)<=paddle.w/2+ball.r*.7){
bouncePaddle();
}
hitBricks();
if(ball.y>H+40){loseLife();return;}
}
ball.trail.push({x:ball.x,y:ball.y});
if(ball.trail.length>11)ball.trail.shift();
}
/* ---------- update ---------- */
function update(dt){
state.time+=dt;
state.shake=Math.max(0,state.shake-34*dt);
state.flash=Math.max(0,state.flash-dt*1.6);
paddle.squash=Math.max(0,paddle.squash-4.5*dt);
for(const a of ambient){
a.y-=a.v*dt;a.x+=Math.sin(state.time*.6+a.sway)*8*dt;
if(a.y<-30){a.y=H+20;a.x=rand(0,W);}
}
for(const b of bricks)if(b.t<.5)b.t+=dt;
if(state.mode==='demo'){
let target;
if(ball.stuck)target=W/2+Math.sin(state.time*.6)*140;
else if(ball.vy>0)target=predictX()+Math.sin(state.time*1.7)*10;
else target=ball.x+ball.vx*.08;
target=clamp(target,paddle.w/2+8,W-paddle.w/2-8);
const d=clamp(target-paddle.x,-560*dt,560*dt);
paddle.x=clamp(paddle.x+d,paddle.w/2+8,W-paddle.w/2-8);
}else if(state.mode==='playing'){
const dir=(keys.right?1:0)-(keys.left?1:0);
if(dir)paddle.x=clamp(paddle.x+dir*680*dt,paddle.w/2+8,W-paddle.w/2-8);
}
if(state.mode==='demo'||state.mode==='playing')stepBall(dt);
for(let i=hearts.length-1;i>=0;i--){
const h=hearts[i];
h.life+=dt;
if(h.life>=h.max){hearts.splice(i,1);continue;}
h.vy+=h.g*dt;
h.x+=h.vx*dt+Math.sin(h.life*7+h.sway)*20*dt;
h.y+=h.vy*dt;
h.rot+=h.vr*dt;
if(h.y>H-6&&h.vy>0){h.y=H-6;h.vy*=-.45;h.vx*=.8;}
else if(h.vy>0&&h.y>paddle.y-10&&h.y<paddle.y+34&&Math.abs(h.x-paddle.x)<paddle.w/2+8){
h.vy=-Math.abs(h.vy)*.6-70;h.vx+=(h.x-paddle.x)*2.5;h.vr=rand(-6,6);
}
}
for(let i=rings.length-1;i>=0;i--){
const r=rings[i];r.r+=r.v*dt;r.a-=2.4*dt;
if(r.a<=0)rings.splice(i,1);
}
for(let i=pops.length-1;i>=0;i--){
const p=pops[i];p.life+=dt;p.y-=44*dt;
if(p.life>=p.max)pops.splice(i,1);
}
if(state.mode==='clear'){
clearT-=dt;
if(clearT<=0){
ovClear.classList.add('hidden');
state.level++;state.mode=prevMode;
buildLevel(state.level);resetBall();updateHUD();
}
}
if(state.mode==='over'&&overFrom==='demo'){
overT-=dt;
if(overT<=0)toDemo();
}
}
/* ---------- drawing ---------- */
function heartPath(s){
ctx.beginPath();
ctx.moveTo(0,-s*.25);
ctx.bezierCurveTo(s*.5,-s*.85,s*1.05,-s*.05,0,s*.75);
ctx.bezierCurveTo(-s*1.05,-s*.05,-s*.5,-s*.85,0,-s*.25);
ctx.closePath();
}
function drawBricks(){
for(const b of bricks){
if(b.t<=0)continue;
const k=Math.min(1,b.t/.4),e=1-Math.pow(1-k,3);
ctx.save();
ctx.globalAlpha=e;
ctx.translate(b.x+b.w/2,b.y+b.h/2);
const sc=.6+.4*e;ctx.scale(sc,sc);
ctx.fillStyle=b.color;
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.38)';
ctx.beginPath();ctx.roundRect(-b.w/2+3,-b.h/2+3,b.w-6,7,4);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.13)';ctx.lineWidth=1.5;
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.stroke();
if(b.gold){
ctx.fillStyle='rgba(255,255,255,.92)';
ctx.save();ctx.translate(0,1);heartPath(6);ctx.fill();ctx.restore();
const tw=.5+.5*Math.sin(state.time*5+b.x);
ctx.strokeStyle='rgba(255,255,255,'+(.7*tw)+')';ctx.lineWidth=1.6;
ctx.beginPath();
ctx.moveTo(b.w/2-9,-b.h/2+6);ctx.lineTo(b.w/2-3,-b.h/2+6);
ctx.moveTo(b.w/2-6,-b.h/2+3);ctx.lineTo(b.w/2-6,-b.h/2+9);
ctx.stroke();
}
if(b.cracked){
ctx.fillStyle='rgba(0,0,0,.14)';
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.fill();
ctx.strokeStyle='rgba(70,30,20,.5)';ctx.lineWidth=1.4;
ctx.beginPath();
ctx.moveTo(-b.w*.18,-b.h/2);ctx.lineTo(-b.w*.05,0);ctx.lineTo(-b.w*.22,b.h/2);
ctx.moveTo(b.w*.12,-b.h/2);ctx.lineTo(b.w*.26,-b.h*.05);ctx.lineTo(b.w*.1,b.h/2);
ctx.stroke();
}
ctx.restore();
}
}
function drawHearts(){
for(const h of hearts){
const k=h.life/h.max;
const a=1-k*k;
const s=h.s*(1-.45*k*k);
ctx.save();
ctx.globalAlpha=a;
ctx.translate(h.x,h.y);
ctx.rotate(h.rot);
ctx.fillStyle=h.color;
heartPath(s);ctx.fill();
ctx.globalAlpha=a*.8;
ctx.fillStyle='rgba(255,255,255,.75)';
ctx.beginPath();ctx.ellipse(-s*.3,-s*.35,s*.14,s*.1,-.5,0,6.283);ctx.fill();
ctx.restore();
}
}
function drawPaddle(){
const px=paddle.x,py=paddle.y,w=paddle.w;
ctx.fillStyle='rgba(84,56,44,.10)';
ctx.beginPath();ctx.ellipse(px,py+36,w*.42,6,0,0,6.283);ctx.fill();
const q=paddle.squash;
ctx.save();
ctx.translate(px,py+8);ctx.scale(1+q*.14,1-q*.18);ctx.translate(-px,-(py+8));
const left=px-w/2,y=py-16;
ctx.fillStyle='#eda44f'; /* bottom bun */
ctx.beginPath();ctx.roundRect(left+4,y+26,w-8,13,6);ctx.fill();
ctx.fillStyle='#6e4227'; /* patty */
ctx.beginPath();ctx.roundRect(left+1,y+19,w-2,12,6);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.08)';
for(let i=0;i<6;i++){ctx.beginPath();ctx.arc(left+14+i*(w-28)/5,y+25,1.6,0,6.283);ctx.fill();}
ctx.fillStyle='#ffc84e'; /* cheese */
ctx.beginPath();ctx.roundRect(left+5,y+15,w-10,9,4);ctx.fill();
ctx.beginPath();ctx.arc(left+w*.30,y+24,5,0,6.283);ctx.fill();
ctx.beginPath();ctx.arc(left+w*.68,y+25,4,0,6.283);ctx.fill();
ctx.fillStyle='#8bc34a'; /* lettuce ruffle */
ctx.beginPath();
ctx.moveTo(left+2,y+12);ctx.lineTo(left+w-2,y+12);
const n=5,step=(w-4)/n;
for(let i=0;i<n;i++){
const x1=left+w-2-i*step;
ctx.quadraticCurveTo(x1-step*.5,y+23,x1-step,y+12);
}
ctx.closePath();ctx.fill();
ctx.fillStyle='#f5b061'; /* top bun */
ctx.beginPath();
ctx.moveTo(left+3,y+13);
ctx.bezierCurveTo(left+3,y-9,left+w-3,y-9,left+w-3,y+13);
ctx.closePath();ctx.fill();
ctx.fillStyle='#e8994a';
ctx.beginPath();ctx.roundRect(left+3,y+11,w-6,4,2);ctx.fill();
ctx.fillStyle='rgba(255,250,235,.95)'; /* sesame seeds */
const seeds=[[.16,7,-.4],[.30,3,.3],[.5,6,0],[.68,2.5,-.25],[.83,7,.35]];
for(const sd of seeds){
ctx.save();ctx.translate(left+sd[0]*w,y+sd[1]);ctx.rotate(sd[2]);
ctx.beginPath();ctx.ellipse(0,0,3,1.8,0,0,6.283);ctx.fill();
ctx.restore();
}
ctx.fillStyle='rgba(255,255,255,.30)';
ctx.beginPath();ctx.ellipse(left+w*.30,y+1,10,3.5,-.35,0,6.283);ctx.fill();
ctx.restore();
}
function drawBall(){
const n=ball.trail.length;
for(let i=0;i<n;i++){
const t=ball.trail[i],k=(i+1)/n;
ctx.globalAlpha=k*.22;
ctx.fillStyle='#f27d93';
ctx.beginPath();ctx.arc(t.x,t.y,ball.r*k*.85,0,6.283);ctx.fill();
}
ctx.globalAlpha=1;
ctx.save();
ctx.translate(ball.x,ball.y);
ctx.fillStyle='#e94b5f';
ctx.beginPath();ctx.arc(0,0,ball.r,0,6.283);ctx.fill();
ctx.save();
ctx.beginPath();ctx.arc(0,0,ball.r,0,6.283);ctx.clip();
ctx.fillStyle='rgba(0,0,0,.08)';
ctx.beginPath();ctx.arc(2.5,3.5,ball.r,0,6.283);ctx.fill();
ctx.restore();
ctx.fillStyle='rgba(255,255,255,.6)';
ctx.beginPath();ctx.ellipse(-3.5,-4,3.2,2.2,-.6,0,6.283);ctx.fill();
ctx.fillStyle='#5fa052'; /* tomato calyx */
const tips=[[-6,-3],[0,-6],[6,-3]];
for(const tp of tips){
ctx.beginPath();
ctx.moveTo(-2,-ball.r+2);
ctx.lineTo(tp[0],-ball.r+tp[1]);
ctx.lineTo(2,-ball.r+2);
ctx.closePath();ctx.fill();
}
ctx.fillStyle='#4c8a41';
ctx.beginPath();ctx.roundRect(-1.5,-ball.r-6,3,6,1.5);ctx.fill();
ctx.restore();
}
function drawPops(){
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.font='700 17px "Chalkboard SE","Comic Sans MS",cursive';
ctx.lineJoin='round';
for(const p of pops){
const k=p.life/p.max;
ctx.globalAlpha=1-k*k;
ctx.lineWidth=4;ctx.strokeStyle='rgba(255,252,245,.95)';
ctx.strokeText(p.text,p.x,p.y);
ctx.fillStyle='#e8455a';
ctx.fillText(p.text,p.x,p.y);
}
ctx.globalAlpha=1;
}
function draw(){
ctx.fillStyle='#fff7ee';
ctx.fillRect(0,0,W,H);
for(const a of ambient){ /* drifting background hearts */
ctx.globalAlpha=a.a;
ctx.fillStyle='#f2a7b4';
ctx.save();
ctx.translate(a.x,a.y);
ctx.rotate(Math.sin(state.time+a.sway)*.2);
heartPath(a.s);ctx.fill();
ctx.restore();
}
ctx.globalAlpha=1;
ctx.strokeStyle='rgba(84,56,44,.12)'; /* diner counter line */
ctx.lineWidth=2;ctx.setLineDash([10,8]);
ctx.beginPath();ctx.moveTo(16,H-16);ctx.lineTo(W-16,H-16);ctx.stroke();
ctx.setLineDash([]);
ctx.save();
if(state.shake>.2)ctx.translate(rand(-1,1)*state.shake*.8,rand(-1,1)*state.shake*.8);
drawBricks();
for(const r of rings){
ctx.globalAlpha=Math.max(r.a,0);
ctx.strokeStyle='#e8455a';ctx.lineWidth=3;
ctx.beginPath();ctx.arc(r.x,r.y,r.r,0,6.283);ctx.stroke();
}
ctx.globalAlpha=1;
drawHearts();
drawPaddle();
if(state.mode==='demo'||state.mode==='playing')drawBall();
drawPops();
ctx.restore();
if(state.flash>0){
ctx.fillStyle='rgba(232,69,94,'+(state.flash*.18)+')';
ctx.fillRect(0,0,W,H);
}
}
/* ---------- input ---------- */
window.addEventListener('pointerdown',()=>{
ensureAudio();
if(state.mode==='demo'||state.mode==='over')startGame();
});
window.addEventListener('pointermove',e=>{
if(state.mode!=='playing')return;
const r=canvas.getBoundingClientRect();
if(r.width===0)return;
const mx=(e.clientX-r.left)/r.width*W;
paddle.x=clamp(mx,paddle.w/2+8,W-paddle.w/2-8);
});
window.addEventListener('keydown',e=>{
if(['ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
ensureAudio();
if(state.mode==='demo'||state.mode==='over'){startGame();return;}
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(e.key===' '&&state.mode==='playing'&&ball.stuck)launch();
});
window.addEventListener('keyup',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A')keys.left=false;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D')keys.right=false;
});
window.addEventListener('blur',()=>{keys.left=keys.right=false;});
/* ---------- boot ---------- */
buildLevel(1);
resetBall();
updateHUD();
let last=performance.now();
requestAnimationFrame(function frame(ts){
const dt=Math.min((ts-last)/1000,.033);
last=ts;
update(dt);
draw();
requestAnimationFrame(frame);
});
</script>
</body>
</html>Reasoning trace (30,809 tokens)
Let me build a Breakout game with a hamburger paddle and heart particle explosions. Key requirements:
1. Self-contained HTML file
2. Game must start in self-playing demo mode with "Click or press a key to start" message
3. Something meaningful visible on first paint (screenshot at ~3s)
4. 1280×800 viewport, adapts to 375px
5. No console errors
6. Games must start in demo mode with AI playing
Design decisions:
- Canvas-based breakout game
- Hamburger paddle drawn with canvas (bun, lettuce, tomato, patty, cheese) — draw it nicely
- Bricks that explode into heart particles (drawn hearts, physics: gravity, bounce, fade, rotation)
- Ball is maybe a tomato/ketchup blob? Could make it a smiley fry or a ketchup drop. Let's make it a heart-shaped ball? No — ball could be a cherry tomato or a golden heart. Actually a nice touch: ball is a tomato.
- Demo mode: AI moves paddle toward ball x
- Bricks could be candy/pastel colored, maybe with little faces
- Add score, lives, level indicator
- Juicy effects: screen shake, particles, brick pop animation, combo
Visual style: playful candy pastel, warm cream background, maybe a diner aesthetic. Let's do a diner: soft cream/pink background, checkered accents? Keep it tasteful. Font: use a Google Font? "External files... no network requests except CDN URLs explicitly allowed" — Google Fonts is a CDN but not explicitly allowed by the task. Safer to use system fonts but with character. I can use a font stack like "Georgia" or draw custom text. I'll avoid Arial/Inter/system-ui as primary. I can use something like `'Comic Sans MS', 'Chalkboard SE', cursive`? Comic Sans fits playful diner... Hmm. Actually could use Google Fonts via link — the rule says "no external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task". The task doesn't allow any CDN. So no Google Fonts. I'll use "Comic Sans MS", "Chalkboard SE", "Marker Felt" stack — playful and appropriate for a game. That's a deliberate choice fitting the theme, not a default.
Layout: Full-page game. Canvas centered, responsive. Header with title "HEARTBREAKER" or "BURGER BREAKOUT". Title: "Heartbreak Diner" — cute. HUD showing score, lives (hearts), level.
Let me write the game:
Canvas logical size: 900x600 or so, scale to fit. Use devicePixelRatio for crispness.
Game objects:
- paddle: hamburger, width ~120, drawn with layers: bottom bun, patty, cheese, lettuce, tomato, top bun with sesame seeds. Bounce ball.
- ball: tomato (red circle with green stem highlight) — cute.
- bricks: rows of pastel bricks, some with hearts drawn on them. Different colors per row. HP maybe 1 for all, or some 2-hit (golden). Keep simple: 1 hit, but some special "heart" bricks give extra particles.
- particles: hearts with position, velocity, gravity, rotation, scale, life, color. Draw heart shape via bezier.
Heart shape function:
```js
function drawHeart(ctx, size, color) {
ctx.beginPath();
const s = size;
ctx.moveTo(0, s*0.35);
ctx.bezierCurveTo(0, 0, -s, 0, -s, s*0.35...
}
```
Standard heart:
```
ctx.moveTo(0, 0.35*s);
ctx.bezierCurveTo(0.5*s, -0.35*s, s, 0.15*s, 0, s);
ctx.bezierCurveTo(-s, 0.15*s, -0.5*s, -0.35*s, 0, 0.35*s);
```
Let me use a known-good one:
```js
function heartPath(ctx, x, y, w) {
const h = w;
ctx.moveTo(x, y + h*0.3);
ctx.bezierCurveTo(x, y, x - w*0.5, y - h*0.3, x - w*0.5, y + h*0.15);
...
}
```
Simpler with translate/scale: draw heart in unit coordinates:
```js
function drawHeart(ctx, size) {
ctx.beginPath();
ctx.moveTo(0, -size*0.25);
ctx.bezierCurveTo(size*0.5, -size*0.75, size*1.1, -size*0.1, 0, size*0.75);
ctx.bezierCurveTo(-size*1.1, -size*0.1, -size*0.5, -size*0.75, 0, -size*0.25);
ctx.closePath();
}
```
Let me verify roughly: start at top notch (0,-0.25s). Curve to right lobe top... control points (0.5s, -0.75s) and (1.1s, -0.1s), end (0, 0.75s) bottom tip. That gives a heart leaning right-heavy. Symmetric version:
```js
ctx.moveTo(0, -0.3*s);
ctx.bezierCurveTo(0.55*s, -0.85*s, 1.15*s, -0.15*s, 0, 0.7*s);
```
Hmm, the right curve: from (0,-0.3s) with cp1 (0.5s,-0.85s) — pulls up right, cp2 (1.1s, -0.1s) — right side, ends at bottom (0, 0.7s). Then mirror for left. That should render a decent heart. I'll test mentally... it's the common approach; fine.
Better well-known one:
```js
ctx.moveTo(x, y + topCurveHeight);
// top left curve
ctx.bezierCurveTo(x, y, x - width/2, y, x - width/2, y + topCurveHeight);
```
I'll just use the parametric-ish approach and tweak visually. Standard canvas heart (from many examples):
```js
function drawHeart(c, x, y, w, h) {
c.beginPath();
c.moveTo(x, y + h/4);
c.quadraticCurveTo(x, y, x + w/4, y);
c.quadraticCurveTo(x + w/2, y, x + w/2, y + h/4);
c.quadraticCurveTo(x + w/2, y, x + 3*w/4, y);
c.quadraticCurveTo(x + w, y, x + w, y + h/4);
c.quadraticCurveTo(x + w, y + h/2, x + 3*w/4, y + 3*h/4);
c.lineTo(x + w/2, y + h);
c.lineTo(x + w/4, y + 3*h/4);
c.quadraticCurveTo(x, y + h/2, x, y + h/4);
}
```
Hmm that's blocky. The bezier classic:
```js
function heart(c, x, y, s) {
c.beginPath();
c.moveTo(x, y + s * 0.3);
c.bezierCurveTo(x, y, x - s * 0.5, y, x - s * 0.5, y + s * 0.3);
c.bezierCurveTo(x - s * 0.5, y + s * 0.6, x, y + s * 0.85, x, y + s);
c.bezierCurveTo(x, y + s * 0.85, x + s * 0.5, y + s * 0.6, x + s * 0.5, y + s * 0.3);
c.bezierCurveTo(x + s * 0.5, y, x, y, x, y + s * 0.3);
}
```
This is a widely used pattern (heart centered top at x,y, size s). It produces a good heart. I'll use this.
Paddle (hamburger) drawing: at paddle x (center), y (top), width W, height H (say 110 x 34):
- Bottom bun: rounded rect bottom half, tan (#e8a33d)
- Patty: dark brown rounded rect
- Cheese: orange-yellow with drips
- Lettuce: green wavy
- Tomato: red strips? maybe skip tomato layer for clarity, keep cheese + lettuce
- Top bun: dome with sesame seeds (small white ellipses)
Order bottom to top: bottom bun (tan), patty (brown), cheese (yellow drips), lettuce (green ruffle), top bun (dome tan, sesame seeds).
Ball: tomato — red circle with darker outline, small green stem + leaf on top, a white shine. Cute. Trail of fading circles.
Bricks: rounded rects, pastel colors, with subtle inner highlight; some bricks have a heart stamp on them (slightly stronger). Multi-hit bricks: some bricks take 2 hits — first hit makes them cracked/darker and spawns fewer hearts. Keep it: normal bricks 1 hp; "special" golden bricks occasionally worth more points + burst of golden hearts.
Explosion: when brick destroyed, spawn ~14–22 heart particles with random velocities upward-ish, gravity, spin, shrink, plus a quick white flash ring. Screen shake tiny. Score popup text floating.
Also spawn hearts on paddle hit? Small sparkle. Keep hearts mainly for bricks as asked, but a couple tiny hearts on paddle bounce is cute — the requirement says every brick explodes into tons of little hearts. I'll do big burst on brick, tiny puff on paddle.
Ball motion: constant speed, reflect off walls, paddle angle based on hit position. Ball may get slightly faster per brick or per level.
Levels: when all bricks cleared → next level with new layout (different patterns: rainbow rows, checker, heart shape!). Level 2 could be a heart-shaped brick arrangement — signature moment. Level 1: rows. Level 2: heart shape. Level 3: checker/diamond. Cycle patterns with increasing speed.
Lives: 3, shown as hearts in HUD. Ball falls → lose life, brief pause, respawn ball on paddle, launch after short delay. Game over screen with restart. In demo mode, AI plays; after game over, auto-restart in demo.
Controls: mouse move / touch to move paddle; arrow keys / A,D. Click or key press to start (take control from demo AI). Overlay: "CLICK OR PRESS ANY KEY TO START" with subtitle "demo mode — the burger is playing itself". After starting, hide overlay. If player idles? Doesn't matter.
Ball stuck prevention: ensure minimum vertical velocity.
Demo AI: paddle target = ball.x with some easing + slight error; also lead the ball based on ball dx. Cap paddle speed so it's not perfect, occasionally misses (fun). Actually to keep demo alive, make AI good but occasionally miss on level 3+. Keep it good — losing lives is fine, it respawns.
Screen shake: offset canvas translate by decaying random.
Responsive: canvas fixed logical 900x640? Let's do W=960, H=600. Fit via CSS: canvas max-width 100%, height auto; page uses flex column with header HUD. For 375px width, canvas scales down; pointer coordinates must be scaled. Use getBoundingClientRect scaling for input.
HUD: top bar with title "HEARTBREAK DINER" (or "BURGER BREAKOUT"), score, lives hearts, level. Style: diner aesthetic — cream background (#fff6ec), stripes? A soft checkered pattern at the top? Keep restrained: cream bg, warm brown text (#5a3b2e), accent strawberry red (#e8546f). Maybe a subtle scalloped border. Title font: bold rounded — use "Comic Sans MS"/"Chalkboard SE"? Hmm, Comic Sans might be divisive but playful and thematically OK. Alternative: use Georgia bold italic for a diner script feel? "Georgia" serif. Or draw title on canvas with custom letter styling. I'll use font stack: `'Chalkboard SE', 'Comic Sans MS', 'Marker Felt', cursive` — playful hand-drawn diner feel, avoiding generic system-ui/Arial.
Actually I could make the whole UI in DOM above canvas: header with score etc., overlays as DOM elements. That's cleaner.
Structure:
```html
<body>
<main class="stage">
<header> title + hud (score / hearts lives / level) </header>
<div class="frame">
<canvas></canvas>
<div class="overlay"> ... start message / game over ... </div>
</div>
<footer hint text>
</main>
</body>
```
Canvas playfield background: soft cream with subtle polka dots or grid; side "walls" visual? Draw inside canvas: background #fff7ee with faint diagonal stripes very low alpha, or confetti hearts floating gently in background (ambient slow drifting hearts — adds life on first paint). Yes! Ambient floating hearts in background of the play area — immediately on first screenshot there will be hearts. But main visual should be the game itself. Bricks visible at first paint, demo AI playing, hearts exploding within seconds — great first paint.
Let me also add: at load, before start, demo mode is already running (self-playing). The overlay message "Click or press any key to start" shown in a corner pill or center but semi-transparent so game visible. Make overlay pointer-events none except click anywhere starts. Actually clicking anywhere should start — attach to window.
Game constants:
- W=960, H=620
- Paddle: w=120, h=36, y = H-56
- Ball r=11, speed start 6.2 (per frame at 60fps — use dt-based: speed px/sec: 380). Use requestAnimationFrame with dt clamp.
- Bricks: rows patterns.
Brick colors: strawberry #ff8fab, peach #ffb86b, butter #ffe08a, mint #9ddfcc, sky #8fc7e8? Keep warm palette:
rows colors: ['#ff7b9c', '#ff9f68', '#ffd166', '#8fd694', '#7fc8f8', '#c39bd3']? Rainbow pastel. With darker stroke versions. I'll compute darker shade via helper or predefine pairs.
Level layouts: function generating brick list. Grid: cols 12, brick w = (W - margins)/cols. Margins 40 each side, gap 8. brickW = (960-80-11*8)/12 = (880-88)/12 = 66. brickH = 26.
Level patterns:
1. Rows: 6 rows x 12 cols (72 bricks).
2. Heart shape: mask on 13 cols x 10 rows heart bitmap.
3. Checker with gaps.
4. Diamond.
Heart mask (classic):
```
0110011001100?
```
Let me make a heart bitmap 13 wide, 11 tall:
```
.XX...XX.....
```
Let me construct 13x10:
Row0: 0011100111000? Hmm width 13, heart:
```
.##.....##. hmm
```
Standard heart 11 wide:
```
.##...##.
```
Let me just do a 12-col heart:
row0: 011100111000? I'll write:
cols=13, rows=10
r0: 0011100 0111 00 → "0011100011100"? that's 13: 0,0,1,1,1,0,0,0,1,1,1,0,0 — hmm top lobes.
Let me define:
r0: ..###...###.. → 2,3,4 and 8,9,10
r1: .#####.#####. → 1..5, 7..11
r2: ############# → all
r3: #############
r4: .###########.
r5: ..#########..
r6: ...#######...
r7: ....#####....
r8: .....###.....
r9: ......#......
That's a nice heart, 13 wide 10 tall. With 12 cols? I have 12 cols planned. 13 cols: brickW = (960 - 2*30 - 12*8)/13 = (900-96)/13 ≈ 61.8. Fine, compute dynamically.
Pattern function returns array of {c, r} or mask. I'll implement layouts as functions returning list of bricks with x,y computed.
Level cycle: patterns = [rows, heart, checker, diamond, pyramid...]. After last, loop with +speed.
Brick types: 10% chance "gold" (hp2, more points, golden burst).
Scoring: normal 50, gold 150, combo multiplier? Add combo: consecutive brick hits without paddle touch multiply — maybe simple: score popup shows value.
Sounds: could do WebAudio bleeps — nice but must not error; AudioContext requires user gesture; in demo before click it would be suspended. I'll create AudioContext lazily on first user gesture; add tiny pop sounds. Optional; adds polish. Keep small: a "boop" on paddle, "pop" on brick, "ow" on losing life. Use oscillator with quick envelope. Guard with try/catch and only if ctx exists & state==='running'. Actually resume on gesture. I'll include it — low risk.
Particles system:
```js
hearts.push({x, y, vx, vy, rot, vr, size, life, maxLife, color, wobble})
```
On brick: count = 16 + rand*8; each: vx = (rand-0.5)*260, vy = -rand*220 - 60, gravity 500 px/s², vr random, size 6-13, colors from pink/red palette (or gold for gold bricks). Life 0.9-1.6s. Fade + shrink at end. Also a ring flash: {x,y,r growing, alpha decaying}.
Floating score texts: {x,y,text,life}.
Screen shake: shake = min(shake + amount, cap), decay; translate random within shake.
Paddle squash animation on hit: scaleX/scaleY pulse.
Ball trail: keep last N positions, draw fading circles.
Ambient background hearts inside canvas: ~12 slow drifting translucent hearts, respawn at bottom.
Demo AI:
```js
if (demo) {
const target = ball.vx !== 0 ? predictX() : ball.x;
paddle target x = clamp
paddle.x += clamp(target - paddle.x, -maxV, maxV)
}
```
Simple: target = ball.x + ball.vx*0.12 (slight lead). maxV = 460 px/s for AI. Add tiny sine error to feel human.
Player input: mousemove over canvas → paddle.x = mouseX (scaled). Touch: touchmove. Keys: arrows set velocity.
Ball-paddle collision: if ball moving down and intersects paddle rect: reflect with angle based on offset:
```js
const rel = (ball.x - paddle.x) / (paddle.w/2); // -1..1
const angle = rel * 60deg from vertical
speed slight increase
ball.vy = -speed*cos; ball.vx = speed*sin;
```
Squash paddle, spawn 3 tiny hearts, boop sound.
Brick collision: simple AABB check each frame; determine side by penetration or previous position. Use standard: compute overlap, flip axis with smaller penetration. Iterate bricks, break one per frame per ball is fine.
Ball speed: base 340 px/s, +8 per brick up to 620. On new level reset to base+level*15.
Lives: 3. On miss: lives--, shake, red flash vignette; if lives>0: reset ball attached to paddle with 1s auto-launch; else game over overlay: "GAME OVER — score" with "click to restart" — clicking restarts in player mode. In demo mode game over → auto restart after 2s staying in demo.
Win: all bricks cleared → brief "LEVEL CLEAR" banner, hearts rain confetti (big burst from top), then next level after 1.6s.
State machine: 'demo' | 'playing' | 'over' | 'clear'. Message overlay DOM shown in demo: pill at bottom center "Click or press any key to play". Keep center top? Put at bottom so it doesn't obscure. Also small "DEMO" tag.
Pause when tab hidden: rAF handles naturally; clamp dt to 0.033.
DPR handling: canvas.width = W*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). CSS size via aspect-ratio and max-width. Container width min(960, 100%).
HUD in DOM: score, level, lives as drawn? Lives as canvas-drawn hearts might be easier consistent — but DOM header with inline SVG hearts? Inline SVG is fine (self-authored, no external). I'll use small inline SVGs for heart icons in lives. That's not an "icon library" but hand-authored SVG — acceptable and appropriate. Or draw lives inside canvas top-right. Simpler: draw HUD inside canvas! Everything in canvas: score top-left, hearts top-right, level center. Then DOM only for overlay messages. That's clean and consistent. But crisp text scaling fine with dpr. Yes — do HUD in canvas.
Actually header outside canvas with big title is nicer typography. Mixed approach: DOM header (title + score), canvas HUD minimal (lives hearts + level inside canvas top corners). Hmm, duplicating. Decision: DOM header: left = title "HEARTBREAK DINER" with small heart mark; right = score (big number) + level badge; lives hearts drawn in-canvas near top-right below bricks? Bricks start at y=70, leaving room for a slim top HUD strip inside canvas (0-64) with lives hearts left and combo right? Let's do: DOM header with SCORE and LEVEL and LIVES (inline SVG hearts, dim when lost). All DOM. Canvas purely playfield. Good.
Overlay messages: absolutely positioned div over canvas:
- demo: bottom pill "Click or press any key to start" + tiny "demo mode" chip top-left of canvas.
- game over: centered card.
- level clear: canvas-drawn text (simple) or DOM banner. DOM banner centered: "LEVEL 2 ❤ MORE HEARTS!" — no emoji! Use text "LEVEL 2 — MORE HEARTS!" without emoji. (No emoji anywhere.) Use drawn hearts around it via particle bursts.
No emoji anywhere. Heart glyphs "❤" count as emoji-ish — avoid in text; use inline SVG for icons.
Colors/design:
- Page bg: warm cream #f7ead9 with subtle noise? Keep flat + faint diagonal stripe? Flat cream with soft radial vignette maybe. Diner checkered strip at very top/bottom: repeating-linear-gradient 45°? Checker via conic-gradient: classic pink/white checkerboard strip along top of the frame — diner vibe.
- Canvas frame: thick rounded border like a diner tray: border: 10px solid #5a3b2e? Or cream tray with red/white checker edge above. Let's do: header card in cream with brown text; canvas in a rounded frame with border 6px #4a2c2a, background inside #fffaf3.
- Accent: strawberry #e84d63.
Typography: title in bold; font-family: "Chalkboard SE","Comic Sans MS","Segoe Print","Bradley Hand",cursive. Score numerals maybe same. OK.
Footer hint: "Move: mouse / ← → · Bricks burst into hearts".
Let me now write the code carefully.
Key numbers:
- W=960, H=600. Top area for bricks starts y=64 (leave a little breathing room; HUD is in DOM).
Actually since HUD is DOM, bricks can start at y=40. Playfield walls: left/right/top inside canvas with drawn wall edges? Draw 12px side rails in canvas colored like ketchup red? Could be cute: side borders as striped candy. Keep subtle: 10px rails #f3d9c6 with darker edge. Simpler: no rails; ball bounces at edges; draw a thin inset border. I'll add playful rails: left/right/top thin bars (10px) in soft tan with scallop? Skip rails; instead rounded canvas bg + soft inner shadow via drawing. Keep it simple: bounce at 0..W, 0..H with slight inset.
Paddle y = H - 52. Ball lost when y > H + 30.
Bricks area: y from 50 to ~300.
Collision detail: brick rect; ball circle vs AABB: find closest point; if dist < r → collide; determine normal by comparing previous position. Simplify: compute overlapX = (r + bw/2) - |ball.x - bx|... Use standard method:
```js
function hitBrick(b) {
const cx = clamp(ball.x, b.x, b.x+b.w), cy = clamp(ball.y, b.y, b.y+b.h);
const dx = ball.x-cx, dy = ball.y-cy;
if (dx*dx+dy*dy <= r*r) { ... normal = ... }
}
```
Normal: if cx is inside horizontally (dx==0) etc. Determine bounce axis: if |dx| > |dy| flip vx else flip vy. If equal, flip both. Works well enough.
Only allow one brick hit per frame to avoid double bounces? Ball can hit two bricks in a corner; handling both in same frame with double flips is glitchy. Break after first hit per frame.
Ball substeps: at higher speeds, tunneling through 26px bricks possible. Use substeps: steps = ceil(speed*dt / 8). Move in increments. Do collision each substep. Good.
Brick hit: hp--; if hp<=0 → destroy: remove from array, spawn hearts burst, ring, score popup, shake 6, sound pop. Else (gold first hit): change color darker, small heart puff (4 hearts), tiny shake, score 25, sound tick.
Score popup: +50 etc. floats up.
Combo: comboCount increments per brick without paddle touch; multiplier = 1 + floor(combo/4)? Show "x2 COMBO" text at paddle? Keep: display combo at brick popup when >2: "+100 ×3". Simple: score += base * combo where combo = min(combo+1 each brick, 8), reset on paddle hit. Popup shows computed points.
Gold bricks: 1 in 9 chance, hp 2, gold color #f5c04a with sparkle, 200 pts.
Level definitions:
```js
function buildLevel(n) {
const pat = (n-1) % 4;
bricks = [];
if (pat===0) rows layout: 6 rows × 12
if (pat===1) heart mask 13×10
if (pat===2) checker 8 rows offset? checker: r%2 + c%2 === 0... produce sparse
if (pat===3) diamond/pyramid
}
```
Compute geometry per layout: given cols, margins: gap=8, side=34, top=56, brickH=26, rowGap=8.
brickW = (W - 2*side - (cols-1)*gap)/cols.
Rows layout cols=12 → brickW = (960-68-88)/12 = 804/12=67.
Heart layout cols=13 → (960-68-96)/13 = 796/13 ≈ 61.2.
Colors by row: palette array; row index mod len. For heart, color all bricks shades of pink→red gradient by row: interpolate #ff9fb2 → #e23a5a.
Checker: cols 12, rows 8, place where (r+c)%2===0.
Diamond: cols 13, rows 9; place where |c-6| <= r && |c-6| <= (8-r)? Make a diamond mask rows 9: width at row r = ... place if abs(c - 6) <= min(r, 8-r)... row0 → abs<=0 → 1 brick; row4 → abs<=4 → 9 bricks; diamond. Good.
Level clear detection: bricks.length === 0 (excluding? all destructible) → state 'clear', after 1.8s buildLevel(n+1).
Ball reset: attach to paddle: ball.stuck = true, follows paddle x, y = paddle.y - r - 2; launch after 0.9s auto (both demo and play) or on click/space when playing. Auto-launch keeps demo flowing.
Game over: overlay DOM with final score, "PLAY AGAIN" button (and demo restart after 3s if never interacted? If state was playing, wait for click). On restart: reset lives/score/level=1.
Demo start: state='demo', score accumulates but resets when player starts? When user takes control, reset score/lives/level to fresh: startGame() resets everything. That's fair.
Input:
- window 'pointerdown' / 'keydown': if state==='demo' → startGame(). If overlay gameover visible → restart. Keyboard: prevent default for arrows/space to avoid scroll.
- pointermove on canvas: if state playing → move paddle via mapped x.
- keys: left/right flags; paddle speed 640 px/s.
Also mobile touch: pointermove covers touch with pointer events. Use `touch-action: none` on canvas.
Overlay DOM:
```html
<div class="overlay" id="overlay">
<div class="pill">Click or press a key to start</div>
</div>
```
Shown when demo. Style: bottom center, cream pill with brown border, gentle bounce animation, pointer-events:none (so clicks pass to window handler — window pointerdown fires anyway even with pointer-events auto; but pill covering canvas blocks paddle control start... fine, but simpler: pointer-events none, window-level listener handles start).
GameOver overlay: card with pointer-events none too, window handler restarts. But a visible button is nice; make whole screen click restart. Add button styled but any click works.
Level-clear banner: DOM element with animation.
Now the hamburger drawing — important signature. Draw at paddle center (px), top y (py), width w=124, height ~40:
Layers (from bottom):
1. Bottom bun: rounded rect, y+26 to y+40, radius bottom bigger. Fill #f0a94e, darker bottom edge #d98c33.
2. Patty: rect y+20..y+30, dark brown #7a4a2b, extends slightly narrower (inset 4).
Actually order top→bottom drawing: draw bottom items first then top covers.
Let me define heights (h total 40):
- top bun dome: y..y+18 (dome)
- lettuce: y+16..y+22 wavy, green #7ec850
- tomato: skip
- cheese: y+20..y+26 with drips, #ffc247
- patty: y+24..y+33, #6f4226
- bottom bun: y+31..y+40, #f0a94e rounded
Draw:
```js
function drawBurger(c, x, y, w, squash) {
c.save();
c.translate(x, y+20); // center
c.scale(sx, sy) for squash
c.translate(-x, -(y+20));
const left = x - w/2;
// bottom bun
roundRect(left+2, y+30, w-4, 11, 5) fill #eba24f;
// patty
roundRect(left, y+23, w, 10, 5) fill #6f4226;
// cheese with drips
path: rect left+3..w-6 at y+19 h 6 plus 3 drip circles below
fill #ffc247
// lettuce wavy: use quadratic zigzag spanning width at y+15..y+22
// top bun dome: arc/ellipse from left..right, height 20, fill #f4b266, with highlight and sesame seeds
}
```
Top bun dome:
```js
c.beginPath();
c.moveTo(left, y+18);
c.bezierCurveTo(left, y-4, left+w, y-4, left+w, y+18);
c.quadraticCurveTo(left+w/2, y+24, left, y+18)?
```
Simpler: dome via bezier: start bottom-left (left, y+18), curve up to top mid and down to bottom-right:
```js
c.moveTo(left+2, y+18);
c.bezierCurveTo(left+2, y-6, right-2, y-6, right-2, y+18);
c.closePath(); // flat bottom
```
That's a nice bun dome (rounded top via single bezier — actually bezier with both control points above gives a dome-ish shape but corners will be pointy at bottom? cubic from (l,y+18) cp (l, y-6), (r, y-6) to (r, y+18): starts vertical, ends vertical, smooth dome. Corners at bottom are where bun meets — slightly rounded via closePath straight line. Fine.
Sesame seeds: small rotated ellipses on dome: positions relative: (-0.3w, y+6), (-0.12w, y+2), (0.05w, y+5), (0.22w, y+1), (0.35w, y+7)... fill #fbe6c2, slight rotate.
Lettuce wavy:
```js
c.beginPath();
c.moveTo(left, y+18);
// series of quadratic bumps along width
for (let i=0;i<=6;i++){ ... }
```
Draw a ruffle: bottom edge straight at y+20, top edge wavy scallops between y+13 and y+19. Path: start (left, y+20), line to (left, y+17), scallops across with quadratics alternating up/down, end (right, y+20), close. Fill green #8bc34a. Bumps hang below too? Classic lettuce ruffle hangs below patty. Keep simple scallop bottom: path top straight y+16, bottom scalloped with bumps down to y+24 alternating. I'll do:
```js
const n=6, step=w/n;
c.moveTo(left, y+16);
c.lineTo(right, y+16);
for (let i=n;i>=0;i--) {
const cx0 = left + i*step;
c.quadraticCurveTo(cx0 - step*0.25, y+24, cx0 - step*0.5, y+18); // hmm
}
```
Getting fiddly. Alternative simpler: draw lettuce as rounded rect slightly wider than bun with scalloped bottom using arcs:
```js
c.beginPath();
c.moveTo(left-2, y+15);
c.lineTo(right+2, y+15);
// scallops on bottom, right to left
const n=5, step=(w+4)/n;
for(let i=0;i<n;i++){
const x1 = right+2 - i*step;
c.quadraticCurveTo(x1 - step*0.5, y+26, x1 - step, y+15);
}
c.closePath();
```
Each quadratic dips down to y+26 at midpoint (control), ends back at y+15 — creates hanging ruffle.
Cheese: rounded rect + drips:
```js
c.fillStyle = '#ffcf4d';
roundRect(left+4, y+18, w-8, 7, 3); fill;
// drips
c.beginPath(); arc(left+w*0.3, y+26, 4) ... arc(left+w*0.68, y+27, 3.5); fill
```
Patty: roundRect(left+1, y+24, w-2, 10, 5), #6b4126, with subtle texture dots.
Bottom bun: roundRect(left+3, y+32, w-6, 12, {bl:10,br:10,tl:4,tr:4}) — roundRect with uniform 6 fine. #eda24f with lighter top.
Total burger height ~ y-6..y+44 ≈ 50px tall. Paddle collision rect: use y+18..y+44, w full — that's the "physical" paddle: store paddle.y as collision top = y+20, h=24. Visual drawn with y offset -20 so dome sticks up. Fine: let paddle.collision = {top: py+20, h: 24}.
Squash on hit: scale (1+k, 1-k*0.6) around center, k decays.
Also add subtle idle bob? Paddle stays player-controlled; skip bob.
Ball tomato: radius 11:
```js
c.fillStyle = '#e8455a'; circle;
// stripe? just shine
ellipse highlight white alpha .5 at (-3,-4) r 3.5
// green stem: small rect + two leaves
c.fillStyle='#5fa052';
// calyx: 3 small triangles at top
```
Stem rotates slightly with movement direction? Keep upright, cute.
Trail: store positions each frame (max 10), draw circles alpha decreasing, radius shrinking, color pink.
Background ambience: floating hearts: 14 items, slow drift upward with sway, alpha 0.06-0.12, size 8-20, color pink. Respawn below.
Also confetti burst on level clear: spawn 60 hearts from top with random fall.
Wall bounce: small dust puff (tiny white circles) — optional; keep a couple.
Ring effect: expanding circle stroke, alpha fade.
Score popup: text rises, fades; font bold 16px cursive stack; color #e8455a with white outline (strokeText white).
HUD DOM update: score with animated count? Just set textContent; maybe pulse class on change. Keep simple: update score, level, lives (hearts svg opacity).
Lives display: 3 heart SVGs; lost ones grayscale/faded via class.
Now sounds via WebAudio:
```js
let actx=null;
function sfx(type){ if(!actx) return; ... }
function ensureAudio(){ if(!actx){ try{actx = new (window.AudioContext||window.webkitAudioContext)()}catch(e){} } if(actx&&actx.state==='suspended') actx.resume(); }
```
Call ensureAudio on first user gesture (pointerdown/keydown). Demo before gesture: silent (fine).
sfx pop: osc square freq 620→? envelope 0.08s. paddle boop: sine 220→330. lose: saw descending 300→90 0.35s. clear: little arpeggio (3 notes scheduled). level? okay.
Implement:
```js
function tone(freq, dur, type='sine', vol=0.15, slide=0){
if(!actx || actx.state!=='running') return;
const t = actx.currentTime;
const o = actx.createOscillator(), g = actx.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t);
if(slide) o.frequency.exponentialRampToValueAtTime(slide, t+dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t+dur);
o.connect(g).connect(actx.destination);
o.start(t); o.stop(t+dur+0.02);
}
```
Brick pop: tone(520+rand*300, .12, 'triangle', .18, 900). Paddle: tone(180, .1, 'sine', .2, 320). Wall: tone(340,.05,'sine',.08). Lose: tone(280,.4,'sawtooth',.15, 70).
Now write the full HTML. Careful with roundRect availability: ctx.roundRect exists in modern Chrome — fine (latest Chrome). I'll use ctx.roundRect with fallback? Latest Chrome has it (since 99). OK use native, but guard: if not present define polyfill quickly. I'll add small polyfill for safety:
```js
if (!CanvasRenderingContext2D.prototype.roundRect) { ... }
```
Eh, Chrome latest definitely has it; still, tiny polyfill is cheap. Include mini polyfill.
Layout CSS:
```css
*{box-sizing:border-box;margin:0}
html,body{height:100%}
body{
font-family:'Chalkboard SE','Comic Sans MS','Segoe Print','Marker Felt',cursive;
background: #f6e8d7;
color:#54382c;
display:flex; align-items:center; justify-content:center;
min-height:100vh; padding: 18px;
overflow-x:hidden;
}
```
Background: cream with subtle polka dots via radial-gradient pattern:
```css
background-color:#f7ecdd;
background-image: radial-gradient(#efdcc4 12%, transparent 13%), radial-gradient(#efdcc4 12%, transparent 13%);
background-size: 34px 34px; background-position: 0 0, 17px 17px;
```
Subtle polka dots — diner wallpaper feel, restrained (low contrast).
Wrapper:
```css
.stage{ width:min(960px, 100%); }
header{ display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
h1{ font-size: clamp(26px, 4vw, 40px); letter-spacing:1px; color:#54382c; }
h1 .accent{color:#e8455a}
```
Title: "HEARTBREAK <span>DINER</span>" with a small SVG heart between? "HEARTBREAKER BURGER"? Title: "Heartbreak Diner". Subtitle: "a breakout of love & lunch". Tagline under title small.
HUD chips: score chip, level chip, lives hearts. Style: cream white bg #fff8ee, border 2px #54382c22, radius 999px, padding.
Checker strip: above header a thin pink/white checker bar (diner awning):
```css
.awning{height:16px; background: conic-gradient(#e8455a 25%, #fff 0 50%, #e8455a 0 75%, #fff 0) 0 0/16px 16px; border-radius: 8px 8px 0 0; margin-bottom:0;}
```
Hmm conic checker: `repeating-conic-gradient(#e8455a 0% 25%, #fff 0% 50%)` with background-size. Yes:
```css
.checker{ height:14px; border-radius:7px;
background: repeating-conic-gradient(#e8455a 0 25%, #fff6ec 0 50%);
background-size: 16px 16px; border: 2px solid #54382c;
}
```
Nice diner detail at top of the stage card. Put the whole stage in a card:
```css
.card{ background:#fff9f0; border:3px solid #54382c; border-radius:18px; box-shadow: 0 10px 0 rgba(84,56,44,.18); overflow:hidden;}
```
Checker strip at very top inside card, then header row (padding), then canvas area.
Canvas: display:block; width:100%; height:auto; touch-action:none; background: drawn in canvas.
Overlay: position absolute over canvas wrap.
Footer hint: small text below card: "mouse / arrows to move · bricks explode into hearts".
Now JS. Let me write it fully.
```js
const W=960, H=600;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
function fit(){ const dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=W*dpr; canvas.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);}
fit(); // no need on resize since CSS scales; but dpr could change on zoom—add resize listener anyway
```
State:
```js
const state={ mode:'demo', score:0, lives:3, level:1, combo:0, time:0, shake:0, flash:0 };
```
Paddle:
```js
const paddle={ x:W/2, y:H-58, w:124, h:24, vx:0, squash:0 };
```
Balls: single ball object:
```js
const ball={ x:0,y:0,vx:0,vy:0,r:11,speed:0, stuck:true, launchT:0, trail:[] };
```
Bricks array; hearts array; rings; pops (score texts); dust.
Main loop with last timestamp.
buildLevel(n):
```js
function buildLevel(n){
bricks.length=0;
const pat=(n-1)%4;
const gold = ... per brick chance
let cols, rows, test;
if(pat===0){cols=12; rows=6; test=()=>true;}
if(pat===1){cols=13; rows=10; test=(r,c)=>MASK[r][c];}
if(pat===2){cols=12; rows=8; test=(r,c)=>(r+c)%2===0;}
if(pat===3){cols=13; rows=9; test=(r,c)=>Math.abs(c-6)<=Math.min(r,8-r);}
const gap=8, side=34, top=64, bh=26;
const bw=(W-side*2-(cols-1)*gap)/cols;
for r,c if test: push {x:side+c*(bw+gap), y:top+r*(bh+gap), w:bw, h:bh, hp, color, row:r, gold}
}
```
Colors for rows pattern:
palette = ['#ff8da1','#ffab6e','#ffd36b','#93d98a','#7cc4ee','#c7a3e0']; heart pattern uses lerp pink→deep red by row.
gold brick: hp=2, color '#f6c14f', stroke darker.
resetBall(stuck=true): position above paddle; speed = 330 + (level-1)*14 (cap 520); launch timer 1.0.
update(dt):
- ambient hearts drift
- paddle: demo AI or player input
- ball stuck: follow paddle, countdown launch → launch (angle slight random ±20° from vertical, biased by paddle position? just random)
- ball physics with substeps
- hearts physics
- rings, pops
- shake decay, squash decay
- check clear
Demo AI:
```js
if(state.mode==='demo' || state.mode==='over' && ...)
target = ball.stuck ? W/2 + Math.sin(t*0.7)*120 : predict ball landing x when vy>0 else ball.x + vx*0.15
```
Simplify:
```js
let target;
if(ball.stuck) target = W/2;
else if(ball.vy>0) {
// simulate x at paddle y with wall bounces
target = predictX();
} else target = ball.x + ball.vx*0.1;
target += Math.sin(state.time*1.3)*18; // human-ish wobble
const d = target - paddle.x;
const mv = clamp(d, -520*dt, 520*dt);
paddle.x = clamp(paddle.x+mv, paddle.w/2+6, W-paddle.w/2-6);
```
predictX:
```js
function predictX(){
let x=ball.x, vx=ball.vx, vy=ball.vy;
const ty = paddle.y - ball.r;
if(vy<=0) return ball.x;
const t=(ty-ball.y)/vy;
let px = x + vx*t;
// reflect within [r, W-r]
const span = W - 2*ball.r;
px = (px - ball.r) % (2*span); if(px<0) px+=2*span;
px = px>span ? 2*span-px : px;
return px + ball.r;
}
```
Good enough.
Player input: keys held → paddle.vx = ±620; else mouse target lerp: store mouseX; paddle.x = lerp toward mouseX fast (or direct). Direct set is most responsive: on pointermove set paddle.x directly (clamped). But if user uses keys after mouse, fine — keys adjust. Implement:
- pointermove: playerMouseX = mapped; if mode==='playing' paddle.x = clamp(playerMouseX...)
- each frame if keys: paddle.x += dir*640*dt.
Ball physics:
```js
function stepBall(dt){
if(ball.stuck){ ball.x=paddle.x; ball.y=paddle.y-ball.r-2; ball.launchT-=dt; if(ball.launchT<=0) launch(); return; }
const steps=Math.max(1, Math.ceil((Math.hypot(ball.vx,ball.vy)*dt)/8));
const sdt=dt/steps;
for(let i=0;i<steps;i++){
ball.x+=ball.vx*sdt; ball.y+=ball.vy*sdt;
// walls
if(ball.x<ball.r){ball.x=ball.r; ball.vx=Math.abs(ball.vx); wallFx(); }
if(ball.x>W-ball.r){ball.x=W-ball.r; ball.vx=-Math.abs(ball.vx); wallFx();}
if(ball.y<ball.r){ball.y=ball.r; ball.vy=Math.abs(ball.vy); wallFx();}
// paddle
if(ball.vy>0 && ball.y+ball.r>=paddle.y && ball.y-ball.r<paddle.y+paddle.h && Math.abs(ball.x-paddle.x)<=paddle.w/2+ball.r*0.8){
bounceOffPaddle();
}
// bricks
hitBricks();
if(ball.y>H+40){ loseLife(); return; }
}
trail push
}
```
bounceOffPaddle:
```js
const rel = clamp((ball.x - paddle.x)/(paddle.w/2), -1.1, 1.1);
const ang = rel * (Math.PI*0.38); // max ~68°
ball.speed = Math.min(ball.speed+6, 640);
ball.vx = Math.sin(ang)*ball.speed;
ball.vy = -Math.cos(ang)*ball.speed;
ball.y = paddle.y - ball.r - 0.5;
state.combo=0;
paddle.squash=1;
spawn tiny hearts at contact (3-4)
sfx paddle
```
hitBricks: loop bricks, circle-AABB:
```js
for(let i=bricks.length-1;i>=0;i--){
const b=bricks[i];
const cx=clamp(ball.x,b.x,b.x+b.w), cy=clamp(ball.y,b.y,b.y+b.h);
const dx=ball.x-cx, dy=ball.y-cy;
if(dx*dx+dy*dy<=ball.r*ball.r){
// bounce
if(Math.abs(dx)>Math.abs(dy)) ball.vx = dx>0?Math.abs(ball.vx):-Math.abs(ball.vx);
else ball.vy = dy>0?Math.abs(ball.vy):-Math.abs(ball.vy);
damageBrick(i);
break;
}
}
```
Edge case dx==dy==0 (center inside brick): then abs check 0>0 false → flips vy with dy=0 → vy=-abs(vy) hmm dy>0 false → -abs → upward. Fine.
damageBrick(i):
```js
const b=bricks[i];
b.hp--;
if(b.hp<=0){
bricks.splice(i,1);
burstHearts(b, b.gold?34:20, b.gold?'gold':'pink');
state.combo=Math.min(state.combo+1,9);
const pts=(b.gold?200:50)*state.combo;
addScore(pts, centerX, centerY);
state.shake=Math.min(state.shake+ (b.gold?9:5), 14);
rings.push(...);
sfx pop
} else {
b.cracked=true; small puff 5 hearts; addScore(25...); shake 2; sfx tick;
}
if(bricks.length===0) levelClear();
```
burstHearts(b, n, kind):
```js
for n: hearts.push({
x: b.x+Math.random()*b.w, y: b.y+Math.random()*b.h,
vx:(Math.random()-0.5)*300, vy:-40-Math.random()*260,
g: 620, rot:Math.random()*6.3, vr:(Math.random()-0.5)*9,
size:6+Math.random()*9, life:0, max:0.9+Math.random()*0.8,
color: pick(pinks or golds), sway:Math.random()*6.3
});
```
Colors pink set: ['#ff5d7e','#ff8da1','#f73b57','#ffb3c1','#e8455a']. Gold: ['#f6c14f','#ffd97a','#f0a63a','#ffeba8'].
Heart update:
```js
h.life+=dt; h.vy+=h.g*dt; h.x+=h.vx*dt + Math.sin(h.life*6+h.sway)*12*dt; h.y+=h.vy*dt; h.rot+=h.vr*dt;
if(h.life>h.max) remove;
```
Draw with alpha = 1 - (life/max)^2, size *= easeOut? draw scale = size*(1 - 0.5*(life/max)^2). Slight flip when hitting floor? Hearts could bounce once on paddle top / bottom of canvas: if y > H-8 && vy>0: vy*=-0.45; spawn fewer. Cute! Hearts bounce off the burger paddle too? Just floor bounce, and bounce off paddle: if within paddle rect while falling → bounce up with dampening + tiny squash? That's a delightful detail — hearts bouncing off the hamburger. Implement: if h.vy>0 and h inside paddle rect expanded → h.vy=-Math.abs(h.vy)*0.55 - 40; h.vx += (h.x-paddle.x)*2. Only if not already... fine.
Draw hearts: filled heart path with slight white outline? Just fill + darker stroke maybe not needed. Add tiny highlight dot? Keep fill + subtle stroke same color darker skip. Just fill with globalAlpha.
Rings: {x,y,r:6,v:260,a:0.7} update r+=v*dt, a-=2.2*dt.
Pops (score text): {x,y,vy:-40,life:0,max:0.9,text}. draw fillText with stroke white.
loseLife:
```js
state.lives--; state.combo=0; state.shake=12; state.flash=0.5; sfx lose;
if(lives<=0){ gameOver(); } else { resetBall(); }
```
gameOver: mode='over'; show overlay with score; if was demo → auto restart in 3.5s; if playing → wait click. Actually track wasDemo. Simpler: on game over, show card; restart on click/keypress OR after 4s automatically (auto-restart returns to demo mode? If user was playing and lost, auto-restarting to demo is odd but keeps screen alive for screenshot judging — fine either way. I'll auto-restart to demo after 4s only if the user never started; if playing, wait for input. Hmm mode 'over' after user played: wait for click "PLAY AGAIN". If demo: auto-restart demo after 2.5s.
levelClear: mode='clear'; banner DOM show "LEVEL X CLEARED — LOVE LEVELS UP"; rainHearts (60 hearts from top across width, gentle fall); after 1.8s: level++, buildLevel, resetBall, mode = previous mode ('demo' or 'playing'). Track prevMode. During 'clear', ball hidden/frozen; paddle still moves.
startGame (from demo): mode='playing'; score=0; lives=3; level=1; buildLevel(1); resetBall(); hide overlay; ensureAudio.
restart after over: same as startGame.
Frame:
```js
function frame(ts){
const dt=Math.min((ts-last)/1000, 0.033); last=ts; state.time+=dt;
update(dt); draw();
requestAnimationFrame(frame);
}
```
Draw:
```js
ctx.clearRect? fill background:
// bg
ctx.fillStyle='#fff6ec'; fillRect(0,0,W,H);
// subtle vertical side rails: two thin rounded bars? skip
// ambient hearts behind bricks
drawAmbient();
// shake
ctx.save();
const s=state.shake; if(s>0){ ctx.translate((Math.random()-0.5)*s, (Math.random()-0.5)*s); }
draw bricks, rings, hearts, pops, paddle, ball, trail
ctx.restore();
// top vignette flash red when hurt
if(state.flash>0){ ctx.fillStyle=`rgba(232,69,94,${state.flash*0.25})`; fillRect... }
```
Wait: shake should apply to game objects, ambient fine outside.
Bricks draw:
```js
for b:
ctx.fillStyle=b.color;
roundRect(b.x,b.y,b.w,b.h,7); fill;
// top gloss: rounded rect inset white alpha .35 height 8
// border: stroke darker
if(b.gold && !b.cracked) draw small star sparkles / heart stamp
if(b.cracked): overlay darker alpha + crack lines
```
Darker stroke: use rgba(0,0,0,0.15) stroke, or precomputed darker. Stroke '#00000022' fine. Add heart stamp on some bricks? Draw a small white-alpha heart on gold bricks. Cute: every brick has tiny heart? Maybe only gold. Keep gold with white heart mark; normal bricks get gloss only.
Cracked: draw two crack lines:
```js
ctx.strokeStyle='rgba(0,0,0,.3)'; polyline from top edge random-ish fixed offsets.
```
Simplify: darken brick fill by overlay rgba(0,0,0,0.18) rounded rect + draw 2 short crack strokes.
Ball draw + trail:
```js
trail: push {x,y} each frame (cap 12). draw from old to new: for i, alpha=i/len*0.25, r=ball.r*(i/len*0.8), pink.
```
Draw ball tomato:
```js
ctx.save(); translate(x,y); rotate(ball.vx/1600? no keep 0);
// body
fill #e94b5f circle r
// darker bottom shade arc? small
// shine
white alpha .55 ellipse at (-r*0.32,-r*0.35) r*0.28
// calyx: 3 leaves: from top point small triangles green
ctx.fillStyle='#5c9e4b';
leaf: bezier small... draw 3 lines thick? Use small triangles:
for k in -1..1: triangle from (0,-r+2) to (k*4, -r-4) to (k*7, -r+1)?
// stem: roundRect(-1.5, -r-5, 3, 5)
```
Good enough — reads as tomato.
Paddle draw: as designed. Also shadow under paddle: ellipse dark alpha .08 at y+46.
HUD DOM update each score change: elScore.textContent = score formatted. Level chip "LV n". Lives: three svg hearts, toggle .lost class.
DOM overlay elements:
```html
<div class="overlay" id="ovDemo"> pill: "Click or press a key to start" + sub "demo · the burger plays itself" </div>
<div class="overlay" id="ovOver"> card: "GAME OVER" score, "click to play again" </div>
<div class="banner" id="ovClear"> LEVEL CLEARED </div>
<div class="chip-demo">DEMO</div>?
```
Demo pill includes demo note; no separate chip needed. Add chip "AUTOPLAY" top-left inside frame? The pill suffices, put "demo mode" text within.
Keyboard: keydown:
- if demo → startGame (and ensureAudio)
- if over → restart
- arrows set flags, prevent default for arrows/space.
- space: if playing and ball stuck → launch immediately.
keyup clears flags.
pointerdown on window: same start/restart logic. Note: clicking shouldn't restart during play. Only demo/over.
Pointer move mapping:
```js
canvas.addEventListener('pointermove', e=>{
const r=canvas.getBoundingClientRect();
const mx=(e.clientX-r.left)/r.width*W;
if(state.mode==='playing') paddle.x=clamp(mx, ...);
});
```
Touch: pointermove covers if touch-action none. Also on touchstart? pointerdown starts game then move works.
Fonts in canvas: use `bold 20px 'Chalkboard SE','Comic Sans MS',cursive` for pops.
Score formatting: toLocaleString? use plain.
Ambient hearts init:
```js
const ambient=[]; for(let i=0;i<14;i++) ambient.push({x:rand*W,y:rand*H,s:7+rand*14,v:8+rand*14,sway:rand*6.28,a:0.05+rand*0.06});
```
update: y-=v*dt; x+=sin; if y<-30 → respawn bottom.
Level clear rain: spawn hearts with vy small 40-90, vx small, g=140 gentle, from y=-20. They'll fall past paddle — floor bounce? give them max life long 4s.
Careful: heart floor bounce might make them pile at bottom bouncing forever until life ends — fine, life limited.
Flash decay: state.flash-=dt*1.2.
Shake decay: state.shake*=Math.pow(0.001,dt)?? simpler: state.shake=Math.max(0,state.shake-30*dt).
Squash decay: paddle.squash=Math.max(0,paddle.squash-4*dt).
Now DOM structure & CSS polish. Header:
```html
<header class="top">
<div class="brand">
<svg class="logo" ...heart+burger?...>
<div><h1>Heartbreak <em>Diner</em></h1><p class="tag">breakout · served with extra love</p></div>
</div>
<div class="hud">
<div class="chip">SCORE <b id="score">0</b></div>
<div class="chip">LEVEL <b id="level">1</b></div>
<div class="lives" id="lives"> 3 svg hearts </div>
</div>
</header>
```
Logo: inline SVG heart with burger inside? Simpler: small heart SVG in strawberry red. OK.
Lives hearts svg path: "M12 21s-7.5-4.9-9.7-9C.7 8.9 2 5 5.5 5c2 0 3.4 1.2 4.2 2.6? ..." Use standard heart path:
`M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z` (material favorite icon). Fill #e8455a, lost: fill #e8d5c8.
Mobile responsiveness: at 375px, header wraps; canvas full width; font sizes clamp. The pill overlay small.
Everything must avoid horizontal scroll — wrapper max-width 100%, canvas width:100%.
Overlay pill style:
```css
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none;}
.pill{ background:#fff8ee; border:3px solid #54382c; border-radius:999px; padding:14px 26px; font-size:clamp(15px,2.4vw,20px); box-shadow:0 6px 0 rgba(84,56,44,.2); animation: bob 1.6s ease-in-out infinite; text-align:center}
.pill small{display:block; font-size:12px; opacity:.65; margin-top:2px}
@keyframes bob{50%{transform:translateY(-7px)}}
```
Position demo pill lower (bottom: 12%) so it doesn't block bricks: use flex-end + padding-bottom? Set align-items:flex-end; padding-bottom:9%.
Game over card: centered, cream, border, big "OUT OF LOVE" heading (themed), score, "click to play again".
Clear banner: centered top-ish, "LEVEL CLEAR!" with sub.
Order of drawing overlays: they're DOM above canvas.
Wait: overlay container must be inside .frame which is position:relative wrapping canvas.
Edge: clicking to start also sets paddle position? pointerdown on window: fine.
Also while in 'over' from playing, demo AI shouldn't move; mode 'over' → freeze ball (no ball), still draw hearts etc.
Let me also handle: after user starts playing and ball stuck, show tiny "launching..." arrow? Auto-launch after 1s anyway; also space launches.
Combo popups: when combo>=3 show "COMBO x3!" small at top of brick popup? Just include in pop text: `+${pts}` and if combo>2 append ` (x${combo})`. Fine.
Now, precise burger layering with numbers. Paddle: x center, y = top of collision rect. Visual: define draw at (paddle.x, paddle.y) where burger visual top (bun crest) is at paddle.y-16.
Let me set constants: BW=124 (paddle.w), collision height 26. Visual drawing area from y-18 to y+30 (48 tall).
drawBurger(px, py) where py = paddle.y (collision top):
```js
const w=BW, left=px-w/2, y=py-16;
// shadow
ellipse (px, py+34, w*0.42, 6) rgba(84,56,44,.10)
save; translate(px, py+8); scale(sx,sy); translate(-px, -(py+8));
// bottom bun
fill '#eda44f'; roundRect(left+4, y+26, w-8, 13, 6); fill;
// patty
fill '#6e4227'; roundRect(left+1, y+19, w-2, 12, 6); fill;
// cheese
fill '#ffc84e'; roundRect(left+5, y+15, w-10, 9, 4); fill;
// drips
beginPath; arc(left+w*0.30, y+24, 5, 0, 7); arc(left+w*0.66, y+25, 4, 0, 7); fill; (need moveTo between arcs to avoid connecting lines) — use two separate fills or moveTo before each arc.
// lettuce ruffle
fill '#8bc34a';
beginPath; moveTo(left+2, y+12); lineTo(left+w-2, y+12);
for scallops right→left: n=5; step=(w-4)/5;
for(i=0;i<5;i++){ const x1=left+w-2-i*step; quadraticCurveTo(x1-step*0.5, y+22, x1-step, y+12); }
closePath; fill;
// hmm this makes ruffle dip below y+22 — overlapping cheese nicely.
// top bun
fill '#f5b061'; beginPath; moveTo(left+3, y+13); bezierCurveTo(left+3, y-8, left+w-3, y-8, left+w-3, y+13); closePath; fill;
// bun underside line
fill '#e8974a'? small rounded rect at bottom of dome: roundRect(left+3, y+11, w-6, 4, 2) fill '#f0a452'?? skip.
// sesame seeds
fill 'rgba(255,248,230,.95)';
seeds at offsets: [ [0.18,6],[0.32,2.5],[0.5,5],[0.68,2],[0.82,6.5] ] each: save translate(left+o[0]*w, y+o[1]) rotate(o) ellipse 2.6x1.6 fill
// gloss on bun: arc stroke? small white alpha ellipse near top-left: alpha .35 ellipse at (left+w*0.28, y+2) 10x4 rotate -0.3
restore
```
Wait layering order matters: draw bottom bun first, then patty on top (patty should be between bottom bun and cheese) — order: bottom bun, patty, cheese, lettuce, top bun. Since later drawn on top. But physically: bottom bun at very bottom; patty sits on it (drawn after → appears in front, positioned above). Cheese over patty, lettuce over cheese, top bun covers everything's top. Positions: bottom bun y+26..39, patty y+19..31, cheese y+15..24, lettuce y+12..~24 dips, top bun y-8..13. Overlap makes it look stacked.
Paddle collision top py; visual: dome crest at py-24? y=py-16, dome top y-8 → py-24; bottom bun bottom y+39 → py+23. So visual spans py-24..py+23, centered around py. Collision rect py..py+26 — reasonable.
Total paddle visual bottom at py+23; shadow at py+34. Paddle.y = H-64? Then bottom at H-41, shadow H-30. Ball lost below H+40. Good: paddle.y = H-64.
Bricks bottom around y=64+6*(26+8)=268 (rows). Lots of play space.
Ball speed 340 start; paddle AI speed 540 — should be enough.
Hmm, one more idea for signature: when ball hits paddle, burger "bites" — squash + sesame seeds pop? Squash is enough.
Also add ketchup/mustard drips? enough.
Check requirement: "every brick you break explodes into tons of little hearts" — 20-30 hearts per brick, gold 36. "tons" — maybe 26 normal. Particle count: 72 bricks * 26 = fine.
Also bricks drawn with tiny heart icon on some? Add small white heart on gold bricks only, plus gloss on all.
Now write out full code. Also remember: no console errors. Ensure all element IDs referenced exist.
CSS for chip hearts in lives: svg width 22.
Let me write final HTML.
Also: title heading should not start reply with # — that's about my reply text; reply is only a code block anyway.
Font: I said avoid Inter/Roboto/Arial/system-ui as primary identity. Using Chalkboard/Comic Sans/Segoe Print cursive stack — distinctive for a diner game. OK.
Draw text pops with that font too.
Level clear detection: called from damageBrick after splice. Also banner hide after timeout.
Let me handle mode transitions cleanly:
modes: 'demo', 'playing', 'clear', 'over'. In 'clear', prevMode stored. update():
- if mode==='clear': still update hearts, ambient, paddle (player or AI), skip ball. clearT timer → advance.
- if mode==='over': update hearts/ambient only (and demoRestartT if wasDemo).
Ball update in demo & playing.
AI also in 'clear'? sure, harmless — center paddle.
startGame ensures overlay hidden, gameover hidden.
Auto demo restart: in gameOver(): demoOverT = 2.5 if mode was demo... mode is 'over'; track state.prev='demo'|'playing'. In update if over && prev==='demo': timer→ resetToDemo() which resets everything and mode='demo', show demo pill again.
Also if user clicks during demo-over overlay → startGame (playing). Good.
pointerdown handler:
```js
window.addEventListener('pointerdown', e=>{ ensureAudio();
if(state.mode==='demo') startGame();
else if(state.mode==='over') startGame();
});
```
Careful: clicking the "PLAY AGAIN" button also triggers window pointerdown → restart. Button is decorative-interactive; fine, remove separate handler or keep both (double restart harmless if idempotent — startGame resets, called once anyway since both fire on same event... button click fires pointerdown on window first, then click on button → two restarts. Make button not have own handler. Just window pointerdown.)
keydown:
```js
window.addEventListener('keydown', e=>{
if(['ArrowLeft','ArrowRight',' ','Spacebar'].includes(e.key)) e.preventDefault();
ensureAudio();
if(state.mode==='demo'||state.mode==='over'){ startGame(); return; }
if(e.key==='ArrowLeft'||e.key==='a') keys.left=true;
if(e.key==='ArrowRight'||e.key==='d') keys.right=true;
if(e.key===' ' && ball.stuck && state.mode==='playing') launch();
});
```
Note: pressing key during 'clear' mode — ignore start; flags ok but mode check. Movement flags apply in playing & demo? In demo user shouldn't control (AI). Only playing.
keyup: clear flags.
pointermove: only playing (and 'clear') move paddle.
Ball launch():
```js
ball.stuck=false;
const a=(Math.random()*0.6-0.3) - Math.PI/2?
```
angle from vertical: dir = (Math.random()-0.5)*0.5; ball.vx=ball.speed*Math.sin(dir); vy=-cos. Also slight bias away from nearest wall? skip.
Speed set in resetBall: ball.speed=Math.min(330+ (level-1)*16, 520). paddle bounces add +5 up to 640.
resetBall(): ball.stuck=true; launchT=1.0; trail=[].
Level clear hearts rain + banner:
```js
function levelClear(){
prevMode = state.mode; // 'demo' or 'playing'
state.mode='clear'; clearT=1.8;
banner show with level text;
for 50 hearts: hearts.push({x:rand*W, y:-20-rand*160, vx:(r-.5)*40, vy:60+rand*80, g:60, ...max:3.5+rand});
sfx arpeggio;
addScore bonus 500? add popup center "+500".
}
```
After clearT: state.level++; buildLevel; resetBall; mode=prevMode; hide banner.
updateHUD after changes: score/level/lives.
Draw order in canvas: bg, ambient, (shake group: bricks, rings, hearts, paddle, ball+trail, pops), flash overlay, bottom "counter" strip? Maybe draw a subtle line at paddle area? no.
Also draw top inner border rounded: stroke around playfield: strokeStyle 'rgba(84,56,44,.08)'? The card border frames it already. skip.
One more polish: bricks entrance animation on level build: each brick has delay and drops/fades in: b.t=0; draw with scale ease from 0.6 & alpha. delay = (r*0.06 + c*0.02). Update b.t+=dt. This makes level starts lively. During entrance (first ~0.6s) ball is stuck on paddle anyway (launchT=1.0).
Heart path function (canvas, centered at 0,0 with size s):
```js
function heartPath(s){
ctx.beginPath();
ctx.moveTo(0, -s*0.25);
ctx.bezierCurveTo(s*0.5, -s*0.85, s*1.05, -s*0.05, 0, s*0.75);
ctx.bezierCurveTo(-s*1.05, -s*0.05, -s*0.5, -s*0.85, 0, -s*0.25);
ctx.closePath();
}
```
Check: start top notch (0,-0.25s). cp1 (0.5s,-0.85s) pulls up-right, cp2 (1.05s,-0.05s) right side, end bottom tip (0, 0.75s). Right lobe formed. Mirror left. Should be a plausible heart. Width ~ from -0.8s..0.8s effective. OK.
Score pop draw: font `700 17px` stack; strokeStyle '#fff' lineWidth 4 strokeText then fill '#e8455a'.
Alright — also small detail: hearts from brick bounce on floor and paddle:
```js
if(h.y>H-6 && h.vy>0){ h.y=H-6; h.vy*=-0.45; h.vr*=0.7; if(Math.abs(h.vy)<40) h.vy=0? }
```
Paddle bounce:
```js
if(h.vy>0 && h.y>paddle.y-8 && h.y<paddle.y+30 && Math.abs(h.x-paddle.x)<paddle.w/2+6){ h.vy=-Math.abs(h.vy)*0.6-60; h.vx+=(h.x-paddle.x)*3; h.rot=(Math.random()-.5)*4; paddle tiny squash? no }
```
Careful this doesn't conflict with floor. Fine.
Performance: arrays with splice fine at this scale.
Ambient draw uses heartPath too with alpha.
HUD lives update:
```js
function updateHUD(){ scoreEl.textContent=state.score; levelEl.textContent=state.level; [...livesEl.children].forEach((el,i)=> el.classList.toggle('lost', i>=state.lives)); }
```
Demo pill show/hide: ovDemo.classList.toggle('hidden', mode!=='demo').
Write CSS `.hidden{display:none!important}`.
GameOver card content: heading "OUT OF LOVE", final score, "Click anywhere to play again".
Banner (level clear): "LEVEL CLEAR!" sub "+500 sweetheart bonus".
Score bonus on clear: state.score+=500; pop center.
Also don't forget: screenshot at 3s — demo mode playing, hearts exploding. Demo starts immediately with rAF. buildLevel(1) at init, resetBall, mode='demo'. Ball launches at ~1s, reaches bricks ~1.5-2s, hearts by 2-3s.
Maybe make demo faster to action: launchT initial 0.7. Paddle AI: position under ball. Ball from center paddle goes up. Bricks start y=64. Distance ~470px at 330px/s ≈ 1.4s. Hearts visible ~2.2s. OK within 3s. Could raise speed slightly: 360. Fine.
Edge: In demo, when ball stuck and paddle moves, ball follows — fine.
Now, write everything. Also include mini roundRect polyfill.
Let me carefully write the heart mask for level 2 (13 cols × 10 rows):
```js
const HEART_MASK=[
'0011100111000',
'0111111111000', // hmm need symmetric
];
```
13 chars symmetric rows:
r0: 0011100111000 → wait count: "0011100111000" = 13 chars: 0,0,1,1,1,0,0,1,1,1,0,0,0 — asymmetric (left lobe 3 wide at pos2-4, right lobe pos7-9, trailing zeros 3). Total width 13: positions 0-12; center 6. Left lobe 2-4 (center 3), right lobe 7-9 (center 8) — not symmetric around 6. Fix: lobes at 2-4 and 8-10: "0011100011100" → 0,0,1,1,1,0,0,0,1,1,1,0,0 — 13 chars ✓ symmetric.
r1: "0111111111110" (1-11) ✓13.
r2: "1111111111111" all 13.
r3: "1111111111111".
r4: "0111111111110".
r5: "0011111111100".
r6: "0001111111000".
r7: "0000111110000".
r8: "0000011100000".
r9: "0000001000000".
Count bricks: 6+11+13+13+11+9+7+5+3+1=79.
Diamond (pat 3): cols 13, rows 9, |c-6|<=min(r,8-r): r0:1, r1:3, r2:5, r3:7, r4:9, r5:7, r6:5, r7:3, r8:1 = 41.
Checker (pat 2): 12 cols, 8 rows, (r+c)%2===0 → 48.
Rows (pat 0): 12×6=72.
Gold chance: 0.09, not in heart pattern? Anywhere fine; heart pattern maybe pure pink gradient with occasional gold — okay allow everywhere except maybe keep heart all-pink for aesthetics... allow gold everywhere, it's charming.
Colors:
rowPalette=['#ff8da1','#ffb27a','#ffd971','#9ad98c','#84c7ec','#c9a6e3'];
For heart level: lerp between (255,141,161) and (226,49,86) by r/9 → use helper mix.
Checker: use rowPalette by row.
Diamond: by row.
Rows: by row.
Gold overrides color.
drawBrick:
```js
function drawBrick(b){
let a=1, sc=1;
if(b.t<0.45){ const k=b.t/0.45; a=k; sc=0.5+0.5*easeOutBack? }
simpler: const k=Math.min(1,b.t/0.4); const e=1-Math.pow(1-k,3); a=e; scale=0.6+0.4*e;
ctx.save(); translate(cx,cy); scale; translate(-)
fill b.color; roundRect(-w/2,-h/2,...) — easier: work in local coords.
gloss: rgba(255,255,255,.4) roundRect(-w/2+3,-h/2+3,w-6,7,4)
stroke rgba(0,0,0,.12) lw1.5
if gold: white heart stamp alpha .8 size 7 at center? plus tiny sparkles rotating by time.
if cracked: overlay rgba(0,0,0,.15) + cracks
restore
}
```
Gold sparkles: two tiny 4-point stars (draw as small plus lines) with alpha oscillating by state.time + b.x. Cute.
Crack lines: from top center-ish: strokes:
```js
ctx.strokeStyle='rgba(60,30,20,.45)'; lw 1.5;
path: moveTo(-w*0.2, -h/2); lineTo(-w*0.05, -h*0.1); lineTo(-w*0.25, h/2-2);
another: moveTo(w*0.15,-h/2); lineTo(w*0.3, 0); lineTo(w*0.12, h/2);
```
ok.
Entrance timing: b.t starts negative for stagger: b.t = -(r*0.05 + c*0.018); update b.t+=dt; draw uses max(0,...). When t<=0 skip draw? Or scale 0 → skip if b.t<0.
Collision only when b.t>0.2 (mostly entered) — minor, skip check; bricks at top, ball takes time anyway.
Sound arpeggio on clear: tone(523,.12), setTimeout? schedule via start times: modify tone to accept delay. Add param when=0: t=actx.currentTime+when. Then tone(523,.12,'triangle',.2,0,0); tone(659,.12,...,0.12); tone(784,.2,...,0.24); tone(1046,.3,...,0.36).
OK also add gentle background? no music.
Now HTML skeleton:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heartbreak Diner — Breakout</title>
<style> ... </style>
</head>
<body>
<main class="stage">
<div class="card">
<div class="checker"></div>
<header class="top">
<div class="brand">
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<div class="titles">
<h1>Heartbreak <em>Diner</em></h1>
<p class="tag">breakout, served with extra love</p>
</div>
</div>
<div class="hud">
<div class="chip">Score <b id="score">0</b></div>
<div class="chip">Level <b id="level">1</b></div>
<div class="lives" id="lives" aria-label="lives">
<svg viewBox="0 0 24 24"><path d="..."/></svg> ×3
</div>
</div>
</header>
<div class="frame">
<canvas id="game" width="960" height="600"></canvas>
<div class="overlay ov-demo" id="ovDemo">
<div class="pill">Click or press a key to start<small>demo · the burger plays itself</small></div>
</div>
<div class="overlay hidden" id="ovOver">
<div class="card-over">
<h2>Out of love</h2>
<p>Final score <b id="finalScore">0</b></p>
<p class="again">Click anywhere to play again</p>
</div>
</div>
<div class="banner hidden" id="ovClear">
<h2>Level clear!</h2>
<p>+500 sweetheart bonus</p>
</div>
</div>
</div>
<p class="hint">Move with the mouse or ← → · every brick bursts into hearts</p>
</main>
<script>...</script>
</body>
</html>
```
Lives: 3 separate svg hearts with class toggling.
```html
<div class="lives" id="lives">
<svg ...>path</svg> ×3
</div>
```
Generate via JS or hardcode 3 svgs. Hardcode 3.
Canvas sizing: CSS `width:100%; height:auto; display:block; aspect-ratio handled by attribute ratio`. Since canvas has width/height attributes 960/600, CSS width:100% + height:auto preserves ratio. But we set canvas.width = W*dpr in JS → CSS still needs width:100% (CSS width independent of attribute). height:auto keeps ratio from attributes? If attribute width=1920 height=1200 (dpr2), ratio still 1.6 ✓.
But careful: I set canvas.width via JS with dpr; also initial attribute 960. Fine.
Resize listener: not needed (CSS scales), but dpr change on browser zoom triggers resize — re-run fit() on resize for crispness. Note re-setting width clears canvas — we redraw every frame anyway. Also setTransform resets each fit.
Pointer mapping uses rect — correct regardless of scale.
Prevent page scroll on touch drag over canvas: touch-action:none.
Now full JS write-out. I'll be careful about referencing ctx inside helper heartPath — use global ctx.
Also ambient hearts drawn behind bricks (before shake group) — subtle.
Flash red vignette on losing life: fill rect full with rgba pink alpha flash*0.2. Decay fast.
Let me write the code now, mentally compiling as I go.
```js
'use strict';
const W=960,H=600;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y);
this.arcTo(x+w,y,x+w,y+h,r);
this.arcTo(x+w,y+h,x,y+h,r);
this.arcTo(x,y+h,x,y,r);
this.arcTo(x,y,x+w,y,r);
this.closePath();
return this;
};
}
```
Note native roundRect begins its own path? Native roundRect adds to current path; need beginPath before. I'll always beginPath before roundRect.
Helpers:
```js
const rand=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const pick=arr=>arr[(Math.random()*arr.length)|0];
```
DOM refs: scoreEl, levelEl, livesEl children, ovDemo, ovOver, ovClear, finalScore.
State & entities as planned.
HEART_MASK defined.
buildLevel(n):
```js
function buildLevel(n){
bricks.length=0;
const pat=(n-1)%4;
let cols,rows,test;
if(pat===0){cols=12;rows=6;test=()=>true;}
else if(pat===1){cols=13;rows=10;test=(r,c)=>HEART_MASK[r][c]==='1';}
else if(pat===2){cols=12;rows=8;test=(r,c)=>(r+c)%2===0;}
else {cols=13;rows=9;test=(r,c)=>Math.abs(c-6)<=Math.min(r,8-r);}
const gap=8, side=34, top=64, bh=26;
const bw=(W-side*2-(cols-1)*gap)/cols;
for(let r=0;r<rows;r++)for(let c=0;c<cols;c++){
if(!test(r,c))continue;
const gold=Math.random()<0.09;
let color=gold?'#f4b64a':mix('#ff8da1','#e23156', rows>1?r/(rows-1):0);
if(pat!==1 && !gold) color=ROWCOLORS[r%ROWCOLORS.length];
bricks.push({x:side+c*(bw+gap),y:top+r*(bh+gap),w:bw,h:bh,hp:gold?2:1,gold,cracked:false,color,t:-(r*0.05+c*0.02)});
}
}
```
Hmm heart uses gradient pink→red by row, others use row palette. Wait rows palette for pat0 6 rows cycles 6 colors rainbow — fun. mix helper:
```js
function mix(a,b,t){
const pa=parseInt(a.slice(1),16),pb=parseInt(b.slice(1),16);
const r=((pa>>16)+(((pb>>16)-(pa>>16))*t))|0 ...
}
```
Write properly:
```js
function mix(a,b,t){
const A=parseInt(a.slice(1),16),B=parseInt(b.slice(1),16);
const ch=(sh)=>Math.round(((A>>sh)&255)+(((B>>sh)&255)-((A>>sh)&255))*t);
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}
```
resetBall, launch as planned.
burstHearts(x,y,n,kind):
```js
const PINKS=['#ff5d7e','#ff8da1','#f43b5c','#ffb3c1','#e8455a','#ffd1da'];
const GOLDS=['#f6c14f','#ffd97a','#f0a63a','#ffeba8','#e8901f'];
function burstHearts(x,y,n,kind){
const cols=kind==='gold'?GOLDS:PINKS;
for(let i=0;i<n;i++){
hearts.push({x:x+rand(-6,6),y:y+rand(-6,6),vx:rand(-170,170),vy:rand(-300,-60),g:560,rot:rand(0,6.28),vr:rand(-9,9),s:rand(6,14),life:0,max:rand(0.9,1.7),color:pick(cols),sway:rand(0,6.28)});
}
}
```
"tons": normal brick n=24.
rainHearts for clear: separate push with gentle params.
update(dt):
```js
function update(dt){
state.time+=dt;
state.shake=Math.max(0,state.shake-34*dt);
state.flash=Math.max(0,state.flash-dt*1.6);
paddle.squash=Math.max(0,paddle.squash-4.5*dt);
// ambient
for(const a of ambient){ a.y-=a.v*dt; a.x+=Math.sin(state.time*0.6+a.sway)*8*dt; if(a.y<-30){a.y=H+20;a.x=rand(0,W);} }
// bricks entrance
for(const b of bricks) if(b.t<0.5) b.t+=dt;
// paddle control
if(state.mode==='demo'){
let target;
if(ball.stuck) target=W/2+Math.sin(state.time*0.6)*140;
else if(ball.vy>0) target=predictX()+Math.sin(state.time*1.7)*10;
else target=ball.x+ball.vx*0.08;
target=clamp(target,paddle.w/2+8,W-paddle.w/2-8);
const d=clamp(target-paddle.x,-560*dt,560*dt);
paddle.x=clamp(paddle.x+d,paddle.w/2+8,W-paddle.w/2-8);
} else if(state.mode==='playing'){
let dir=(keys.right?1:0)-(keys.left?1:0);
if(dir) paddle.x=clamp(paddle.x+dir*680*dt,paddle.w/2+8,W-paddle.w/2-8);
}
// ball
if(state.mode==='demo'||state.mode==='playing') stepBall(dt);
// hearts physics
for(let i=hearts.length-1;i>=0;i--){
const h=hearts[i];
h.life+=dt;
if(h.life>=h.max){hearts.splice(i,1);continue;}
h.vy+=h.g*dt;
h.x+=h.vx*dt+Math.sin(h.life*7+h.sway)*20*dt;
h.y+=h.vy*dt;
h.rot+=h.vr*dt;
if(h.y>H-6&&h.vy>0){h.y=H-6;h.vy*=-0.45;h.vx*=0.8;}
else if(h.vy>0&&h.y>paddle.y-10&&h.y<paddle.y+34&&Math.abs(h.x-paddle.x)<paddle.w/2+8){
h.vy=-Math.abs(h.vy)*0.6-70; h.vx+=(h.x-paddle.x)*2.5; h.vr=rand(-6,6);
}
}
for(let i=rings.length-1;i>=0;i--){const r=rings[i]; r.r+=r.v*dt; r.a-=2.4*dt; if(r.a<=0)rings.splice(i,1);}
for(let i=pops.length-1;i>=0;i--){const p=pops[i]; p.life+=dt; p.y-=44*dt; if(p.life>=p.max)pops.splice(i,1);}
if(state.mode==='clear'){
clearT-=dt;
if(clearT<=0){
ovClear.classList.add('hidden');
state.level++; state.mode=prevMode;
buildLevel(state.level); resetBall(); updateHUD();
}
}
if(state.mode==='over'&&overFrom==='demo'){
overT-=dt;
if(overT<=0) toDemo();
}
}
```
stepBall with substeps & trail:
```js
function stepBall(dt){
if(ball.stuck){
ball.x=paddle.x; ball.y=paddle.y-ball.r-1;
ball.launchT-=dt;
if(ball.launchT<=0) launch();
return;
}
const sp=Math.hypot(ball.vx,ball.vy);
const steps=Math.max(1,Math.ceil(sp*dt/8));
const sdt=dt/steps;
for(let s=0;s<steps;s++){
ball.x+=ball.vx*sdt; ball.y+=ball.vy*sdt;
if(ball.x<ball.r){ball.x=ball.r;ball.vx=Math.abs(ball.vx);sfx('wall');}
else if(ball.x>W-ball.r){ball.x=W-ball.r;ball.vx=-Math.abs(ball.vx);sfx('wall');}
if(ball.y<ball.r){ball.y=ball.r;ball.vy=Math.abs(ball.vy);sfx('wall');}
if(ball.vy>0&&ball.y+ball.r>=paddle.y&&ball.y-ball.r<paddle.y+paddle.h+6&&Math.abs(ball.x-paddle.x)<=paddle.w/2+ball.r*0.7){
bouncePaddle();
}
if(hitBricks()) continue; // bounced; continue substeps
if(ball.y>H+40){ loseLife(); return; }
}
ball.trail.push({x:ball.x,y:ball.y});
if(ball.trail.length>11)ball.trail.shift();
}
```
bouncePaddle:
```js
function bouncePaddle(){
const rel=clamp((ball.x-paddle.x)/(paddle.w/2),-1,1);
const ang=rel*1.15; // rad from vertical, max ~66°
ball.speed=Math.min(ball.speed+5,620);
ball.vx=Math.sin(ang)*ball.speed;
ball.vy=-Math.cos(ang)*ball.speed;
ball.y=paddle.y-ball.r-0.5;
state.combo=0;
paddle.squash=1;
for(let i=0;i<3;i++) hearts.push({...tiny heart at ball pos, vy:-rand(40,120), small s:4-6, max:.6});
sfx('paddle');
}
```
hitBricks returns bool:
```js
function hitBricks(){
for(let i=0;i<bricks.length;i++){
const b=bricks[i];
if(b.t<0.15)continue;
const cx=clamp(ball.x,b.x,b.x+b.w), cy=clamp(ball.y,b.y,b.y+b.h);
const dx=ball.x-cx,dy=ball.y-cy;
if(dx*dx+dy*dy<=ball.r*ball.r){
if(Math.abs(dx)>Math.abs(dy)) ball.vx=dx>0?Math.abs(ball.vx):-Math.abs(ball.vx);
else ball.vy=dy>=0?Math.abs(ball.vy):-Math.abs(ball.vy);
damage(i);
return true;
}
}
return false;
}
```
damage:
```js
function damage(i){
const b=bricks[i];
const cx=b.x+b.w/2, cy=b.y+b.h/2;
b.hp--;
if(b.hp<=0){
bricks.splice(i,1);
state.combo=Math.min(state.combo+1,9);
const pts=(b.gold?200:50)*state.combo;
state.score+=pts;
burstHearts(cx,cy,b.gold?36:24,b.gold?'gold':'pink');
rings.push({x:cx,y:cy,r:8,v:300,a:0.55});
pops.push({x:cx,y:cy-6,life:0,max:0.85,text:'+'+pts+(state.combo>2?' x'+state.combo:'')});
state.shake=Math.min(state.shake+(b.gold?8:4.5),13);
updateHUD();
sfx(b.gold?'gold':'pop');
if(bricks.length===0) levelClear();
} else {
b.cracked=true;
state.score+=25; updateHUD();
burstHearts(cx,cy,6,'pink');
state.shake=Math.min(state.shake+2,13);
sfx('tick');
}
}
```
loseLife:
```js
function loseLife(){
state.lives--; state.combo=0; state.shake=11; state.flash=0.6;
updateHUD(); sfx('lose');
burstHearts(ball.x,H-14,10,'pink'); // sad hearts falling? they'd fly up... fine
if(state.lives<=0){ gameOver(); } else resetBall();
}
```
gameOver:
```js
function gameOver(){
overFrom = state.mode; // 'demo' or 'playing'
state.mode='over'; overT=3.2;
finalScoreEl.textContent=state.score;
ovOver.classList.remove('hidden');
}
function toDemo(){
ovOver.classList.add('hidden');
state.mode='demo'; state.score=0;state.lives=3;state.level=1;state.combo=0;
buildLevel(1); resetBall(); updateHUD(); ovDemo.classList.remove('hidden');
}
function startGame(){
ovDemo.classList.add('hidden'); ovOver.classList.add('hidden');
ovClear.classList.add('hidden');
state.mode='playing'; state.score=0;state.lives=3;state.level=1;state.combo=0;
hearts.length=0; pops.length=0;
buildLevel(1); resetBall(); updateHUD();
}
```
levelClear:
```js
function levelClear(){
prevMode=state.mode; state.mode='clear'; clearT=1.9;
state.score+=500; updateHUD();
ovClear.classList.remove('hidden');
ovClear.querySelector('h2').textContent='Level '+state.level+' clear!';
for(let i=0;i<55;i++){
hearts.push({x:rand(20,W-20),y:rand(-260,-10),vx:rand(-40,40),vy:rand(50,130),g:70,rot:rand(0,6.28),vr:rand(-3,3),s:rand(8,16),life:0,max:rand(3,4.5),color:pick(PINKS),sway:rand(0,6.28)});
}
pops.push({x:W/2,y:H/2-40,life:0,max:1.2,text:'+500'});
sfx('clear');
}
```
Note: levelClear called from damage during stepBall; setting mode='clear' stops further ball stepping next frame; current frame continues fine.
Careful: levelClear during demo → prevMode 'demo'. After clear returns to demo, ovDemo stays visible (never hidden) ✓.
resetBall:
```js
function resetBall(){
ball.stuck=true; ball.launchT=0.9; ball.trail=[];
ball.speed=Math.min(340+(state.level-1)*15,500);
ball.x=paddle.x; ball.y=paddle.y-ball.r-1;
}
function launch(){
ball.stuck=false;
const a=rand(-0.35,0.35);
ball.vx=Math.sin(a)*ball.speed;
ball.vy=-Math.cos(a)*ball.speed;
}
```
predictX as designed.
Draw:
```js
function draw(){
ctx.clearRect(0,0,W,H);
// bg
ctx.fillStyle='#fff7ee';
ctx.fillRect(0,0,W,H);
// faint dotted counter line near bottom? draw a subtle horizontal line where paddle sits? skip
// ambient hearts
for(const a of ambient){
ctx.globalAlpha=a.a;
ctx.fillStyle='#f2a7b4';
ctx.save();ctx.translate(a.x,a.y);ctx.rotate(Math.sin(state.time+a.sway)*0.2);heartPath(a.s);ctx.fill();ctx.restore();
}
ctx.globalAlpha=1;
ctx.save();
if(state.shake>0.2) ctx.translate(rand(-1,1)*state.shake*0.8,rand(-1,1)*state.shake*0.8);
drawBricks();
for(const r of rings){ctx.globalAlpha=Math.max(r.a,0);ctx.strokeStyle='#e8455a';ctx.lineWidth=3;ctx.beginPath();ctx.arc(r.x,r.y,r.r,0,6.283);ctx.stroke();}
ctx.globalAlpha=1;
drawHearts();
drawPaddle();
drawBall();
drawPops();
ctx.restore();
if(state.flash>0){ctx.fillStyle='rgba(232,69,94,'+(state.flash*0.18)+')';ctx.fillRect(0,0,W,H);}
}
```
Wait: brick positions vs shake translate — bricks in local coords per brick; I translate to brick center within drawBricks. Fine.
drawBricks:
```js
function drawBricks(){
for(const b of bricks){
if(b.t<=0)continue;
const k=Math.min(1,b.t/0.4), e=1-Math.pow(1-k,3);
const cx=b.x+b.w/2, cy=b.y+b.h/2;
ctx.save();
ctx.globalAlpha=e;
ctx.translate(cx,cy);
ctx.scale(0.6+0.4*e,0.6+0.4*e);
ctx.fillStyle=b.color;
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.fill();
// gloss
ctx.fillStyle='rgba(255,255,255,.38)';
ctx.beginPath();ctx.roundRect(-b.w/2+3,-b.h/2+3,b.w-6,7,4);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.13)';ctx.lineWidth=1.5;
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.stroke();
if(b.gold){
// heart stamp
ctx.fillStyle='rgba(255,255,255,.9)';
heartStamp? ctx.save();ctx.translate(0,1);heartPath(6);ctx.fill();ctx.restore();
// sparkle
const tw=0.5+0.5*Math.sin(state.time*5+b.x);
ctx.strokeStyle='rgba(255,255,255,'+(0.7*tw)+')';
ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(b.w/2-8,-b.h/2+6);ctx.lineTo(b.w/2-2,-b.h/2+6);ctx.moveTo(b.w/2-5,-b.h/2+3);ctx.lineTo(b.w/2-5,-b.h/2+9);ctx.stroke();
}
if(b.cracked){
ctx.fillStyle='rgba(0,0,0,.14)';
ctx.beginPath();ctx.roundRect(-b.w/2,-b.h/2,b.w,b.h,7);ctx.fill();
ctx.strokeStyle='rgba(70,30,20,.5)';ctx.lineWidth=1.4;
ctx.beginPath();
ctx.moveTo(-b.w*0.18,-b.h/2);ctx.lineTo(-b.w*0.05,0);ctx.lineTo(-b.w*0.22,b.h/2);
ctx.moveTo(b.w*0.12,-b.h/2);ctx.lineTo(b.w*0.26,-b.h*0.05);ctx.lineTo(b.w*0.1,b.h/2);
ctx.stroke();
}
ctx.restore();
}
}
```
heartPath at translate(0,1) size 6 — heartPath(s) uses size as roughly half-width*1.05; s=6 gives ~12px wide heart, fits 26px tall brick ✓.
drawHearts:
```js
function drawHearts(){
for(const h of hearts){
const k=h.life/h.max;
const a=1-k*k;
const s=h.s*(1-0.45*k*k);
ctx.save();
ctx.globalAlpha=a;
ctx.translate(h.x,h.y);
ctx.rotate(h.rot);
ctx.fillStyle=h.color;
heartPath(s);
ctx.fill();
// tiny shine
ctx.globalAlpha=a*0.8;
ctx.fillStyle='rgba(255,255,255,.75)';
ctx.beginPath();ctx.ellipse(-s*0.3,-s*0.35,s*0.14,s*0.1,-0.5,0,6.283);ctx.fill();
ctx.restore();
}
}
```
drawPaddle as designed with squash:
```js
function drawPaddle(){
const px=paddle.x, py=paddle.y;
// shadow
ctx.fillStyle='rgba(84,56,44,.10)';
ctx.beginPath();ctx.ellipse(px,py+36,paddle.w*0.42,6,0,0,6.283);ctx.fill();
const q=paddle.squash;
const sx=1+q*0.14, sy=1-q*0.18;
ctx.save();
ctx.translate(px,py+8);ctx.scale(sx,sy);ctx.translate(-px,-(py+8));
const w=paddle.w, left=px-w/2, y=py-16;
// bottom bun
ctx.fillStyle='#eda44f';
ctx.beginPath();ctx.roundRect(left+4,y+26,w-8,13,6);ctx.fill();
// patty
ctx.fillStyle='#6e4227';
ctx.beginPath();ctx.roundRect(left+1,y+19,w-2,12,6);ctx.fill();
// patty texture
ctx.fillStyle='rgba(255,255,255,.08)';
for(let i=0;i<6;i++){ctx.beginPath();ctx.arc(left+14+i*(w-28)/5,y+25,1.6,0,6.283);ctx.fill();}
// cheese
ctx.fillStyle='#ffc84e';
ctx.beginPath();ctx.roundRect(left+5,y+15,w-10,9,4);ctx.fill();
ctx.beginPath();ctx.arc(left+w*0.30,y+24,5,0,6.283);ctx.fill();
ctx.beginPath();ctx.arc(left+w*0.68,y+25,4,0,6.283);ctx.fill();
// lettuce
ctx.fillStyle='#8bc34a';
ctx.beginPath();
ctx.moveTo(left+2,y+12);
ctx.lineTo(left+w-2,y+12);
const n=5,step=(w-4)/n;
for(let i=0;i<n;i++){
const x1=left+w-2-i*step;
ctx.quadraticCurveTo(x1-step*0.5,y+23,x1-step,y+12);
}
ctx.closePath();ctx.fill();
// top bun
ctx.fillStyle='#f5b061';
ctx.beginPath();
ctx.moveTo(left+3,y+13);
ctx.bezierCurveTo(left+3,y-9,left+w-3,y-9,left+w-3,y+13);
ctx.closePath();ctx.fill();
// bun base line
ctx.fillStyle='#e8994a';
ctx.beginPath();ctx.roundRect(left+3,y+11,w-6,4,2);ctx.fill();
// sesame
ctx.fillStyle='rgba(255,250,235,.95)';
const seeds=[[0.16,7,-0.4],[0.30,3,0.3],[0.5,6,0],[0.68,2.5,-0.25],[0.83,7,0.35]];
for(const sd of seeds){
ctx.save();ctx.translate(left+sd[0]*w,y+sd[1]);ctx.rotate(sd[2]);
ctx.beginPath();ctx.ellipse(0,0,3,1.8,0,0,6.283);ctx.fill();ctx.restore();
}
// gloss
ctx.fillStyle='rgba(255,255,255,.30)';
ctx.beginPath();ctx.ellipse(left+w*0.30,y+1,10,3.5,-0.35,0,6.283);ctx.fill();
ctx.restore();
}
```
Hmm lettuce drawn AFTER cheese → lettuce in front covers cheese top — visually lettuce sits above cheese ✓ (draw order: bottom bun, patty, cheese, lettuce, top bun on top). But cheese drips at y+24,24 — the lettuce dips to y+23 overlapping drips. Fine, looks layered.
Wait, lettuce spans y+12..y+23; top bun covers up to y+13 bottom edge... top bun bottom at y+13, so lettuce visible from y+13 to y+23 below bun ✓, cheese at y+15..24 partially hidden behind lettuce — mostly. Order: cheese drawn before lettuce, lettuce covers middle. Cheese drips at x 0.30w and 0.68w may be covered by lettuce dips. OK — layered food look. Maybe draw lettuce before cheese? Classic burger: lettuce under patty or between cheese and bun varies. Visually: lettuce ruffle hanging is nice; cheese drips hanging is nice. To show both, offset them: cheese drips at 0.30 and 0.68; lettuce scallop troughs at different x. The scallops go across whole width though. Fine — draw order: bottom bun, patty, cheese, lettuce, top bun. Good enough; it'll read as a burger.
drawBall:
```js
function drawBall(){
// trail
const n=ball.trail.length;
for(let i=0;i<n;i++){
const t=ball.trail[i], k=(i+1)/n;
ctx.globalAlpha=k*0.22;
ctx.fillStyle='#f27d93';
ctx.beginPath();ctx.arc(t.x,t.y,ball.r*k*0.85,0,6.283);ctx.fill();
}
ctx.globalAlpha=1;
if(ball.stuck&&state.mode!=='demo'? no draw anyway fine)
ctx.save();
ctx.translate(ball.x,ball.y);
// body
ctx.fillStyle='#e94b5f';
ctx.beginPath();ctx.arc(0,0,ball.r,0,6.283);ctx.fill();
// shading
ctx.fillStyle='rgba(0,0,0,.10)';
ctx.beginPath();ctx.arc(2,3,ball.r*0.8,0,6.283);ctx.fill(); // hmm this darkens offset circle — outside body arc? clip to body:
```
Better: draw body, then shine; skip shading or clip:
```js
ctx.save();
ctx.beginPath();ctx.arc(0,0,ball.r,0,6.283);ctx.clip();
ctx.fillStyle='rgba(0,0,0,.08)';
ctx.beginPath();ctx.arc(2.5,3.5,ball.r,0,6.283);ctx.fill();
ctx.restore();
// outline
ctx.strokeStyle='rgba(120,20,35,.35)'; hmm skip.
// shine
ctx.fillStyle='rgba(255,255,255,.6)';
ctx.beginPath();ctx.ellipse(-3.5,-4,3.2,2.2,-0.6,0,6.283);ctx.fill();
// calyx
ctx.fillStyle='#5c9e4b';
ctx.beginPath();
ctx.moveTo(0,-ball.r+3);
ctx.lineTo(-6,-ball.r-2);
ctx.lineTo(-2,-ball.r+1);
ctx.lineTo(-1,-ball.r-4)?
```
Simplify calyx: draw a small 3-point star at top:
```js
ctx.fillStyle='#5fa052';
for(const [dx,dy] of [[-5,-2],[0,-4],[5,-2]]){
ctx.beginPath();
ctx.moveTo(dx*0.4, -ball.r+1);
ctx.lineTo(dx, -ball.r+dy);
ctx.lineTo(dx*0.2+?
```
Easier: three small triangles fanning from center top point (0,-r+2):
```js
const tips=[[-6,-3],[0,-6],[6,-3]];
for(const [tx,ty] of tips){
ctx.beginPath();
ctx.moveTo(-2,-ball.r+2);
ctx.lineTo(tx,-ball.r+ty);
ctx.lineTo(2,-ball.r+2);
ctx.closePath();ctx.fill();
}
// stem
ctx.fillStyle='#4c8a41';
ctx.beginPath();ctx.roundRect(-1.5,-ball.r-6,3,6,1.5);ctx.fill();
```
tips ty: [-3,-6,-3] means above the top. moveTo points at (±2, -r+2), tip at (tx, -r+ty) where ty negative → above. Leaves span above tomato. Good.
drawPops:
```js
ctx.textAlign='center';
for(const p of pops){
const k=p.life/p.max;
ctx.globalAlpha=1-k*k;
ctx.font='700 17px "Chalkboard SE","Comic Sans MS",cursive';
ctx.lineWidth=4;ctx.lineJoin='round';
ctx.strokeStyle='rgba(255,252,245,.95)';
ctx.strokeText(p.text,p.x,p.y);
ctx.fillStyle='#e8455a';
ctx.fillText(p.text,p.x,p.y);
}
ctx.globalAlpha=1;
```
sfx implementation with tones map:
```js
function sfx(name){
if(!actx||actx.state!=='running')return;
switch(name){
case 'wall': tone(300,.05,'sine',.06,260);break;
case 'paddle': tone(170,.09,'sine',.18,320);break;
case 'pop': tone(rand(500,760),.11,'triangle',.16,rand(900,1150));break;
case 'tick': tone(380,.06,'square',.08,300);break;
case 'gold': tone(700,.14,'triangle',.18,1400);tone(1050,.18,'sine',.1,1600);break;
case 'lose': tone(300,.4,'sawtooth',.14,70);break;
case 'clear': tone(523,.12,'triangle',.16,0,0);tone(659,.12,'triangle',.16,0,.12);tone(784,.12,'triangle',.16,0,.24);tone(1047,.25,'triangle',.18,0,.36);break;
}
}
function tone(freq,dur,type,vol,slide,delay){
try{
const t=actx.currentTime+(delay||0);
const o=actx.createOscillator(),g=actx.createGain();
o.type=type;o.frequency.setValueAtTime(freq,t);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,t+dur);
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g);g.connect(actx.destination);
o.start(t);o.stop(t+dur+0.05);
}catch(e){}
}
```
tone signature (freq,dur,type,vol,slide,delay) — sfx clear uses tone(523,.12,'triangle',.16,0,.12) where slide=0 falsy skip ✓.
ensureAudio on gestures. Note: creating AudioContext without gesture → state 'suspended'; sfx checks state==='running' → silent. On first user pointerdown we create+resume. But pointerdown that starts game resumes — good. Chrome: resume within gesture works.
updateHUD:
```js
function updateHUD(){
scoreEl.textContent=state.score.toLocaleString();
levelEl.textContent=state.level;
const hs=livesEl.querySelectorAll('svg');
hs.forEach((el,i)=>el.classList.toggle('lost',i>=state.lives));
}
```
Init:
```js
buildLevel(1);resetBall();updateHUD();
let last=performance.now();
requestAnimationFrame(function frame(ts){
const dt=Math.min((ts-last)/1000,0.033);last=ts;
update(dt);draw();
requestAnimationFrame(frame);
});
```
Also handle window blur → keys reset (avoid stuck keys): window 'blur' → keys.left=keys.right=false.
Ambient init.
Prevent context menu? not needed.
One concern: pointermove moving paddle even in 'clear' — allow playing only. In clear, AI/none. Set condition mode==='playing'||state.mode==='clear'.
Another: when player starts via keydown (e.g., presses ArrowRight in demo) — startGame called and return; next keydowns move. Fine.
Space during playing & stuck → launch. Also prevent scroll ✓.
Mobile: tap starts game; then drag moves paddle via pointermove (touch pointer). But also window pointerdown starts game when tapping during play? No-op ✓. However pointerdown during 'over' restarts — includes taps anywhere.
Also on touch, demo→start via tap, then paddle follows finger only while touching; ok.
Check overlay pointer-events none so canvas receives pointer events? Canvas listeners on canvas element; overlay covers canvas — with pointer-events:none events pass through ✓. But I attached pointermove to canvas — with overlay pointer-events none, canvas gets them ✓.
Actually I'll attach pointermove to window instead, using canvas rect mapping — safer when overlay present. But moving outside canvas shouldn't jump paddle wildly? Map anyway, clamped — fine. Use window pointermove with rect from canvas. But then mouse movement anywhere moves paddle — acceptable, even better UX.
Also mousemove changes paddle only when mode playing (else ignore) so demo AI unaffected ✓.
Text scaling on small screens: canvas text (pops) scales with canvas ✓.
HUD chips: on 375px, header wraps; chips row small.
Hint text below card.
Edge: hearts.length could grow with rain 55 + bursts; fine.
Now CSS details:
```css
:root{
--ink:#54382c;
--paper:#fff9f0;
--accent:#e8455a;
--cream:#f6e9d6;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
min-height:100vh;
display:flex;align-items:center;justify-content:center;
padding:20px 14px;
font-family:'Chalkboard SE','Comic Sans MS','Segoe Print','Marker Felt',cursive;
color:var(--ink);
background-color:#f6e9d6;
background-image:
radial-gradient(#eddcc2 14%, transparent 15%),
radial-gradient(#eddcc2 14%, transparent 15%);
background-size:36px 36px;
background-position:0 0,18px 18px;
-webkit-font-smoothing:antialiased;
}
.stage{width:min(960px,100%);}
.card{background:var(--paper);border:3px solid var(--ink);border-radius:20px;box-shadow:0 10px 0 rgba(84,56,44,.16);overflow:hidden;}
.checker{height:16px;background:repeating-conic-gradient(var(--accent) 0 25%, #fff3e4 0 50%);background-size:18px 18px;border-bottom:3px solid var(--ink);}
header.top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px 10px;flex-wrap:wrap;}
.brand{display:flex;align-items:center;gap:12px;}
.logo{width:34px;height:34px;fill:var(--accent);flex:none;filter:drop-shadow(0 2px 0 rgba(84,56,44,.15));}
h1{font-size:clamp(22px,3.6vw,34px);line-height:1;letter-spacing:.5px;}
h1 em{font-style:normal;color:var(--accent);}
.tag{font-size:13px;opacity:.62;margin-top:4px;}
.hud{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.chip{background:#fff;border:2px solid rgba(84,56,44,.25);border-radius:999px;padding:6px 14px;font-size:13px;text-transform:uppercase;letter-spacing:.6px;display:flex;align-items:center;gap:7px;}
.chip b{font-size:17px;color:var(--accent);min-width:2ch;text-align:right}
.lives{display:flex;align-items:center;gap:3px;background:#fff;border:2px solid rgba(84,56,44,.25);border-radius:999px;padding:6px 12px;}
.lives svg{width:20px;height:20px;fill:var(--accent);transition:transform .25s, opacity .25s, fill .25s;}
.lives svg.lost{fill:#e9d6c6;opacity:.55;transform:scale(.85)}
.frame{position:relative;}
canvas{display:block;width:100%;height:auto;touch-action:none;cursor:none? }
```
cursor:none on canvas hides pointer while playing — could be annoying; use `cursor:crosshair`? Keep default. Actually hide cursor over canvas feels game-like; but user might get confused. Use cursor:none only during playing? Skip — default.
Overlays:
```css
.overlay{position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;padding-bottom:9%;pointer-events:none;}
#ovOver{align-items:center;padding-bottom:0;}
.pill{background:var(--paper);border:3px solid var(--ink);border-radius:999px;padding:12px 26px;text-align:center;font-size:clamp(15px,2.2vw,19px);box-shadow:0 6px 0 rgba(84,56,44,.18);animation:bob 1.5s ease-in-out infinite;}
.pill small{display:block;font-size:12px;opacity:.6;margin-top:3px;}
@keyframes bob{50%{transform:translateY(-8px)}}
.card-over{background:var(--paper);border:3px solid var(--ink);border-radius:18px;padding:26px 40px;text-align:center;box-shadow:0 8px 0 rgba(84,56,44,.18);animation:pop-in .3s cubic-bezier(.2,1.6,.4,1) both;}
.card-over h2{font-size:clamp(22px,3.4vw,30px);color:var(--accent);margin-bottom:8px;}
.card-over p{font-size:15px;margin-bottom:6px}
.card-over b{color:var(--accent);font-size:18px}
.card-over .again{opacity:.6;font-size:13px;margin-top:10px}
@keyframes pop-in{from{transform:scale(.6);opacity:0}}
.banner{position:absolute;top:26%;left:50%;transform:translateX(-50%);background:var(--paper);border:3px solid var(--ink);border-radius:16px;padding:16px 34px;text-align:center;pointer-events:none;box-shadow:0 8px 0 rgba(84,56,44,.18);animation:pop-in .3s cubic-bezier(.2,1.6,.4,1) both;}
.banner h2{color:var(--accent);font-size:clamp(20px,3vw,28px)}
.banner p{font-size:14px;opacity:.65;margin-top:4px}
.hidden{display:none!important}
.hint{text-align:center;font-size:13px;opacity:.55;margin-top:12px}
```
Checker strip: repeating-conic-gradient support fine in Chrome.
repeating-conic-gradient syntax: `repeating-conic-gradient(var(--accent) 0 25%, #fff3e4 0 50%)` — yes.
Frame background is canvas-drawn cream #fff7ee — card paper is #fff9f0; close but distinct border between header and canvas needed: add border-top on .frame? The canvas has its own drawn bg slightly different tone + I'll draw a thin top rail. Add `border-top:3px solid rgba(84,56,44,.15)`? Actually header sits on paper; canvas below—separation via 2px dashed? Add .frame{border-top:3px solid var(--ink)}? Too heavy. Use rgba(84,56,44,.2). OK.
Wait — checker has border-bottom 3px solid ink; header below; then frame. Between header and canvas: subtle. Add margin? Canvas draws its own bg color #fff7ee (slightly warmer than paper) — plus I'll draw a top border line inside canvas: stroke line at y=1 rgba ink .1. Eh, .frame{border-top:3px solid rgba(84,56,44,.15)} good.
Now, double-check the ball-paddle collision when paddle squash etc: fine.
Demo AI imperfect: with predict + exact tracking it rarely misses; speed cap 560 vs ball up to 620 — at high speeds it may miss occasionally. Lives 3, game over in demo auto-restarts. OK.
But demo: level 1 speed 340 — AI basically perfect → satisfying heart explosions constantly.
One more check: state.combo resets on paddle hit AND on life loss. Score popups show combo text.
Check startGame during 'clear' mode: clicking during clear? window pointerdown: only if demo or over — clear mode click ignored ✓ (mode==='clear' not matched).
But keydown during 'clear': `if(state.mode==='demo'||state.mode==='over')` → not matched, then arrow flags set; paddle moves only in 'playing' — during 'clear' movement ignored... after clear ends mode returns to playing ✓. pointermove sets paddle only playing/clear — ok.
Ball stuck drawing during clear? mode 'clear' skips stepBall → ball frozen midair. Better: hide ball during clear (it just cleared bricks; ball somewhere). Draw ball only when mode demo/playing/over? On 'over', ball was lost — don't draw. Draw ball if mode==='demo'||mode==='playing'. ✓. Trail likewise inside drawBall.
Also during 'clear', prevMode demo: ovDemo pill visible — fine, still says click to start. Good.
overFrom/overT handling in update: only when mode==='over'&&overFrom==='demo'. If user played and lost, waits for input ✓.
toDemo shows ovDemo; startGame hides both.
Initial: ovDemo visible (default in HTML, no hidden class).
Final check on `predictX` math:
```js
function predictX(){
const ty=paddle.y-ball.r;
if(ball.vy<=0)return ball.x;
const t=(ty-ball.y)/ball.vy;
let px=ball.x+ball.vx*t;
const lo=ball.r, span=W-2*ball.r;
let m=(px-lo)%(2*span); if(m<0)m+=2*span;
return lo+(m>span?2*span-m:m);
}
```
✓.
HUD score element id="score" etc.
Lives svgs: three identical inline svgs with path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z".
Title h1: "Heartbreak Diner" with em on "Diner".
The reply: single code block with full HTML. Let me assemble now, watching for typos and undeclared vars.
Variables: W,H,canvas,ctx; DOM els; state; paddle; ball; bricks=[]; hearts=[]; rings=[]; pops=[]; ambient=[]; keys={left:false,right:false}; actx=null; clearT=0; prevMode='demo'; overFrom='demo'; overT=0.
PINKS, GOLDS, ROWCOLORS, HEART_MASK.
One subtlety: `ctx.font` includes cursive fallback list with quotes — in canvas font string, need quotes around font names with spaces: `'700 17px "Chalkboard SE","Comic Sans MS",cursive'` ✓.
heartPath uses ctx global ✓.
Also textAlign/baseline: set textBaseline='middle' for pops.
draw() ambient uses ctx.save/restore around heartPath fill — I wrote translate/rotate then heartPath(s); fill inside save/restore ✓.
Edge: `ctx.roundRect` then fill — native roundRect adds subpath; must beginPath first — I do.
Patty texture loop: `left+14+i*(w-28)/5` fine.
For gold sparkle I referenced b.w/2-8 etc — within translated local coords where center is origin: -b.w/2..b.w/2 ✓ my code uses b.w/2-8 positive right side ✓.
drawBricks entrance: b.t increments only `if(b.t<0.5)` — after 0.5 stops growing ✓; collision guard b.t<0.15 skip.
Init bricks t negative: -(r*0.05+c*0.02) so appear staggered ✓. But update loop: `for(const b of bricks) if(b.t<0.5) b.t+=dt;` — b.t<0.5 includes negatives ✓.
hitBricks skip if b.t<0.15 ✓.
Level clear when bricks.length===0 — bricks exist immediately (not staggered-removed) ✓.
loseLife called within substep loop; after return from stepBall (we return) ✓.
burstHearts in bouncePaddle: small hearts:
```js
for(let i=0;i<3;i++){
hearts.push({x:ball.x+rand(-8,8),y:ball.y,vx:rand(-70,70),vy:rand(-160,-60),g:560,rot:rand(0,6.28),vr:rand(-6,6),s:rand(4,6),life:0,max:rand(.5,.8),color:pick(PINKS),sway:rand(0,6.28)});
}
```
drawBall only when demo/playing — but 'over' hides; also stuck ball visible waiting ✓.
In 'over' mode, paddle still drawn ✓, hearts finish animating ✓.
update in 'over': paddle AI not run (mode over) ✓; ambient & hearts continue ✓.
Potential issue: after startGame from demo, hearts from demo cleared — I clear hearts/pops in startGame ✓.
Ball.speed growth: +5 per paddle bounce; over a level could reach cap 620. resetBall resets to base each life/level. Fine.
Hmm the ball also speeds per brick? Not needed.
Score combo max 9 → 50*9=450 per late brick — generous, fun.
Check updateHUD called at init and on changes ✓.
Now the overlay for demo — position: .overlay has inset:0 within .frame (position:relative) ✓.
Banner hidden default ✓.
The game over card `#ovOver` overlay display flex align center ✓.
Add `user-select:none` on body to avoid text selection while playing. And `-webkit-user-select`.
Also `overflow` guard: body overflow-x hidden? width min() prevents overflow; safe to add `overflow-x:hidden` on body.
Viewport height: at 1280×800, stage 960 wide, canvas 600 tall + header ~90 + padding → ~750 fits ✓. On shorter screens body scrolls vertically — allowed (no horizontal). align-items:center with min-height could clip top when overflow — use `align-items:center` on body with margin auto pattern: body{display:flex} .stage{margin:auto} — same. It's fine.
Mobile 375: card width ~347, canvas height ~217px tall — small but playable; overlays scale via clamp ✓. Header wraps: brand + hud stack. Font sizes clamp.
Hmm 375 width: 960/600 ratio → canvas height ≈ 347*0.625 = 217. Game tiny but acceptable. Could increase min height? Keep.
Let me also draw inside canvas a subtle bottom "counter" area: horizontal line at y=H-14 pink dashed like diner counter? A scalloped edge? Draw a soft line: ctx.strokeStyle 'rgba(84,56,44,.12)' dashed at H-16. Hearts bounce at H-6. Add: cute.
Also maybe ambient hearts slight pink tone ✓ done.
Sound check: sfx('wall') triggers many times on rapid bounces — fine.
Now finalize HTML. Also `<meta name="viewport">` ✓.
Double-check no emoji anywhere in text ✓ (hint uses "← →" arrows — those are fine, they're not emoji).
Write final code block now. Also add `aria-hidden` on decorative svgs.
One more: pointermove handler:
```js
window.addEventListener('pointermove',e=>{
if(state.mode!=='playing'&&state.mode!=='clear')return;
const r=canvas.getBoundingClientRect();
if(r.width===0)return;
const mx=(e.clientX-r.left)/r.width*W;
if(state.mode==='playing') paddle.x=clamp(mx,paddle.w/2+8,W-paddle.w/2-8);
});
```
During clear ignore movement (mode check inside). Simplify: only playing.
Also during 'clear' with prevMode playing, after resume user regains control ✓.
keydown 'a'/'d' also. keyup:
```js
window.addEventListener('keyup',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A')keys.left=false;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D')keys.right=false;
});
```
Also handle 'A' uppercase in keydown.
I think we're ready. Write it all out carefully.
Actually one more check on drawPaddle squash transform: translate(px,py+8); scale(sx,sy); translate(-px,-(py+8)) — scaling about point (px,py+8) ✓.
drawBall trail before body ✓.
For the stuck ball waiting: draw small bounce hint? draw upward arrow above ball pulsing? Skip; auto-launch is 0.9s.
Also during demo, before ball launch, pill visible — fine.
Level clear banner text set via JS querySelector('h2') — define ids simpler: id="clearTitle".
Let me also ensure `ovClear` z-index above canvas — it's in DOM after canvas inside frame, absolute — ✓.
Final code assembly.