Run details
- Model
- meta/muse-ember
- Route
- OpenRouter API, one user message
- Served by
- Meta
- Effort
- max, OpenRouter's default for this model
- Limits
- 60 min · 943,718 output tokens (the model's limit)
- Date
- 9 Nov 2026
- Time
- 61 s
- Size
- 40 kB
- Tokens
- 85 in, 39,247 out (25,314 of them thinking)
- List price
- $0.020 at today's price ($0.10 in / $0.50 out per M)
- Paid
- $0.020
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
40 kB 956 lines Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<title>Neon Breakout</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:
radial-gradient(1200px 700px at 20% -10%, rgba(124,58,237,.35), transparent 60%),
radial-gradient(1000px 600px at 90% 0%, rgba(34,211,238,.22), transparent 55%),
#050816;
color:#e2e8f0;
min-height:100vh;
}
.wrap{max-width:920px;margin:0 auto;padding:20px 16px 34px}
header{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:14px;flex-wrap:wrap}
h1{
margin:0;font-size:clamp(28px,5vw,44px);letter-spacing:.06em;font-weight:900;
background:linear-gradient(90deg,#67e8f9,#f472b6,#a78bfa);
-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;
}
.tag{margin:4px 0 0;color:#94a3b8}
.hi{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);
padding:10px 14px;border-radius:12px;color:#94a3b8;font-size:12px;letter-spacing:.12em;
}
.hi span{display:block;color:#fde68a;font-size:20px;font-weight:800;letter-spacing:0;margin-top:2px}
.hud{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:10px}
.hud-card{
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.09);
border-radius:14px;padding:10px 14px;display:flex;flex-direction:column;gap:2px;backdrop-filter:blur(6px);
}
.hud-card.hot{border-color:rgba(250,204,21,.55);box-shadow:0 0 18px rgba(250,204,21,.18)}
.hud-card.hot strong{color:#fde68a}
.label{font-size:11px;letter-spacing:.18em;color:#94a3b8;font-weight:700}
.hud-card strong{font-size:22px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1.1}
.hud-card small{color:#64748b;font-size:12px}
.hearts{color:#fb7185;letter-spacing:2px}
.powers{display:flex;gap:8px;align-items:center;min-height:30px;margin-bottom:10px;flex-wrap:wrap}
.chip{font-size:12px;font-weight:800;letter-spacing:.08em;padding:6px 10px;border-radius:999px;border:1px solid;display:inline-flex;gap:6px;align-items:center}
.chip.timer{display:none}
.chip-expand{color:#67e8f9;border-color:rgba(34,211,238,.5);background:rgba(34,211,238,.12)}
.chip-fire{color:#fdba74;border-color:rgba(251,146,60,.5);background:rgba(251,146,60,.12)}
.chip-slow{color:#93c5fd;border-color:rgba(96,165,250,.5);background:rgba(96,165,250,.12)}
.chip.info{color:#94a3b8;border-color:rgba(148,163,184,.25);background:rgba(148,163,184,.08);font-weight:600;letter-spacing:0}
.game-shell{
position:relative;border-radius:16px;overflow:hidden;
border:2px solid rgba(34,211,238,.4);
box-shadow:0 0 42px rgba(34,211,238,.22), 0 24px 60px rgba(0,0,0,.5);
background:#050816;
}
canvas{display:block;width:100%;height:auto;touch-action:none;user-select:none;-webkit-user-select:none;cursor:crosshair}
.scanlines{position:absolute;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px)}
.overlay{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;
background:rgba(5,8,22,.74);backdrop-filter:blur(6px);padding:24px;z-index:5;
transition:opacity .25s ease;
}
.overlay.hidden{opacity:0;pointer-events:none}
.panel{max-width:560px;animation:pop .35s cubic-bezier(.2,1.6,.4,1)}
@keyframes pop{from{transform:scale(.92) translateY(10px);opacity:0}to{transform:scale(1) translateY(0);opacity:1}}
.badge{display:inline-block;padding:6px 12px;border-radius:999px;background:rgba(244,114,182,.15);color:#f9a8d4;border:1px solid rgba(244,114,182,.4);font-size:12px;font-weight:800;letter-spacing:.18em}
.overlay h2{
font-size:clamp(30px,6vw,52px);margin:14px 0 10px;letter-spacing:.04em;
background:linear-gradient(90deg,#67e8f9,#f472b6,#a78bfa);
-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;
}
.overlay p{color:#cbd5e1;line-height:1.55;margin:0 0 18px}
.btn{
font:inherit;font-weight:900;letter-spacing:.08em;cursor:pointer;border:none;color:#fff;
padding:14px 28px;border-radius:999px;
background:linear-gradient(90deg,#ec4899,#06b6d4);
box-shadow:0 8px 26px rgba(236,72,153,.35),0 0 0 1px rgba(255,255,255,.15) inset;
transition:transform .15s ease, box-shadow .15s ease;
}
.btn:hover{transform:translateY(-2px) scale(1.02);box-shadow:0 12px 32px rgba(6,182,212,.45)}
.btn:active{transform:translateY(0)}
.btn:focus-visible,.ctl:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
.tiny{font-size:12px;color:#64748b;margin-top:14px}
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:14px}
.ctl{
font:inherit;font-weight:700;cursor:pointer;color:#e2e8f0;
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);
padding:9px 14px;border-radius:999px;transition:border-color .15s,background .15s,transform .15s;
}
.ctl:hover{border-color:rgba(34,211,238,.7);background:rgba(34,211,238,.1);transform:translateY(-1px)}
.spacer{flex:1}
.keys{color:#64748b;font-size:13px}
kbd{background:#1e293b;border:1px solid #334155;border-bottom-width:3px;border-radius:6px;padding:2px 6px;font-family:inherit;font-size:11px;color:#e2e8f0}
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:16px}
.legend article{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:12px 14px}
.legend h3{margin:0 0 6px;font-size:14px;letter-spacing:.06em}
.legend p{margin:0;color:#94a3b8;font-size:13px;line-height:1.55}
.dot{display:inline-block;width:11px;height:11px;border-radius:3px;background:var(--c);margin-right:6px;box-shadow:0 0 10px var(--c);vertical-align:-1px}
@media (max-width:700px){
.hud{grid-template-columns:repeat(2,1fr)}
.legend{grid-template-columns:1fr}
.chip.info{display:none}
.keys{flex-basis:100%}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>NEON BREAKOUT</h1>
<p class="tag">Smash bricks. Chain combos. Don't drop the ball.</p>
</div>
<div class="hi">HI-SCORE<span id="hiScore">000000</span></div>
</header>
<section class="hud" aria-label="Score">
<div class="hud-card"><span class="label">SCORE</span><strong id="score">000000</strong><small>points</small></div>
<div class="hud-card"><span class="label">LEVEL</span><strong id="level">1 / 7</strong><small id="levelName">First Contact</small></div>
<div class="hud-card"><span class="label">LIVES</span><strong id="lives" class="hearts">♥♥♥</strong><small>don't let it fall</small></div>
<div class="hud-card"><span class="label">COMBO</span><strong id="multiplier">×1</strong><small id="comboText">hit bricks consecutively</small></div>
</section>
<div class="powers" aria-live="polite">
<span class="chip timer chip-expand" id="chipExpand">EXPAND <b>0s</b></span>
<span class="chip timer chip-fire" id="chipFire">FIREBALL <b>0s</b></span>
<span class="chip timer chip-slow" id="chipSlow">SLOW-MO <b>0s</b></span>
<span class="chip info">Catch falling capsules for power-ups!</span>
</div>
<main class="game-shell" id="shell">
<canvas id="game" width="800" height="600" aria-label="Breakout game canvas"></canvas>
<div class="scanlines"></div>
<div class="overlay" id="overlay">
<div class="panel">
<div class="badge" id="overlayBadge">★ ARCADE CLASSIC</div>
<h2 id="overlayTitle">NEON BREAKOUT</h2>
<p id="overlayText">Break every brick, chain combos, catch power-ups and clear all 7 levels.</p>
<button id="primaryBtn" class="btn">▶ START GAME</button>
<p class="tiny" id="overlayHint">Mouse or ← → / A D to move • SPACE to launch • P pause • M mute</p>
</div>
</div>
</main>
<nav class="controls">
<button id="pauseBtn" class="ctl">⏸ Pause</button>
<button id="soundBtn" class="ctl">🔊 Sound on</button>
<button id="restartBtn" class="ctl">⟳ Restart game</button>
<span class="spacer"></span>
<span class="keys"><kbd>←</kbd><kbd>→</kbd> or mouse / drag <kbd>SPACE</kbd> launch <kbd>P</kbd> pause <kbd>M</kbd> mute</span>
</nav>
<section class="legend">
<article>
<h3>Bricks</h3>
<p><span class="dot" style="--c:#ff4d6d"></span>1 hit <span class="dot" style="--c:#22d3ee"></span>2 hits <span class="dot" style="--c:#a78bfa"></span>3 hits<br><span class="dot" style="--c:#fb923c"></span>Explosive — blows up neighbours <span class="dot" style="--c:#94a3b8"></span>Unbreakable</p>
</article>
<article>
<h3>Power-ups</h3>
<p><b style="color:#67e8f9">E</b> Expand paddle <b style="color:#f472b6">M</b> Multi-ball<br><b style="color:#93c5fd">S</b> Slow-mo <b style="color:#fdba74">F</b> Fireball <b style="color:#fb7185">+</b> Extra life</p>
</article>
<article>
<h3>Juice</h3>
<p>Particles, ball trails, screen shake, hit-flash, floating score popups and a combo multiplier — every 8 bricks in a row without touching the paddle raises it.</p>
</article>
</section>
</div>
<script>
(() => {
'use strict';
const W = 800, H = 600;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
const $ = id => document.getElementById(id);
const scoreEl = $('score'), levelEl = $('level'), levelNameEl = $('levelName'),
livesEl = $('lives'), multiEl = $('multiplier'), comboEl = $('comboText'), hiEl = $('hiScore');
const overlay = $('overlay'), ovBadge = $('overlayBadge'), ovTitle = $('overlayTitle'),
ovText = $('overlayText'), ovHint = $('overlayHint'), primaryBtn = $('primaryBtn');
const chipExpand = $('chipExpand'), chipFire = $('chipFire'), chipSlow = $('chipSlow');
const pauseBtn = $('pauseBtn'), soundBtn = $('soundBtn'), restartBtn = $('restartBtn');
const LEVELS = [
{ name:'First Contact', rows:[
'1111111111','1111111111','2222222222','2222222222','1111111111','1111111111']},
{ name:'Pyramid', rows:[
'....11....','...1221...','..123321..','.12333321.','1233333321','1111111111']},
{ name:'Fortress', rows:[
'1111111111','X22222222X','X23333332X','X22222222X','1111111111','X..1111..X']},
{ name:'Diamond', rows:[
'....11....','...1221...','..123321..','.12333321.','..123321..','...1221...','....11....']},
{ name:'Columns', rows:[
'1.1.1.1.1.','.2.2.2.2.2','3.3.3.3.3.','.2.2.2.2.2','1.1.1.1.1.','.3.3.3.3.3']},
{ name:'Blast Zone', rows:[
'1111111111','1E222222E1','12E3333E21','1E222222E1','1111111111','.E.1111.E.']},
{ name:'Final Chaos', rows:[
'3X3X3X3X33','1111111111','2E222222E2','2222222222','X1X1X1X1X1','1111111111']}
];
const BRICK_TYPES = {
'1': { hp:1, color:'#ff4d6d', points:50 },
'2': { hp:2, color:'#22d3ee', points:100 },
'3': { hp:3, color:'#a78bfa', points:150 },
'E': { hp:1, color:'#fb923c', points:120 },
'X': { hp:Infinity, color:'#64748b', points:0 }
};
const POWER_INFO = {
expand:{ label:'E', name:'EXPAND', color:'#22d3ee' },
multi: { label:'M', name:'MULTI-BALL', color:'#f472b6' },
slow: { label:'S', name:'SLOW-MO', color:'#60a5fa' },
fire: { label:'F', name:'FIREBALL', color:'#fb923c' },
life: { label:'+', name:'EXTRA LIFE', color:'#f43f5e' }
};
let state = 'menu';
let score = 0, hiScore = 0, lives = 3, levelIndex = 0;
try { hiScore = parseInt(localStorage.getItem('neonBreakoutHi') || '0', 10) || 0; } catch(e) {}
let combo = 0, multiplier = 1;
let bricks = [], balls = [], particles = [], floaters = [], powerups = [], stars = [];
let gameTime = 0, animTime = 0;
let expandUntil = 0, fireUntil = 0, slowUntil = 0, wasSlow = false;
let shake = 0, flash = 0, flashColor = '255,255,255';
let muted = false;
const paddle = { x:W/2, y:H-28, w:110, baseW:110, h:14, targetX:W/2, tilt:0, squash:0, vel:0 };
const keys = {};
let usingMouse = false;
function clamp(v, a, b){ return v < a ? a : (v > b ? b : v); }
/* ---------- Audio (WebAudio, no files) ---------- */
let audioCtx = null;
function ensureAudio(){
if(!audioCtx){ try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {} }
if(audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
function tone(freq, dur, type, vol, slideTo){
if(muted || !audioCtx) return;
dur = dur || 0.08; type = type || 'square'; vol = vol || 0.12;
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t);
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(audioCtx.destination);
o.start(t); o.stop(t + dur);
}
function noiseBurst(dur, vol){
if(muted || !audioCtx) return;
dur = dur || 0.25; vol = vol || 0.28;
const n = Math.floor(audioCtx.sampleRate * dur);
const buf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
const d = buf.getChannelData(0);
for(let i = 0; i < n; i++) d[i] = (Math.random()*2 - 1) * (1 - i/n);
const src = audioCtx.createBufferSource(); src.buffer = buf;
const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 900;
const g = audioCtx.createGain(); g.gain.value = vol;
src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start();
}
const sfx = {
paddle(){ tone(200, .06, 'square', .12, 320); },
wall(){ tone(160, .05, 'square', .08); },
clank(){ tone(120, .05, 'square', .10, 90); },
hit(){ tone(340, .05, 'square', .08, 260); },
breakBrick(c){ const b = 420 + ((c||0) % 10) * 35; tone(b, .08, 'square', .13, b*1.8); },
explosion(){ noiseBurst(.3, .28); tone(90, .3, 'sawtooth', .18, 40); },
power(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,.09,'square',.12), i*55)); },
launch(){ tone(300, .15, 'square', .12, 700); },
loseBall(){ tone(400, .25, 'sawtooth', .14, 80); },
combo(){ tone(880, .1, 'square', .12, 1320); },
levelClear(){ [523,659,784,1046,1318].forEach((f,i)=>setTimeout(()=>tone(f,.12,'square',.13), i*70)); },
gameOver(){ [392,330,262,196].forEach((f,i)=>setTimeout(()=>tone(f,.18,'sawtooth',.12), i*130)); }
};
/* ---------- HUD & overlay ---------- */
function saveHi(){ try { localStorage.setItem('neonBreakoutHi', String(hiScore)); } catch(e) {} }
function updateHUD(){
scoreEl.textContent = String(score).padStart(6, '0');
hiEl.textContent = String(hiScore).padStart(6, '0');
levelEl.textContent = (levelIndex + 1) + ' / ' + LEVELS.length;
levelNameEl.textContent = LEVELS[levelIndex].name;
livesEl.textContent = '♥'.repeat(Math.max(0, lives)) || '—';
multiEl.textContent = '×' + multiplier;
comboEl.textContent = combo > 1 ? (combo + ' brick combo') : 'hit bricks consecutively';
const card = multiEl.closest('.hud-card');
if(card) card.classList.toggle('hot', multiplier > 1);
}
function showOverlay(kind, data){
data = data || {};
overlay.classList.remove('hidden');
if(kind === 'menu'){
ovBadge.textContent = '★ ARCADE CLASSIC';
ovTitle.textContent = 'NEON BREAKOUT';
ovText.textContent = 'Break every brick, chain combos, catch power-ups and clear all 7 levels. You have 3 lives — angle the ball off the paddle edge to aim where it goes!';
ovHint.textContent = 'Mouse / touch drag or ← → / A D to move • SPACE to launch • P pause • M mute';
primaryBtn.textContent = '▶ START GAME';
} else if(kind === 'paused'){
ovBadge.textContent = '⏸ PAUSED';
ovTitle.textContent = 'PAUSED';
ovText.textContent = 'The balls are frozen in mid-air. Take your time — your combo and power-ups are safe.';
ovHint.textContent = 'P or ESC to resume • M to mute';
primaryBtn.textContent = '▶ RESUME';
} else if(kind === 'levelComplete'){
ovBadge.textContent = '★ LEVEL CLEAR';
ovTitle.textContent = 'LEVEL CLEAR!';
ovText.textContent = 'Nice smashing! Bonus +' + data.bonus + ' points. Next up: ' + data.nextName + '. New patterns, faster ball.';
ovHint.textContent = 'PRESS SPACE or click below for the next level';
primaryBtn.textContent = '▶ NEXT LEVEL';
} else if(kind === 'gameOver'){
ovBadge.textContent = '✕ GAME OVER';
ovTitle.textContent = 'OUT OF LIVES';
ovText.textContent = 'You scored ' + score.toLocaleString() + ' points. Hi-score: ' + hiScore.toLocaleString() + '.';
ovHint.textContent = 'PRESS SPACE to try again';
primaryBtn.textContent = '↻ PLAY AGAIN';
} else if(kind === 'victory'){
ovBadge.textContent = '🏆 CHAMPION';
ovTitle.textContent = 'YOU WIN!';
ovText.textContent = 'All 7 levels cleared! Final score: ' + score.toLocaleString() + ' points. Hi-score: ' + hiScore.toLocaleString() + '. Neon legend status: achieved.';
ovHint.textContent = 'PRESS SPACE to play again';
primaryBtn.textContent = '↻ PLAY AGAIN';
}
}
function hideOverlay(){ overlay.classList.add('hidden'); }
/* ---------- Game flow ---------- */
function currentBallSpeed(){ return 400 + levelIndex * 18; }
function resetBallToPaddle(){
balls = [{ x:paddle.x, y:paddle.y - 9, vx:0, vy:0, r:7, trail:[], stuck:true }];
}
function loadLevel(idx){
levelIndex = idx;
const rows = LEVELS[idx].rows;
const cols = Math.max.apply(null, rows.map(r => r.length));
const gap = 4, top = 72, margin = 30;
const bw = (W - margin*2 - gap*(cols-1)) / cols, bh = 22;
bricks = [];
rows.forEach((row, r) => {
for(let c = 0; c < row.length; c++){
const ch = row[c];
if(ch === '.' || !BRICK_TYPES[ch]) continue;
const cfg = BRICK_TYPES[ch];
bricks.push({
x: margin + c*(bw+gap), y: top + r*(bh+gap), w: bw, h: bh,
hp: cfg.hp, maxHp: cfg.hp, type: ch, color: cfg.color, points: cfg.points,
flash: 0, wobble: 0, dead: false
});
}
});
powerups = []; combo = 0; multiplier = 1;
expandUntil = 0; fireUntil = 0; slowUntil = 0; wasSlow = false;
paddle.w = paddle.baseW; paddle.x = W/2; paddle.targetX = W/2; paddle.tilt = 0; paddle.squash = 0;
resetBallToPaddle();
state = 'ready';
hideOverlay();
pauseBtn.textContent = '⏸ Pause';
updateHUD();
}
function startGame(){
score = 0; lives = 3; combo = 0; multiplier = 1;
loadLevel(0);
}
function nextLevel(){
if(levelIndex + 1 < LEVELS.length) loadLevel(levelIndex + 1);
}
function launchBall(){
if(state !== 'ready') return;
state = 'playing';
hideOverlay();
const speed = currentBallSpeed();
const ang = Math.random()*0.6 - 0.3;
balls.forEach(b => { b.stuck = false; b.vx = Math.sin(ang)*speed; b.vy = -Math.cos(ang)*speed; });
sfx.launch();
floater(paddle.x, paddle.y - 32, 'GO!', '#ffffff', 22);
}
function primaryAction(){
ensureAudio();
if(state === 'menu' || state === 'gameOver' || state === 'victory') startGame();
else if(state === 'levelComplete') nextLevel();
else if(state === 'ready') launchBall();
else if(state === 'paused') togglePause();
}
function togglePause(){
if(state === 'playing'){ state = 'paused'; showOverlay('paused'); pauseBtn.textContent = '▶ Resume'; }
else if(state === 'paused'){ state = 'playing'; hideOverlay(); pauseBtn.textContent = '⏸ Pause'; }
}
function toggleMute(){
muted = !muted;
soundBtn.textContent = muted ? '🔇 Sound off' : '🔊 Sound on';
if(!muted) ensureAudio();
}
function gameOver(){
state = 'gameOver';
saveHi(); updateHUD();
sfx.gameOver();
shake = 16; flash = .4; flashColor = '244,63,94';
showOverlay('gameOver');
}
function onAllBallsLost(){
lives--; combo = 0; multiplier = 1; updateHUD();
if(lives <= 0){ gameOver(); return; }
state = 'ready';
expandUntil = 0; fireUntil = 0; slowUntil = 0; wasSlow = false;
paddle.w = paddle.baseW;
resetBallToPaddle();
floater(W/2, H/2, 'BALL LOST', '#fda4af', 28);
}
function checkLevelComplete(){
if(state !== 'playing') return;
const remaining = bricks.some(b => !b.dead && b.type !== 'X');
if(remaining) return;
const bonus = 500 + Math.max(0, lives - 1) * 100;
score += bonus;
if(score > hiScore) hiScore = score;
spawnConfetti();
sfx.levelClear();
if(levelIndex + 1 >= LEVELS.length){
state = 'victory';
saveHi(); updateHUD();
showOverlay('victory');
} else {
state = 'levelComplete';
saveHi(); updateHUD();
showOverlay('levelComplete', { bonus: bonus, nextName: LEVELS[levelIndex+1].name });
}
}
/* ---------- Particles & popups ---------- */
function spawnParticles(x, y, color, n, speed, gravity){
n = n || 12; speed = speed || 200; gravity = (gravity === undefined) ? 520 : gravity;
for(let i = 0; i < n; i++){
const ang = Math.random()*Math.PI*2;
const sp = speed * (0.3 + Math.random()*0.7);
particles.push({
x:x, y:y, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp - 40,
life:0, maxLife:0.45 + Math.random()*0.5,
size:1.5 + Math.random()*3.5, color:color, gravity:gravity,
rot:Math.random()*Math.PI, vr:(Math.random()-0.5)*10,
shape: Math.random() < 0.3 ? 'rect' : 'circle'
});
}
}
function spawnConfetti(){
const colors = ['#ff4d6d','#22d3ee','#a78bfa','#facc15','#4ade80','#fb923c'];
for(let i = 0; i < 140; i++){
particles.push({
x:Math.random()*W, y:-20 - Math.random()*200,
vx:(Math.random()-0.5)*80, vy:80 + Math.random()*160,
life:0, maxLife:2 + Math.random()*1.5,
size:4 + Math.random()*5, color:colors[i % colors.length],
gravity:60, rot:Math.random()*Math.PI, vr:(Math.random()-0.5)*12, shape:'rect'
});
}
}
function updateParticles(dt){
for(let i = particles.length-1; i >= 0; i--){
const p = particles[i];
p.life += dt;
if(p.life >= p.maxLife){ particles.splice(i,1); continue; }
p.vy += p.gravity*dt; p.x += p.vx*dt; p.y += p.vy*dt; p.rot += p.vr*dt;
}
for(let i = floaters.length-1; i >= 0; i--){
const f = floaters[i];
f.life += dt; f.y -= 42*dt;
if(f.life > 1.1) floaters.splice(i,1);
}
}
function floater(x, y, text, color, size){
floaters.push({ x:clamp(x,60,W-60), y:y, text:text, color:color||'#ffffff', size:size||16, life:0 });
}
function addScore(points, x, y, color, size){
score += points;
if(score > hiScore) hiScore = score;
if(x !== undefined) floater(x, y, '+' + points, color || '#ffffff', size || 14);
updateHUD();
}
/* ---------- Bricks, power-ups ---------- */
function damageBrick(br, dmg){
if(br.dead) return;
if(br.type === 'X'){
br.flash = 1; br.wobble = 1;
spawnParticles(br.x + br.w/2, br.y + br.h/2, '#94a3b8', 4, 100);
sfx.clank();
return;
}
br.hp -= dmg; br.flash = 1; br.wobble = 1;
const cx = br.x + br.w/2, cy = br.y + br.h/2;
if(br.hp > 0){
addScore(10, cx, cy, '#e2e8f0', 12);
spawnParticles(cx, cy, br.color, 6, 140);
sfx.hit();
return;
}
br.dead = true;
combo++;
const oldMult = multiplier;
multiplier = 1 + Math.floor(combo / 8);
if(multiplier > oldMult){
floater(W/2, H/2 - 40, 'COMBO ×' + multiplier, '#fde68a', 30);
sfx.combo();
}
addScore(br.points * multiplier, cx, cy, br.color, 16);
spawnParticles(cx, cy, br.color, br.type === 'E' ? 26 : 16, br.type === 'E' ? 320 : 220);
shake = Math.min(14, shake + (br.type === 'E' ? 7 : 2.5));
sfx.breakBrick(combo);
maybeDropPowerup(cx, cy);
if(br.type === 'E') explodeBrick(br);
updateHUD();
}
function explodeBrick(br){
flash = .25; flashColor = '251,146,60';
sfx.explosion();
spawnParticles(br.x + br.w/2, br.y + br.h/2, '#f97316', 30, 380);
const cx = br.x + br.w/2, cy = br.y + br.h/2;
const neighbors = bricks.filter(o => o !== br && !o.dead &&
Math.abs((o.x + o.w/2) - cx) < br.w * 1.6 &&
Math.abs((o.y + o.h/2) - cy) < br.h * 2.2);
neighbors.forEach(n => damageBrick(n, 1));
}
function maybeDropPowerup(x, y){
if(powerups.length > 3) return;
if(Math.random() > 0.16) return;
const roll = Math.random();
let type = 'expand';
if(roll < 0.30) type = 'expand';
else if(roll < 0.55) type = 'multi';
else if(roll < 0.73) type = 'slow';
else if(roll < 0.93) type = 'fire';
else type = 'life';
powerups.push({ x:x, y:y, vy:155, type:type, phase:Math.random()*Math.PI*2 });
}
function applyPowerup(type, x, y){
const info = POWER_INFO[type];
floater(x, y - 14, info.name + '!', info.color, 20);
spawnParticles(x, y, info.color, 18, 200);
sfx.power();
if(type === 'expand') expandUntil = gameTime + 10;
else if(type === 'fire') fireUntil = gameTime + 7;
else if(type === 'slow'){
if(gameTime >= slowUntil){
balls.forEach(b => { if(!b.stuck){ b.vx *= 0.65; b.vy *= 0.65; } });
}
slowUntil = gameTime + 8;
} else if(type === 'multi'){
const clones = [];
for(const b of balls){
if(b.stuck) continue;
for(let k = 0; k < 2 && balls.length + clones.length < 6; k++){
const ang = Math.atan2(b.vy, b.vx) + (k === 0 ? -0.5 : 0.5);
const sp = Math.hypot(b.vx, b.vy);
clones.push({ x:b.x, y:b.y, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp, r:b.r, trail:[], stuck:false });
}
}
balls = balls.concat(clones);
} else if(type === 'life'){
lives = Math.min(5, lives + 1);
}
updateHUD();
}
function updatePowerups(dt){
for(let i = powerups.length-1; i >= 0; i--){
const p = powerups[i];
p.y += p.vy*dt; p.phase += dt*6; p.x += Math.sin(p.phase)*0.3;
if(Math.abs(p.x - paddle.x) <= paddle.w/2 + 14 && Math.abs(p.y - paddle.y) <= 20){
applyPowerup(p.type, p.x, p.y);
powerups.splice(i,1); continue;
}
if(p.y > H + 20) powerups.splice(i,1);
}
}
function handleSlowExpiry(){
const isSlow = gameTime < slowUntil;
if(wasSlow && !isSlow){
balls.forEach(b => {
if(b.stuck) return;
const s = Math.hypot(b.vx, b.vy);
const ns = Math.min(620, s / 0.65);
if(s > 0){ b.vx = b.vx/s*ns; b.vy = b.vy/s*ns; }
});
}
wasSlow = isSlow;
}
/* ---------- Update ---------- */
function updatePaddle(dt){
const left = keys['ArrowLeft'] || keys['KeyA'];
const right = keys['ArrowRight'] || keys['KeyD'];
if(left){ paddle.targetX -= 620*dt; usingMouse = false; }
if(right){ paddle.targetX += 620*dt; usingMouse = false; }
paddle.targetX = clamp(paddle.targetX, paddle.w/2, W - paddle.w/2);
const prev = paddle.x;
paddle.x += (paddle.targetX - paddle.x) * Math.min(1, dt*30);
paddle.x = clamp(paddle.x, paddle.w/2, W - paddle.w/2);
paddle.vel = dt > 0 ? (paddle.x - prev)/dt : 0;
paddle.tilt = clamp(paddle.vel/900, -1, 1);
const targetW = (gameTime < expandUntil) ? 172 : paddle.baseW;
paddle.w += (targetW - paddle.w) * Math.min(1, dt*10);
paddle.squash = Math.max(0, paddle.squash - dt*4);
if(state === 'ready' && balls[0]){
balls[0].x = paddle.x;
balls[0].y = paddle.y - balls[0].r - 2;
}
}
function increaseBallSpeed(b, amt){
const s = Math.hypot(b.vx, b.vy);
if(s === 0 || s >= 620) return;
const ns = Math.min(620, s + amt);
b.vx = b.vx/s*ns; b.vy = b.vy/s*ns;
}
function updateBalls(dt){
for(let i = balls.length-1; i >= 0; i--){
const b = balls[i];
if(b.stuck) continue;
b.trail.push({ x:b.x, y:b.y });
if(b.trail.length > 14) b.trail.shift();
const prevX = b.x, prevY = b.y;
b.x += b.vx*dt; b.y += b.vy*dt;
if(b.x - b.r < 0){ b.x = b.r; b.vx = Math.abs(b.vx); sfx.wall(); }
else if(b.x + b.r > W){ b.x = W - b.r; b.vx = -Math.abs(b.vx); sfx.wall(); }
if(b.y - b.r < 0){ b.y = b.r; b.vy = Math.abs(b.vy); sfx.wall(); }
if(b.vy > 0 && b.y + b.r >= paddle.y && b.y - b.r <= paddle.y + paddle.h + 6 &&
Math.abs(b.x - paddle.x) <= paddle.w/2 + b.r){
const rel = clamp((b.x - paddle.x) / (paddle.w/2), -1, 1);
const speed = Math.hypot(b.vx, b.vy) || currentBallSpeed();
const ang = rel * 1.05;
b.vx = Math.sin(ang)*speed;
b.vy = -Math.cos(ang)*speed;
b.y = paddle.y - b.r - 1;
combo = 0; multiplier = 1; updateHUD();
paddle.squash = 1;
spawnParticles(b.x, paddle.y, '#67e8f9', 6, 120);
sfx.paddle();
}
for(const br of bricks){
if(br.dead) continue;
if(b.x + b.r > br.x && b.x - b.r < br.x + br.w && b.y + b.r > br.y && b.y - b.r < br.y + br.h){
if(gameTime < fireUntil && br.type !== 'X'){
damageBrick(br, 999);
spawnParticles(b.x, b.y, '#fb923c', 10, 200);
break;
}
if(prevY + b.r <= br.y || prevY - b.r >= br.y + br.h) b.vy = -b.vy;
else if(prevX + b.r <= br.x || prevX - b.r >= br.x + br.w) b.vx = -b.vx;
else b.vy = -b.vy;
damageBrick(br, 1);
increaseBallSpeed(b, 4);
break;
}
}
if(b.y - b.r > H){
balls.splice(i,1);
spawnParticles(b.x, H - 10, '#f43f5e', 18, 240);
shake = Math.min(18, shake + 8);
flash = .35; flashColor = '244,63,94';
sfx.loseBall();
if(balls.length === 0){ onAllBallsLost(); return; }
}
}
bricks = bricks.filter(br => !br.dead);
}
function setChip(el, remain){
if(remain > 0){
el.style.display = 'inline-flex';
el.querySelector('b').textContent = Math.ceil(remain) + 's';
} else {
el.style.display = 'none';
}
}
function update(dt){
if(state !== 'paused') updateParticles(dt);
if(state === 'playing' || state === 'ready'){
handleSlowExpiry();
updatePaddle(dt);
if(state === 'playing'){
updateBalls(dt);
updatePowerups(dt);
checkLevelComplete();
}
}
shake = Math.max(0, shake - dt*28);
flash = Math.max(0, flash - dt*1.8);
for(const b of bricks){
b.flash = Math.max(0, b.flash - dt*5);
b.wobble = Math.max(0, b.wobble - dt*3);
}
setChip(chipExpand, expandUntil - gameTime);
setChip(chipFire, fireUntil - gameTime);
setChip(chipSlow, slowUntil - gameTime);
}
/* ---------- Draw ---------- */
let bgGrad = null;
function rr(x, y, w, h, r){
r = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.arcTo(x+w, y, x+w, y+h, r);
ctx.arcTo(x+w, y+h, x, y+h, r);
ctx.arcTo(x, y+h, x, y, r);
ctx.arcTo(x, y, x+w, y, r);
ctx.closePath();
}
function drawNebula(x, y, r, color){
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, color); g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g; ctx.fillRect(x-r, y-r, r*2, r*2);
}
function drawBackground(){
if(!bgGrad){
bgGrad = ctx.createLinearGradient(0, 0, 0, H);
bgGrad.addColorStop(0, '#0f0c29'); bgGrad.addColorStop(.5, '#141433'); bgGrad.addColorStop(1, '#050816');
}
ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, W, H);
drawNebula(180 + Math.sin(animTime*0.2)*30, 160, 260, 'rgba(124,58,237,0.18)');
drawNebula(620 + Math.cos(animTime*0.16)*30, 420, 280, 'rgba(34,211,238,0.13)');
drawNebula(400, 520, 240, 'rgba(255,77,109,0.10)');
for(const s of stars){
const tw = 0.5 + 0.5*Math.sin(animTime*2 + s.tw);
ctx.globalAlpha = 0.35 + 0.4*tw;
ctx.fillStyle = '#e0e7ff';
ctx.beginPath(); ctx.arc(s.x, (s.y + animTime*s.sp) % H, s.s, 0, Math.PI*2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawBricks(){
for(const b of bricks){
if(b.dead) continue;
const wob = b.wobble > 0 ? Math.sin(animTime*40 + b.x)*b.wobble*1.5 : 0;
const y = b.y + wob;
let color = b.color;
if(b.type === '2' && b.hp === 1) color = '#67e8f9';
if(b.type === '3' && b.hp === 2) color = '#c4b5fd';
if(b.type === '3' && b.hp === 1) color = '#ddd6fe';
ctx.save();
ctx.shadowColor = color; ctx.shadowBlur = (b.type === 'X') ? 4 : 12;
ctx.fillStyle = color; rr(b.x, y, b.w, b.h, 5); ctx.fill();
ctx.shadowBlur = 0;
const g = ctx.createLinearGradient(b.x, y, b.x, y+b.h);
g.addColorStop(0, 'rgba(255,255,255,0.35)'); g.addColorStop(.5, 'rgba(255,255,255,0.05)'); g.addColorStop(1, 'rgba(0,0,0,0.22)');
ctx.fillStyle = g; rr(b.x, y, b.w, b.h, 5); ctx.fill();
if(b.type === 'X'){
ctx.strokeStyle = 'rgba(226,232,240,0.7)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(b.x+6, y+b.h-6); ctx.lineTo(b.x+b.w-6, y+6); ctx.stroke();
ctx.beginPath(); ctx.moveTo(b.x+6, y+6); ctx.lineTo(b.x+b.w-6, y+b.h-6); ctx.stroke();
} else if(b.type === 'E'){
const pulse = 1 + Math.sin(animTime*8 + b.x)*0.18;
ctx.fillStyle = '#fff7ed';
ctx.beginPath(); ctx.arc(b.x+b.w/2, y+b.h/2, 5*pulse, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#7c2d12'; ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('!', b.x+b.w/2, y+b.h/2 + 0.5);
} else if(b.maxHp > 1){
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for(let i = 0; i < b.hp; i++){
ctx.beginPath(); ctx.arc(b.x + b.w/2 + (i - (b.hp-1)/2)*9, y + b.h/2, 2.2, 0, Math.PI*2); ctx.fill();
}
}
if(b.flash > 0){ ctx.fillStyle = 'rgba(255,255,255,' + (b.flash*0.8) + ')'; rr(b.x, y, b.w, b.h, 5); ctx.fill(); }
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; rr(b.x, y, b.w, b.h, 5); ctx.stroke();
ctx.restore();
}
}
function drawPaddle(){
const p = paddle, expanded = gameTime < expandUntil;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.tilt * 0.06);
ctx.scale(1 + p.squash*0.08, 1 - p.squash*0.25);
ctx.shadowColor = expanded ? '#f0abfc' : '#22d3ee'; ctx.shadowBlur = 18;
const g = ctx.createLinearGradient(0, -p.h/2, 0, p.h/2);
g.addColorStop(0, '#a5f3fc');
g.addColorStop(.45, expanded ? '#e879f9' : '#22d3ee');
g.addColorStop(1, expanded ? '#a21caf' : '#0369a1');
ctx.fillStyle = g; rr(-p.w/2, -p.h/2, p.w, p.h, 7); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.85)'; rr(-p.w/2, -p.h/2, p.w, 3, 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.9)'; rr(-10, -p.h/2 + 3, 20, 4, 2); ctx.fill();
ctx.restore();
}
function drawBalls(){
const fire = gameTime < fireUntil;
for(const b of balls){
for(let i = 0; i < b.trail.length; i++){
const t = b.trail[i], a = (i / b.trail.length) * 0.25;
ctx.fillStyle = fire ? 'rgba(251,146,60,' + a + ')' : 'rgba(165,243,252,' + a + ')';
ctx.beginPath(); ctx.arc(t.x, t.y, b.r * (i / b.trail.length) * 0.8, 0, Math.PI*2); ctx.fill();
}
ctx.save();
ctx.shadowColor = fire ? '#fb923c' : '#67e8f9'; ctx.shadowBlur = 16;
const g = ctx.createRadialGradient(b.x-2, b.y-2, 1, b.x, b.y, b.r);
if(fire){ g.addColorStop(0,'#fff7ed'); g.addColorStop(.5,'#fdba74'); g.addColorStop(1,'#ea580c'); }
else { g.addColorStop(0,'#ffffff'); g.addColorStop(.5,'#a5f3fc'); g.addColorStop(1,'#0891b2'); }
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
function drawPowerups(){
for(const p of powerups){
const info = POWER_INFO[p.type];
ctx.save();
ctx.translate(p.x, p.y + Math.sin(p.phase)*2);
ctx.shadowColor = info.color; ctx.shadowBlur = 14;
ctx.fillStyle = info.color; rr(-16, -10, 32, 20, 10); ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1.5; rr(-16, -10, 32, 20, 10); ctx.stroke();
ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.font = 'bold 13px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(info.label, 0, 1);
ctx.restore();
}
}
function drawParticles(){
for(const p of particles){
const a = 1 - p.life/p.maxLife;
ctx.save();
ctx.globalAlpha = Math.max(0, a);
ctx.translate(p.x, p.y); ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if(p.shape === 'rect') ctx.fillRect(-p.size/2, -p.size/3, p.size, p.size*0.66);
else { ctx.beginPath(); ctx.arc(0, 0, p.size*0.6, 0, Math.PI*2); ctx.fill(); }
ctx.restore();
}
}
function drawFloaters(){
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for(const f of floaters){
const a = f.life < 0.8 ? 1 : 1 - (f.life - 0.8)/0.3;
ctx.save();
ctx.globalAlpha = Math.max(0, a);
ctx.font = '900 ' + f.size + 'px system-ui, sans-serif';
ctx.shadowColor = f.color; ctx.shadowBlur = 10;
ctx.fillStyle = f.color;
ctx.fillText(f.text, f.x, f.y);
ctx.restore();
}
}
function drawReadyHint(){
const pulse = 0.7 + 0.3*Math.sin(animTime*5);
ctx.save();
ctx.globalAlpha = pulse; ctx.textAlign = 'center';
ctx.font = '900 24px system-ui, sans-serif';
ctx.shadowColor = '#22d3ee'; ctx.shadowBlur = 16; ctx.fillStyle = '#e0faff';
ctx.fillText('CLICK or press SPACE to launch', W/2, H/2 + 30);
ctx.font = '700 15px system-ui, sans-serif';
ctx.fillStyle = '#94a3b8'; ctx.shadowBlur = 0;
ctx.fillText('Move the paddle with your mouse or ← → keys', W/2, H/2 + 58);
if(balls[0]){
ctx.strokeStyle = '#e0faff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(balls[0].x, balls[0].y - 18); ctx.lineTo(balls[0].x, balls[0].y - 42); ctx.stroke();
ctx.beginPath();
ctx.moveTo(balls[0].x - 7, balls[0].y - 34);
ctx.lineTo(balls[0].x, balls[0].y - 46);
ctx.lineTo(balls[0].x + 7, balls[0].y - 34);
ctx.closePath(); ctx.fillStyle = '#e0faff'; ctx.fill();
}
ctx.restore();
}
function draw(){
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if(shake > 0.2) ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
drawBackground();
drawBricks();
drawPowerups();
drawPaddle();
drawBalls();
drawParticles();
drawFloaters();
if(state === 'ready') drawReadyHint();
if(flash > 0){ ctx.fillStyle = 'rgba(' + flashColor + ',' + flash + ')'; ctx.fillRect(-20, -20, W+40, H+40); }
const vg = ctx.createRadialGradient(W/2, H/2, H*0.35, W/2, H/2, H*0.85);
vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
}
/* ---------- Input ---------- */
window.addEventListener('keydown', e => {
if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].indexOf(e.code) >= 0) e.preventDefault();
keys[e.code] = true;
if(e.code === 'ArrowLeft' || e.code === 'ArrowRight' || e.code === 'KeyA' || e.code === 'KeyD') usingMouse = false;
if(e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') primaryAction();
if(e.code === 'KeyP' || e.code === 'Escape') togglePause();
if(e.code === 'KeyM') toggleMute();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
function pointerX(e){
const r = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
return (clientX - r.left) * (W / r.width);
}
canvas.addEventListener('mousemove', e => {
paddle.targetX = pointerX(e); usingMouse = true;
});
canvas.addEventListener('click', () => {
ensureAudio();
if(state === 'ready') launchBall();
});
canvas.addEventListener('touchstart', e => {
e.preventDefault(); ensureAudio();
paddle.targetX = pointerX(e); usingMouse = true;
if(state === 'ready') launchBall();
}, { passive:false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
paddle.targetX = pointerX(e); usingMouse = true;
}, { passive:false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
primaryBtn.addEventListener('click', () => { ensureAudio(); primaryAction(); primaryBtn.blur(); });
pauseBtn.addEventListener('click', () => { ensureAudio(); togglePause(); pauseBtn.blur(); });
soundBtn.addEventListener('click', () => { toggleMute(); soundBtn.blur(); });
restartBtn.addEventListener('click', () => { ensureAudio(); startGame(); restartBtn.blur(); });
document.addEventListener('visibilitychange', () => { if(document.hidden && state === 'playing') togglePause(); });
window.addEventListener('blur', () => { if(state === 'playing') togglePause(); });
/* ---------- Init & main loop ---------- */
function initStars(){
stars = [];
for(let i = 0; i < 90; i++){
stars.push({ x:Math.random()*W, y:Math.random()*H, s:Math.random()*1.8 + 0.3, sp:Math.random()*18 + 6, tw:Math.random()*Math.PI*2 });
}
}
initStars();
loadLevel(0);
state = 'menu';
updateHUD();
showOverlay('menu');
let last = performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt = (now - last) / 1000;
last = now;
if(dt > 0.033) dt = 0.033;
animTime += dt;
if(state === 'playing' || state === 'ready') gameTime += dt;
update(dt);
draw();
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>

