Hamburger breakoutOne-line
DeepSeek V4.1 Flash 1017
by DeepSeekReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 37 s
- Cost
- $0.030
- Output tokens
- 41,985
- Spent thinking
- 33,022 tokens
- Speed
- 268.4 tok/s
- Console errors
- 1
- First token after
- 730 ms
- First visible text after
- 2 min 12 s
- Prompt tokens
- 385
- Code size
- 22.6 KB
- Finish reason
- stop
- Served by
- DigitalOcean
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"high"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
Failed to load resource: the server responded with a status of 404 (Not Found)
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 · 798 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Hamburger Breakout</title>
<style>
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#07060c;}
body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;}
#game{display:block;position:fixed;inset:0;width:100%;height:100%;touch-action:none;}
#overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;padding:18px;}
#overlay.hidden{display:none;}
.card{
text-align:center;padding:20px 28px;border-radius:22px;
background:rgba(18,11,28,0.62);
border:1px solid rgba(255,255,255,0.14);
-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);
box-shadow:0 20px 60px rgba(0,0,0,0.6), 0 0 70px rgba(255,77,109,0.20);
max-width:min(92vw,560px);
}
.card h1{margin:0 0 8px;font-size:clamp(17px,3.2vw,30px);font-weight:800;letter-spacing:0.3px;color:#fff;text-shadow:0 2px 24px rgba(255,77,109,0.55);}
#ov-msg{margin:0;font-size:clamp(13px,1.9vw,17px);font-weight:700;color:#ffd9e2;animation:pulse 1.7s ease-in-out infinite;}
#ov-hint{margin:8px 0 0;font-size:clamp(11px,1.4vw,13px);color:rgba(255,255,255,0.5);letter-spacing:0.4px;}
@keyframes pulse{0%,100%{opacity:.55;}50%{opacity:1;}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="overlay">
<div class="card">
<h1 id="ov-title">🍔 Hamburger Breakout</h1>
<p id="ov-msg">Click or press a key to start</p>
<p id="ov-hint">Demo mode — the AI is playing</p>
</div>
</div>
<script>
(function(){
'use strict';
/* ---------------- helpers ---------------- */
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const rand = (a,b)=> a + Math.random()*(b-a);
/* ---------------- world sizing ---------------- */
const vw0 = window.innerWidth || 1280;
const vh0 = window.innerHeight || 800;
const ar = clamp(vw0/vh0, 0.55, 2.05);
const AREA = 900*620;
const W = Math.round(Math.sqrt(AREA*ar));
const H = Math.round(Math.sqrt(AREA/ar));
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ov-title');
const ovMsg = document.getElementById('ov-msg');
const ovHint = document.getElementById('ov-hint');
const PLAY_LEFT = W*0.014;
const PLAY_RIGHT = W - W*0.014;
const PLAY_TOP = H*0.018;
let dpr=1, scale=1, ox=0, oy=0, cssW=vw0, cssH=vh0;
function resize(){
dpr = Math.min(window.devicePixelRatio||1, 2);
cssW = window.innerWidth; cssH = window.innerHeight;
canvas.width = Math.round(cssW*dpr);
canvas.height = Math.round(cssH*dpr);
canvas.style.width = cssW+'px';
canvas.style.height = cssH+'px';
scale = Math.min(cssW/W, cssH/H);
ox = (cssW - W*scale)/2;
oy = (cssH - H*scale)/2;
}
window.addEventListener('resize', resize);
resize();
/* ---------------- cached gradients ---------------- */
const bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#1b1132');
bgGrad.addColorStop(0.55,'#120c22');
bgGrad.addColorStop(1,'#210e20');
const glowGrad = ctx.createRadialGradient(W*0.5, H*1.05, H*0.04, W*0.5, H*1.05, H*0.95);
glowGrad.addColorStop(0,'rgba(255,140,80,0.24)');
glowGrad.addColorStop(1,'rgba(255,140,80,0)');
/* ---------------- heart path ---------------- */
const heartPath = new Path2D();
heartPath.moveTo(0, 7.5);
heartPath.bezierCurveTo(-11, -1.5, -7, -13, 0, -5.5);
heartPath.bezierCurveTo(7, -13, 11, -1.5, 0, 7.5);
heartPath.closePath();
function drawHeart(x, y, size, rot, color, alpha){
const k = size/22;
ctx.save();
ctx.translate(x, y);
if (rot) ctx.rotate(rot);
ctx.scale(k, k);
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fill(heartPath);
ctx.restore();
}
function roundRectPath(c, x, y, w, h, r){
r = Math.min(r, Math.abs(w)/2, Math.abs(h)/2);
c.beginPath();
c.moveTo(x+r, y);
c.lineTo(x+w-r, y);
c.quadraticCurveTo(x+w, y, x+w, y+r);
c.lineTo(x+w, y+h-r);
c.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
c.lineTo(x+r, y+h);
c.quadraticCurveTo(x, y+h, x, y+h-r);
c.lineTo(x, y+r);
c.quadraticCurveTo(x, y, x+r, y);
c.closePath();
}
function lighten(hex, amt){
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
r = Math.round(r + (255-r)*amt);
g = Math.round(g + (255-g)*amt);
b = Math.round(b + (255-b)*amt);
return 'rgb('+r+','+g+','+b+')';
}
/* ---------------- game state ---------------- */
const paddle = { x: W/2, y: H*0.8, w: 130, h: 50, vx: 0, tilt: 0 };
const ball = { x: W/2, y: H*0.8, vx: 0, vy: 0, r: 8, speed: 340, held: true, holdT: 0, trail: [] };
let bricks = [];
let brickBottom = 0;
let parts = [];
let rings = [];
let score = 0, lives = 3, level = 1;
let mode = 'demo'; // 'demo' | 'play' | 'over'
let shake = 0, time = 0, overTimer = 0, levelFlash = 0;
let baseSpeed = 360;
let pointerX = W/2, pointerActive = false;
const keys = Object.create(null);
const PALETTE = ['#ff4d6d','#ff7a45','#ffc233','#8ee85b','#4cc9f0','#b388ff'];
const HEART_COLORS = ['#ff4d6d','#ff85a1','#ff2e63','#ff9ebb','#ffd1dc','#ff6b9d','#ffb3c1'];
/* ---------------- layout ---------------- */
function layout(){
paddle.w = clamp(W*0.17, 92, 172);
paddle.h = clamp(paddle.w*0.34, 32, 62);
paddle.y = H - Math.max(36, H*0.072) - paddle.h;
ball.r = clamp(H*0.0145, 6.5, 13);
baseSpeed = H*0.60;
}
layout();
/* ---------------- bricks ---------------- */
const COLS = 9, ROWS = 6;
function makeBricks(){
bricks = [];
const padX = Math.max(16, W*0.05);
const gap = Math.max(6, W*0.0085);
const bw = (W - padX*2 - gap*(COLS-1))/COLS;
const bh = clamp(H*0.048, 18, 34);
const top = H*0.145;
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
const x = padX + c*(bw+gap);
const y = top + r*(bh+gap);
const col = PALETTE[r % PALETTE.length];
const g = ctx.createLinearGradient(0, y, 0, y+bh);
g.addColorStop(0, lighten(col, 0.42));
g.addColorStop(1, col);
bricks.push({ x, y, w: bw, h: bh, color: col, alive: true, grad: g });
}
}
brickBottom = top + ROWS*(bh+gap);
}
/* ---------------- particles ---------------- */
function addHeartBurst(x, y, count, power){
for (let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const sp = rand(0.25, 1.15)*power;
parts.push({
k:0, x, y,
vx: Math.cos(a)*sp,
vy: Math.sin(a)*sp*0.85 - power*0.55,
g: power*2.6,
s: power*0.042*rand(0.55,1.4),
rot: rand(-0.8,0.8),
vr: rand(-7,7),
life: 0,
max: rand(0.85, 1.75),
c: HEART_COLORS[(Math.random()*HEART_COLORS.length)|0]
});
}
if (parts.length > 900) parts.splice(0, parts.length-900);
}
function addSparks(x, y, count, power){
for (let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const sp = rand(0.3,1.2)*power;
parts.push({
k:1, x, y,
vx: Math.cos(a)*sp,
vy: Math.sin(a)*sp - power*0.3,
g: power*1.2,
s: power*0.014*rand(0.6,1.5),
life: 0,
max: rand(0.35,0.8),
c: Math.random()<0.5 ? '#ffe9a8' : '#ffffff'
});
}
if (parts.length > 900) parts.splice(0, parts.length-900);
}
function addRing(x, y, maxR, color){
rings.push({ x, y, max: maxR, life: 0, dur: 0.5, color });
}
function updateParts(dt){
for (let i=parts.length-1;i>=0;i--){
const p = parts[i];
p.life += dt;
if (p.life >= p.max){ parts.splice(i,1); continue; }
p.vy += p.g*dt;
p.vx -= p.vx*1.9*dt;
p.vy -= p.vy*0.4*dt;
p.x += p.vx*dt;
p.y += p.vy*dt;
if (p.k === 0) p.rot += p.vr*dt;
}
}
function updateRings(dt){
for (let i=rings.length-1;i>=0;i--){
rings[i].life += dt;
if (rings[i].life > rings[i].dur) rings.splice(i,1);
}
}
/* ---------------- ball ---------------- */
function resetBall(){
ball.held = true;
ball.holdT = 0;
ball.speed = Math.min(baseSpeed*(1 + 0.08*(level-1)), baseSpeed*1.8);
ball.trail.length = 0;
ball.x = paddle.x;
ball.y = paddle.y - ball.r - 2;
ball.vx = 0;
ball.vy = 0;
}
function launch(){
ball.held = false;
ball.holdT = 0;
const sp = ball.speed || baseSpeed;
const a = rand(-0.45, 0.45);
ball.vx = Math.sin(a)*sp;
ball.vy = -Math.cos(a)*sp;
}
function hitPaddle(){
ball.y = paddle.y - ball.r - 0.5;
const off = clamp((ball.x - paddle.x)/(paddle.w/2), -1, 1);
const angle = off * 1.05;
const sp = Math.hypot(ball.vx, ball.vy) || baseSpeed;
ball.vx = Math.sin(angle)*sp + paddle.vx*0.12;
ball.vy = -Math.abs(Math.cos(angle)*sp);
const ns = Math.hypot(ball.vx, ball.vy) || 1;
ball.vx = ball.vx/ns*sp;
ball.vy = ball.vy/ns*sp;
if (ball.vy > -sp*0.35) ball.vy = -sp*0.35;
const ns2 = Math.hypot(ball.vx, ball.vy) || 1;
ball.vx = ball.vx/ns2*sp;
ball.vy = ball.vy/ns2*sp;
addHeartBurst(ball.x, paddle.y, 4, H*0.22);
shake = Math.min(shake + 2, 12);
}
function breakBrick(b){
b.alive = false;
score += 10;
const cx = b.x + b.w/2, cy = b.y + b.h/2;
const power = H*0.52;
addHeartBurst(cx, cy, 15 + ((Math.random()*5)|0), power);
addSparks(cx, cy, 6, power*0.9);
addRing(cx, cy, Math.max(b.w, b.h)*1.5, 'rgba(255,180,210,1)');
shake = Math.min(shake + 3.5, 12);
}
function ballLost(){
addHeartBurst(ball.x, H*0.98, 9, H*0.4);
shake = Math.min(shake + 6, 12);
if (mode === 'play'){
lives--;
if (lives <= 0){ gameOver(); return; }
}
resetBall();
}
const PADDLE_GRAB = 6;
function moveBall(dt){
const total = Math.hypot(ball.vx, ball.vy)*dt;
const steps = Math.max(1, Math.ceil(total/(ball.r*0.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 < PLAY_LEFT){
ball.x = PLAY_LEFT + ball.r; ball.vx = Math.abs(ball.vx);
addSparks(ball.x, ball.y, 2, H*0.16);
} else if (ball.x + ball.r > PLAY_RIGHT){
ball.x = PLAY_RIGHT - ball.r; ball.vx = -Math.abs(ball.vx);
addSparks(ball.x, ball.y, 2, H*0.16);
}
if (ball.y - ball.r < PLAY_TOP){
ball.y = PLAY_TOP + ball.r; ball.vy = Math.abs(ball.vy);
addSparks(ball.x, ball.y, 2, H*0.16);
}
// paddle
if (ball.vy > 0 &&
ball.y + ball.r >= paddle.y &&
ball.y - ball.r <= paddle.y + paddle.h &&
ball.x >= paddle.x - paddle.w/2 - ball.r &&
ball.x <= paddle.x + paddle.w/2 + ball.r){
hitPaddle();
}
// bricks
for (let i=0;i<bricks.length;i++){
const b = bricks[i];
if (!b.alive) continue;
if (ball.x + ball.r < b.x || ball.x - ball.r > b.x+b.w ||
ball.y + ball.r < b.y || ball.y - ball.r > b.y+b.h) continue;
const bcx = b.x + b.w/2, bcy = b.y + b.h/2;
const ovx = (b.w/2 + ball.r) - Math.abs(ball.x - bcx);
const ovy = (b.h/2 + ball.r) - Math.abs(ball.y - bcy);
if (ovx < ovy){
ball.x += (ball.x < bcx ? -ovx : ovx);
ball.vx = -ball.vx;
} else {
ball.y += (ball.y < bcy ? -ovy : ovy);
ball.vy = -ball.vy;
}
breakBrick(b);
break;
}
}
if (ball.y - ball.r > H + 30) ballLost();
}
/* ---------------- AI / player control ---------------- */
function aiMove(dt){
let target = paddle.x;
if (!ball.held && ball.vy > 0){
let px = ball.x, py = ball.y, vx = ball.vx, vy = ball.vy;
const yTarget = paddle.y - ball.r;
let guard = 0;
while (py < yTarget && guard < 700){
const st = 0.012;
px += vx*st; py += vy*st;
if (px < PLAY_LEFT + ball.r){
px = (PLAY_LEFT + ball.r)*2 - px; vx = -vx;
} else if (px > PLAY_RIGHT - ball.r){
px = (PLAY_RIGHT - ball.r)*2 - px; vx = -vx;
}
guard++;
}
target = px;
} else if (!ball.held){
target = ball.x + ball.vx*0.10;
} else {
target = ball.x;
}
target += Math.sin(time*2.3)*W*0.012;
const maxV = W*1.5;
const d = target - paddle.x;
paddle.x += clamp(d, -maxV*dt, maxV*dt);
}
function playerMove(dt){
let kx = 0;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) kx -= 1;
if (keys['ArrowRight']|| keys['d'] || keys['D']) kx += 1;
if (kx !== 0){
pointerActive = false;
paddle.x += kx * W*1.15 * dt;
} else if (pointerActive){
paddle.x += (pointerX - paddle.x) * Math.min(1, dt*22);
}
}
/* ---------------- flow ---------------- */
function resetGame(){
score = 0; lives = 3; level = 1;
parts = []; rings = [];
shake = 0; levelFlash = 0;
paddle.x = W/2;
paddle.vx = 0;
pointerX = W/2;
makeBricks();
resetBall();
}
function startGame(){
mode = 'play';
resetGame();
overlay.classList.add('hidden');
}
function toDemo(){
mode = 'demo';
resetGame();
ovTitle.textContent = '🍔 Hamburger Breakout';
ovMsg.textContent = 'Click or press a key to start';
ovHint.textContent = 'Demo mode — the AI is playing';
overlay.classList.remove('hidden');
}
function gameOver(){
mode = 'over';
overTimer = 3.0;
ovTitle.textContent = 'Game Over';
ovMsg.textContent = 'Score ' + score + ' — click or press a key to play again';
ovHint.textContent = 'A fresh demo starts in a moment…';
overlay.classList.remove('hidden');
resetBall();
}
function checkLevel(){
let any = false;
for (let i=0;i<bricks.length;i++){ if (bricks[i].alive){ any = true; break; } }
if (any) return;
level++;
makeBricks();
ball.speed = Math.min(baseSpeed*(1 + 0.08*(level-1)), baseSpeed*1.8);
const sp = Math.hypot(ball.vx, ball.vy) || ball.speed;
if (sp > 0){
ball.vx = ball.vx/sp*ball.speed;
ball.vy = ball.vy/sp*ball.speed;
}
if (ball.y < brickBottom + ball.r){
ball.y = brickBottom + ball.r + 2;
ball.vy = Math.abs(ball.vy) || ball.speed;
}
levelFlash = 1.4;
addRing(W/2, H*0.45, W*0.45, 'rgba(255,220,150,1)');
}
/* ---------------- update ---------------- */
function update(dt){
time += dt;
if (shake > 0) shake = Math.max(0, shake - dt*45);
if (levelFlash > 0) levelFlash = Math.max(0, levelFlash - dt*0.75);
const prevX = paddle.x;
if (mode === 'play') playerMove(dt); else aiMove(dt);
paddle.x = clamp(paddle.x, PLAY_LEFT + paddle.w/2, PLAY_RIGHT - paddle.w/2);
paddle.vx = (paddle.x - prevX)/Math.max(dt, 0.0001);
paddle.tilt += (clamp(paddle.vx*0.00035, -0.12, 0.12) - paddle.tilt)*Math.min(1, dt*12);
if (ball.held){
ball.holdT += dt;
ball.x = paddle.x;
ball.y = paddle.y - ball.r - 2;
if (ball.holdT > (mode === 'play' ? 0.55 : 0.65)) launch();
} else {
moveBall(dt);
ball.trail.push({ x: ball.x, y: ball.y });
if (ball.trail.length > 12) ball.trail.shift();
checkLevel();
}
updateParts(dt);
updateRings(dt);
if (mode === 'over'){
overTimer -= dt;
if (overTimer <= 0) toDemo();
}
}
/* ---------------- drawing ---------------- */
function drawBurger(cx, topY, w, h, tilt){
ctx.save();
ctx.translate(cx, topY);
ctx.rotate(tilt);
const half = w/2;
const xL = -half + 1, xR = half - 1;
let g;
// ---- bottom bun
const bbY = h*0.72, bbH = h*0.28, rr = Math.min(bbH*0.9, half*0.6);
roundRectPath(ctx, xL, bbY, xR-xL, bbH, rr);
g = ctx.createLinearGradient(0, bbY, 0, bbY+bbH);
g.addColorStop(0, '#f6bd63');
g.addColorStop(1, '#bd7c2c');
ctx.fillStyle = g;
ctx.fill();
// ---- patty
const py = h*0.56, ph = h*0.24;
roundRectPath(ctx, xL+2, py, (xR-xL)-4, ph, ph*0.5);
g = ctx.createLinearGradient(0, py, 0, py+ph);
g.addColorStop(0, '#8d5531');
g.addColorStop(0.5, '#6b3a1f');
g.addColorStop(1, '#42200e');
ctx.fillStyle = g;
ctx.fill();
// ---- cheese (drips then slice)
const cy = h*0.455, chh = h*0.16;
ctx.fillStyle = '#ffc93c';
for (let i=0;i<4;i++){
const dx = xL + (i+0.5)*((xR-xL)/4);
ctx.beginPath();
ctx.arc(dx, cy + chh + h*0.015, h*0.075, 0, Math.PI*2);
ctx.fill();
}
roundRectPath(ctx, xL-2, cy, (xR-xL)+4, chh, Math.min(5, chh*0.4));
g = ctx.createLinearGradient(0, cy, 0, cy+chh);
g.addColorStop(0, '#ffe680');
g.addColorStop(1, '#f3ac16');
ctx.fillStyle = g;
ctx.fill();
// ---- top bun dome
const tbY = h*0.445;
const k = tbY/0.75;
ctx.beginPath();
ctx.moveTo(xL+2, tbY);
ctx.bezierCurveTo(xL+2, -k, xR-2, -k, xR-2, tbY);
ctx.quadraticCurveTo(xR-2, tbY+2, xR-8, tbY+2);
ctx.lineTo(xL+8, tbY+2);
ctx.quadraticCurveTo(xL+2, tbY+2, xL+2, tbY);
ctx.closePath();
g = ctx.createLinearGradient(0, 0, 0, tbY);
g.addColorStop(0, '#ffd692');
g.addColorStop(1, '#d9963a');
ctx.fillStyle = g;
ctx.fill();
// ---- sesame seeds
ctx.fillStyle = 'rgba(255,247,222,0.95)';
const seeds = [[-0.46,0.34],[-0.14,0.17],[0.20,0.18],[0.52,0.36],[-0.74,0.56],[0.74,0.58],[0.03,0.44]];
for (let i=0;i<seeds.length;i++){
const sx = seeds[i][0], sy = seeds[i][1];
ctx.save();
ctx.translate(sx*half, sy*tbY);
ctx.rotate(sx*0.7);
ctx.beginPath();
ctx.ellipse(0, 0, Math.max(1.4, w*0.032), Math.max(0.9, w*0.017), 0, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
// ---- lettuce
const lTop = h*0.365, lBot = h*0.525;
const n = 7, segW = (xR-xL)/n;
ctx.beginPath();
ctx.moveTo(xL, lTop);
ctx.lineTo(xR, lTop);
ctx.lineTo(xR, lBot);
for (let i=0;i<n;i++){
const xa = xR - i*segW;
const xb = xR - (i+1)*segW;
ctx.quadraticCurveTo((xa+xb)/2, lBot + h*0.14, xb, lBot);
}
ctx.closePath();
g = ctx.createLinearGradient(0, lTop, 0, lBot + h*0.14);
g.addColorStop(0, '#a2e874');
g.addColorStop(1, '#3f9a2b');
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
}
function drawPaddle(){
const gx = paddle.x, gy = paddle.y + paddle.h*0.55;
const gl = ctx.createRadialGradient(gx, gy, 2, gx, gy, paddle.w*0.85);
gl.addColorStop(0, 'rgba(255,170,90,0.22)');
gl.addColorStop(1, 'rgba(255,170,90,0)');
ctx.fillStyle = gl;
ctx.fillRect(gx - paddle.w, gy - paddle.h*1.4, paddle.w*2, paddle.h*2.8);
drawBurger(paddle.x, paddle.y, paddle.w, paddle.h, paddle.tilt);
}
function drawBricks(){
for (let i=0;i<bricks.length;i++){
const b = bricks[i];
if (!b.alive) continue;
roundRectPath(ctx, b.x, b.y, b.w, b.h, Math.min(8, b.h*0.32));
ctx.fillStyle = b.grad;
ctx.fill();
ctx.save();
roundRectPath(ctx, b.x, b.y, b.w, b.h, Math.min(8, b.h*0.32));
ctx.clip();
ctx.globalAlpha = 0.22;
ctx.fillStyle = '#ffffff';
roundRectPath(ctx, b.x + b.w*0.07, b.y + b.h*0.10, b.w*0.86, b.h*0.30, b.h*0.15);
ctx.fill();
ctx.globalAlpha = 1;
drawHeart(b.x + b.w/2, b.y + b.h*0.56, b.h*0.6, 0, '#ffffff', 0.34);
ctx.restore();
}
}
function drawParticles(){
for (let i=0;i<parts.length;i++){
const p = parts[i];
const t = p.life/p.max;
let a = t < 0.12 ? t/0.12 : 1 - (t-0.12)/0.88;
if (a < 0) a = 0;
if (p.k === 0){
drawHeart(p.x, p.y, p.s*(1 - t*0.25), p.rot, p.c, a);
} else {
ctx.globalAlpha = a*0.9;
ctx.fillStyle = p.c;
ctx.beginPath();
ctx.arc(p.x, p.y, p.s*(1-t*0.5), 0, Math.PI*2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
function drawRings(){
for (let i=0;i<rings.length;i++){
const r = rings[i];
const t = r.life/r.dur;
const rad = 4 + (r.max-4)*(1 - Math.pow(1-t, 2));
ctx.globalAlpha = (1-t)*0.5;
ctx.strokeStyle = r.color;
ctx.lineWidth = Math.max(1.2, H*0.006)*(1-t);
ctx.beginPath();
ctx.arc(r.x, r.y, rad, 0, Math.PI*2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function drawBallAndTrail(){
const r = ball.r;
for (let i=0;i<ball.trail.length;i++){
const t = ball.trail[i];
const f = (i+1)/ball.trail.length;
ctx.globalAlpha = f*0.30;
ctx.fillStyle = '#ffc45a';
ctx.beginPath();
ctx.arc(t.x, t.y, r*(0.25 + 0.65*f), 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.save();
ctx.shadowColor = 'rgba(255,170,60,0.9)';
ctx.shadowBlur = r*2.6;
const g = ctx.createRadialGradient(ball.x - r*0.35, ball.y - r*0.4, r*0.12, ball.x, ball.y, r);
g.addColorStop(0, '#fffdf0');
g.addColorStop(0.45, '#ffd24a');
g.addColorStop(1, '#ef8614');
ctx.beginPath();
ctx.arc(ball.x, ball.y, r, 0, Math.PI*2);
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
}
function drawHUD(){
const fs = Math.round(clamp(H*0.032, 12, 22));
ctx.font = '700 ' + fs + 'px ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fillText('SCORE ' + score, W*0.05, H*0.072);
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('LEVEL ' + level, W*0.5, H*0.072);
const hsz = fs*1.15;
for (let i=0;i<lives;i++){
drawHeart(W*0.95 - i*(hsz*1.35), H*0.072, hsz, 0, '#ff4d6d', 0.95);
}
if (levelFlash > 0){
ctx.globalAlpha = Math.min(1, levelFlash);
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe9a8';
ctx.font = '800 ' + Math.round(fs*2.2) + 'px ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
ctx.fillText('LEVEL ' + level, W*0.5, H*0.45);
ctx.globalAlpha = 1;
}
}
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.fillStyle = '#07060c';
ctx.fillRect(0,0,cssW,cssH);
let shx = 0, shy = 0;
if (shake > 0.3){
shx = (Math.random()-0.5)*shake;
shy = (Math.random()-0.5)*shake;
}
ctx.setTransform(dpr*scale, 0, 0, dpr*scale, dpr*ox, dpr*oy);
ctx.translate(shx, shy);
const rad = Math.min(28, W*0.03);
ctx.save();
roundRectPath(ctx, 0, 0, W, H, rad);
ctx.clip();
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = glowGrad;
ctx.fillRect(0, 0, W, H);
drawBricks();
drawPaddle();
drawBallAndTrail();
drawParticles();
drawRings();
drawHUD();
ctx.restore();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
roundRectPath(ctx, 1, 1, W-2, H-2, rad);
ctx.stroke();
}
/* ---------------- loop ---------------- */
let lastT = performance.now();
function frame(now){
let dt = (now - lastT)/1000;
lastT = now;
if (!isFinite(dt) || dt <= 0) dt = 0.016;
dt = Math.min(dt, 0.05);
update(dt);
render();
requestAnimationFrame(frame);
}
/* ---------------- input ---------------- */
function startOrRestart(){
if (mode === 'demo' || mode === 'over') startGame();
}
window.addEventListener('pointerdown', function(){
startOrRestart();
}, { passive: true });
window.addEventListener('keydown', function(e){
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Spacebar'){
e.preventDefault();
}
keys[e.key] = true;
if (mode === 'demo' || mode === 'over') startGame();
});
window.addEventListener('keyup', function(e){
keys[e.key] = false;
});
window.addEventListener('pointermove', function(e){
const rect = canvas.getBoundingClientRect();
pointerX = (e.clientX - rect.left - ox)/scale;
pointerActive = true;
}, { passive: true });
window.addEventListener('blur', function(){
for (const k in keys) keys[k] = false;
});
/* ---------------- boot ---------------- */
toDemo();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (33,022 tokens)
The user wants a Breakout game where the paddle is a hamburger and each brick broken explodes into lots of little hearts. Must include self-playing demo mode with "Click or press a key to start" message.
Let me build a single HTML file with canvas. Draw a hamburger paddle using canvas shapes (bun, sesame seeds, lettuce, patty, cheese). Bricks in various colors. On brick break, spawn hearts particles (heart shapes drawn with bezier or two arcs + triangle). Add a soft glow, trails, etc.
Design:
- Canvas full window, responsive with devicePixelRatio.
- Game field virtual coordinate system: fixed logical size e.g., width 900, height 600, scaled to fit viewport with letterboxing. Actually easier: use logical coords 1000x640 and scale to fit. Keep paddle at bottom.
- Demo AI: paddle follows ball's x position with some smoothing and slight lag/imperfection.
- Start message overlay: "Click or press a key to start" — after start, player controls with mouse/touch/arrow keys. But demo continues until interaction? Requirement: games start in self-playing demo mode behind a "Click or press a key to start" message. So demo runs, message displayed; on click/keypress, switch to player mode.
Careful with sandbox: no localStorage etc. Fine.
Implementation details:
Logical size: W=960, H=640? Let's do W = 900, H = 600.
Layout: top HUD maybe minimal. Bricks rows: 6 rows, 9 columns. Brick width = (W - 2*margin - gaps)/cols.
Ball: radius 8, speed ~ 420 px/s initially, increases slightly.
Hearts: each brick break spawns ~14-22 hearts with random velocities, gravity, rotation, fade. Draw heart path.
Heart drawing function:
```js
function heartPath(ctx, x, y, size, rot){ ... }
```
Use path: moveTo(0, size*0.3); bezierCurveTo to make lobes. A common heart:
```
ctx.beginPath();
const s = size;
ctx.moveTo(0, s*0.35);
ctx.bezierCurveTo(0, s*0.1, -s*0.5, -s*0.15, -s*0.5, -s*0.45); // hmm
```
Simpler approach: two circles + triangle, but with rotation scale it's fine.
Let me define heart with parametric:
```
function traceHeart(ctx, s){
ctx.beginPath();
ctx.moveTo(0, 0.35*s);
ctx.bezierCurveTo(-0.5*s, -0.1*s, -0.5*s, -0.5*s, 0, -0.25*s);
...
}
```
Hmm let's use the classic:
```
ctx.beginPath();
ctx.moveTo(0, -0.2*s);
ctx.bezierCurveTo(0.4*s, -0.9*s, 1.1*s, -0.1*s, 0, 0.7*s);
ctx.bezierCurveTo(-1.1*s, -0.1*s, -0.4*s, -0.9*s, 0, -0.2*s);
ctx.closePath();
```
That should look like a heart roughly (top notch at (0,-0.2s), lobes up). Actually this is a standard heart path. Let's check: start at bottom-middle? Standard: moveTo(0, size*0.3) then curves... I'll trust the shape: it draws two symmetric curves creating lobes at the top and a point at bottom. Let's test mentally: start point (0,-0.2s). Curve control points (0.4s,-0.9s) and (1.1s,-0.1s), ending at (0, 0.7s). That creates the right half going up and around to bottom point. Then mirrored. Yes looks like a heart.
Better to precompute a Path2D for hearts and scale via transform to be efficient with ~hundreds of particles.
Performance: cap particles at maybe 900.
Also add screen shake on brick break, glow, ball trail.
Paddle hamburger drawing:
- bottom bun (rounded, golden brown)
- patty (dark brown)
- cheese (yellow-orange with drips) — maybe simple
- lettuce (green wavy)
- top bun (rounded dome with sesame seeds)
Width ~ paddle width 120, height ~ 46.
Let's draw paddle as burger with paddle collision box approximating: use x center, width w, height h; ball bounces off top of burger.
Bricks: colorful, maybe with a small heart pattern? Bricks colored: pink/red/coral/orange/yellow shades. Some bricks take 2 hits? Keep it simple: 1 hit but some "tough" bricks crack. Let's do simple: all 1 hit; some bricks reinforced (2 hits) shown with darker outline. Actually keep simple but visually interesting. I'll do 1-hit bricks for constant heart explosions.
Level regeneration when all bricks cleared.
AI: predict ball x simplified: move paddle toward ball.x with max speed; add small error. Also if ball moving down, aim to intercept. Simple: target = ball.x + (ball.vx * something). Just follow ball.x with lag; that's enough to look decent. Include error oscillation to be imperfect but keep rally. To make demo look good, add prediction: when ball.vy > 0, compute time to reach paddle y and predicted x with wall reflections, then target that.
Ball speed maybe 340 px/s so demo survives.
Let's write it.
Structure:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hamburger Breakout</title>
<style>...</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
</div>
<div id="overlay">...</div>
<script>...</script>
</body>
</html>
```
Actually simpler: canvas fixed to full window and draw everything including text on canvas? HUD overlay text via DOM is easier for the "Click or press a key to start" message and crisp text. Let's do a DOM overlay centered for start message, and canvas for the game. Score/lives drawn on canvas.
Layout: canvas fills viewport (devicePixelRatio aware), game world scaled to fit with letterbox: compute scale = min(cssW / W, cssH / H), offset centered. Draw background gradient across whole canvas.
Let me code:
```js
const W = 900, H = 620;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let view = {scale:1, ox:0, oy:0};
function resize(){
const dpr = Math.min(window.devicePixelRatio||1, 2);
const cw = window.innerWidth, ch = window.innerHeight;
canvas.width = Math.floor(cw*dpr); canvas.height = Math.floor(ch*dpr);
canvas.style.width = cw+'px'; canvas.style.height = ch+'px';
const scale = Math.min(cw/W, ch/H);
view.scale = scale*dpr;
view.ox = (cw - W*scale)/2 * dpr;
view.oy = (ch - H*scale)/2 * dpr;
view.dpr = dpr;
view.cssScale = scale;
}
```
Render: ctx.setTransform(1,0,0,1,0,0); clear whole canvas with background; then ctx.setTransform(view.scale,0,0,view.scale, view.ox, view.oy) and draw the world in logical coords 0..W, 0..H.
Background inside world: rounded rect dark with subtle gradient, plus glow.
Game state:
```js
const state = {
mode: 'demo', // 'demo' | 'play' | 'gameover' | 'levelclear'
score: 0, best: 0(no), lives: 3,
shake: 0,
...
}
```
Wait, in demo mode lives matter? Demo AI plays; if it misses, reset ball. Keep it infinite in demo. When player starts (click/key), keep the current game? Better: on start, reset score/lives and start fresh game from demo position. Simpler: on start, switch mode='play', reset ball and paddle, keep bricks. Actually cleaner: restart full game on start. But screenshot at 3s should show demo running with hearts flying. Fine.
Let's write the loop with dt clamped.
Entities:
- ball {x,y,vx,vy,r,speed}
- paddle {x, w, h, y}
- bricks array {x,y,w,h,color,hp,alive,hitFlash}
- particles [{x,y,vx,vy,size,rot,vr,life,maxLife,color,type}]
- floating texts maybe.
Ball physics: move with substeps to avoid tunneling: split movement into steps of max 6px.
Collision with bricks: AABB circle test.
On brick break:
- spawn 16 hearts + 6 sparkle dots
- score += 10
- shake = 6
- sound? No audio (could do WebAudio but not needed; skip or add subtle blips? Keep it silent to avoid issues; maybe add tiny WebAudio on user gesture only. Skip.)
Hearts colors: pinks/reds: '#ff4d6d','#ff85a1','#ff2e63','#ff6b9d','#ffd1dc','#ff9ecd'.
Heart particle physics: vx random spread, vy random up, gravity 900 px/s², drag, rotation, fade over ~1.2-1.8s, size shrink slightly.
Ball trail: store last N positions, draw fading circles.
Paddle drawing function drawBurger(ctx, x, y, w, h, squash).
Let's define paddle geometry: paddle.x = center, paddle.y = top of burger? Let's define paddle baseline: paddleY = H - 70 (bottom of burger). Burger height ~ 48. Collision surface: top of burger at y = paddleY - h.
Draw burger in local coords with center at (0,0) at top surface? Let's define function drawBurger(cx, topY, w, h) where topY is the top of the top bun, h total height. Ball bounces at topY.
Burger layers from bottom to top (drawn bottom-up):
- bottom bun: bottom portion, rounded bottom.
- patty: dark brown rounded rect.
- cheese: yellow, slightly wider with drips.
- lettuce: green zigzag.
- top bun: dome.
Actually visually, when paddle is at bottom, we want the burger to look nice. Let's do:
total h = 46, width w (120).
Bottom bun: y from topY+32 to topY+46, rounded bottom corners radius 16.
Patty: y topY+24 to topY+34, width w*0.98, rounded.
Cheese: y topY+19 to topY+27 with drips down to 30 — draw as rounded rect plus little triangles.
Lettuce: y topY+15..topY+22, wavy.
Top bun: dome from topY to topY+18 — semicircle-ish arc with flat bottom.
Hmm layering order: draw top bun last? No—top bun is at top, drawn first in y-order doesn't matter for overlapping since they don't overlap much, except cheese drips over patty. Draw bottom bun, patty, cheese, lettuce, top bun.
Let me define with h = 48:
- top bun height 20 (dome), from y0 to y0+20
- lettuce from y0+18 to y0+26 (wavy band)
- cheese from y0+24 to y0+30 + drips to y0+36
- patty from y0+29 to y0+39
- bottom bun from y0+38 to y0+48
Overlaps ok since drawn in order bottom→top? If we draw bottom bun first then patty on top overlapping, patty would cover bottom bun's top edge — good. Then cheese (drips over patty), then lettuce, then top bun (covers lettuce top edge). Good, draw order: bottom bun → patty → cheese → lettuce → top bun.
Colors: top bun #f0a94a → gradient to #d98632; sesame #ffe9c0. Lettuce #6ec14a / #4fa832. Cheese #ffc233 → #ffae00. Patty #6b3a22 → #4a2415. Bottom bun #e8a04a.
Ball: a cherry tomato? Or a meatball! Ball as a small meatball (brown sphere with highlight) — fits the burger theme. Yellow-ish? Let's make it a golden fried ball / meatball with shine. Actually a "cherry tomato" red would contrast with hearts. Let's make it a meatball (dark brown with lighter speckles) — hmm may be hard to see. Let's make the ball a bright yellow-ish sesame ball / or a glowing white-yellow ball with trail. I'll do a meatball with a warm rim light and glow so it's visible. Simpler: glowing golden ball. Let's do ball as a "fries"? no. Golden glowing ball with a shine highlight — clean.
I'll do ball as a glossy red-orange ball (like a cherry) with highlight and glow, contrasting with dark bg.
Background: dark navy/purple gradient with soft radial glows; bricks bright pastel.
Bricks: rows with hue palette: ['#ff5d8f','#ff8fab','#ffb703','#8ecae6','#a0e7a0','#c79bff'] maybe assign by row. Add rounded rect with inner highlight, and a tiny heart icon on the brick? That's cute: each brick has a small heart shape in the middle at low alpha. Might be heavy with many bricks; it's fine (63 bricks). Precompute Path2D for tiny heart, use transform. OK.
Actually maybe simpler: bricks have a subtle gloss and a heart outline. Let's include small heart on brick — cute.
Ball collision with bricks: check circle vs rect, determine bounce axis.
Tough bricks: rows 0-1 have hp 2 (darker). When hit but not destroyed, flash white and spawn small hearts (2-3). Let's do that: hp 2 for top rows... Actually simplest: all bricks hp=1 for maximum heart explosions. I'll give top row hp=2 for variety? Slight risk of demo AI stalling. Fine, it's ok. Let's just make all 1 hit. Actually a bit of depth is nice. I'll do: row 0 (top) hp=2 with crack indication. Meh — keep it 1-hit for consistent joy. Decision: 1 hit.
Level clear: when no bricks alive, rebuild bricks, speed up slightly, show "LEVEL X" text briefly.
Lives: 3 in play mode. Missing ball → lose life, reset ball on paddle, wait a moment. In demo mode, no life loss, just reset ball.
Score display: canvas top-left "SCORE 1230", top-right hearts for lives (draw hearts). Nice touch: lives shown as hearts. But the theme is hearts exploding... fine, hearts as lives is cute.
HUD: draw at top inside world.
Start overlay: DOM div centered with text "Click or press a key to start" + subtitle. Also show in demo a "DEMO" badge. On interaction (click anywhere, keydown, touchstart), hide overlay, set mode 'play', reset game.
Actually requirement: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So message overlays, and clicking starts real play. Good.
Pointer control: mousemove over canvas → paddle target = worldX. Also touch. In play mode, paddle follows pointer smoothly. Arrow keys also.
Let's handle pointer coordinates: convert client coords to world coords: worldX = (clientX*dpr - view.ox)/view.scale.
Simpler: use canvas.getBoundingClientRect() and cssScale: worldX = (clientX - rect.left - (cw - W*scale)/2)/scale.
Let me store view.cssOx, view.cssOy, view.cssScale for pointer mapping.
Let's write the code carefully.
```js
const W = 900, H = 620;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
let dpr = 1, scale = 1, ox = 0, oy = 0; // css px offsets
function resize(){
dpr = Math.min(window.devicePixelRatio || 1, 2);
const cw = window.innerWidth, ch = window.innerHeight;
canvas.width = Math.round(cw*dpr);
canvas.height = Math.round(ch*dpr);
canvas.style.width = cw+'px';
canvas.style.height = ch+'px';
scale = Math.min(cw/W, ch/H);
ox = (cw - W*scale)/2;
oy = (ch - H*scale)/2;
}
window.addEventListener('resize', resize);
```
Render:
```js
ctx.setTransform(dpr,0,0,dpr,0,0);
// background full
ctx.fillStyle = bgGrad (create gradient across cw,ch)
```
Hmm creating gradient every frame is ok-ish but let's cache on resize.
World transform: ctx.setTransform(dpr*scale, 0,0, dpr*scale, dpr*ox, dpr*oy).
Background of the world: fill rounded rect r=24 with gradient + border glow.
Let's just fill the world rect with a dark gradient and draw outside as near-black. That gives a "cabinet" feel.
Screen shake: apply translate by random offsets within the world transform.
Now the game code.
```js
const PADDLE_H = 48;
const paddle = {
x: W/2, y: H - 56, w: 132, h: PADDLE_H,
targetX: W/2, vx: 0
};
```
Top of burger = y (paddle.y is top of burger). So paddle occupies y..y+h. H=620, y = 520, bottom 568.
Ball radius 9.
Ball rest on paddle: after reset, ball sits on paddle top, launched after short delay via angle.
Ball speed: 380 initially, +12 per level, cap 620.
Ball physics step:
```
function stepBall(dt){
const steps = Math.ceil(Math.hypot(ball.vx,ball.vy)*dt / 5) || 1;
for(let i=0;i<steps;i++){ substep(dt/steps); }
}
```
substep:
- ball.x += vx*dt; ball.y += vy*dt
- walls: left/right at 14 and W-14 (world padding). Top at 14? Bricks start at y=90.
- paddle collision: if ball.vy > 0 and ball.y + r > paddle.y and ball.y - r < paddle.y + h and ball.x within paddle.x ± w/2 + r → bounce with angle based on hit offset.
- bricks: loop and check.
Paddle collision: compute offset = (ball.x - paddle.x)/(paddle.w/2), clamp -1..1. angle = offset * 1.05 (max ~60°). speed preserved. vx = speed*sin(angle), vy = -speed*cos(angle). Set ball.y = paddle.y - r.
Also add paddle velocity influence: vx += paddle.vx*0.12, clamp.
Brick collision: for circle vs rect: find closest point, if dist < r → collide. Determine normal from penetration depths.
Simplify with rect-circle:
```js
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) { ... }
```
Determine bounce: if ball center outside rect horizontally (dx != 0) → horizontal bounce; else vertical. Better: compute overlap.
```js
const overlapX = (b.w/2 + r) - Math.abs(ball.x - (b.x+b.w/2));
const overlapY = (b.h/2 + r) - Math.abs(ball.y - (b.y+b.h/2));
if (overlapX < overlapY) { ball.vx *= -1; ball.x += Math.sign(ball.x-(b.x+b.w/2)) * overlapX; }
else { ball.vy *= -1; ball.y += Math.sign(ball.y-(b.y+b.h/2)) * overlapY; }
```
Good.
Now particles.
```js
const particles = [];
function spawnHearts(x,y,count,palette){
for(...){
particles.push({
type:'heart', x, y,
vx: (Math.random()-0.5)*420,
vy: -Math.random()*420 - 60,
size: 6 + Math.random()*12,
rot: (Math.random()-0.5)*0.8,
vr: (Math.random()-0.5)*10,
life: 0, maxLife: 0.9 + Math.random()*0.9,
color: palette[...],
grav: 900,
drift: ...
});
}
}
```
Update: p.life += dt; if life > maxLife remove. vy += grav*dt; vx *= (1 - 1.5*dt) drag; x += vx*dt...
Alpha = 1 - (life/maxLife)^2 maybe with fade in first 10%.
Draw heart:
```js
function drawHeart(x,y,size,rot,alpha,color){
ctx.save(); ctx.translate(x,y); ctx.rotate(rot); ctx.scale(size/20, size/20); // path in 20-unit space
ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fill(heartPath); ctx.restore();
}
```
heartPath = new Path2D with heart in a 20x20 box centered at origin:
```js
const heartPath = new Path2D();
heartPath.moveTo(0, 7);
heartPath.bezierCurveTo(-10, -1, -6, -12, 0, -5);
heartPath.bezierCurveTo(6, -12, 10, -1, 0, 7);
heartPath.closePath();
```
Check: start (0,7) bottom tip. Curve to (0,-5) via control (-10,-1) and (-6,-12): left lobe. Then from (0,-5) to (0,7) via (6,-12),(10,-1): right lobe. Yes, heart with notch at (0,-5) and tip at (0,7). Size span x: -10..10, y: -12..7. Good enough; center roughly.
Bound it in size param: scale = size/14 maybe. Fine.
Sparkles: small circles that fade.
Also add a "ring" shockwave on brick break: expanding circle with fading stroke. Nice.
Screen shake on brick break.
Score popup text? Maybe not needed.
Ball trail: array of {x,y} last 12 positions; draw with decreasing alpha and radius.
Now AI:
```js
function updateAI(dt){
let target;
if (ball.vy > 0) {
// predict landing x at paddle top
let px = ball.x, py = ball.y, vx = ball.vx, vy = ball.vy;
// simulate
let t = 0;
const yTarget = paddle.y - ball.r;
while (py < yTarget && t < 4) {
const dtStep = 0.01;
px += vx*dtStep; py += vy*dtStep;
if (px < 14+ball.r){ px = 14+ball.r + (14+ball.r-px); vx = -vx; }
if (px > W-14-ball.r){ px = W-14-ball.r - (px-(W-14-ball.r)); vx = -vx; }
t += dtStep;
}
target = px;
} else {
target = ball.x + ball.vx*0.12;
}
// add tiny imprecision
aiError = ...
const desired = clamp(target, 20, W-20);
const maxSpeed = 900;
const d = desired - paddle.x;
paddle.x += clamp(d, -maxSpeed*dt, maxSpeed*dt);
}
```
Hmm the AI should be a bit imperfect to be interesting. Add wobble: target += Math.sin(time*2)*18. Also cap paddle speed so it sometimes misses... but missing causes resets which is fine. Keep it decent so the rally continues and hearts keep flying.
Actually if the AI misses, ball resets and there's a lull. Better to make AI near-perfect but with lag so it looks alive. Use maxSpeed 1200 and slight error ~10px. Should mostly catch.
Note: prediction loop with dt 0.01 over up to 4s = 400 iterations per frame — fine.
Paddle movement in play mode: track pointer target smoothly:
```js
paddle.x += (targetX - paddle.x) * min(1, dt*20);
```
Hmm, better: pointer sets targetX directly; paddle moves toward it at high speed with easing. Use lerp factor 1-exp(-dt*24).
paddle.vx computed from position delta / dt for spin.
Let's code the main update:
```js
let last = performance.now();
let acc = 0;
function frame(now){
let dt = (now-last)/1000; last = now;
dt = Math.min(dt, 0.05);
update(dt);
render();
requestAnimationFrame(frame);
}
```
Update:
```js
if (state.shake > 0) state.shake = Math.max(0, state.shake - dt*40);
if (mode === 'demo') updateAI(dt);
// paddle movement for play mode handled by pointer/keyboard
```
Keyboard: ArrowLeft/Right, A/D. keys object. In play mode:
```js
let kv = 0; if(keys.left) kv -= 1; if(keys.right) kv += 1;
if (kv !== 0) { pointerActive = false; paddle.x += kv*760*dt; }
```
And pointer: if pointerActive, ease to pointerX.
Launch: ball held on paddle when `ball.held` true. In demo, hold time 0.6s then launch at slight random angle. In play, launch immediately after respawn? Let's hold and auto-launch after 0.8s too, simpler.
Ball reset:
```js
function resetBall(){
ball.held = true; ball.holdT = 0;
ball.x = paddle.x; ball.y = paddle.y - ball.r - 4;
ball.vx = 0; ball.vy = 0;
ball.speed = baseSpeed;
}
```
While held, ball.x = paddle.x, y = paddle.y - r - 3. holdT += dt; if > 0.7 → launch: angle = (Math.random()*0.5-0.25) (radians from vertical); vx = speed*sin, vy = -speed*cos.
Wait, we also want a small trail.
Lives: in play mode, missing → lives--; if 0 → game over → show overlay "Game Over — click to play again"? Requirement says demo behind start message. Game over: show overlay with score and "Click to play again", then restart. Keep it simple. But demo mode never ends.
Let me implement mode: 'demo' | 'play' | 'over'. On game over, after a moment, we could auto-return to demo mode. That's elegant: game over → back to demo with overlay message. Let's do: on game over, mode='over', overlay shows "GAME OVER — CLICK TO PLAY AGAIN", after 2.5s auto switch to demo mode with overlay "Click or press a key to start". Actually keep overlay always shown in demo. Simplest:
- mode 'demo': overlay visible with start message. AI plays.
- mode 'play': overlay hidden.
- mode 'over': overlay visible with game over + click to restart → goes to demo? No, click should start a new game directly. Let's make click → new game (play mode), score reset.
Careful: click handler while overlay visible. Use a single function startGame().
I'll implement:
```js
function startGame(){
mode = 'play'; resetGame(); overlay.classList.add('hidden');
}
```
and on game over: mode='over'; after 3s → mode='demo'; resetGame(); overlay shows start message again (with title changed back). Let's do that, with a timer.
Hmm, the overlay text: in 'over' show "GAME OVER" + score. Then in demo show start message. Nice.
Simplify:
- overlay contains h1 "HAMBURGER BREAKOUT", p id 'msg'.
- In demo: msg = "Click or press a key to start".
- In over: msg = "GAME OVER · Score 1234 — Click to play again".
Clicking always calls startGame() which resets and sets play mode. Good.
Now, in demo, since overlay covers center with pointer-events none except we listen on window for click. Overlay should have pointer-events: none so clicks reach window. Listen on window 'pointerdown' and 'keydown'.
Now write render.
```js
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
// clear with page bg
ctx.fillStyle = '#0a0710'; // nearly black
ctx.fillRect(0,0,canvas.width/dpr, canvas.height/dpr);
```
Hmm canvas.width/dpr = css width. Fine.
Then world:
```js
const s = dpr*scale;
let sx = 0, sy = 0;
if (state.shake > 0.1){
sx = (Math.random()-0.5)*state.shake;
sy = (Math.random()-0.5)*state.shake;
}
ctx.setTransform(s,0,0,s, dpr*(ox+sx), dpr*(oy+sy));
```
Then draw world from 0,0 to W,H. Clip to rounded rect? Let's just fill a rounded rect as the play area background.
World background: linear gradient from #1b1030 to #0d0a17 plus radial glow at bottom center (burger warmth) and top.
Draw order: bg, grid/ stars, bricks, particles behind?, paddle, ball, trail, HUD.
Particles should render above bricks but below HUD. Hearts in front of everything is nice.
Let's write drawing functions.
drawBackground():
```js
const bgGrad cached? create each frame is fine but let's cache since W,H constant.
```
Cache gradients created once (they're in world coords, so fine).
```js
const bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#1a1130'); bgGrad.addColorStop(0.6,'#120c22'); bgGrad.addColorStop(1,'#1d0f1f');
```
But ctx.createLinearGradient before transform is fine — gradients are in user space at fill time. Yes, gradients are transformed by current transform when used. Good.
Add radial glow:
```js
const glow = ctx.createRadialGradient(W/2, H+40, 20, W/2, H+40, 520);
glow.addColorStop(0,'rgba(255,140,80,0.20)'); glow.addColorStop(1,'rgba(255,140,80,0)');
```
Rounded rect clip for the whole world:
```js
ctx.save();
roundRect(ctx, 0,0,W,H, 26); ctx.clip();
... draw bg + content
ctx.restore();
```
But offsets (ox,oy) are already applied via transform so the rounded rect sits at the letterboxed area. Good.
Then draw border stroke around it.
Bricks:
```js
const COLS = 9, ROWS = 6;
const padX = 40, topY = 96, gap = 8, brickH = 26;
const brickW = (W - padX*2 - gap*(COLS-1))/COLS;
```
W=900, padX=40 → 820 width. gap 8*8=64 → 756/9 = 84. brickH 26, gap vertical 8 → total 6*34 = 204, from 96 to 300. Ball starts at y=520. Hmm bricks occupy up to y=300, plenty of space. Maybe make bricks taller: brickH = 30, gap 9 → 6*39 = 234 → 96..330. Fine.
Colors per row:
row0: '#ff4d6d' (red)
row1: '#ff70a6' (pink)
row2: '#ff9770' (coral)
row3: '#ffd670' (yellow)
row4: '#a3e8b0'? Hmm keep warm palette: '#8ecae6' too different. Let's use a sunset palette:
['#ff3d5a','#ff6b6b','#ff9f45','#ffd93d','#c8f56b','#7be0c6']? Mixed.
Maybe better: each brick gets a color from a palette of lovely pinks/reds/oranges with hearts. The theme: hearts. Palette: '#ff2d6f', '#ff5c8a', '#ff8fab', '#ffb3c6', '#ff77a9', '#ff4d6d'. All pinkish, might be monotone but consistent. Add a couple of gold/peach: '#ffb26b', '#ffd166'.
I'll use per-row:
0: '#ff3b6b'
1: '#ff5f8f'
2: '#ff8a5b'
3: '#ffb545'
4: '#ff7ea8'
5: '#ff4d8d'
Eh. Let's do a rainbow-ish but soft:
['#ff4d6d','#ff7b54','#ffb703','#8ac926','#4cc9f0','#b07bff'] — bright and distinct rows. With hearts on each brick, it'll look festive. I'll go with this but slightly softened.
Hmm, the background is dark purple; bright rows pop nicely. OK.
Brick rendering: rounded rect r=6, fill with gradient from color to darker, plus top highlight line, plus small heart in center with white alpha 0.22.
Actually simpler & pretty: fill solid color with a vertical gradient (lighter top). Then a heart glyph centered at 0.28 alpha white? Might be noisy at small size. Let's include it — it's cute and on-theme. Size ~ 12. With 54 bricks, fine.
Hmm, at 84 wide brick, a 14px heart centered looks good.
Also each brick has a subtle white top-left highlight.
Let's write:
```js
function drawBrick(b){
const r = 7;
ctx.save();
roundRect(ctx, b.x, b.y, b.w, b.h, r);
const g = ctx.createLinearGradient(0, b.y, 0, b.y+b.h);
g.addColorStop(0, b.colorLight);
g.addColorStop(1, b.color);
ctx.fillStyle = g;
ctx.fill();
// inner top gloss
ctx.globalAlpha = 0.35;
roundRect(ctx, b.x+3, b.y+3, b.w-6, b.h*0.35, 4); ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fill();
...
}
```
Careful with the gloss: it might look odd. Use a simple highlight rect with low alpha.
Add flash: if b.flash > 0, overlay white with alpha.
Now ball drawing: radial gradient gold:
```js
const g = ctx.createRadialGradient(ball.x-ball.r*0.35, ball.y-ball.r*0.4, ball.r*0.15, ball.x, ball.y, ball.r);
g.addColorStop(0,'#fff6d8'); g.addColorStop(0.5,'#ffcc4d'); g.addColorStop(1,'#f08a1a');
```
Plus outer glow: ctx.shadowBlur = 24; shadowColor '#ffb347'.
Shadow blur can be slow but ok for a few objects.
Paddle burger drawing — the star of the show. Let's write carefully.
```js
function drawBurger(cx, topY, w, h){
const half = w/2;
const x0 = cx - half;
// layer heights
const bunTopH = h*0.42; // 20 of 48
...
}
```
Let's define with h=48 relative fractions:
topBun: occupies y0 .. y0+20 (dome)
lettuce: y0+17 .. y0+25
cheese: y0+22 .. y0+30 with drips to 36
patty: y0+28 .. y0+38
bottomBun: y0+37 .. y0+48
Let's set:
```js
const y0 = topY;
const H_ = h; // 48
// bottom bun
const bbH = h*0.24;
const bbY = y0 + h - bbH;
```
I'll just hardcode ratios based on h = 48 and scale, since paddle h fixed at 48. Let me write generic with fractions:
fTopBun: 0 → 0.42
fLettuce: 0.36 → 0.52
fCheese: 0.46 → 0.62 (drips to 0.72)
fPatty: 0.58 → 0.80
fBottomBun: 0.76 → 1.0
Overlap gives a nice stacked look.
Drawing:
Bottom bun: rounded rect with bottom corners radius = 18, top corners 6.
```js
ctx.beginPath();
const r = h*0.34; // bottom radius
const x = x0+1, ww = w-2, yb = y0 + h*0.76, hb = h*0.24;
ctx.moveTo(x, yb);
ctx.lineTo(x+ww, yb);
ctx.lineTo(x+ww, yb+hb-r);
ctx.quadraticCurveTo(x+ww, yb+hb, x+ww-r, yb+hb);
ctx.lineTo(x+r, yb+hb);
ctx.quadraticCurveTo(x, yb+hb, x, yb+hb-r);
ctx.closePath();
fill gradient #f2b45c → #cf8a3a
```
Patty: rounded rect with radius h*0.2, fill gradient #7a4526 → #4a2616, with a slightly wavy edge? Keep rounded rect.
Cheese: draw a rect with wavy bottom (drips):
```js
ctx.beginPath();
const cy0 = y0+h*0.46, ch = h*0.22;
ctx.moveTo(x0+2, cy0);
ctx.lineTo(x0+w-2, cy0);
// wavy bottom with drips
```
Simpler: rounded rect for main cheese slice, then 3 small rounded triangles/drips hanging below. I'll do: main rect from y0+0.46h to y0+0.62h with radius 5, plus drips as circles at x positions with radius ~ h*0.08 at y = 0.62h.
Cheese over hang: make cheese width slightly larger than burger (w*1.0 vs patty w*0.96) so it peeks. Fine.
Lettuce: wavy band — draw as a path with a series of arcs along the top and bottom:
```js
ctx.beginPath();
const ly = y0+h*0.40, lh = h*0.16;
// top wavy
const n = 8;
ctx.moveTo(x0, ly);
for (let i=0;i<n;i++){
const x1 = x0 + (i+0.5)*(w/n), x2 = x0 + (i+1)*(w/n);
ctx.quadraticCurveTo(x1, ly - lh*0.7, x2, ly); // hmm inverted
}
```
Let's make lettuce a band with scalloped bottom edge:
```js
// top edge straight-ish
ctx.moveTo(x0, ly);
ctx.lineTo(x0+w, ly);
// bottom scalloped from right to left
for (let i=n; i>0; i--){
const xa = x0 + i*(w/n), xb = x0 + (i-1)*(w/n);
ctx.quadraticCurveTo((xa+xb)/2, ly+lh*1.9, xb, ly+lh*0.85);
}
```
Hmm, need consistent. Let me do: bottom edge with scallops: for each segment going right-to-left, quadratic curve with control point below the midpoint, ending at the left segment boundary at the baseline y = ly + lh.
```js
const baseY = ly + lh;
ctx.lineTo(x0+w, baseY); // no, start from top-right
```
Full path:
```js
ctx.beginPath();
ctx.moveTo(x0+2, ly);
ctx.lineTo(x0+w-2, ly);
ctx.lineTo(x0+w-2, baseY - lh*0.2);
for (let i=0;i<n;i++){
const xa = x0+w-2 - i*(w-4)/n;
const xb = x0+w-2 - (i+1)*(w-4)/n;
ctx.quadraticCurveTo((xa+xb)/2, baseY + lh*0.55, xb, baseY - lh*0.2);
}
ctx.closePath();
```
Wait that gives scallops pointing down (control below endpoint line) — bumpy leaf edge. Good.
Hmm, but with control point below, the curve dips down between endpoints: bumps downward. Good.
Fill: gradient #7ed957 → #3f9e2e. Add a darker outline maybe.
Top bun: dome.
```js
const tbY = y0 + h*0.42; // bottom of dome
const tbTop = y0;
ctx.beginPath();
ctx.moveTo(x0+2, tbY);
ctx.bezierCurveTo(x0+2, tbTop - h*0.1, x0+w-2, tbTop - h*0.1, x0+w-2, tbY);
ctx.closePath();
```
Hmm bezier from left-bottom to right-bottom with control points above y0 gives a dome. The dome height: control points at y0 - h*0.1 gives a top around y0 - 0.075h. We want the dome top exactly at y0. Use control points at y0 + something... For a cubic from (x0, tbY) to (x0+w, tbY) with controls at (x0, tbY - k) and (x0+w, tbY - k), the curve's max height is at t=0.5: y = 0.125*tbY + 0.375*(tbY-k) + 0.375*(tbY-k) + 0.125*tbY = tbY - 0.75k. So set k = (tbY - y0)/0.75 to reach y0. With tbY = y0+0.42h and h=48: tbY-y0 = 20.16, k = 26.9. OK compute dynamically.
Also the dome should be a bit wider at base and rounded at the ends — add small quadratic at the sides. Simple approach:
```js
ctx.moveTo(x0+1, tbY);
ctx.bezierCurveTo(x0+1, tbY - k*1.0, x0+w-1, tbY - k*1.0, x0+w-1, tbY);
ctx.quadraticCurveTo(x0+w-1, tbY+2, x0+w-4, tbY+2);
...
```
Getting complicated. Just close with lineTo back to start; the bottom edge is a straight line at tbY which gets covered by lettuce/cheese anyway. Actually top bun's bottom is covered by lettuce drawn after? No — I said draw order bottom bun → patty → cheese → lettuce → top bun. If top bun is drawn last, its straight bottom edge would show above the lettuce. Let's draw top bun BEFORE lettuce? Then lettuce covers the bun's bottom edge. But lettuce sits below the dome bottom...
Order top to bottom: top bun dome (0 to 0.42), lettuce (0.36-0.52). So lettuce overlaps the bun's bottom 0.06h. Drawing order: bottom bun, patty, cheese, top bun, lettuce? Then lettuce covers the dome bottom edge. But then lettuce is drawn over the dome, that's what we want visually (lettuce peeking under the bun). Hmm actually lettuce should be under the bun, so the bun covers the lettuce's top edge. Both work visually as long as overlap region exists.
Easiest: draw bun with a rounded bottom edge (small radius) and draw lettuce after with its top edge at 0.40h overlapping the bun bottom at 0.42h. Then lettuce is over the bun bottom, hiding the straight edge. Slight weirdness but fine.
Alternative: draw top bun with rounded bottom corners so no hard straight line issue. I'll add a small radius. Let's just do:
```js
// top bun
ctx.beginPath();
ctx.moveTo(x0, tbY);
ctx.bezierCurveTo(x0, y0 - k*0.55, x0+w, y0 - k*0.55, x0+w, tbY); // wait
```
Let me restructure: use moveTo at left-bottom (x0, tbY), then bezierCurveTo(cx1, cy1, cx2, cy2, x0+w, tbY) where cy = y0 - k with k = (tbY - y0)/0.75... but here control y = y0 - extra. Let's just set control y = y0 - (tbY-y0)*0.333 giving apex at y0. Then close path with lineTo back. Fine, and the bottom straight edge gets covered by lettuce drawn after. Let's do draw order: bottom bun, patty, cheese, top bun, lettuce. Hmm but then the lettuce (drawn last) sits over the cheese/patty region... lettuce band is 0.36-0.52, cheese 0.46-0.62. Lettuce drawn over cheese top part — that's fine/expected (lettuce above cheese).
Wait no, in a real burger from bottom: bottom bun, patty, cheese, lettuce, tomato, top bun. So lettuce is above cheese — yes, drawing lettuce over the cheese's top edge is correct.
So order: bottomBun, patty, cheese, topBun, lettuce? But topBun bottom (0.42) is above lettuce top (0.36) — lettuce covers bun bottom, so we should draw bun before lettuce. Yes: bottomBun → patty → cheese → topBun → lettuce. Hmm but cheese drips should hang over the patty — cheese drawn after patty ✓.
But lettuce drawn last will cover the top bun's bottom edge, good.
Hmm, actually if lettuce covers the bun, then visually the bun looks shorter. That's fine.
Order final: bottomBun, patty, cheese, topBun, lettuce. Wait—but the top bun should visually be ON TOP of the lettuce (it's above). Since the bun's region (0..0.42) doesn't overlap much with the lettuce (0.36..0.52), only 0.06 overlap. Whichever is on top in that thin band. Drawing lettuce last means lettuce covers bun bottom 0.06 — looks like lettuce tucked under. Good.
Sesame seeds on the dome: 3-4 small ellipses, rotated, cream color.
Also add a subtle shadow beneath the burger on the "plate"? skip.
Add ketchup? no.
Also the paddle should have a slight squash/rotation when moving? Add a tilt based on velocity: rotate whole burger by clamp(vx*0.0004, -0.12, 0.12). Cute. Let's include a small tilt.
Draw with ctx.save(); translate(cx, topY); rotate(tilt); then draw with local coords starting at 0 for top and y increasing down, x from -half..half. Then restore.
That's simpler! Let me rewrite with local coords: origin at (cx, topY), so x from -half to half, y from 0 to h.
```js
function drawBurger(cx, topY, w, h, tilt){
ctx.save();
ctx.translate(cx, topY);
ctx.rotate(tilt);
const half = w/2;
const x0 = -half;
// --- bottom bun
const bbY = h*0.74, bbH = h*0.26, r = Math.min(h*0.36, 22);
ctx.beginPath();
ctx.moveTo(x0+1, bbY);
ctx.lineTo(-x0-1, bbY); // hmm
```
Careful: x from -half to +half; using x0 = -half and x1 = half.
```js
const xL = -half + 1, xR = half - 1;
// bottom bun
const bbY = h*0.74, bbH = h*0.26, r = bbH*0.85;
ctx.beginPath();
ctx.moveTo(xL, bbY);
ctx.lineTo(xR, bbY);
ctx.lineTo(xR, bbY + bbH - r);
ctx.quadraticCurveTo(xR, bbY + bbH, xR - r, bbY + bbH);
ctx.lineTo(xL + r, bbY + bbH);
ctx.quadraticCurveTo(xL, bbY + bbH, xL, bbY + bbH - r);
ctx.closePath();
const bg1 = ctx.createLinearGradient(0, bbY, 0, bbY+bbH);
bg1.addColorStop(0,'#f2b95e'); bg1.addColorStop(1,'#c8863a');
ctx.fillStyle = bg1; ctx.fill();
```
Note: createLinearGradient inside a transformed context uses the current transform at creation? Gradients are defined in user space at the time of creation... Actually, per spec, gradient coordinates are in the coordinate space at the time the gradient is used for painting (the current transform when filling). Hmm. The CanvasGradient coordinates are transformed by the CTM at paint time. Yes: "the gradient is transformed by the current transformation matrix when painting". So creating inside local coords and filling there works fine.
// patty
```js
const pY = h*0.58, pH = h*0.22, pr = pH*0.55;
roundRect(ctx, xL+1, pY, (xR-xL)-2, pH, pr);
grad #7d4a2a → #46220f
fill
```
// cheese
```js
const cY = h*0.48, cH = h*0.15;
// main
roundRect(ctx, xL-2, cY, (xR-xL)+4, cH, 4); fill gradient #ffd23f → #f5a623
// drips
for (let i=0;i<3;i++){ circle at x = xL + (i+0.5+...)*... y = cY+cH, r = h*0.07 } fill same
```
Wait cheese wider than the bun might look odd (hanging over edges) — actually it's realistic and cute. Keep slight overhang of 2px.
Hmm, but the paddle collision uses paddle.w; the cheese overhang is 2px each side. negligible.
// top bun dome
```js
const tbY = h*0.44;
const k = (tbY - 0) / 0.75; // control offset to make apex at y=0
ctx.beginPath();
ctx.moveTo(xL+2, tbY);
ctx.bezierCurveTo(xL+2, -k, xR-2, -k, xR-2, tbY);
ctx.quadraticCurveTo(xR-2, tbY+1, xR-6, tbY+1);
ctx.lineTo(xL+6, tbY+1);
ctx.quadraticCurveTo(xL+2, tbY+1, xL+2, tbY);
ctx.closePath();
```
Hmm the apex: with control y = -k, apex y = tbY - 0.75k = tbY - (tbY) = 0 ✓ (since k = tbY/0.75). But apex y=0 means the dome top is exactly at the paddle top — good, and the ball bounces at y=0 local = topY.
Actually with control points at -k = -tbY/0.75 which is negative, larger than needed for a nice dome? Apex exactly at 0 ✓.
But the dome looks nicer if the control points are also spread horizontally... standard.
Gradient for bun: vertical from #ffc978 (top) to #e09b3f.
Sesame seeds: at positions along the dome, small ellipses rotated.
// lettuce
```js
const lY = h*0.40, lH = h*0.13;
// scalloped band
```
Hmm lettuce after top bun. With lY = 0.40h and top bun bottom at tbY=0.44h, the lettuce top edge is above the bun bottom by 0.04h, so the bun's bottom edge is hidden. Good.
Draw n=7 scallops.
Let's write:
```js
const lTop = h*0.38, lBot = h*0.52;
const n = 8, segW = (xR-xL)/n;
ctx.beginPath();
ctx.moveTo(xL, lTop);
ctx.lineTo(xR, lTop);
for (let i=0;i<n;i++){
const xa = xR - i*segW, xb = xR - (i+1)*segW;
ctx.quadraticCurveTo((xa+xb)/2, lBot + lH*0.5, xb, lBot);
}
```
Hmm mixing. Let me define: bottom edge baseline at lBot; scallops dip below by lH*0.35.
```js
ctx.beginPath();
ctx.moveTo(xL, lTop);
ctx.lineTo(xR, lTop);
ctx.lineTo(xR, lBot);
for (let i=0;i<n;i++){
const xa = xR - i*segW;
const xb = xR - (i+1)*segW;
ctx.quadraticCurveTo((xa+xb)/2, lBot + dip, xb, lBot);
}
ctx.closePath();
```
where dip = lH*0.9. Ends at (xL, lBot) then closePath goes back to (xL, lTop). Good.
Fill green gradient.
OK. Then restore.
Now the paddle's collision top surface = topY = paddle.y. Good.
Let's also draw a soft glow under the burger.
HUD:
```js
ctx.font = 'bold 20px system-ui...';
ctx.fillStyle = '#fff';
ctx.fillText('SCORE ' + score, 30, 46);
```
And hearts for lives at top-right.
Let's do a top bar: score left, level center, lives hearts right.
Now, the ball starting position: on paddle top.
Let me now think about the demo AI needing to hit the ball. Since the ball launches from paddle at a random angle, and the AI tracks predicted landing x. Good.
Also the AI should be able to reach: paddle speed 1400 px/s. Ball crosses ~500px vertically at 400px/s → 1.25s. Plenty.
To keep the demo lively, the ball speed should be moderate: base 340, +8 per level, max 520.
Brick clear → new level → ball speed increase → back to level 1 layout.
Let me also add slight randomness so the demo doesn't loop forever identically.
Now let's write the code.
```js
(function(){
'use strict';
// ...
})();
```
Full code:
```js
const W = 900, H = 620;
// DOM
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('ov-title');
const overlayMsg = document.getElementById('ov-msg');
let dpr=1, scale=1, ox=0, oy=0, cssW=0, cssH=0;
function resize(){
dpr = Math.min(window.devicePixelRatio || 1, 2);
cssW = window.innerWidth; cssH = window.innerHeight;
canvas.width = Math.round(cssW*dpr);
canvas.height = Math.round(cssH*dpr);
canvas.style.width = cssW+'px';
canvas.style.height = cssH+'px';
scale = Math.min(cssW/W, cssH/H);
ox = (cssW - W*scale)/2;
oy = (cssH - H*scale)/2;
}
```
Note: at 1280x800, scale = min(1280/900=1.42, 800/620=1.29) = 1.29. World 900*1.29=1161 x 620*1.29=800. Fills height exactly. Good.
At 375x800 (mobile portrait): scale = min(0.416, 1.29) = 0.416 → world 375x258. Tiny but fits. Hmm, that's quite small on mobile. Could rotate/adapt: for narrow screens, use a different aspect? Simpler: keep it. Actually at 375px wide, the game is 375x258 centered vertically — playable-ish but small. Alternative: on narrow screens, allow the canvas to fit width and keep aspect — same thing. It's OK; a nicer approach: reduce world height for mobile? Not worth the complexity. But maybe make the world aspect adapt: if cssW/cssH < 1, use W=620, H=900? That would require re-laying out bricks. Hmm.
Compromise: use the scale-to-fit and accept the small mobile view. Many demos do that. But "adapt gracefully down to 375px" — letterboxed game with dark background is graceful enough. I could add a subtle vertical centering. Fine.
Actually, I can improve: dynamically choose world width based on aspect ratio. Let's keep it simple but scale up slightly: on very narrow screens we could use `Math.min(cssW/W, cssH/H)`. It's fine.
Hmm, alternatively make the world height adapt: H stays 620 but W could shrink for narrow screens... changing W changes brick layout and physics. Since everything derives from W at init, I could set W dynamically at load: if window is portrait, set W = 560, H = 780? Then scale = min(375/560=0.67, 800/780=1.03) = 0.67 → 375 x 522. Better use of space.
I'll implement a simple adaptive world size: choose based on aspect ratio at load time (and re-layout on resize? risky mid-game — just do at load; on resize, keep the world but recompute scale). Let's do:
```js
const portrait = window.innerHeight > window.innerWidth * 1.1;
const W = portrait ? 620 : 900;
const H = portrait ? 860 : 620;
```
Hmm, but with the code using W/H as constants, need them set before everything. Use `let W, H;` set at init. And bricks depend on W, paddles etc. On resize, we don't re-layout. But if a user rotates... edge case, ignore. Actually, let's just recompute layout on resize if the orientation class changes? Too complex. I'll fix W/H at load. Hmm, but the screenshot is at 1280x800 landscape. Fine.
Actually let's keep it simpler and more robust: fixed W=900, H=620, letterboxed. On a 375x800 phone the game is 375x258. Meh, small.
Let me reconsider: use a responsive world size computed at load based on aspect:
- if aspect (w/h) >= 1: W=900, H=900/aspectKey... no.
OK decision: compute at load:
```js
const vw = window.innerWidth, vh = window.innerHeight;
const aspect = vw/vh;
let W, H;
if (aspect > 1.25) { W = 900; H = Math.round(W/aspect) ... }
```
No — that makes H variable and the brick layout variable. It's fine actually! The bricks are laid out proportionally. Let me do:
```js
// world size picked so it fills the viewport nicely
const ar = Math.max(0.55, Math.min(2.0, vw/vh));
const AREA = 900*620;
const W = Math.round(Math.sqrt(AREA*ar));
const H = Math.round(Math.sqrt(AREA/ar));
```
For 1280x800: ar = 1.6 → W = sqrt(558000*1.6)= sqrt(892800)=945, H = sqrt(558000/1.6)= sqrt(348750)=590. So world 945x590, scale = min(1280/945=1.354, 800/590=1.356)=1.354 → fills both.
For 375x800: ar = 0.469 → clamped to 0.55 → W = sqrt(558000*0.55)= sqrt(306900)=554, H = sqrt(558000/0.55)= sqrt(1014545)=1007. That's taller than 800. scale = min(375/554=0.677, 800/1007=0.794) = 0.677 → 375x682. Fits nicely.
Great, this is nicer. Everything derived from W,H: brick layout, paddle y, etc.
For 945x590: bricks region: padX = W*0.05, topY = 90, brickH based on rows.
Let me define layout generically:
```js
const COLS = 9, ROWS = 6;
const padX = Math.max(24, W*0.05);
const gap = 8;
const brickW = (W - padX*2 - gap*(COLS-1)) / COLS;
const brickH = Math.max(18, Math.min(34, H*0.05));
const topY = H*0.13;
```
For 945x590: padX=47, brickW = (945-94-64)/9 = 87.4, brickH = min(34, 29.5)=29.5, topY=76.7. Bricks from 77 to 77+6*(29.5+8)=302. Paddle at H-70 = 520. Ball area between. Good.
For 554x1007: padX = 27.7, brickW = (554-55.4-64)/9 = 48.3, brickH = min(34, 50)=34, topY = 131. Bricks 131..383. Paddle at 1007-70=937. Big gap of 550px. Hmm, that's a lot of empty space. Maybe paddle y should be H - max(56, H*0.1). For H=1007, that's H-100=907. Still a large gap. It's fine — the ball speed relative to distance... The gap means long travel; fine.
Alternatively increase brickH for tall worlds. Meh, fine.
Ball speed should scale with world size: baseSpeed = H*0.62 (for H=590 → 366; H=1007 → 624). Yeah, scale speeds with H. Good idea: SPEED_UNIT = H*0.6.
Paddle width: W*0.16 clamped [90, 170]. For W=945 → 151; W=554 → 89 → clamped to 90? 554*0.16=88.6 → clamp min 90. ok.
Ball radius: max(7, H*0.015).
Paddle height: 48 for W=945; scale: h = clamp(W*0.055, 34, 56). For 945 → 52; 554 → 30.5→34. Fine, keep aspect ratio ~2.9:1. Let's use paddleH = paddleW*0.36 clamped.
OK, and the burger drawing uses fractions of h so it scales.
Particles sizes scale with H too.
Alright, write the code.
One more consideration: creating gradients inside the draw of each brick (54 gradients/frame) is a bit wasteful but fine at 60fps. Actually creating 54 gradients per frame may cause GC churn but it's OK. To be safe, precompute per-brick gradient at creation? Gradients depend on the current transform at fill time... but they're created in world space so we can create them once at brick creation (world coords, no transform at creation—it doesn't matter, the gradient object stores coordinates in user space and gets transformed at paint time by the then-current CTM). Since we always paint bricks under the same world transform, caching is safe.
Hmm, but careful: gradient coordinates get transformed by CTM at paint time. If created with y from b.y to b.y+b.h in world coords, and painted with world transform, correct. ✓ Cache it.
Let's cache: b.grad = createLinearGradient(0, b.y, 0, b.y+b.h).
For the burger, gradients are created in local coords after translate — those depend on the transform at creation? No, they're transformed at paint time by the CTM which includes the translate. Since we create them in local coordinates and paint under the same transform, it's consistent. Creating them each frame is fine (only ~6 gradients).
OK now let's write everything.
```js
// ===== State =====
let mode = 'demo'; // demo, play, over
let score = 0, lives = 3, level = 1, best = 0;
let shake = 0;
let time = 0;
let gameOverTimer = 0;
```
Ball/paddle objects.
```js
const paddle = { x: W/2, y: 0, w: 0, h: 0, vx: 0, tilt: 0, targetX: W/2 };
const ball = { x: 0, y: 0, vx: 0, vy: 0, r: 0, speed: 0, held: true, holdT: 0, trail: [] };
```
Let me write initGeometry():
```js
function layout(){
paddle.w = clamp(W*0.17, 92, 168);
paddle.h = clamp(paddle.w*0.38, 36, 62);
paddle.y = H - Math.max(46, H*0.09) - paddle.h; // hmm
```
We want the paddle near the bottom: paddle.y (top) = H - 0.13*H - paddle.h? For H=590: paddle.h = 58, y = 590 - 77 - 58 = 455? Hmm that's a bit high. Let's define bottom margin = 40 (fixed-ish): paddle.y = H - 44 - paddle.h. For H=590, paddle.h=58: y = 488, bottom = 546, leaving 44 to the bottom edge. Good. For H=1007: paddle.h=57, y = 906, bottom 963, 44 margin. Good.
Wall left/right inner bounds: 12 and W-12. Actually the world is clipped to a rounded rect; walls at 10 inset with a visible border line? Let's define PLAY_LEFT = 14, PLAY_RIGHT = W-14, PLAY_TOP = 14.
Bricks layout computed in layout().
Ball radius r = clamp(H*0.014, 7, 13).
baseSpeed = H * 0.62.
Alright, writing the final code now.
```js
function newLevel(keepScore){
bricks.length = 0;
const COLS = 9, ROWS = 6;
const padX = Math.max(20, W*0.05);
const gap = Math.max(6, W*0.008);
const bw = (W - padX*2 - gap*(COLS-1))/COLS;
const bh = clamp(H*0.048, 20, 34);
const top = H*0.14;
const palette = ['#ff4d6d','#ff7a45','#ffc233','#9ee85b','#4cc9f0','#b388ff'];
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
const x = padX + c*(bw+gap);
const y = top + r*(bh+gap);
const col = palette[r % palette.length];
bricks.push({ x, y, w: bw, h: bh, color: col, alive: true, flash: 0,
grad: makeBrickGrad(y, bh, col) });
}
}
}
```
makeBrickGrad: lighten color. Write a helper to lighten hex.
```js
function lighten(hex, amt){
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
r = Math.round(r + (255-r)*amt);
g = Math.round(g + (255-g)*amt);
b = Math.round(b + (255-b)*amt);
return `rgb(${r},${g},${b})`;
}
```
Brick gradient: from lighten(col,0.45) at top to col at bottom.
OK. Let's write update logic.
```js
function update(dt){
time += dt;
if (shake > 0) shake = Math.max(0, shake - dt*50);
// paddle input / AI
if (mode === 'demo' || mode === 'over'){
aiMove(dt);
} else {
playerMove(dt);
}
// paddle tilt
paddle.tilt = clamp(paddle.vx * 0.00035, -0.14, 0.14);
// smooth
paddle.tilt += (clamp(paddle.vx*0.0004,-0.13,0.13) - paddle.tilt) * Math.min(1, dt*10);
```
Hmm just set directly with smoothing of vx. Simpler: paddle.vx is computed as (x - prevX)/dt each frame, smoothed.
Let me maintain paddle.px for velocity: at the start of the move, record prevX; after moving compute vx = (x-prevX)/dt.
Then tilt target = clamp(vx*0.0004, -0.12, 0.12), smoothed.
Ball update:
```js
if (ball.held){
ball.holdT += dt;
ball.x = paddle.x;
ball.y = paddle.y - ball.r - 3;
if (ball.holdT > 0.75){ launch(); }
} else {
moveBall(dt);
}
```
In play mode maybe launch faster (0.5s).
moveBall with substeps.
Collisions with bricks inside substep.
If ball.y > H + 40 → lost.
Now, "over" mode: ball resets, AI continues? During game over, freeze the ball. Let's have mode 'over' → after 2.6s go to demo mode and reset the game.
Let's define resetGame() which sets score=0, lives=3, level=1, newLevel(), resetBall().
startGame(): mode='play'; resetGame(); hide overlay; ball.held=true holdT=0 so ball launches soon. Actually we want the player to have a moment: holdT starting at 0 with 0.75s delay. Good.
Initial demo: mode='demo'; resetGame() with AI.
Ball lost:
```js
function ballLost(){
if (mode === 'demo'){
resetBall();
} else if (mode === 'play'){
lives--;
if (lives <= 0){ gameOver(); }
else resetBall();
}
}
```
Add a small particle burst at the bottom when lost.
gameOver(): mode='over'; overlayTitle.textContent='GAME OVER'; overlayMsg.textContent = `Score ${score} — click or press a key to play again`; show overlay; gameOverTimer = 2.8;
Then in update, if mode==='over' and timer <=0 → mode='demo'; resetGame(); overlay shows start message.
Hmm but the overlay in demo mode says "Click or press a key to start". After game over, going back to demo with that message is fine.
Wait, but if the player's game is over, and they click during the "over" screen, startGame() is called → new game. Good.
Also during game over the overlay covers the board. It's semi-transparent with backdrop blur so you can still see hearts. OK.
Level clear:
```js
function checkLevel(){
if (bricks.every(b=>!b.alive)){
level++;
newLevel();
ball.speed = Math.min(baseSpeed + level*18, baseSpeed*1.7);
ball.held = true; ball.holdT = 0; // reset to paddle? Or keep going.
// flash effect
levelFlash = 1;
}
}
```
Keeping the ball in play is nicer: just rebuild the bricks and speed up. But the ball might be inside the new brick area... The bricks are at the top; the ball could be there. Add a check: if ball overlaps any new brick, nudge. Simpler: set ball.held = true (respawn on paddle) and holdT = 0.4. Slight lull but safe. Hmm, lull breaks the flow of the demo. Let's keep the ball but push it down if needed: just check overlap and if so, set vy = |vy| and move ball below the brick area. Actually if the ball is inside the brick zone when bricks respawn, the collision code will push it out next frame. That could cause weird bounces but not a crash. I'll do the safe-ish approach: only rebuild bricks after a tiny delay? Nah.
Simplest safe: new bricks appear; if the ball's center is within the brick region (y < top + rows area), move it below and set vy positive. I'll add that.
Actually, when all bricks are cleared, the ball is usually near the top of where the bricks were. It's fine; the collision resolution will push it out with a bounce. Let me just do the ball reposition check to be safe:
```js
const zoneBottom = ...;
if (ball.y < zoneBottom + ball.r) { ball.y = zoneBottom + ball.r; ball.vy = Math.abs(ball.vy); }
```
Hmm, but it's fine, do it.
Now the render function.
Let me write particles update & draw.
```js
const parts = [];
function addHeartBurst(x, y, count, power){
for (let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const sp = (0.3+Math.random()*1.1) * power;
parts.push({
kind: 'heart',
x, y,
vx: Math.cos(a)*sp + (Math.random()-0.5)*60,
vy: Math.sin(a)*sp - power*0.55,
g: power*2.4,
size: power*0.055 * (0.55+Math.random()*0.9),
rot: (Math.random()-0.5)*1.2,
vr: (Math.random()-0.5)*8,
life: 0,
max: 0.9 + Math.random()*0.9,
color: HEART_COLORS[(Math.random()*HEART_COLORS.length)|0]
});
}
}
```
power ~ H*0.16? Let's think: for H=590, power should give velocities around 200-450 px/s. Let power = H*0.55 (≈325). Then sp = (0.3..1.4)*325 = 100..455. vy adds -0.55*325 = -180 upward bias. Gravity g = 2.4*325 = 780. Good. size = 325*0.055*(0.55..1.45) = 17.9 * (0.55..1.45) = 9.8..26. Hmm, heart size ~10-26px in a 945-wide world. That's reasonably big. Maybe scale down: size = power*0.04 → 13 * (0.55..1.45) = 7..19. Good.
Hearts per brick: 14-18. With 54 bricks cleared quickly in a burst... could be 300+ particles at once. Cap at ~700 total; when over, shift out the oldest.
Also add sparks: small white/gold circles, 5 per brick.
And a shockwave ring.
Update particles:
```js
for (let i=parts.length-1;i>=0;i--){
const p = parts[i];
p.life += dt;
if (p.life >= p.max){ parts.splice(i,1); continue; }
p.vy += p.g*dt;
p.vx *= 1 - 1.6*dt;
p.vy *= 1 - 0.5*dt;
p.x += p.vx*dt;
p.y += p.vy*dt;
p.rot += p.vr*dt;
}
```
Hmm drag on vy too much might make them float. Use light drag.
Rings: separate array or part kind 'ring' with radius growth.
I'll add kind 'ring' with r0, r1.
Draw particles: for hearts, globalAlpha with fade curve.
Now the ball trail: array of positions, push each frame, max 14. Draw as fading circles with radius shrinking.
Let me now write out all the code in order.
Also keyboard handling: prevent default for arrows/space to avoid scrolling.
Pointer:
```js
window.addEventListener('pointermove', e => {
const rect = canvas.getBoundingClientRect();
const wx = (e.clientX - rect.left - ox)/scale;
pointerX = wx;
pointerActive = true;
});
```
where ox is the css offset. ✓ (ox, oy are in CSS px.)
Clamp pointerX to [PLAY_LEFT + paddle.w/2, PLAY_RIGHT - paddle.w/2].
In playerMove:
```js
function playerMove(dt){
const prev = paddle.x;
let kx = 0;
if (keys['ArrowLeft']||keys['a']||keys['A']) kx -= 1;
if (keys['ArrowRight']||keys['d']||keys['D']) kx += 1;
if (kx !== 0){ pointerActive = false; paddle.x += kx * W*1.1 * dt; }
else if (pointerActive){ paddle.x += (pointerX - paddle.x) * Math.min(1, dt*22); }
paddle.x = clamp(paddle.x, PLAY_LEFT + paddle.w/2, PLAY_RIGHT - paddle.w/2);
paddle.vx = (paddle.x - prev)/Math.max(dt, 1e-4);
}
```
Keydown also triggers startGame if in demo/over. Good.
Now writing the render of the world.
```js
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
// page background
ctx.fillStyle = '#07060c';
ctx.fillRect(0,0,cssW,cssH);
// world transform with shake
const s = scale;
let shx=0, shy=0;
if (shake>0.2){ shx = (Math.random()-0.5)*shake*0.5; shy=(Math.random()-0.5)*shake*0.5; }
ctx.setTransform(dpr*s, 0, 0, dpr*s, dpr*(ox+shx), dpr*(oy+shy));
```
Wait: shake in world units should be applied inside the world transform. Since we set the transform with translate in css px, adding shx in css px means shake gets multiplied... Let's just apply shake in world coordinates: translate(dpr*(ox), dpr*(oy)) then translate in world units: use setTransform(dpr*s,0,0,dpr*s, dpr*ox, dpr*oy) and then ctx.translate(shx, shy) in world units. Cleaner.
```js
ctx.setTransform(dpr*s,0,0,dpr*s, dpr*ox, dpr*oy);
ctx.translate(shx, shy);
```
Then draw the rounded-rect world background.
```js
ctx.save();
roundRect(ctx, 0,0,W,H, 24);
ctx.clip();
// bg gradient
ctx.fillStyle = bgGrad; ctx.fillRect(0,0,W,H);
// glows
ctx.fillStyle = glowGrad; ctx.fillRect(0,0,W,H);
...
```
bgGrad and glowGrad created once at layout (world coords) — must be re-created if W/H change, but they don't after load. Create after layout.
Also maybe draw subtle dotted grid. Skip.
Bricks, then paddle, ball, particles, HUD. Wait: particles should be in front of bricks but behind HUD; the paddle should be behind hearts too (hearts fly everywhere). Order: bricks → paddle → ball → particles → HUD.
Now HUD:
```js
ctx.font = `600 ${Math.round(H*0.032)}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`;
```
For H=590 → 19px. Fill white with some alpha.
Score at left x=W*0.05, y = H*0.09? The bricks start at H*0.14, so the HUD at H*0.075 is fine. Let's use y = H*0.075 baseline.
Level centered, lives as hearts at right.
Draw hearts for lives: small hearts of size ~H*0.03 at x from right.
OK, and during play mode there might be a "level" label.
Let's write drawHeart(x,y,size,rot,color,alpha).
```js
const heartPath = new Path2D();
heartPath.moveTo(0, 7.5);
heartPath.bezierCurveTo(-11, -1.5, -7, -13, 0, -5.5);
heartPath.bezierCurveTo(7, -13, 11, -1.5, 0, 7.5);
heartPath.closePath();
```
Scale: the path spans y from -13 to 7.5 (20.5) and x -11..11 (22). So a "size" parameter s maps to scale = s/22 roughly where s = width. Let's use scale = s/22.
drawHeart:
```js
function drawHeart(x,y,size,rot,color,alpha){
ctx.save();
ctx.translate(x,y);
if (rot) ctx.rotate(rot);
const k = size/22;
ctx.scale(k,k);
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fill(heartPath);
ctx.restore();
}
```
Note globalAlpha restore via save/restore ✓.
For performance with hundreds of hearts, save/restore + fill is okay.
Optional: add a small white highlight on big hearts. Skip.
Now the burger paddle drawing. Also add a soft shadow/glow beneath.
I also want the paddle to show a "shine" and maybe a plate. Skip the plate.
Let me write drawBurger:
```js
function drawBurger(cx, topY, w, h, tilt){
ctx.save();
ctx.translate(cx, topY);
ctx.rotate(tilt);
const half = w/2;
const xL = -half + 1, xR = half - 1;
// bottom bun
const bbY = h*0.72, bbH = h*0.28, r = Math.min(bbH*0.9, half*0.5);
ctx.beginPath();
ctx.moveTo(xL, bbY);
ctx.lineTo(xR, bbY);
ctx.lineTo(xR, bbY+bbH-r);
ctx.quadraticCurveTo(xR, bbY+bbH, xR-r, bbY+bbH);
ctx.lineTo(xL+r, bbY+bbH);
ctx.quadraticCurveTo(xL, bbY+bbH, xL, bbY+bbH-r);
ctx.closePath();
let g = ctx.createLinearGradient(0, bbY, 0, bbY+bbH);
g.addColorStop(0, '#f6bd63'); g.addColorStop(1, '#c07f2e');
ctx.fillStyle = g; ctx.fill();
// patty
const py = h*0.56, ph = h*0.24;
roundRect(ctx, xL+2, py, (xR-xL)-4, ph, ph*0.5);
g = ctx.createLinearGradient(0, py, 0, py+ph);
g.addColorStop(0, '#8a5230'); g.addColorStop(0.5,'#6b3a1f'); g.addColorStop(1, '#46230f');
ctx.fillStyle = g; ctx.fill();
// cheese
const cy = h*0.46, chh = h*0.16;
// drips first (behind main slice)
ctx.fillStyle = '#ffc93c';
for (let i=0;i<4;i++){
const dx = xL + (i+0.5)*((xR-xL)/4);
ctx.beginPath();
ctx.arc(dx, cy+chh+ h*0.02, h*0.075, 0, Math.PI*2);
ctx.fill();
}
roundRect(ctx, xL-2, cy, (xR-xL)+4, chh, 4);
g = ctx.createLinearGradient(0, cy, 0, cy+chh);
g.addColorStop(0,'#ffe066'); g.addColorStop(1,'#f5b21a');
ctx.fillStyle = g; ctx.fill();
// top bun
const tbY = h*0.44;
const k = tbY/0.75;
ctx.beginPath();
ctx.moveTo(xL+2, tbY);
ctx.bezierCurveTo(xL+2, -k, xR-2, -k, xR-2, tbY);
ctx.quadraticCurveTo(xR-2, tbY+2, xR-8, tbY+2);
ctx.lineTo(xL+8, tbY+2);
ctx.quadraticCurveTo(xL+2, tbY+2, xL+2, tbY);
ctx.closePath();
g = ctx.createLinearGradient(0, 0, 0, tbY);
g.addColorStop(0,'#ffcf82'); g.addColorStop(1,'#dd9b3c');
ctx.fillStyle = g; ctx.fill();
// sesame
ctx.fillStyle = 'rgba(255,246,220,0.95)';
const seeds = [[-0.45,0.30],[-0.12,0.14],[0.22,0.16],[0.52,0.34],[-0.72,0.52],[0.72,0.55]];
for (const [sx, sy] of seeds){
ctx.save();
ctx.translate(sx*half, sy*tbY);
ctx.rotate(sx*0.6);
ctx.beginPath();
ctx.ellipse(0,0, w*0.035, w*0.018, 0, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
// lettuce
const lTop = h*0.36, lBot = h*0.52;
const n = 7, segW = (xR-xL)/n;
ctx.beginPath();
ctx.moveTo(xL, lTop);
ctx.lineTo(xR, lTop);
ctx.lineTo(xR, lBot);
for (let i=0;i<n;i++){
const xa = xR - i*segW, xb = xR-(i+1)*segW;
ctx.quadraticCurveTo((xa+xb)/2, lBot + h*0.14, xb, lBot);
}
ctx.closePath();
g = ctx.createLinearGradient(0, lTop, 0, lBot+h*0.14);
g.addColorStop(0,'#9be36a'); g.addColorStop(1,'#3f9a2b');
ctx.fillStyle = g; ctx.fill();
ctx.restore();
}
```
Wait, lettuce drawn last covers the top bun's bottom edge only if lTop < tbY. lTop = 0.36h < 0.44h ✓. But lettuce is drawn after the bun so it covers the bun's bottom from 0.36h to 0.44h — the bun's visible dome bottom is at 0.44h, now covered from 0.36. Fine.
But the lettuce top edge at 0.36h is above the cheese top at 0.46h — so the lettuce covers part of the cheese. That's fine (lettuce over cheese).
Hmm wait, actually the layering means: cheese drawn at 0.46-0.62, then top bun 0-0.44, then lettuce 0.36-0.52+. Lettuce covers cheese's top from 0.46-0.52. OK.
Also I want a subtle dark outline around the burger for definition? Maybe a thin shadow. Let's add ctx.shadowColor/shadowBlur for the whole burger: shadowColor 'rgba(0,0,0,0.5)', shadowBlur = h*0.3, shadowOffsetY = h*0.1. Setting shadow on each fill might be slow but fine. Actually shadows on each sub-fill could look muddy. Skip; instead draw a soft ellipse shadow beneath the burger before drawing (in world coords, outside the transform).
Let me instead add a glow behind the paddle: radial gradient in warm color at the paddle base. Nice.
I'll add in render before drawBurger:
```js
const gl = ctx.createRadialGradient(paddle.x, paddle.y+paddle.h*0.5, 4, paddle.x, paddle.y+paddle.h*0.5, paddle.w*0.9);
gl.addColorStop(0,'rgba(255,170,80,0.20)'); gl.addColorStop(1,'rgba(255,170,80,0)');
ctx.fillStyle = gl; ctx.fillRect(...)
```
Create this each frame — 1 gradient, fine. Or cache per frame; fine.
Also floor line at the bottom: a subtle "plate" ellipse. Skip.
Now the ball. Let's make it a golden fried ball with a glow:
```js
function drawBall(){
const r = ball.r;
ctx.save();
// trail
for (let i=0;i<ball.trail.length;i++){
const t = ball.trail[i];
const a = (i/ball.trail.length) * 0.35;
ctx.beginPath();
ctx.arc(t.x, t.y, r*(0.3 + 0.7*(i/ball.trail.length)), 0, Math.PI*2);
ctx.fillStyle = `rgba(255,190,90,${a})`;
ctx.fill();
}
// glow
ctx.shadowColor = 'rgba(255,170,60,0.9)';
ctx.shadowBlur = r*2.5;
const g = ctx.createRadialGradient(ball.x - r*0.35, ball.y - r*0.4, r*0.1, ball.x, ball.y, r);
g.addColorStop(0, '#fffdf0');
g.addColorStop(0.45, '#ffd24a');
g.addColorStop(1, '#f08a1a');
ctx.beginPath(); ctx.arc(ball.x, ball.y, r, 0, Math.PI*2);
ctx.fillStyle = g; ctx.fill();
ctx.restore();
}
```
Trail array push each frame with {x,y}, max ~10.
Now, main loop with dt.
Let me also handle the "levelFlash" — skip.
Screen shake value: on brick break, shake = min(shake + 3, 10) in world units. Applied as translate.
Now writing the whole file.
Also need the overlay CSS with a nice look: centered card, glassy, with a pulsing message.
Overlay HTML:
```html
<div id="overlay">
<div class="card">
<h1>🍔 Hamburger Breakout</h1>
<p id="ov-msg">Click or press a key to start</p>
<p class="hint">Move with mouse / arrow keys</p>
</div>
</div>
```
Emoji in the title is fine (no external assets).
Hmm, "no external fonts" — using emoji is fine, rendered by the system font.
The overlay should not block the canvas view entirely: semi-transparent card with backdrop-filter blur.
But note: since the demo plays behind it, the card should be small enough not to hide the action. Put it in the center but with semi-transparency and smaller size. Actually the ball area center is where the action is. Hmm. Put the card at the center-bottom area? Like a banner near the bottom-center just above the paddle? That could block the paddle.
Alternative: place the message as a horizontal pill centered vertically at ~60% height with a subtle backdrop. Or at the top-center below the HUD.
I think center is standard and readable. Let's do center with a moderately transparent card (rgba(10,8,20,0.55) + blur). The action will still be visible around it. Actually with a big title and card ~400x160 centered in a 945x590 world, that covers a lot.
Compromise: place the card at 62% height (below center), and make it compact. The bricks are at the top and the ball usually bounces around the middle... Whatever. Put it centered horizontally at y ≈ 55% with a compact size.
Let's do it: `#overlay { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; }` and the card with padding, blur, etc. Use a slight translateY(0). I'll place items at center but design the card to be translucent so the demo shows through.
Fine. Let's finalize.
Also add a small "DEMO" badge shown only in demo mode, and hide it in play mode. Nice touch. Put it top-center? The HUD has level in the center. Let's put the badge as part of the overlay card ("DEMO — watching an AI play").
Alright, writing the final code.
One more: the ball must not get stuck bouncing horizontally forever. Add a safeguard: if |vy| < 0.2*speed, nudge vy. Also clamp the paddle bounce angle to a max of ~65° from vertical so it never goes horizontal.
Also add a minimum vy after a paddle hit.
Let's write moveBall carefully:
```js
function moveBall(dt){
const dist = Math.hypot(ball.vx, ball.vy) * dt;
const steps = Math.max(1, Math.ceil(dist / (ball.r*0.8)));
const sdt = dt/steps;
for (let i=0;i<steps;i++){
ball.x += ball.vx*sdt;
ball.y += ball.vy*sdt;
// walls
if (ball.x - ball.r < PLAY_LEFT){ ball.x = PLAY_LEFT + ball.r; ball.vx = Math.abs(ball.vx); wallHit(); }
if (ball.x + ball.r > PLAY_RIGHT){ ball.x = PLAY_RIGHT - ball.r; ball.vx = -Math.abs(ball.vx); wallHit(); }
if (ball.y - ball.r < PLAY_TOP){ ball.y = PLAY_TOP + ball.r; ball.vy = Math.abs(ball.vy); wallHit(); }
// paddle
if (ball.vy > 0 && ball.y + ball.r >= paddle.y && ball.y - ball.r <= paddle.y + paddle.h && ball.x >= paddle.x - paddle.w/2 - ball.r && ball.x <= paddle.x + paddle.w/2 + ball.r){
hitPaddle();
}
// bricks
for (const b of bricks){
if (!b.alive) continue;
if (ball.x + ball.r < b.x || ball.x - ball.r > b.x+b.w || ball.y + ball.r < b.y || ball.y - ball.r > b.y+b.h) continue;
// resolve
const cx = b.x + b.w/2, cy = b.y + b.h/2;
const ox_ = (b.w/2 + ball.r) - Math.abs(ball.x - cx);
const oy_ = (b.h/2 + ball.r) - Math.abs(ball.y - cy);
if (ox_ < oy_){
ball.x += (ball.x < cx ? -ox_ : ox_);
ball.vx = -ball.vx;
} else {
ball.y += (ball.y < cy ? -oy_ : oy_);
ball.vy = -ball.vy;
}
breakBrick(b);
break;
}
}
}
```
Break out of the brick loop after one collision per substep. Good.
hitPaddle:
```js
function hitPaddle(){
ball.y = paddle.y - ball.r - 0.5;
const off = clamp((ball.x - paddle.x)/(paddle.w/2), -1, 1);
const angle = off * 1.05; // max ~60deg
const sp = Math.hypot(ball.vx, ball.vy);
ball.vx = Math.sin(angle)*sp + paddle.vx*0.12;
ball.vy = -Math.abs(Math.cos(angle)*sp);
// renormalize to keep speed
const ns = Math.hypot(ball.vx, ball.vy) || 1;
ball.vx = ball.vx/ns*sp; ball.vy = ball.vy/ns*sp;
// ensure some upward movement
if (ball.vy > -sp*0.35) ball.vy = -sp*0.35;
...renormalize again
spawn small hearts at contact
shake += 1.5
}
```
Simplify: compute vx,vy then normalize to sp, then if |vy| < 0.3*sp adjust.
Actually after the angle-based computation, |vy| = cos(angle)*sp ≥ cos(60°)*sp = 0.5sp. Safe enough. Then renormalizing including the paddle.vx addition keeps the speed. Good.
Wall hit: small sparkle.
breakBrick(b):
```js
function breakBrick(b){
b.alive = false;
score += 10;
shake = Math.min(shake + 4, 12);
addHeartBurst(b.x+b.w/2, b.y+b.h/2, 16, H*0.5);
addSparks(...)
rings.push({x: cx, y: cy, r: 6, max: Math.max(b.w,b.h)*1.6, life:0, dur:0.45});
// remove after
}
```
Cap particles.
Also, I'll add a small chance the ball speeds up slightly per brick? no.
Now writing everything for real. I need to be careful about ordering of declarations (function hoisting handles most).
Let me write the code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Hamburger Breakout</title>
<style>
* { box-sizing: border-box; }
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#07060c; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#game { display:block; position:fixed; inset:0; touch-action:none; cursor:none; }
#overlay { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; padding:16px; }
#overlay.hidden { display:none; }
.card { text-align:center; padding:22px 30px; border-radius:20px; background:rgba(16,10,26,0.62); border:1px solid rgba(255,255,255,0.14); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 18px 50px rgba(0,0,0,0.55), 0 0 60px rgba(255,90,140,0.18); max-width: 92vw;}
h1 { margin:0 0 10px; font-size: clamp(18px, 3.4vw, 34px); letter-spacing:0.5px; color:#fff; text-shadow: 0 2px 20px rgba(255,90,140,0.55);}
...
</style>
```
Note: `cursor:none` on the canvas might be annoying; keep default cursor. Actually hide the cursor over the canvas during play? No, keep it visible so the user knows where to point. Keep default.
Font sizes with clamp for responsiveness.
The h1 emoji 🍔 before text.
Message pulsing animation:
```css
@keyframes pulse { 0%,100%{opacity:.65} 50%{opacity:1} }
#ov-msg { animation: pulse 1.8s ease-in-out infinite; }
```
Now the JS. Let me write it fully.
```js
(function(){
'use strict';
// ---------- viewport / world ----------
const vw = window.innerWidth, vh = window.innerHeight;
const ar = Math.max(0.55, Math.min(2.05, vw/vh));
const AREA = 900*620;
const W = Math.round(Math.sqrt(AREA*ar));
const H = Math.round(Math.sqrt(AREA/ar));
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ov-title');
const ovMsg = document.getElementById('ov-msg');
const ovHint = document.getElementById('ov-hint');
let dpr = 1, scale = 1, ox = 0, oy = 0, cssW = vw, cssH = vh;
function clamp(v,a,b){ return v<a?a:v>b?b:v; }
function rand(a,b){ return a + Math.random()*(b-a); }
const PLAY_LEFT = W*0.015, PLAY_RIGHT = W - W*0.015, PLAY_TOP = H*0.02;
let bgGrad, glowGrad;
function resize(){
dpr = Math.min(window.devicePixelRatio||1, 2);
cssW = window.innerWidth; cssH = window.innerHeight;
canvas.width = Math.round(cssW*dpr);
canvas.height = Math.round(cssH*dpr);
canvas.style.width = cssW+'px';
canvas.style.height = cssH+'px';
scale = Math.min(cssW/W, cssH/H);
ox = (cssW - W*scale)/2;
oy = (cssH - H*scale)/2;
}
window.addEventListener('resize', resize);
resize();
```
Note: bgGrad created in world coords without transform — fine.
```js
bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#1a1030');
bgGrad.addColorStop(0.55,'#120c22');
bgGrad.addColorStop(1,'#200e20');
glowGrad = ctx.createRadialGradient(W*0.5, H*1.05, H*0.05, W*0.5, H*1.05, H*0.95);
glowGrad.addColorStop(0,'rgba(255,140,80,0.22)');
glowGrad.addColorStop(1,'rgba(255,140,80,0)');
```
But gradients are transformed by the CTM at paint time — created now with the identity transform, coordinates in world space ✓ (we paint under the world transform, so they map correctly).
Hmm, careful: at creation time the CTM is identity, so the gradient's coordinates are "user space" numbers. When painting with the world transform, those coordinates are interpreted in the current user space = world space ✓.
Now geometry.
```js
const paddle = { x: W/2, y: 0, w: 0, h: 0, vx: 0, tilt: 0 };
const ball = { x: W/2, y: 0, vx: 0, vy: 0, r: 8, speed: 0, held: true, holdT: 0, trail: [] };
let bricks = [];
let parts = [];
let rings = [];
let score = 0, lives = 3, level = 1;
let mode = 'demo';
let shake = 0, time = 0, overTimer = 0;
let baseSpeed = 0;
let pointerX = W/2, pointerActive = false;
const keys = {};
```
layout():
```js
function layout(){
paddle.w = clamp(W*0.17, 92, 172);
paddle.h = clamp(paddle.w*0.40, 34, 64);
paddle.y = H - Math.max(38, H*0.075) - paddle.h;
ball.r = clamp(H*0.0145, 6.5, 13);
baseSpeed = H*0.60;
}
```
For H=590: paddle.w = clamp(945*0.17=160, 92,172) = 160, paddle.h = 64 → clamp to 64. paddle.y = 590 - 44.25 - 64 = 481. Ball r = 8.55. baseSpeed = 354.
Hmm paddle.h = 64 is quite tall relative to the world height (11%). Burger aspect: w/h = 160/64 = 2.5. That's a chunky burger. Fine, looks good.
Actually maybe paddle.h = paddle.w*0.34 → 54. Let's use 0.34 and clamp [32, 60]. w/h = 2.94. Good.
paddle.y = H - max(38, H*0.075) - paddle.h = 590-44-54 = 492.
Bricks: top = H*0.14 = 82.6; bh = clamp(H*0.048, 20, 34) = 28.3; gap = 8; 6 rows → 82.6 to 82.6+6*36.3 = 300. Ball area 300..492. Good.
Now newLevel:
```js
const PALETTE = ['#ff4d6d','#ff7a45','#ffc233','#8ee85b','#4cc9f0','#b388ff'];
function makeBricks(){
bricks = [];
const COLS = 9, ROWS = 6;
const padX = Math.max(18, W*0.05);
const gap = Math.max(6, W*0.0085);
const bw = (W - padX*2 - gap*(COLS-1))/COLS;
const bh = clamp(H*0.048, 18, 34);
const top = H*0.145;
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
const x = padX + c*(bw+gap);
const y = top + r*(bh+gap);
const col = PALETTE[r % PALETTE.length];
const g = ctx.createLinearGradient(0, y, 0, y+bh);
g.addColorStop(0, lighten(col, 0.42));
g.addColorStop(1, col);
bricks.push({x,y,w:bw,h:bh,color:col,alive:true,grad:g,flash:0});
}
}
brickBottom = top + ROWS*(bh+gap);
}
```
Need `brickBottom` variable declared.
Colors: lighten() helper.
Heart colors: ['#ff4d6d','#ff85a1','#ff2e63','#ff9ebb','#ffd1dc','#ff6b9d','#ffb3c1'].
Now the update loop.
```js
let lastT = performance.now();
function frame(now){
let dt = (now - lastT)/1000;
lastT = now;
if (!isFinite(dt)) dt = 0.016;
dt = Math.min(dt, 0.05);
update(dt);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```
Hmm, first frame dt could be large; clamped to 0.05. Fine.
update(dt):
```js
function update(dt){
time += dt;
if (shake > 0) shake = Math.max(0, shake - dt*45);
// paddle
const prevX = paddle.x;
if (mode === 'play') playerMove(dt); else aiMove(dt);
paddle.x = clamp(paddle.x, PLAY_LEFT + paddle.w/2, PLAY_RIGHT - paddle.w/2);
paddle.vx = (paddle.x - prevX)/Math.max(dt, 0.0001);
paddle.tilt += (clamp(paddle.vx*0.00035, -0.12, 0.12) - paddle.tilt) * Math.min(1, dt*12);
// ball
if (ball.held){
ball.holdT += dt;
ball.x = paddle.x;
ball.y = paddle.y - ball.r - 2;
if (ball.holdT > (mode==='play'?0.55:0.7)) launch();
} else {
moveBall(dt);
ball.trail.push({x:ball.x, y:ball.y});
if (ball.trail.length > 12) ball.trail.shift();
}
// particles
updateParts(dt);
updateRings(dt);
// game over timer
if (mode === 'over'){
overTimer -= dt;
if (overTimer <= 0) toDemo();
}
}
```
level clear check inside breakBrick or after moveBall: call checkLevel() after moveBall each frame.
checkLevel:
```js
function checkLevel(){
if (bricks.length && bricks.some(b=>b.alive)) return;
level++;
ball.speed = Math.min(baseSpeed*(1 + 0.08*(level-1)), baseSpeed*1.8);
makeBricks();
// reposition ball if inside brick zone
if (ball.y < brickBottom + ball.r){
ball.y = brickBottom + ball.r + 2;
ball.vy = Math.abs(ball.vy) || ball.speed;
}
levelMsgT = 1.6;
}
```
Also speed up the current ball: set ball velocity magnitude to ball.speed.
Actually let me store ball.speed as the current speed and renormalize on level up.
launch():
```js
function launch(){
ball.held = false;
ball.holdT = 0;
const sp = ball.speed || baseSpeed;
const a = rand(-0.45, 0.45);
ball.vx = Math.sin(a)*sp;
ball.vy = -Math.cos(a)*sp;
}
```
Wait, ball.speed needs to be initialized in resetBall.
resetBall():
```js
function resetBall(){
ball.held = true; ball.holdT = 0;
ball.speed = Math.min(baseSpeed*(1+0.08*(level-1)), baseSpeed*1.8);
ball.trail.length = 0;
ball.x = paddle.x; ball.y = paddle.y - ball.r - 2;
ball.vx = 0; ball.vy = 0;
}
```
ballLost:
```js
function ballLost(){
// burst at bottom
addHeartBurst(ball.x, H-10, 8, H*0.35);
if (mode === 'play'){
lives--;
shake = 10;
if (lives <= 0){ gameOver(); return; }
}
resetBall();
}
```
In demo mode just reset.
gameOver():
```js
function gameOver(){
mode = 'over';
overTimer = 3.0;
ovTitle.textContent = 'Game Over';
ovMsg.textContent = `Score ${score} — click or press a key to play again`;
ovHint.textContent = 'A new demo starts in a moment…';
overlay.classList.remove('hidden');
resetBall();
}
```
Hmm, but on timer end we go to demo and show the start message.
toDemo():
```js
function toDemo(){
mode = 'demo';
resetGame();
ovTitle.textContent = '🍔 Hamburger Breakout';
ovMsg.textContent = 'Click or press a key to start';
ovHint.textContent = 'Demo mode — the AI is playing';
overlay.classList.remove('hidden');
}
```
startGame():
```js
function startGame(){
mode = 'play';
resetGame();
overlay.classList.add('hidden');
}
```
resetGame():
```js
function resetGame(){
score = 0; lives = 3; level = 1;
parts.length = 0; rings.length = 0;
shake = 0;
makeBricks();
paddle.x = W/2;
resetBall();
pointerX = W/2;
}
```
Careful: resetGame is called on load for demo. But the demo should show some action immediately: ball launches after 0.7s. Good.
Hmm, one thing: on first paint the screenshot at 3s should show hearts. The ball takes ~0.7s to launch, then travels up ~1s to hit bricks → hearts at ~1.8s. Good, hearts visible at 3s.
To be safer, pre-seed the demo with some hearts already flying at start? Nah, 3s is enough. Actually let's reduce the initial hold to 0.4s in demo mode for the first launch. Fine.
Actually, to be extra safe: at init, break a couple of bricks immediately to spawn hearts right away? That'd be weird. Instead, spawn a gentle "ambient hearts" field at the bottom? No. It's fine.
Hmm — the screenshot timing is "about 3 seconds after load". Ball launches at 0.7s, travels from y=492 up to y=300 at 354 px/s = 0.54s, hits bricks at ~1.24s, hearts explode. At 3s there will be another brick hit probably. Good, hearts will be visible.
Let's make the demo AI slightly aggressive so it clears bricks fast... it's fine.
Now, aiMove:
```js
function aiMove(dt){
let target = paddle.x;
if (!ball.held && ball.vy > 0){
// predict
let px = ball.x, py = ball.y, vx = ball.vx, vy = ball.vy;
const yTarget = paddle.y - ball.r;
let guard = 0;
while (py < yTarget && guard < 600){
const step = 0.012;
px += vx*step; py += vy*step;
if (px < PLAY_LEFT + ball.r){ px = PLAY_LEFT + ball.r + (PLAY_LEFT + ball.r - px); vx = -vx; }
else if (px > PLAY_RIGHT - ball.r){ px = PLAY_RIGHT - ball.r - (px - (PLAY_RIGHT - ball.r)); vx = -vx; }
guard++;
}
target = px;
} else if (!ball.held) {
target = ball.x + ball.vx*0.10;
} else {
target = ball.x;
}
target += Math.sin(time*2.3)*W*0.012; // little wobble
const maxV = W*1.35;
const d = target - paddle.x;
paddle.x += clamp(d, -maxV*dt, maxV*dt);
}
```
The wobble makes it slightly imperfect. W*0.012 for 945 = 11px. Fine.
Note the wobble might make it miss occasionally. Acceptable — in demo it just resets.
Hmm, `ball.x + ball.vx*0.10` when ball moving up — fine.
playerMove as described.
Now particles.
```js
const HEART_COLORS = ['#ff4d6d','#ff85a1','#ff2e63','#ff9ebb','#ffd1dc','#ff6b9d','#ffb3c1'];
function addHeartBurst(x, y, count, power){
for (let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const sp = rand(0.25, 1.15)*power;
parts.push({
k:0, x, y,
vx: Math.cos(a)*sp,
vy: Math.sin(a)*sp*0.85 - power*0.55,
g: power*2.6,
s: power*0.042*rand(0.55,1.4),
rot: rand(-0.8,0.8),
vr: rand(-7,7),
life: 0,
max: rand(0.85, 1.75),
c: HEART_COLORS[(Math.random()*HEART_COLORS.length)|0]
});
}
if (parts.length > 900) parts.splice(0, parts.length-900);
}
function addSparks(x,y,count,power){
for (let i=0;i<count;i++){
const a = Math.random()*Math.PI*2;
const sp = rand(0.3,1.2)*power;
parts.push({
k:1, x, y,
vx: Math.cos(a)*sp, vy: Math.sin(a)*sp - power*0.3,
g: power*1.2,
s: power*0.014*rand(0.6,1.5),
life:0, max: rand(0.35,0.8),
c: Math.random()<0.5 ? '#ffe9a8' : '#ffffff'
});
}
}
```
power ~ H*0.5 = 295 for H=590. Then heart speeds: 0.25..1.15 * 295 = 74..340 px/s. size = 295*0.042 = 12.4 * (0.55..1.4) = 6.8..17.4. Good.
Gravity = 2.6*295 = 767 px/s². Over 1.5s that's a big fall; with the initial upward velocity (-0.55*295 = -162), they arc nicely. Good.
updateParts:
```js
function updateParts(dt){
for (let i=parts.length-1;i>=0;i--){
const p = parts[i];
p.life += dt;
if (p.life >= p.max){ parts.splice(i,1); continue; }
p.vy += p.g*dt;
p.vx -= p.vx*1.9*dt;
p.vy -= p.vy*0.4*dt;
p.x += p.vx*dt;
p.y += p.vy*dt;
if (p.k===0) p.rot += p.vr*dt;
}
}
```
Wait, applying `p.vy -= p.vy*0.4*dt` is drag which counteracts gravity — fine.
Hmm, the drag on vx will make them slow horizontally. Fine.
Draw parts:
```js
for (const p of parts){
const t = p.life/p.max;
const alpha = t < 0.12 ? t/0.12 : 1 - (t-0.12)/0.88;
if (p.k === 0){
drawHeart(p.x, p.y, p.s*(1 - t*0.25), p.rot, p.c, Math.max(0,alpha));
} else {
ctx.globalAlpha = Math.max(0,alpha)*0.9;
ctx.fillStyle = p.c;
ctx.beginPath(); ctx.arc(p.x,p.y,p.s,0,Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
}
```
Careful to reset globalAlpha. drawHeart does save/restore and sets globalAlpha internally. For sparks, restore manually. Let me wrap sparks in save/restore or set globalAlpha=1 after. I'll set after.
Rings:
```js
function addRing(x,y,maxR,color){
rings.push({x,y,r:4,max:maxR,life:0,dur:0.5,color});
}
function updateRings(dt){
for (let i=rings.length-1;i>=0;i--){
const r = rings[i];
r.life += dt;
if (r.life > r.dur) rings.splice(i,1);
}
}
```
Draw ring: alpha = 1-life/dur; radius = ease out.
Now, breaking a brick calls addRing with maxR = max(b.w,b.h)*1.5.
Now the render function.
```js
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.fillStyle = '#07060c';
ctx.fillRect(0,0,cssW,cssH);
const s = scale;
let shx = 0, shy = 0;
if (shake > 0.3){
shx = (Math.random()-0.5)*shake;
shy = (Math.random()-0.5)*shake;
}
ctx.setTransform(dpr*s,0,0,dpr*s, dpr*ox, dpr*oy);
ctx.translate(shx, shy);
// world bg
ctx.save();
roundRectPath(ctx, 0,0,W,H, Math.min(28, W*0.03));
ctx.clip();
ctx.fillStyle = bgGrad;
ctx.fillRect(0,0,W,H);
ctx.fillStyle = glowGrad;
ctx.fillRect(0,0,W,H);
drawBricks();
drawPaddle();
drawBallAndTrail();
drawParticles();
drawRings();
drawHUD();
ctx.restore();
// border
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
roundRectPath(ctx, 1,1,W-2,H-2, Math.min(28, W*0.03));
ctx.stroke();
}
```
Wait, the border must also be drawn under the world transform (it is, since we're still in it). But we did ctx.restore() which restored the transform clipping state (save/restore inside doesn't change the transform since we didn't modify it). Actually ctx.save()/clip()/restore() — the transform stays as set. Fine.
Also the world may be larger than the canvas in the letterbox direction? No, scale fits it.
drawBricks with the cached gradient and a heart icon.
```js
function drawBricks(){
for (const b of bricks){
if (!b.alive) continue;
ctx.save();
roundRectPath(ctx, b.x, b.y, b.w, b.h, Math.min(8, b.h*0.3));
ctx.fillStyle = b.grad;
ctx.fill();
// gloss
ctx.globalAlpha = 0.28;
ctx.fillStyle = '#ffffff';
roundRectPath(ctx, b.x + b.w*0.08, b.y + b.h*0.12, b.w*0.84, b.h*0.26, b.h*0.13);
ctx.fill();
ctx.globalAlpha = 1;
// heart icon
drawHeart(b.x + b.w/2, b.y + b.h*0.58, Math.min(b.w,b.h*2.4)*0.42, 0, 'rgba(255,255,255,0.55)', 1);
ctx.restore();
}
}
```
Hmm the heart size: b.h ~28, b.w ~87. min(87, 67) = 67 * 0.42 = 28 — too big for the brick height. Let's use size = min(b.w*0.3, b.h*0.95) → min(26, 26.6) = 26. Hmm still large. Heart size is the width of the heart; height is 20/22 of that. For a 28px tall brick, a heart of width 16 → height 14.5. Use size = b.h*0.55 → 15.4. Good.
drawHeart(x,y,size...) with size = width. OK: size = b.h*0.6.
White at 0.35 alpha. Looks like a subtle icon.
Wait, the gloss overlay rect might look like a highlight bar. Fine.
drawPaddle:
```js
function drawPaddle(){
// warm glow
const gx = paddle.x, gy = paddle.y + paddle.h*0.55;
const gl = ctx.createRadialGradient(gx, gy, 2, gx, gy, paddle.w*0.85);
gl.addColorStop(0,'rgba(255,170,90,0.20)');
gl.addColorStop(1,'rgba(255,170,90,0)');
ctx.fillStyle = gl;
ctx.fillRect(gx - paddle.w, gy - paddle.h*1.2, paddle.w*2, paddle.h*2.6);
drawBurger(paddle.x, paddle.y, paddle.w, paddle.h, paddle.tilt);
}
```
drawBallAndTrail as described.
drawHUD:
```js
function drawHUD(){
const fs = Math.round(clamp(H*0.032, 13, 22));
ctx.font = `700 ${fs}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fillText('SCORE ' + score, W*0.05, H*0.072);
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText('LEVEL ' + level, W*0.5, H*0.072);
// lives
ctx.textAlign = 'right';
const hsz = fs*1.15;
for (let i=0;i<lives;i++){
drawHeart(W*0.95 - i*(hsz*1.4), H*0.072, hsz, 0, '#ff4d6d', 0.95);
}
}
```
Careful: drawHeart uses translate/rotate/scale in the world transform ✓.
Hmm, drawing lives at W*0.95 going left. Good.
In demo mode, lives = 3 always displayed. Fine.
Now the input handlers:
```js
function startOrRestart(){
if (mode === 'demo' || mode === 'over') startGame();
}
window.addEventListener('pointerdown', e => {
startOrRestart();
}, {passive:true});
window.addEventListener('keydown', e => {
if (['ArrowLeft','ArrowRight',' ','Spacebar'].includes(e.key)) e.preventDefault();
keys[e.key] = true;
if (mode === 'demo' || mode === 'over') startGame();
});
window.addEventListener('keyup', e => { keys[e.key] = false; });
```
Careful: pressing a key repeatedly triggers startGame while in play mode? No, guarded by mode.
But: the keydown that starts the game also sets keys — fine.
Also 'pointerdown' anywhere starts the game, including on the overlay (pointer-events: none, so the event goes to the window/canvas) ✓.
pointermove handler updates pointerX. Only when mode === 'play' does it matter.
```js
window.addEventListener('pointermove', e => {
const rect = canvas.getBoundingClientRect();
pointerX = (e.clientX - rect.left - ox)/scale;
pointerActive = true;
}, {passive:true});
```
canvas is fixed inset:0 so rect.left = 0. Fine.
Note: pointerActive should only be set when the pointer actually moves; on mobile, touch drag also fires pointermove. Good.
Now, initial state setup:
```js
layout();
makeBricks();
resetBall();
toDemo(); // sets overlay text and mode
```
But toDemo calls resetGame which calls makeBricks etc. Just call toDemo() at init after layout.
Careful: resetGame sets paddle.x = W/2 and calls resetBall which sets ball.x = paddle.x. Fine.
Order: layout() must run before makeBricks (uses H) and resetBall (uses paddle.y). ✓
requestAnimationFrame(frame) at the end.
One more: `roundRectPath` helper. Use ctx.roundRect if available? Chrome supports ctx.roundRect. But to be safe, write my own:
```js
function roundRectPath(c, x, y, w, h, r){
r = Math.min(r, w/2, h/2);
c.beginPath();
c.moveTo(x+r, y);
c.lineTo(x+w-r, y);
c.quadraticCurveTo(x+w, y, x+w, y+r);
c.lineTo(x+w, y+h-r);
c.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
c.lineTo(x+r, y+h);
c.quadraticCurveTo(x, y+h, x, y+h-r);
c.lineTo(x, y+r);
c.quadraticCurveTo(x, y, x+r, y);
c.closePath();
}
```
Note in drawBurger I used `roundRect(ctx, ...)` as a fill path — I'll use roundRectPath then fill.
Let's write drawBurger carefully with roundRectPath.
Also potential issue: `ctx.ellipse` — supported ✓.
Let me now assemble the whole thing.
Also the `bricks.some(b=>b.alive)` check every frame — 54 items, fine.
Also I should handle the case where the ball gets stuck bouncing on the paddle repeatedly (ball.vy>0 and it's right at paddle.y). Since we set ball.y = paddle.y - r - 0.5 and vy negative, it moves away. Fine.
Edge: ball hits the paddle's side and gets pushed... Our paddle collision only triggers when the ball is above/at paddle.y and within the x range (±r). If the ball comes from the side, it could bounce weirdly. Acceptable.
Let me add: after a paddle hit, spawn a few small hearts at the contact point for juice.
```js
addHeartBurst(ball.x, paddle.y, 4, H*0.22);
```
That's ~4 hearts each paddle hit. Nice.
Also let's make the ball bounce feel good with a slight ball squash? Skip.
Now, one issue: the demo starts with `mode='demo'` and the overlay is visible; the overlay's card covers the center. Fine.
Also, on the first load, the message should say "Click or press a key to start". ✓
Let me now consider the `levelMsgT` — I mentioned it but let's skip the level message to reduce complexity. Actually a brief "LEVEL 2" flash is nice. I'll add a simple centered text that fades. Store `levelFlash` (0..1) decreasing, and draw "LEVEL n" in the center with alpha.
OK.
Let me also make sure the ball doesn't tunnel through bricks at high speed: substeps of r*0.8 ≈ 7px. At 620 px/s and 60fps, movement is 10px per frame → 2 substeps. Fine.
Let me write the final code now.
One more detail: `ctx.textAlign`/`textBaseline` set in drawHUD persists into other draws — set them where needed. drawHeart doesn't use text. Fine.
Writing final:
Also, on window resize, we recompute scale/ox/oy — the world W/H stays fixed. Good.
Potential bug: `resize()` is called before `bgGrad` creation — that's fine since bgGrad uses W,H constants.
But bgGrad is created at module scope before ctx transform is set. Since gradients use coordinates in the user space at paint time... Actually no! I need to double check: CanvasGradient coordinates are in the coordinate space of the canvas at the time the gradient is *used*? Let me recall the spec: "The gradient is created with coordinates in the current user coordinate space" — hmm. The spec says for createLinearGradient: "the gradient's coordinates are in the current coordinate space at the time the gradient is created"?
Let me recall precisely. From the HTML spec (canvas 2D): "createLinearGradient(x0, y0, x1, y1) — Returns a CanvasGradient object that represents a linear gradient that paints along the line given by the coordinates represented by the arguments." And there's a note: gradient coordinates are relative to the current coordinate space at the time of *painting*?
Actually, the standard behavior in browsers: the gradient is affected by the transform *at draw time*, not creation time. This is well-known — e.g., you can create a gradient before translating and it will still follow the transform when filling. Yes, I'm confident: gradients are transformed by the CTM at paint time.
Hmm, wait. Actually I recall that in some older specs the gradient coordinates were transformed by the CTM at creation. Let me think about a known example:
```js
const g = ctx.createLinearGradient(0,0,0,100);
ctx.translate(50,0);
ctx.fillStyle = g;
ctx.fillRect(0,0,100,100);
```
Does the gradient shift? I believe yes — the gradient is defined in user space and transforms apply at paint time. Yes, this is standard and widely used for e.g. drawing the same gradient in rotated contexts.
OK, either way, we paint with the world transform and created with identity — if it were creation-time transform, since the identity at creation, the gradient coordinates would be in world space anyway... no wait, if created at identity and then the CTM changes at paint, the result under "paint-time" semantics is the gradient transformed to world coords → correct. Under "creation-time" semantics, the gradient would be fixed in world coords too → also correct. Either way it's correct since the CTM was identity at creation.
But for the brick gradients created inside makeBricks() — makeBricks may be called during the game when the CTM is whatever was last set (the world transform). Hmm! That's a problem if creation-time semantics apply: the brick gradient would be pre-transformed.
To be safe, wrap makeBricks gradient creation with a reset transform... but they're painted under the world transform. Under paint-time semantics, I need them created with identity CTM (i.e., gradient coords in world space).
Safest: in makeBricks, do `ctx.save(); ctx.setTransform(1,0,0,1,0,0);` create gradients `ctx.restore();`. Under creation-time semantics, the gradient is fixed in the space at creation = world coords, and it would be painted transformed by the world CTM again → wrong (double transform). Ugh. These two semantics differ.
Since all modern browsers use paint-time semantics (the CTM at fill time transforms the gradient), and my gradients are defined in world coordinates, everything works as long as I create them with coordinates in world space — which is true regardless of the CTM at creation time, because under paint-time semantics the creation CTM is irrelevant.
So: gradients created with world coords, painted with world CTM → correct under paint-time semantics. Under (hypothetical) creation-time semantics it'd be wrong, but browsers don't do that. Chrome definitely uses paint-time. Good.
However, for the burger's gradients created inside a translated/rotated context — those are in local coords and painted under the same transform → correct under paint-time semantics. ✓
OK, proceeding.
Let me write everything out now, carefully.
Final code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Hamburger Breakout</title>
<style>
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#07060c;}
body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;}
#game{display:block;position:fixed;inset:0;width:100%;height:100%;touch-action:none;}
#overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;padding:18px;}
#overlay.hidden{display:none;}
.card{
text-align:center;padding:20px 28px;border-radius:22px;
background:rgba(18,11,28,0.62);
border:1px solid rgba(255,255,255,0.14);
-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);
box-shadow:0 20px 60px rgba(0,0,0,0.6), 0 0 70px rgba(255,77,109,0.20);
max-width:min(92vw,560px);
}
.card h1{margin:0 0 8px;font-size:clamp(17px,3.2vw,30px);font-weight:800;letter-spacing:0.3px;color:#fff;text-shadow:0 2px 24px rgba(255,77,109,0.55);}
#ov-msg{margin:0;font-size:clamp(13px,1.9vw,17px);font-weight:700;color:#ffd9e2;animation:pulse 1.7s ease-in-out infinite;}
#ov-hint{margin:8px 0 0;font-size:clamp(11px,1.4vw,13px);color:rgba(255,255,255,0.5);letter-spacing:0.4px;}
@keyframes pulse{0%,100%{opacity:.55;}50%{opacity:1;}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="overlay">
<div class="card">
<h1 id="ov-title">🍔 Hamburger Breakout</h1>
<p id="ov-msg">Click or press a key to start</p>
<p id="ov-hint">Demo mode — the AI is playing</p>
</div>
</div>
<script> ... </script>
</body>
</html>
```
Now the script.
I'll write it all now in order, being careful.
```js
(function(){
'use strict';
// ---------- helpers ----------
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const rand = (a,b)=> a + Math.random()*(b-a);
// ---------- world sizing ----------
const vw0 = window.innerWidth || 1280;
const vh0 = window.innerHeight || 800;
const ar = clamp(vw0/vh0, 0.55, 2.05);
const AREA = 900*620;
const W = Math.round(Math.sqrt(AREA*ar));
const H = Math.round(Math.sqrt(AREA/ar));
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ov-title');
const ovMsg = document.getElementById('ov-msg');
const ovHint = document.getElementById('ov-hint');
const PLAY_LEFT = W*0.014;
const PLAY_RIGHT = W - W*0.014;
const PLAY_TOP = H*0.018;
let dpr=1, scale=1, ox=0, oy=0, cssW=vw0, cssH=vh0;
function resize(){
dpr = Math.min(window.devicePixelRatio||1, 2);
cssW = window.innerWidth; cssH = window.innerHeight;
canvas.width = Math.round(cssW*dpr);
canvas.height = Math.round(cssH*dpr);
canvas.style.width = cssW+'px';
canvas.style.height = cssH+'px';
scale = Math.min(cssW/W, cssH/H);
ox = (cssW - W*scale)/2;
oy = (cssH - H*scale)/2;
}
window.addEventListener('resize', resize);
resize();
```
Then gradients, state, etc.
I'm going to write the rest now directly into the final answer. Let me be careful about every function.
One thing: `drawHeart` inside `drawBricks` uses ctx.save/restore — but drawBricks also does save/restore around it; nested is fine.
For performance, drawing 54 hearts per frame with save/restore/scale is fine.
Let me write out the final code text.
Also for the bricks' heart icon, I use color 'rgba(255,255,255,0.5)' and alpha 1 in drawHeart → fillStyle set to that rgba. Fine.
Final check on the heart path scale: size param = width of the heart. In drawHeart, k = size/22 (since the path spans -11..11). Good.
Now writing out.
For the level flash:
```js
let levelFlash = 0;
```
In checkLevel set levelFlash = 1. In update: levelFlash = Math.max(0, levelFlash - dt*0.8). Draw centered text with alpha = levelFlash.
OK writing.
Actually one more consideration on the AI: when the ball is held (before launch), target = ball.x = paddle.x, so the AI stays put. Good.
And `mode === 'over'` → the AI still moves (mode !== 'play' → aiMove). Fine, though the ball is held.
Let me write playerMove and aiMove to handle 'over' as demo.
In update: `if (mode === 'play') playerMove(dt); else aiMove(dt);` ✓
Now writing the final answer.
Also add: on ball lost in demo, no life change; keeps playing.
Let me write the code.
```js
// ---------- state ----------
const paddle = { x: W/2, y: H*0.8, w: 120, h: 48, vx: 0, tilt: 0 };
const ball = { x: W/2, y: H*0.8, vx: 0, vy: 0, r: 8, speed: 320, held: true, holdT: 0, trail: [] };
let bricks = [];
let brickBottom = 0;
let parts = [];
let rings = [];
let score = 0, lives = 3, level = 1;
let mode = 'demo';
let shake = 0, time = 0, overTimer = 0, levelFlash = 0;
let baseSpeed = 360;
let pointerX = W/2, pointerActive = false;
const keys = Object.create(null);
```
layout function sets paddle.w etc.
Now writing everything.
I also want to double check `roundRectPath` usage in the clip: `ctx.clip()` uses the current path ✓.
Alright. Writing the final answer now.
For safety in the demo, if the ball somehow gets a near-horizontal trajectory and bounces forever between walls without hitting anything, there's no problem — it'll hit bricks eventually.
Also add a safety: if the ball has been alive for > 20s without hitting a brick, nudge it. Skip.
Writing out now — final.
Also note the `drawParticles` needs ctx.globalAlpha reset.
Let me write drawRings:
```js
function drawRings(){
for (const r of rings){
const t = r.life/r.dur;
const rad = 4 + (r.max-4)*(1-Math.pow(1-t,2));
ctx.globalAlpha = (1-t)*0.55;
ctx.strokeStyle = r.color;
ctx.lineWidth = Math.max(1.5, H*0.006)*(1-t);
ctx.beginPath(); ctx.arc(r.x, r.y, rad, 0, Math.PI*2); ctx.stroke();
}
ctx.globalAlpha = 1;
}
```
Ring color: white/pink.
OK final write-up.
One more: the game over overlay appears while the ball is held — fine.
And after `toDemo()` the overlay shows again.
I need to make sure `toDemo` is called at init. Also `resetGame` inside `toDemo`.
Order at init:
```js
layout();
toDemo(); // sets mode demo, resets everything, shows overlay
requestAnimationFrame(frame);
```
toDemo calls resetGame which calls makeBricks (needs layout done ✓) and resetBall (needs paddle.y ✓).
Good.
But `overlay.classList.remove('hidden')` — starts visible anyway.
Write it.