Run details
- Model
- unbiased/pareto-26.9
- Route
- OpenRouter API, one user message
- Served by
- Unbiased
- Reasoning
- provider default
- Limits
- 60 min · 80,000 output tokens ($0.60 at $7.5/M)
- Date
- 22 Oct 2026
- Time
- 2 min 28 s
- Size
- 24 kB
- Tokens
- 83 in, 8,440 out
- List price
- $0.064 ($2.5 in / $7.5 out per M)
- Paid
- $0.064
Exact prompt
Make a Breakout game I can play right now with the keyboard or the mouse. Give it several levels, a score and some juice. It must be one complete, self-contained HTML file: inline all CSS and JavaScript, and make no network requests of any kind (no CDNs, web fonts or remote images). Reply with the file in a single ```html code block.
Source
24 kB 705 lines Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#090c19">
<title>PRISM — Breakout</title>
<style>
:root { color-scheme: dark; --ink:#eef5ff; --muted:#8e9bb9; --cyan:#67efff; }
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; min-height:100svh; padding:28px 18px;
display:flex; align-items:center; justify-content:center;
background:radial-gradient(ellipse at 20% 10%,#202041 0,transparent 52%),
radial-gradient(ellipse at 85% 85%,#102d3a 0,transparent 45%),#090c19;
color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
main { width:min(1040px,100%); }
header { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:20px; }
.brand { font-weight:900; font-size:28px; letter-spacing:7px; }
.brand span { color:var(--cyan); }
.tag { color:var(--muted); font-size:10px; letter-spacing:3px; margin-top:5px; }
.buttons { display:flex; gap:8px; }
button {
background:#ffffff08; border:1px solid #ffffff20; color:var(--ink);
border-radius:10px; padding:10px 13px; cursor:pointer; font:600 12px system-ui;
transition:background .15s,border-color .15s;
}
button:hover { background:#ffffff14; border-color:#67efff80; }
button:focus-visible { outline:2px solid var(--cyan); outline-offset:3px; }
.stats {
display:grid; grid-template-columns:1.2fr 1.2fr 1fr 1fr;
border:1px solid #ffffff13; border-bottom:0; border-radius:16px 16px 0 0;
background:#0e1424; padding:17px 22px; gap:16px;
}
.label { font-size:10px; font-weight:700; color:var(--muted); letter-spacing:2px; }
.value { font-size:23px; font-weight:750; font-variant-numeric:tabular-nums; margin-top:3px; }
#score { color:var(--cyan); }
#lives { color:#ff82af; letter-spacing:5px; }
.screen {
position:relative; overflow:hidden; border:1px solid #ffffff1c;
border-radius:0 0 16px 16px; background:#090e1d;
box-shadow:0 30px 90px #0006,0 0 55px #67efff05;
}
canvas { display:block; width:100%; aspect-ratio:8/5; touch-action:none; cursor:crosshair; }
footer { display:flex; justify-content:space-between; gap:16px; margin-top:16px; color:var(--muted); font-size:12px; line-height:1.9; }
kbd { font:11px system-ui; background:#ffffff09; border:1px solid #ffffff18; border-radius:4px; padding:2px 5px; color:#d6e2f7; }
.accent { color:#67efffaa; }
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
@media (max-width:600px) {
body { padding:16px 10px; }
header { margin-bottom:14px; }
.brand { font-size:22px; letter-spacing:4px; }
.tag { letter-spacing:1.5px; font-size:9px; }
button { padding:9px 8px; font-size:11px; }
.stats { padding:12px; gap:8px; }
.label { font-size:8px; letter-spacing:1px; }
.value { font-size:18px; }
#lives { letter-spacing:2px; }
footer { flex-direction:column; gap:3px; font-size:11px; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<div class="brand">PR<span>I</span>SM</div>
<div class="tag">BREAK THE SPECTRUM</div>
</div>
<div class="buttons">
<button id="sound" aria-pressed="false" title="Toggle synthesized sound">Sound: off</button>
<button id="pause" title="Pause or resume (P)">Pause</button>
<button id="restart" title="Restart (R)">Restart</button>
</div>
</header>
<section class="stats" aria-label="Game statistics">
<div><div class="label">SCORE</div><div class="value" id="score">000000</div></div>
<div><div class="label">PERSONAL BEST</div><div class="value" id="best">000000</div></div>
<div><div class="label">SECTOR</div><div class="value" id="level">01 / 05</div></div>
<div><div class="label">LIVES</div><div class="value" id="lives">♥♥♥</div></div>
</section>
<div class="screen">
<canvas id="game" width="960" height="600" tabindex="0"
aria-label="Prism Breakout. Move with left and right arrows, A and D, mouse, or touch. Space or click to launch. P pauses. R restarts.">
Your browser needs canvas support to play.
</canvas>
</div>
<footer>
<div><kbd>←</kbd> <kbd>→</kbd> / <kbd>A</kbd> <kbd>D</kbd> or mouse to move · <kbd>Space</kbd> / click to launch · <kbd>P</kbd> pause</div>
<div class="accent">5 sectors · Angled paddle shots · Keep the combo alive</div>
</footer>
<div id="announce" class="sr" aria-live="polite"></div>
</main>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const $ = id => document.getElementById(id);
const W = 960, H = 600, TAU = Math.PI * 2;
const colors = ['#ff79b0','#bc91ff','#7aa6ff','#65e5ff','#6cf0c0','#ffd280'];
const names = ['FIRST LIGHT','DIAMOND DUST','CROSS CURRENT','NEON FORTRESS','SUPERNOVA'];
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
const rand = (a,b) => a + Math.random() * (b-a);
const keys = new Set();
let mode = 'ready', pausedFrom = 'playing';
let level = 0, score = 0, lives = 3, best = 0, combo = 0;
let bricks = [], particles = [], rings = [], labels = [], trail = [];
let remaining = 0, shake = 0, flash = 0, elapsed = 0, lastTime = 0;
let pointerTarget = W/2, control = 'keyboard', launchDirection = 1;
let readyHeading = 'FIND YOUR ANGLE';
let soundOn = false, audio = null;
let scoreShown = -1, bestShown = -1, levelShown = -1, livesShown = -1;
const paddle = {x:W/2, y:546, w:128, h:14, velocity:0};
const ball = {x:W/2, y:530, vx:0, vy:0, r:8, speed:390};
try { best = Math.max(0, Number(localStorage.getItem('prism-breakout-best')) || 0); } catch (_) {}
function announce(text) { $('announce').textContent = text; }
function initAudio() {
if (!soundOn) return;
try {
if (!audio) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) audio = new AC();
}
if (audio && audio.state === 'suspended') audio.resume().catch(() => {});
} catch (_) {}
}
function tone(freq, duration=.08, type='sine', volume=.04, delay=0, endFreq=freq) {
if (!soundOn || !audio || audio.state !== 'running') return;
const t = audio.currentTime + delay;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(freq,t);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(20,endFreq),t+duration);
gain.gain.setValueAtTime(.0001,t);
gain.gain.exponentialRampToValueAtTime(volume,t+.006);
gain.gain.exponentialRampToValueAtTime(.0001,t+duration);
oscillator.connect(gain);
gain.connect(audio.destination);
oscillator.start(t);
oscillator.stop(t+duration+.015);
}
function chord(win) {
(win ? [392,494,587,784] : [260,196,130]).forEach((n,i) =>
tone(n,.24,'triangle',.055,i*.085));
}
function hud() {
if (score !== scoreShown) {
$('score').textContent = String(score).padStart(6,'0');
scoreShown = score;
}
if (best !== bestShown) {
$('best').textContent = String(best).padStart(6,'0');
bestShown = best;
}
if (level !== levelShown) {
$('level').textContent = String(level+1).padStart(2,'0')+' / 05';
levelShown = level;
}
if (lives !== livesShown) {
$('lives').textContent = '♥'.repeat(lives)+'♡'.repeat(3-lives);
$('lives').setAttribute('aria-label',lives+' lives');
livesShown = lives;
}
}
function addScore(points) {
score += points;
if (score > best) {
best = score;
try { localStorage.setItem('prism-breakout-best',String(best)); } catch (_) {}
}
}
function makeLevel() {
bricks = [];
const cols = 12, rows = 6, bw = 66, bh = 23, gap = 7;
const left = (W-(cols*bw+(cols-1)*gap))/2;
for (let row=0;row<rows;row++) {
for (let col=0;col<cols;col++) {
let exists = true, hp = 1;
if (level === 1) {
exists = Math.abs(col-5.5)+Math.abs(row-2.5)*1.45 < 7;
hp = row === 2 || row === 3 ? 2 : 1;
} else if (level === 2) {
exists = (col+row)%4 !== 0;
hp = (col+row)%3 === 0 ? 2 : 1;
} else if (level === 3) {
exists = row < 2 || col%3 !== 1 || row === 5;
hp = row < 2 ? 3 : 2;
} else if (level === 4) {
exists = !(row===0 && (col===0 || col===11));
hp = 1+((row+col)%3);
}
if (!exists) continue;
bricks.push({
x:left+col*(bw+gap), y:80+row*(bh+gap), w:bw, h:bh,
hp, maxHp:hp, color:colors[(row+level)%colors.length], hit:0
});
}
}
remaining = bricks.length;
paddle.w = 128-level*5;
paddle.x = W/2;
pointerTarget = W/2;
combo = 0;
resetBall();
}
function resetBall() {
ball.speed = 390+level*35;
ball.vx = 0; ball.vy = 0;
ball.x = paddle.x;
ball.y = paddle.y-ball.r-3;
trail = [];
paddle.velocity = 0;
}
function restart() {
level = 0; score = 0; lives = 3; combo = 0;
particles = []; rings = []; labels = []; shake = 0; flash = 0;
keys.clear();
readyHeading = 'FIND YOUR ANGLE';
mode = 'ready';
$('pause').textContent = 'Pause';
makeLevel();
hud();
announce('New game. Sector 1. Press Space or click to launch.');
}
function launch() {
if (mode !== 'ready') return;
mode = 'playing';
const angle = .31 * launchDirection;
launchDirection *= -1;
ball.vx = Math.sin(angle)*ball.speed;
ball.vy = -Math.cos(angle)*ball.speed;
burst(ball.x,ball.y,'#67efff',14,120);
tone(430,.14,'triangle',.06,0,740);
announce('Sector '+(level+1)+'. '+names[level]+'.');
}
function pause() {
if (mode === 'paused') {
mode = pausedFrom;
$('pause').textContent = 'Pause';
announce('Resumed.');
} else if (mode === 'playing' || mode === 'ready') {
pausedFrom = mode;
mode = 'paused';
keys.clear();
$('pause').textContent = 'Resume';
announce('Paused.');
}
}
function activate() {
initAudio();
if (mode === 'paused') pause();
else if (mode === 'ready') launch();
else if (mode === 'over' || mode === 'won') { restart(); launch(); }
}
function burst(x,y,color,n=18,speed=180) {
if (reduced) n = Math.ceil(n*.35);
for (let i=0;i<n;i++) {
const a=rand(0,TAU), v=rand(speed*.2,speed);
const life=rand(.25,.65);
particles.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v,
life,maxLife:life,size:rand(1.5,4),color});
}
if (particles.length>650) particles.splice(0,particles.length-650);
}
function ring(x,y,color,max=55) {
rings.push({x,y,color,age:0,duration:.4,max});
}
function hitBrick(b) {
b.hp--;
b.hit = .16;
combo++;
const multiplier = Math.min(5,1+Math.floor((combo-1)/4));
if (b.hp === 0) {
remaining--;
const pts = 100*multiplier;
addScore(pts);
burst(b.x+b.w/2,b.y+b.h/2,b.color,22,215);
ring(b.x+b.w/2,b.y+b.h/2,b.color,43);
if (multiplier > 1) labels.push({
x:b.x+b.w/2,y:b.y,text:'+'+pts,color:b.color,life:.75,maxLife:.75
});
shake = Math.max(shake,3.2);
ball.speed = Math.min(720,ball.speed+3);
} else {
addScore(25*multiplier);
burst(ball.x,ball.y,b.color,9,120);
shake = Math.max(shake,1.7);
}
tone(290*Math.pow(2,Math.min(combo,20)/15),.085,'triangle',.045);
const length = Math.hypot(ball.vx,ball.vy);
if (length) {
ball.vx *= ball.speed/length;
ball.vy *= ball.speed/length;
}
}
function completeLevel() {
addScore(500*(level+1));
chord(true);
flash = .26;
shake = 7;
for (let i=0;i<6;i++) burst(rand(130,W-130),rand(150,380),colors[i],28,280);
if (level === 4) {
mode = 'won';
announce('All five sectors cleared! Final score '+score+'. Click or press Space to play again.');
} else {
level++;
mode = 'ready';
readyHeading = 'SECTOR CLEAR';
makeLevel();
announce('Sector cleared. Sector '+(level+1)+'. Press Space or click to launch.');
}
}
function loseLife() {
lives--;
combo = 0;
burst(ball.x,H-15,'#ff79b0',32,240);
shake = 9;
tone(220,.35,'sawtooth',.035,0,50);
if (lives <= 0) {
mode = 'over';
chord(false);
announce('Game over. Score '+score+'. Press Space or click to try again.');
} else {
mode = 'ready';
readyHeading = 'STAY IN THE FLOW';
resetBall();
announce(lives+' lives left. Press Space or click to launch.');
}
}
// Circle versus rectangle: resolve along the actual contact normal.
function collideBrick(b) {
const nx = clamp(ball.x,b.x,b.x+b.w);
const ny = clamp(ball.y,b.y,b.y+b.h);
let dx = ball.x-nx, dy = ball.y-ny;
let dist = Math.hypot(dx,dy);
if (dist >= ball.r) return false;
if (dist < .00001) {
const edges = [
{d:ball.x-b.x,x:-1,y:0},
{d:b.x+b.w-ball.x,x:1,y:0},
{d:ball.y-b.y,x:0,y:-1},
{d:b.y+b.h-ball.y,x:0,y:1}
];
edges.sort((a,b)=>a.d-b.d);
dx = edges[0].x; dy = edges[0].y;
dist = -edges[0].d;
} else { dx /= dist; dy /= dist; }
ball.x += dx*(ball.r-dist+.05);
ball.y += dy*(ball.r-dist+.05);
const dot = ball.vx*dx+ball.vy*dy;
if (dot < 0) {
ball.vx -= 2*dot*dx;
ball.vy -= 2*dot*dy;
hitBrick(b);
return true;
}
return false;
}
function step(dt) {
let direction = 0;
if (keys.has('ArrowLeft') || keys.has('KeyA')) direction--;
if (keys.has('ArrowRight') || keys.has('KeyD')) direction++;
const oldX = paddle.x;
if (control === 'pointer') {
paddle.x += clamp(pointerTarget-paddle.x,-1500*dt,1500*dt);
} else {
paddle.x += direction*700*dt;
}
paddle.x = clamp(paddle.x,20+paddle.w/2,W-20-paddle.w/2);
paddle.velocity = (paddle.x-oldX)/dt;
if (mode === 'ready') {
ball.x = paddle.x; ball.y = paddle.y-ball.r-3;
return;
}
if (mode !== 'playing') return;
const previousY = ball.y;
ball.x += ball.vx*dt;
ball.y += ball.vy*dt;
if (ball.x-ball.r < 16 && ball.vx < 0) {
ball.x = 16+ball.r; ball.vx = Math.abs(ball.vx);
burst(17,ball.y,'#67efff',5,80); tone(180,.045);
}
if (ball.x+ball.r > W-16 && ball.vx > 0) {
ball.x = W-16-ball.r; ball.vx = -Math.abs(ball.vx);
burst(W-17,ball.y,'#67efff',5,80); tone(180,.045);
}
if (ball.y-ball.r < 16 && ball.vy < 0) {
ball.y = 16+ball.r; ball.vy = Math.abs(ball.vy);
tone(220,.045);
}
if (ball.vy > 0 &&
previousY+ball.r <= paddle.y+4 &&
ball.y+ball.r >= paddle.y &&
ball.x+ball.r >= paddle.x-paddle.w/2 &&
ball.x-ball.r <= paddle.x+paddle.w/2) {
const offset = clamp((ball.x-paddle.x)/(paddle.w/2),-1,1);
const angle = clamp(offset*1.08+paddle.velocity*.00010,-1.13,1.13);
ball.vx = Math.sin(angle)*ball.speed;
ball.vy = -Math.cos(angle)*ball.speed;
ball.y = paddle.y-ball.r-.1;
combo = 0;
burst(ball.x,paddle.y,'#67efff',12,150);
ring(ball.x,paddle.y,'#67efff',30);
tone(280+Math.abs(offset)*170,.11,'sine',.065,0,180);
}
for (const b of bricks) {
if (b.hp > 0 && collideBrick(b)) {
// Avoid trajectories that get stuck nearly horizontal.
if (Math.abs(ball.vy)<ball.speed*.22) {
ball.vy = (ball.vy<0?-1:1)*ball.speed*.22;
ball.vx = (ball.vx<0?-1:1)*Math.sqrt(ball.speed**2-ball.vy**2);
}
break;
}
}
if (remaining === 0) { completeLevel(); return; }
if (ball.y-ball.r > H) loseLife();
}
function update(dt) {
if (mode === 'paused') return;
elapsed += dt;
for (const b of bricks) b.hit = Math.max(0,b.hit-dt);
shake = Math.max(0,shake-dt*23);
flash = Math.max(0,flash-dt);
for (const p of particles) {
p.life -= dt; p.x += p.vx*dt; p.y += p.vy*dt;
p.vy += 210*dt;
p.vx *= Math.exp(-dt*2);
}
particles = particles.filter(p=>p.life>0);
for (const r of rings) r.age += dt;
rings = rings.filter(r=>r.age<r.duration);
for (const l of labels) { l.life -= dt; l.y -= dt*42; }
labels = labels.filter(l=>l.life>0);
if (mode === 'playing' || mode === 'ready') {
const count = Math.max(1,Math.ceil(dt/(1/180)));
for (let i=0;i<count;i++) {
step(dt/count);
if (mode !== 'ready' && mode !== 'playing') break;
}
}
if (mode === 'playing') {
trail.unshift({x:ball.x,y:ball.y});
if (trail.length>15) trail.pop();
} else if (trail.length) trail.pop();
hud();
}
function rounded(x,y,w,h,r,fill) {
ctx.fillStyle = fill;
ctx.beginPath();
ctx.roundRect(x,y,w,h,r);
ctx.fill();
}
function text(str,x,y,size,color='#eef5ff',weight=600) {
ctx.font = weight+' '+size+'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(str,x,y);
}
function drawOverlay() {
if (mode === 'playing') return;
ctx.fillStyle = mode === 'ready' ? '#090e1d55' : '#090e1dc9';
ctx.fillRect(0,0,W,H);
const boxY = 287;
rounded(200,boxY,560,192,20,'#10182bf5');
ctx.strokeStyle = '#9adfff32';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(200,boxY,560,192,20); ctx.stroke();
let eyebrow,heading,sub,action;
if (mode === 'ready') {
eyebrow = 'SECTOR '+String(level+1).padStart(2,'0')+' / '+names[level];
heading = readyHeading;
sub = level === 0 && lives === 3
? 'Clear the bricks. Catch the light. Chase your best.'
: (level >= 1 ? 'Armored bricks take extra hits. You’ve got this.' : 'One good bounce changes everything.');
action = 'SPACE / CLICK TO LAUNCH';
} else if (mode === 'paused') {
eyebrow = 'TAKE A BREATHER';
heading = 'ON HOLD';
sub = 'Your next perfect shot can wait.';
action = 'P / SPACE / CLICK TO RESUME';
} else if (mode === 'won') {
eyebrow = 'ALL 5 SECTORS CLEARED';
heading = 'SPECTRUM COMPLETE';
sub = 'Final score '+score.toLocaleString()+' · Beautifully broken.';
action = 'SPACE / CLICK TO PLAY AGAIN';
} else {
eyebrow = 'THE LIGHT FADES. YOU DON’T.';
heading = 'ONE MORE RUN?';
sub = 'Final score '+score.toLocaleString()+' · Best '+best.toLocaleString();
action = 'SPACE / CLICK TO RESTART';
}
text(eyebrow,W/2,boxY+30,11,'#67efff',750);
text(heading,W/2,boxY+70,32,'#f3f7ff',850);
text(sub,W/2,boxY+109,14,'#a6b6d0',450);
const pulse = reduced ? 1 : .8+.2*Math.sin(elapsed*3);
ctx.globalAlpha = pulse;
rounded(W/2-165,boxY+138,330,34,8,'#67efff18');
text(action,W/2,boxY+155,12,'#8af3ff',750);
ctx.globalAlpha = 1;
}
function draw() {
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#090e1d';
ctx.fillRect(0,0,W,H);
ctx.save();
if (!reduced && shake>0 && mode !== 'paused')
ctx.translate(rand(-shake,shake),rand(-shake,shake));
// A quiet grid and a low horizon glow keep the action readable.
ctx.strokeStyle = '#839cdb09'; ctx.lineWidth = 1;
ctx.beginPath();
for (let x=0;x<=W;x+=40) { ctx.moveTo(x,0); ctx.lineTo(x,H); }
for (let y=0;y<=H;y+=40) { ctx.moveTo(0,y); ctx.lineTo(W,y); }
ctx.stroke();
const glow = ctx.createRadialGradient(W/2,555,10,W/2,555,430);
glow.addColorStop(0,'#17435835'); glow.addColorStop(1,'#17435800');
ctx.fillStyle=glow; ctx.fillRect(0,0,W,H);
ctx.strokeStyle = '#6bdbff28'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(16,520); ctx.lineTo(16,16);
ctx.lineTo(W-16,16); ctx.lineTo(W-16,520); ctx.stroke();
text(names[level],W/2,43,11,'#627695',650);
ctx.strokeStyle = '#ff79b025'; ctx.setLineDash([4,9]);
ctx.beginPath(); ctx.moveTo(24,H-15); ctx.lineTo(W-24,H-15); ctx.stroke();
ctx.setLineDash([]);
for (const b of bricks) {
if (b.hp<=0) continue;
ctx.shadowColor = b.color; ctx.shadowBlur = reduced ? 0 : 9;
rounded(b.x,b.y,b.w,b.h,5,b.hit>0?'#ffffff':b.color);
ctx.shadowBlur = 0;
rounded(b.x+2,b.y+2,b.w-4,3,2,'#ffffff50');
rounded(b.x+2,b.y+b.h-5,b.w-4,3,2,'#00000022');
if (b.hp>1) {
for (let j=0;j<b.hp;j++) {
rounded(b.x+b.w/2+(j-(b.hp-1)/2)*9-2,b.y+b.h/2-2,4,4,1,'#17203980');
}
}
}
for (const r of rings) {
const t=r.age/r.duration;
ctx.globalAlpha=(1-t)*.6; ctx.strokeStyle=r.color;
ctx.lineWidth=2*(1-t)+.5;
ctx.beginPath(); ctx.arc(r.x,r.y,4+t*r.max,0,TAU); ctx.stroke();
}
ctx.globalAlpha=1;
if (!reduced) {
for (let i=trail.length-1;i>=0;i--) {
ctx.globalAlpha=(1-i/trail.length)*.28;
ctx.fillStyle='#82efff';
ctx.beginPath(); ctx.arc(trail[i].x,trail[i].y,ball.r*(1-i/(trail.length+2)),0,TAU); ctx.fill();
}
ctx.globalAlpha=1;
}
ctx.shadowColor='#67efff'; ctx.shadowBlur=reduced?0:22;
const pg=ctx.createLinearGradient(paddle.x-paddle.w/2,0,paddle.x+paddle.w/2,0);
pg.addColorStop(0,'#61b8ff'); pg.addColorStop(.5,'#b7fbff'); pg.addColorStop(1,'#61b8ff');
rounded(paddle.x-paddle.w/2,paddle.y,paddle.w,paddle.h,7,pg);
ctx.shadowBlur=0;
rounded(paddle.x-14,paddle.y+5,28,3,2,'#246c8b80');
rounded(paddle.x-paddle.w/2+8,paddle.y+2,paddle.w-16,2,1,'#ffffffbb');
if (mode !== 'over' && mode !== 'won') {
ctx.shadowColor='#a8f5ff'; ctx.shadowBlur=reduced?0:19;
ctx.fillStyle='#f5ffff';
ctx.beginPath(); ctx.arc(ball.x,ball.y,ball.r,0,TAU); ctx.fill();
ctx.shadowBlur=0;
}
for (const p of particles) {
ctx.globalAlpha=Math.max(0,p.life/p.maxLife);
ctx.fillStyle=p.color;
ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
}
ctx.globalAlpha=1;
for (const l of labels) {
ctx.globalAlpha=l.life/l.maxLife;
text(l.text,l.x,l.y,15,l.color,800);
}
ctx.globalAlpha=1;
if (mode === 'playing' && combo>=4) {
const mult=Math.min(5,1+Math.floor((combo-1)/4));
text(mult+'× COMBO',W-105,306,18,'#ffd280',800);
text(combo+' consecutive hits',W-105,329,11,'#8e9bb9',500);
}
ctx.restore();
if (!reduced && flash>0) {
ctx.fillStyle='rgba(174,245,255,'+(flash*.5)+')';
ctx.fillRect(0,0,W,H);
}
drawOverlay();
}
function resize() {
const rect=canvas.getBoundingClientRect();
const dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(rect.width*dpr);
canvas.height=Math.round(rect.height*dpr);
ctx.setTransform(canvas.width/W,0,0,canvas.height/H,0,0);
draw();
}
function pointerPosition(e) {
const rect=canvas.getBoundingClientRect();
pointerTarget=clamp((e.clientX-rect.left)/rect.width*W,0,W);
control='pointer';
}
canvas.addEventListener('pointermove',pointerPosition);
canvas.addEventListener('pointerdown',e=>{
if (e.button !== 0) return;
pointerPosition(e);
if (mode === 'ready') {
paddle.x=clamp(pointerTarget,20+paddle.w/2,W-20-paddle.w/2);
ball.x=paddle.x;
}
canvas.focus({preventScroll:true});
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
activate();
});
document.addEventListener('keydown',e=>{
const handled=['ArrowLeft','ArrowRight','KeyA','KeyD','Space','KeyP','KeyR','Escape'];
if (!handled.includes(e.code)) return;
if (e.target instanceof HTMLButtonElement && e.code==='Space') return;
e.preventDefault();
if (['ArrowLeft','ArrowRight','KeyA','KeyD'].includes(e.code)) {
keys.add(e.code); control='keyboard'; return;
}
if (e.repeat) return;
initAudio();
if (e.code==='Space') activate();
if (e.code==='KeyP' || e.code==='Escape') pause();
if (e.code==='KeyR') restart();
});
document.addEventListener('keyup',e=>keys.delete(e.code));
window.addEventListener('blur',()=>{
keys.clear();
if (mode==='playing') pause();
});
document.addEventListener('visibilitychange',()=>{
if (document.hidden && mode==='playing') pause();
});
$('pause').addEventListener('click',()=>{initAudio();pause();});
$('restart').addEventListener('click',()=>{initAudio();restart();});
$('sound').addEventListener('click',()=>{
soundOn=!soundOn;
$('sound').textContent='Sound: '+(soundOn?'on':'off');
$('sound').setAttribute('aria-pressed',String(soundOn));
initAudio();
if (soundOn) tone(660,.12,'sine',.04);
});
window.addEventListener('resize',resize);
function frame(time) {
const dt=Math.min((time-lastTime)/1000 || 1/60,.035);
lastTime=time;
update(dt);
draw();
requestAnimationFrame(frame);
}
restart();
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>

