Run details
- Model
- unbiased/pareto-26.10
- Route
- OpenRouter API, one user message
- Served by
- Unbiased
- Reasoning
- provider default
- Limits
- 60 min · 131,072 output tokens
- Date
- 22 Oct 2026
- Time
- 2 min 30 s
- Size
- 23 kB
- Tokens
- 100 in, 8,649 out
- List price
- $0.028 ($0.8 in / $3.2 out per M)
- Paid
- $0.028
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
23 kB 515 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="#0a0d18">
<title>PRISM — Breakout</title>
<style>
:root { color-scheme: dark; --ink:#eaf0ff; --muted:#8d9ab7; --mint:#6fffd5; }
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; background:#090c16; color:var(--ink);
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
display:flex; align-items:center; justify-content:center; padding:24px;
background-image:radial-gradient(ellipse at 15% 0%,#192543 0%,transparent 55%),
radial-gradient(ellipse at 90% 100%,#211735 0%,transparent 50%);
}
main { width:min(100%,1000px); }
header { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:20px; }
.brand { font-size:28px; font-weight:900; letter-spacing:7px; line-height:1; }
.brand span { color:var(--mint); }
.tag { color:var(--muted); font-size:10px; letter-spacing:3px; margin-top:9px; }
.stats { display:flex; gap:28px; text-align:right; }
.label { font-size:10px; color:var(--muted); letter-spacing:2px; margin-bottom:4px; }
.value { font-size:23px; font-weight:750; font-variant-numeric:tabular-nums; }
#lives { color:#ff7ba8; letter-spacing:3px; }
.arena {
position:relative; border:1px solid #303b56; border-radius:18px; overflow:hidden;
background:#0c1120; box-shadow:0 25px 90px #0007,0 0 50px #67ffd507;
}
canvas { width:100%; aspect-ratio:3/2; display:block; touch-action:none; cursor:crosshair; outline:none; }
canvas:focus-visible { outline:2px solid var(--mint); outline-offset:-4px; }
.toolbar { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:16px; flex-wrap:wrap; }
.help { color:var(--muted); font-size:12px; line-height:1.9; }
kbd { font:11px system-ui; color:#dbe7ff; background:#192236; padding:3px 6px; border-radius:4px; border:1px solid #303a51; }
.buttons { display:flex; gap:8px; }
button {
color:#dce6fa; background:#161f32; border:1px solid #34415b; padding:10px 13px;
font:600 11px system-ui; letter-spacing:.5px; border-radius:8px; cursor:pointer;
}
button:hover { border-color:var(--mint); color:var(--mint); }
button:focus-visible { outline:2px solid var(--mint); outline-offset:3px; }
.footer { margin-top:14px; display:flex; justify-content:space-between; gap:16px; color:#71809f; font-size:10px; letter-spacing:1px; }
#status { color:#a0b2d0; }
@media(max-width:600px) {
body { padding:12px; align-items:flex-start; padding-top:28px; }
header { gap:10px; }
.brand { font-size:22px; letter-spacing:4px; }
.tag { font-size:8px; letter-spacing:1.5px; }
.stats { gap:12px; }
.value { font-size:17px; }
.label { font-size:8px; letter-spacing:1px; }
#lives { letter-spacing:0; }
.arena { border-radius:10px; }
.footer { font-size:8px; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<div class="brand">PR<span>I</span>SM</div>
<div class="tag">BREAKOUT / FIVE SECTORS</div>
</div>
<div class="stats">
<div><div class="label">SCORE</div><div class="value" id="score">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>
</div>
</header>
<div class="arena">
<canvas id="game" width="960" height="640" tabindex="0"
aria-label="Prism Breakout. Move with the mouse, touch, or arrow keys. Click or press Space to launch. P pauses. R restarts."></canvas>
</div>
<div class="toolbar">
<div class="help">
<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="buttons">
<button id="pause" type="button">Pause</button>
<button id="sound" type="button" aria-pressed="false">Sound: off</button>
<button id="restart" type="button">Restart</button>
</div>
</div>
<div class="footer">
<span>W = WIDE PADDLE S = SLOW BALL + = EXTRA LIFE</span>
<span id="status" aria-live="polite">Ready when you are.</span>
</div>
</main>
<script>
(() => {
"use strict";
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const scoreEl = document.getElementById("score");
const levelEl = document.getElementById("level");
const livesEl = document.getElementById("lives");
const statusEl = document.getElementById("status");
const pauseBtn = document.getElementById("pause");
const soundBtn = document.getElementById("sound");
const W = 960, H = 640;
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const colors = ["#ff719f","#ffab73","#ffe28a","#71ffd4","#70caff","#ac95ff"];
const names = ["FIRST LIGHT","CROSS SIGNAL","DIAMOND SKY","THE GATE","PRISM CORE"];
const keys = new Set();
let state = "title", beforePause = "ready", level = 0, score = 0, lives = 3;
let bricks = [], particles = [], drops = [], popups = [], trail = [];
let remaining = 0, combo = 0, shake = 0, flash = 0, clearTime = 0;
let wideTime = 0, slowTime = 0, elapsed = 0;
let mouseMode = true, targetX = W / 2, lastFrame = 0;
let soundOn = false, audio = null;
const paddle = { x:W/2, y:590, w:124, h:14 };
const ball = { x:W/2, y:575, r:8, vx:0, vy:0, speed:465 };
const stars = Array.from({length:65}, (_,i) => ({
x:(i*173.73+43)%W, y:(i*97.31+19)%H, size:i%3===0?1.5:.8, phase:i*1.7
}));
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.round(r.width*dpr);
canvas.height = Math.round(r.height*dpr);
}
window.addEventListener("resize",fit);
fit();
const clamp = (n,a,b) => Math.max(a,Math.min(b,n));
const rand = (a,b) => a+Math.random()*(b-a);
function hud() {
scoreEl.textContent = String(score).padStart(6,"0");
levelEl.textContent = String(level+1).padStart(2,"0")+" / 05";
livesEl.textContent = "♥".repeat(lives) + "♡".repeat(Math.max(0,3-lives));
pauseBtn.textContent = state==="paused" ? "Resume" : "Pause";
}
function say(s) { statusEl.textContent=s; }
function unlockAudio() {
if (!soundOn) return;
try {
if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)();
if (audio.state==="suspended") audio.resume().catch(()=>{});
} catch (_) { soundOn=false; soundBtn.textContent="Sound: unavailable"; }
}
function tone(freq,duration=.07,type="sine",volume=.035,slide=0) {
if (!soundOn || !audio || audio.state!=="running") return;
const t=audio.currentTime, o=audio.createOscillator(), g=audio.createGain();
o.type=type; o.frequency.setValueAtTime(freq,t);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,freq+slide),t+duration);
g.gain.setValueAtTime(volume,t);
g.gain.exponentialRampToValueAtTime(.0001,t+duration);
o.connect(g); g.connect(audio.destination); o.start(t); o.stop(t+duration);
}
function burst(x,y,color,n=15,force=1) {
if(reduced) n=Math.ceil(n/3);
for(let i=0;i<n;i++) {
const angle=rand(0,Math.PI*2), speed=rand(35,210)*force;
const life=rand(.25,.65);
particles.push({x,y,vx:Math.cos(angle)*speed,vy:Math.sin(angle)*speed,
color,life,max:life,size:rand(2,5)});
}
if(particles.length>600) particles.splice(0,particles.length-600);
}
function popup(text,x,y,color="#eaf0ff") {
popups.push({text,x,y,color,life:1,max:1});
}
function makeLevel() {
bricks=[]; drops=[]; trail=[]; combo=0; wideTime=0; slowTime=0;
paddle.w=124; paddle.x=W/2; targetX=paddle.x;
const cols=12, rows=6, bw=65, bh=23, gap=8, startX=(W-(cols*(bw+gap)-gap))/2;
for(let row=0;row<rows;row++) {
for(let col=0;col<cols;col++) {
let include=true, hp=1;
if(level===0) include = !(row===5 && (col<2 || col>9));
if(level===1) { include=(row+col)%3!==0; hp=row<2?2:1; }
if(level===2) { include=Math.abs(col-5.5)+Math.abs(row-2.5)*1.35<6.2; hp=row===2||row===3?2:1; }
if(level===3) { include=col<3||col>8||row<2||row===5; hp=row<2||col===0||col===11?2:1; }
if(level===4) { include=!(row===5&&(col===0||col===11)); hp=row<2?3:row<4?2:1; }
if(include) bricks.push({x:startX+col*(bw+gap), y:94+row*(bh+gap),
w:bw,h:bh,hp,max:hp,color:colors[row],hit:0});
}
}
remaining=bricks.length;
ball.speed=465+level*36;
ball.vx=0; ball.vy=0;
ball.x=paddle.x; ball.y=paddle.y-ball.r-3;
state="ready"; say(names[level]+" — click or press Space to launch."); hud();
}
function newGame() {
level=0; score=0; lives=3; particles=[]; popups=[]; shake=0; flash=0;
makeLevel();
}
function launch() {
if(state!=="ready") return;
state="playing";
ball.vx=ball.speed*.34*(Math.random()<.5?-1:1);
ball.vy=-Math.sqrt(ball.speed*ball.speed-ball.vx*ball.vx);
say("Keep it alive. Catch the falling upgrades.");
tone(420,.13,"triangle",.045,340);
}
function action() {
unlockAudio();
if(state==="title" || state==="gameover" || state==="win") { newGame(); launch(); }
else if(state==="ready") launch();
else if(state==="paused") togglePause();
}
function togglePause() {
if(state==="paused") { state=beforePause; say(state==="ready"?"Click or Space to launch.":"Back in the flow."); }
else if(state==="playing" || state==="ready") { beforePause=state; state="paused"; say("Paused. Take your time."); }
hud();
}
function loseLife() {
lives--; combo=0; wideTime=0; slowTime=0; paddle.w=124; drops=[]; trail=[];
shake=12; flash=.22;
burst(ball.x,H-15,"#ff719f",40,1.3);
tone(170,.35,"sawtooth",.04,-130);
if(lives<=0) {
state="gameover"; say("Run ended. Click or Space to try again.");
} else {
state="ready"; ball.x=paddle.x; ball.y=paddle.y-ball.r-3;
ball.speed=465+level*36;
say("One more shot — click or Space to launch.");
}
hud();
}
function hitBrick(b) {
b.hp--; b.hit=.14;
tone(320+(5-Math.round((b.y-94)/31))*60+Math.min(combo,16)*22,.075,"triangle",.035,70);
if(b.hp===0) {
remaining--; combo++;
const mult=Math.min(5,1+Math.floor(combo/4));
const points=100*mult;
score+=points;
burst(b.x+b.w/2,b.y+b.h/2,b.color,19);
popup("+"+points,b.x+b.w/2,b.y,b.color);
shake=Math.max(shake,3);
if(Math.random()<.15) {
const n=Math.random(), type=n<.47?"W":n<.9?"S":"+";
drops.push({x:b.x+b.w/2,y:b.y+b.h/2,type,phase:rand(0,6)});
}
if(remaining===0) {
score+=500*(level+1); state="cleared"; clearTime=1.9; drops=[];
flash=.3; shake=8; popup("SECTOR BONUS +"+500*(level+1),W/2,330,"#71ffd4");
tone(660,.5,"sine",.05,660);
say("Sector clear! Bonus awarded.");
}
} else {
score+=10; burst(ball.x,ball.y,b.color,7,.6); shake=Math.max(shake,1.5);
}
hud();
}
function collect(d) {
if(d.type==="W") { wideTime=13; popup("WIDE PADDLE",paddle.x,paddle.y-30,"#71ffd4"); }
if(d.type==="S") { slowTime=10; popup("SLOW MOTION",paddle.x,paddle.y-30,"#70caff"); }
if(d.type==="+") {
if(lives<5) { lives++; popup("EXTRA LIFE",paddle.x,paddle.y-30,"#ff719f"); }
else { score+=500; popup("+500",paddle.x,paddle.y-30,"#ff719f"); }
}
burst(d.x,d.y,"#ffffff",20); tone(740,.2,"sine",.05,500); hud();
}
function movePaddle(dt) {
const dir=(keys.has("ArrowRight")||keys.has("d")?1:0)-(keys.has("ArrowLeft")||keys.has("a")?1:0);
if(dir) { paddle.x+=dir*740*dt; targetX=paddle.x; }
else if(mouseMode) paddle.x+=(targetX-paddle.x)*(1-Math.exp(-26*dt));
paddle.x=clamp(paddle.x,paddle.w/2+12,W-paddle.w/2-12);
}
function simulateBall(dt) {
const factor=slowTime>0?.73:1;
const steps=Math.max(1,Math.ceil(ball.speed*factor*dt/5));
const step=dt*factor/steps;
for(let s=0;s<steps;s++) {
ball.x+=ball.vx*step; ball.y+=ball.vy*step;
if(ball.x<ball.r+10) { ball.x=ball.r+10; ball.vx=Math.abs(ball.vx); tone(230,.035); }
if(ball.x>W-ball.r-10) { ball.x=W-ball.r-10; ball.vx=-Math.abs(ball.vx); tone(230,.035); }
if(ball.y<ball.r+10) { ball.y=ball.r+10; ball.vy=Math.abs(ball.vy); tone(260,.035); }
if(ball.vy>0 && ball.y+ball.r>=paddle.y && ball.y-ball.r<=paddle.y+paddle.h &&
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=offset*1.12;
ball.vx=ball.speed*Math.sin(angle);
if(Math.abs(ball.vx)<55) ball.vx=55*(offset<0?-1:1);
ball.vy=-Math.sqrt(ball.speed*ball.speed-ball.vx*ball.vx);
ball.y=paddle.y-ball.r-.1; combo=0;
burst(ball.x,paddle.y,"#71ffd4",8,.5);
tone(280,.09,"triangle",.04,150);
}
for(const b of bricks) {
if(b.hp<=0) continue;
const nearX=clamp(ball.x,b.x,b.x+b.w), nearY=clamp(ball.y,b.y,b.y+b.h);
let nx=ball.x-nearX, ny=ball.y-nearY;
const dist2=nx*nx+ny*ny;
if(dist2>ball.r*ball.r) continue;
let dist=Math.sqrt(dist2);
if(dist>0) { nx/=dist; ny/=dist; }
else {
const edges=[Math.abs(ball.x-b.x),Math.abs(ball.x-b.x-b.w),Math.abs(ball.y-b.y),Math.abs(ball.y-b.y-b.h)];
const side=edges.indexOf(Math.min(...edges));
nx=side===0?-1:side===1?1:0; ny=side===2?-1:side===3?1:0;
dist=-edges[side];
}
const dot=ball.vx*nx+ball.vy*ny;
ball.x+=nx*(ball.r-dist+.2); ball.y+=ny*(ball.r-dist+.2);
if(dot<0) {
ball.vx-=2*dot*nx; ball.vy-=2*dot*ny;
// Keep every trajectory moving vertically enough to reach the paddle.
const sign=ball.vy<0?-1:1;
if(Math.abs(ball.vy)<ball.speed*.24) {
ball.vy=sign*ball.speed*.24;
ball.vx=(ball.vx<0?-1:1)*Math.sqrt(ball.speed**2-ball.vy**2);
}
hitBrick(b);
}
break;
}
if(state!=="playing") break;
if(ball.y>H+ball.r) { loseLife(); break; }
}
}
function update(dt) {
if(state==="paused") return;
elapsed+=dt;
shake=Math.max(0,shake-dt*24); flash=Math.max(0,flash-dt);
for(const b of bricks) b.hit=Math.max(0,b.hit-dt);
for(const p of particles) {
p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=160*dt; p.vx*=Math.exp(-2*dt);
}
particles=particles.filter(p=>p.life>0);
for(const p of popups) { p.life-=dt; p.y-=33*dt; }
popups=popups.filter(p=>p.life>0);
for(const t of trail) t.life-=dt;
trail=trail.filter(t=>t.life>0);
if(state==="cleared") {
clearTime-=dt;
if(clearTime<=0) {
if(level===4) { state="win"; say("All five sectors cleared. You are the prism master!"); }
else { level++; makeLevel(); }
}
return;
}
if(state!=="ready" && state!=="playing") return;
wideTime=Math.max(0,wideTime-dt); slowTime=Math.max(0,slowTime-dt);
paddle.w+=( (wideTime>0?188:124)-paddle.w)*Math.min(1,dt*12);
movePaddle(dt);
if(state==="ready") { ball.x=paddle.x; ball.y=paddle.y-ball.r-3; return; }
simulateBall(dt);
if(state!=="playing") return;
if(!reduced) trail.push({x:ball.x,y:ball.y,life:.18});
for(const d of drops) {
d.y+=128*dt;
if(d.y+12>=paddle.y && d.y-12<=paddle.y+paddle.h && Math.abs(d.x-paddle.x)<paddle.w/2+12) {
collect(d); d.y=H+50;
}
}
drops=drops.filter(d=>d.y<H+25);
}
function roundRect(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,align="center",weight=600) {
ctx.fillStyle=color; ctx.font=weight+" "+size+"px system-ui, sans-serif";
ctx.textAlign=align; ctx.textBaseline="middle"; ctx.fillText(str,x,y);
}
function drawOverlay(title,sub,button,kicker) {
ctx.fillStyle="rgba(6,10,21,.79)"; ctx.fillRect(0,0,W,H);
const g=ctx.createRadialGradient(W/2,290,0,W/2,290,360);
g.addColorStop(0,"#6fffd510"); g.addColorStop(1,"#6fffd500");
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
text(kicker,W/2,205,13,"#71ffd4","center",750);
text(title,W/2,269,60,"#eff5ff","center",850);
text(sub,W/2,331,18,"#aab9d3","center",400);
roundRect(W/2-145,379,290,52,12,"#71ffd4");
text(button,W/2,405,17,"#0a2021","center",800);
text("CLICK ANYWHERE OR PRESS SPACE",W/2,461,11,"#8596b6","center",600);
}
function render() {
ctx.setTransform(canvas.width/W,0,0,canvas.height/H,0,0);
ctx.clearRect(0,0,W,H);
ctx.fillStyle="#0c1120"; ctx.fillRect(0,0,W,H);
const bg=ctx.createLinearGradient(0,0,0,H);
bg.addColorStop(0,"#18233c55"); bg.addColorStop(1,"#09101d00");
ctx.fillStyle=bg; ctx.fillRect(0,0,W,H);
ctx.strokeStyle="#b0c7ff06"; 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();
for(const s of stars) {
ctx.globalAlpha=.18+.13*Math.sin(elapsed*.6+s.phase);
ctx.fillStyle="#bacdff"; ctx.fillRect(s.x,s.y,s.size,s.size);
}
ctx.globalAlpha=1;
ctx.save();
if(!reduced && shake>0) ctx.translate(rand(-shake,shake),rand(-shake,shake));
text("SECTOR "+String(level+1).padStart(2,"0")+" / "+names[level],35,34,12,"#8e9fbd","left",650);
text(remaining+" BRICKS",W-35,34,12,"#8e9fbd","right",650);
if(combo>=4 && state==="playing") text("×"+Math.min(5,1+Math.floor(combo/4))+" COMBO",W/2,35,16,"#ffe28a","center",800);
ctx.strokeStyle="#35425f"; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(10,60); ctx.lineTo(10,H-55); ctx.moveTo(W-10,60); ctx.lineTo(W-10,H-55); ctx.stroke();
for(const b of bricks) {
if(b.hp<=0) continue;
roundRect(b.x,b.y+3,b.w,b.h,5,"#0005");
roundRect(b.x,b.y,b.w,b.h,5,b.hit>0?"#ffffff":b.color);
roundRect(b.x+2,b.y+2,b.w-4,3,2,"#ffffff55");
ctx.fillStyle="#091526";
if(b.hp>1) {
for(let i=0;i<b.hp;i++) roundRect(b.x+b.w/2-(b.hp*7-3)/2+i*7,b.y+b.h/2-2,4,4,1,"#13243b99");
}
if(b.max>1 && b.hp===1) {
ctx.strokeStyle="#16203255"; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(b.x+b.w*.55,b.y+3); ctx.lineTo(b.x+b.w*.43,b.y+11);
ctx.lineTo(b.x+b.w*.58,b.y+15); ctx.lineTo(b.x+b.w*.49,b.y+b.h-2); ctx.stroke();
}
}
for(const d of drops) {
const color=d.type==="W"?"#71ffd4":d.type==="S"?"#70caff":"#ff719f";
ctx.save(); ctx.translate(d.x,d.y); ctx.rotate(Math.sin(elapsed*4+d.phase)*.1);
ctx.shadowBlur=18; ctx.shadowColor=color;
roundRect(-14,-14,28,28,7,color); ctx.shadowBlur=0;
text(d.type,0,1,17,"#102037","center",850); ctx.restore();
}
for(const t of trail) {
ctx.globalAlpha=t.life/.18*.35; ctx.fillStyle=slowTime>0?"#70caff":"#71ffd4";
ctx.beginPath(); ctx.arc(t.x,t.y,ball.r*t.life/.18,0,Math.PI*2); ctx.fill();
}
ctx.globalAlpha=1;
if(state!=="gameover" && state!=="win" && state!=="cleared") {
ctx.shadowBlur=20; ctx.shadowColor=slowTime>0?"#70caff":"#71ffd4";
ctx.fillStyle="#f5fffe"; ctx.beginPath(); ctx.arc(ball.x,ball.y,ball.r,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
}
ctx.shadowBlur=wideTime>0?24:14; ctx.shadowColor="#71ffd4";
roundRect(paddle.x-paddle.w/2,paddle.y,paddle.w,paddle.h,7,"#71ffd4"); ctx.shadowBlur=0;
roundRect(paddle.x-paddle.w/2+8,paddle.y+2,paddle.w-16,3,2,"#d8fff5");
roundRect(paddle.x-paddle.w/2+12,paddle.y+5,5,5,2,"#158b83");
roundRect(paddle.x+paddle.w/2-17,paddle.y+5,5,5,2,"#158b83");
let badgeX=32;
if(wideTime>0) { text("WIDE "+Math.ceil(wideTime)+"s",badgeX,H-17,11,"#71ffd4","left"); badgeX+=110; }
if(slowTime>0) text("SLOW "+Math.ceil(slowTime)+"s",badgeX,H-17,11,"#70caff","left");
if(state==="playing" && wideTime<=0 && slowTime<=0) text("AIM WITH THE EDGES OF YOUR PADDLE",W/2,H-17,10,"#566987");
for(const p of particles) {
ctx.globalAlpha=Math.max(0,p.life/p.max); ctx.fillStyle=p.color;
ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
}
for(const p of popups) { ctx.globalAlpha=Math.min(1,p.life*2); text(p.text,p.x,p.y,14,p.color,"center",800); }
ctx.globalAlpha=1;
ctx.restore();
if(flash>0 && !reduced) { ctx.fillStyle="rgba(160,230,255,"+flash*.25+")"; ctx.fillRect(0,0,W,H); }
if(state==="ready") {
text(names[level],W/2,399,30,"#eff5ff","center",800);
text("Click or press Space to launch",W/2,441,17,"#aab9d3","center",450);
text("Move the paddle to choose your starting position",W/2,471,12,"#7184a6","center",400);
}
if(state==="cleared") {
text("SECTOR CLEAR",W/2,388,45,"#71ffd4","center",850);
text(level===4?"The whole spectrum. Yours.":"Next sector incoming…",W/2,440,18,"#c3d0e7","center",450);
}
if(state==="title") drawOverlay("CHASE THE LIGHT","Five sectors. One paddle. Make every bounce count.","LET'S PLAY","P R I S M / B R E A K O U T");
if(state==="paused") drawOverlay("ON HOLD","Your next great bounce can wait.","RESUME","P A U S E D");
if(state==="gameover") drawOverlay("ONE MORE RUN?","Final score: "+score.toLocaleString()+" · Sector "+(level+1)+" of 5","PLAY AGAIN","O U T O F L I G H T");
if(state==="win") drawOverlay("SPECTRUM COMPLETE","All five sectors cleared. Final score: "+score.toLocaleString(),"PLAY AGAIN","Y O U A R E T H E P R I S M");
}
function pointerPosition(e) {
const r=canvas.getBoundingClientRect();
targetX=clamp((e.clientX-r.left)/r.width*W,0,W);
mouseMode=true;
}
canvas.addEventListener("pointermove",pointerPosition);
canvas.addEventListener("pointerdown",e=>{
if(e.button!==0) return;
e.preventDefault(); canvas.focus({preventScroll:true}); pointerPosition(e);
if(e.pointerType!=="mouse") canvas.setPointerCapture(e.pointerId);
if(state==="ready") {
paddle.x=clamp(targetX,paddle.w/2+12,W-paddle.w/2-12);
ball.x=paddle.x;
}
action();
});
window.addEventListener("keydown",e=>{
const key=e.key.length===1?e.key.toLowerCase():e.key;
if(["ArrowLeft","ArrowRight"," ","a","d","p","r","Escape"].includes(key)) {
if(e.target instanceof HTMLButtonElement && key===" ") return;
e.preventDefault();
}
if(key==="ArrowLeft"||key==="ArrowRight"||key==="a"||key==="d") {
keys.add(key); mouseMode=false;
}
if(e.repeat) return;
if(key===" ") action();
if(key==="p"||key==="Escape") togglePause();
if(key==="r") { unlockAudio(); newGame(); }
});
window.addEventListener("keyup",e=>keys.delete(e.key.length===1?e.key.toLowerCase():e.key));
function autoPause() {
keys.clear();
if(state==="playing"||state==="ready") togglePause();
}
window.addEventListener("blur",autoPause);
document.addEventListener("visibilitychange",()=>{ if(document.hidden) autoPause(); });
pauseBtn.addEventListener("click",()=>{ unlockAudio(); togglePause(); });
document.getElementById("restart").addEventListener("click",()=>{ unlockAudio(); newGame(); canvas.focus({preventScroll:true}); });
soundBtn.addEventListener("click",()=>{
soundOn=!soundOn; unlockAudio();
soundBtn.textContent="Sound: "+(soundOn?"on":"off");
soundBtn.setAttribute("aria-pressed",String(soundOn));
if(soundOn) tone(700,.13,"sine",.035,200);
});
makeLevel(); state="title"; say("Ready when you are.");
function frame(now) {
const dt=lastFrame?Math.min((now-lastFrame)/1000,.035):0;
lastFrame=now; update(dt); render(); requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>

