hypeorcode
Network offOpen alone (new tab)

Run details

Model
poolside/laguna-s-2.1-free
Route
Command Code CLI
Reasoning
provider default
Limits
60 min
Date
2 Oct 2026
Time
30 min 5 s
Size
28 kB
Tokens
2,738,780 in, 93,207 out
List price
$0.26 ($0.09 in / $0.18 out per M)
Paid
Not billed per run (CLI plan)

Raw files: output · meta.json

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). Save it as index.html in the current directory, then stop. Do not ask questions.

Source

28 kB 958 lines Raw

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breakout</title>
<style>
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;font-family:monospace;font-weight:bold}
canvas{display:block;width:100vw;height:100vh}
</style>
</head>
<body>
<canvas id="g"></canvas>
<script>
(() => {
'use strict';

const canvas = document.getElementById('g');
const ctx = canvas.getContext('2d');

let W, H;
const dpr = window.devicePixelRatio || 1;
function resize(){
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = W * dpr;
  canvas.height = H * dpr;
  canvas.style.width = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  updateConfig();
  generateStars();
}
window.addEventListener('resize', resize);
resize();

const CONFIG = {};

function updateConfig(){
  CONFIG.paddleW = Math.max(80, W * 0.13);
  CONFIG.paddleH = Math.max(12, H * 0.02);
  CONFIG.paddleY = H - 70;
  CONFIG.paddleSpeed = W * 9;
  CONFIG.ballR = Math.max(8, W * 0.011);
  CONFIG.ballSpeed = W * 3.2;
  CONFIG.ballSpeedCap = W * 6;
  CONFIG.ballSpeedInc = 1.06;
  CONFIG.brickGap = W * 0.013;
  CONFIG.maxTrail = 12;
  CONFIG.lives = 3;
  CONFIG.maxParticles = 400;
  CONFIG.maxTexts = 120;
  CONFIG.comboTime = 2000;
}

const rand = (a, b) => a + Math.random() * (b - a);
const lerp = (a, b, t) => a + (b - a) * t;

function hexToRgba(h, a){
  if (h.startsWith('#')){
    if (h.length === 4) h = '#' + h[1]+h[1] + h[2]+h[2] + h[3]+h[3];
    const r = parseInt(h.slice(1,3),16), g = parseInt(h.slice(3,5),16), b = parseInt(h.slice(5,7),16);
    return `rgba(${r},${g},${b},${a})`;
  }
  return h;
}
function adjustBrightness(hex, p){
  if (!hex || !hex.startsWith('#')) return hex;
  if (hex.length === 4) hex = '#' + hex[1]+hex[1] + hex[2]+hex[2] + hex[3]+hex[3];
  const r = Math.max(0, Math.min(255, parseInt(hex.slice(1,3),16) + p));
  const g = Math.max(0, Math.min(255, parseInt(hex.slice(3,5),16) + p));
  const b = Math.max(0, Math.min(255, parseInt(hex.slice(5,7),16) + p));
  return `rgb(${r},${g},${b})`;
}
function roundedRect(x, y, w, h, r, fill, stroke){
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
  if (fill) ctx.fill();
  if (stroke) ctx.stroke();
}

// ===== AUDIO =====
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
function ensureAudio(){
  if (!audioCtx && AudioCtx) audioCtx = new AudioCtx();
}
function playSound(freq, dur, type='square', vol=0.1, decay=true, slide=null){
  ensureAudio();
  if (!audioCtx) return;
  const t = audioCtx;
  const osc = t.createOscillator();
  const gain = t.createGain();
  osc.connect(gain); gain.connect(t.destination);
  osc.type = type;
  if (slide){
    osc.frequency.setValueAtTime(slide[0], t.currentTime);
    osc.frequency.exponentialRampToValueAtTime(slide[1], t.currentTime + dur);
  } else {
    osc.frequency.setValueAtTime(freq, t.currentTime);
  }
  gain.gain.setValueAtTime(vol, t.currentTime);
  if (decay){
    gain.gain.exponentialRampToValueAtTime(0.001, t.currentTime + dur);
  } else {
    gain.gain.setValueAtTime(vol * 0.3, t.currentTime + dur * 0.7);
    gain.gain.exponentialRampToValueAtTime(0.001, t.currentTime + dur);
  }
  osc.start(t.currentTime);
  osc.stop(t.currentTime + dur);
}
function playChord(freqs, dur, type='sine', vol=0.1){
  freqs.forEach(f => playSound(f, dur, type, vol * 0.7));
}
function playNoise(dur, vol=0.15){
  ensureAudio();
  if (!audioCtx) return;
  const t = audioCtx;
  const buffer = t.createBuffer(1, t.sampleRate * dur, t.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++){
    data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
  }
  const src = t.createBufferSource();
  const gain = t.createGain();
  src.buffer = buffer; src.connect(gain); gain.connect(t.destination);
  gain.gain.setValueAtTime(vol, t.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, t.currentTime + dur);
  src.start(t.currentTime);
  src.stop(t.currentTime + dur);
}

// ===== INPUT =====
let mouseX=0, mouseY=0;
let mouseMoved=false;
let lastMouseTime=0;
const keys = {};
canvas.addEventListener('mousemove', e=>{
  const r = canvas.getBoundingClientRect();
  mouseX = (e.clientX - r.left) * (canvas.width / r.width);
  mouseY = (e.clientY - r.top) * (canvas.height / r.height);
  mouseMoved = true;
  lastMouseTime = Date.now();
});
canvas.addEventListener('mousedown', ()=>{
  lastMouseTime = Date.now();
  if (gameState === 'playing' && !ball.launched){
    launchBall();
  } else {
    handleInteraction();
  }
});
canvas.addEventListener('touchstart', e=>{
  e.preventDefault();
  lastMouseTime = Date.now();
  if (gameState === 'playing' && !ball.launched){
    launchBall();
  } else {
    handleInteraction();
  }
});
window.addEventListener('keydown', e=>{
  if (e.code.startsWith('Key') || e.code.startsWith('Arrow')) keys[e.code] = true;
  if (e.code === 'Space'){
    e.preventDefault();
    if (gameState === 'playing' && !ball.launched){
      launchBall();
    } else {
      handleInteraction();
    }
  }
});
window.addEventListener('keyup', e=>{ keys[e.code] = false; });
window.addEventListener('blur', ()=>{ keys = {}; });

// ===== STARS =====
let stars = [];
class Star {
  constructor(){ this.reset(); }
  reset(){
    this.x = Math.random() * W;
    this.y = Math.random() * H;
    this.s = Math.random() * 1.5 + 0.5;
    this.b = 0.3 + Math.random() * 0.7;
    this.t = Math.random() * Math.PI * 2;
  }
  update(dt){ this.t += dt * 2; }
  draw(ctx){
    const a = this.b * (0.7 + 0.3 * Math.sin(this.t));
    ctx.fillStyle = hexToRgba('#fff', a);
    ctx.fillRect(this.x, this.y, this.s, this.s);
  }
}
function generateStars(){
  stars = [];
  const n = Math.floor(W * H / 6000);
  for (let i = 0; i < n; i++) stars.push(new Star());
}

// ===== PARTICLES =====
let particles = [];
class Particle{
  constructor(x, y, c){
    this.x = x; this.y = y;
    this.vx = (Math.random() - 0.5) * W * 0.02;
    this.vy = (Math.random() - 0.5) * W * 0.02;
    this.life = 0.4 + Math.random() * 0.4;
    this.max = this.life;
    this.c = c;
    this.s = Math.random() * 4 + 2;
    this.a = Math.random() * Math.PI * 2;
  }
  update(dt){
    this.life -= dt;
    if (this.life <= 0) return false;
    this.vy += H * 0.7 * dt;
    this.vx *= (0.97 - dt * 30);
    this.x += this.vx; this.y += this.vy;
    this.s *= 0.97;
    this.a += dt * 3;
    return true;
  }
  draw(ctx){
    const a = this.life / this.max;
    ctx.fillStyle = hexToRgba(this.c, a * 0.8);
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.a);
    ctx.fillRect(-this.s / 2, -this.s / 2, this.s, this.s);
    ctx.restore();
  }
}
function spawnBreakParticles(x, y, c){
  for (let i = 0; i < 18; i++) particles.push(new Particle(x, y, c));
  playSound(rand(200, 300), 0.15, 'square', 0.08);
}
function spawnHitParticles(x, y, c){
  for (let i = 0; i < 8; i++) particles.push(new Particle(x, y, c));
}
function spawnPaddleParticles(x, y){
  for (let i = 0; i < 10; i++) particles.push(new Particle(x, y, '#4fc3f7'));
  playSound(180, 0.12, 'triangle', 0.08);
}

// ===== FLOATING TEXT =====
let texts = [];
class FloatingText{
  constructor(text, x, y, c, sz=16){
    this.text = text; this.x = x; this.y = y;
    this.vx = (Math.random() - 0.5) * 80;
    this.vy = -200 - Math.random() * 100;
    this.life = 0.8 + Math.random() * 0.4;
    this.c = c;
    this.sz = sz + Math.random() * 6;
    this.scale = 0.8 + Math.random() * 0.4;
  }
  update(dt){
    this.life -= dt;
    if (this.life <= 0) return false;
    this.vy += H * 0.3 * dt;
    this.x += this.vx * dt;
    this.y += this.vy * dt;
    this.scale *= 0.97;
    return true;
  }
  draw(ctx){
    const a = Math.max(0, this.life / 0.8);
    ctx.fillStyle = hexToRgba(this.c, a);
    ctx.font = `${this.sz * this.scale}px monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(this.text, this.x, this.y);
  }
}
function floatText(text, x, y, c, sz){
  texts.push(new FloatingText(text, x, y, c, sz));
}

// ===== PADDLE =====
class Paddle{
  constructor(){ this.reset(); }
  reset(){
    this.x = W / 2; this.y = CONFIG.paddleY;
    this.w = CONFIG.paddleW; this.h = CONFIG.paddleH;
    this.color = '#4fc3f7';
    this.scaleX = 1; this.scaleY = 1;
    this.glow = 0;
  }
  update(dt){
    this.w = CONFIG.paddleW; this.h = CONFIG.paddleH; this.y = CONFIG.paddleY;
    const mouseActive = (Date.now() - lastMouseTime) < 100;
    let dir = 0;
    if (keys['ArrowLeft']||keys['KeyA']) dir -= 1;
    if (keys['ArrowRight']||keys['KeyD']) dir += 1;
    if (mouseActive && mouseMoved){
      const targetX = mouseX - this.w / 2;
      this.x = lerp(this.x, targetX, 0.25);
    } else if (dir){
      this.x += dir * CONFIG.paddleSpeed * dt;
    }
    this.x = Math.max(5, Math.min(W - this.w - 5, this.x));
    this.scaleX += (1 - this.scaleX) * 0.25;
    this.scaleY += (1 - this.scaleY) * 0.25;
    if (this.glow > 0) this.glow -= dt * 8;
  }
  hit(){
    this.scaleX = 1.25; this.scaleY = 0.65;
    this.glow = 1;
  }
  draw(ctx){
    ctx.save();
    ctx.translate(this.x + this.w / 2, this.y + this.h / 2);
    ctx.scale(this.scaleX, this.scaleY);
    const grad = ctx.createRadialGradient(0, 0, this.w * 0.2, 0, 0, this.w * 0.5);
    if (this.glow > 0){
      grad.addColorStop(0, '#81f0fc');
      grad.addColorStop(0.5, '#4fc3f7');
      grad.addColorStop(1, '#1e88e5');
    } else {
      grad.addColorStop(0, adjustBrightness(this.color, -10));
      grad.addColorStop(1, this.color);
    }
    roundedRect(-this.w/2, -this.h/2, this.w, this.h, Math.max(3, this.h*0.3), true, false);
    ctx.fillStyle = grad;
    ctx.fill();
    if (this.glow > 0){
      ctx.shadowColor = '#81f0fc';
      ctx.shadowBlur = 20 * this.glow;
      ctx.fill();
      ctx.shadowBlur = 0;
    }
    ctx.restore();
  }
}

// ===== BALL =====
class Ball{
  constructor(){ this.reset(); }
  reset(){
    this.r = CONFIG.ballR;
    this.x = paddle.x + paddle.w / 2;
    this.y = CONFIG.paddleY - this.r - 2;
    this.vx = 0; this.vy = 0;
    this.launched = false;
    this.trail = [];
  }
  launch(){
    if (this.launched) return;
    ensureAudio();
    const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI / 3;
    this.vx = Math.cos(angle) * CONFIG.ballSpeed;
    this.vy = Math.sin(angle) * CONFIG.ballSpeed;
    this.launched = true;
    this.trail = [];
    playSound(400, 0.2, 'sine', 0.15, false);
    playSound(600, 0.2, 'sine', 0.15, false);
  }
  update(dt){
    this.r = CONFIG.ballR;
    if (!this.launched){
      this.x = paddle.x + paddle.w / 2;
      this.y = CONFIG.paddleY - this.r - Math.sin(performance.now() * 0.008) * 3;
      this.trail = [];
      return;
    }
    this.x += this.vx * dt;
    this.y += this.vy * dt;
    // trail
    this.trail.push({x: this.x, y: this.y, t: Date.now()});
    if (this.trail.length > CONFIG.maxTrail) this.trail.shift();
    // walls
    if (this.x - this.r < 0){
      this.x = this.r; this.vx = Math.abs(this.vx);
      playSound(220, 0.1, 'square', 0.06);
      spawnHitParticles(this.x, this.y, '#4fc3f7');
    }
    if (this.x + this.r > W){
      this.x = W - this.r; this.vx = -Math.abs(this.vx);
      playSound(220, 0.1, 'square', 0.06);
      spawnHitParticles(this.x, this.y, '#4fc3f7');
    }
    if (this.y - this.r < 0){
      this.y = this.r; this.vy = Math.abs(this.vy);
      playSound(330, 0.1, 'sawtooth', 0.06);
      spawnHitParticles(this.x, this.y, '#4fc3f7');
    }
    // paddle
    if (this.vy > 0 &&
        this.x + this.r > paddle.x &&
        this.x - this.r < paddle.x + paddle.w &&
        this.y + this.r > paddle.y &&
        this.y - this.r < paddle.y + paddle.h){
      const rel = (this.x - paddle.x - paddle.w / 2) / (paddle.w / 2);
      const maxBounce = Math.PI / 3.2;
      const angle = rel * maxBounce;
      const speed = Math.min(CONFIG.ballSpeedCap,
        Math.sqrt(this.vx * this.vx + this.vy * this.vy) * CONFIG.ballSpeedInc);
      this.vx = Math.sin(angle) * speed;
      this.vy = -Math.cos(angle) * speed;
      this.y = paddle.y - this.r;
      spawnPaddleParticles(this.x, this.y);
      paddle.hit();
      this.trail = [];
      this.trail.push({x: this.x, y: this.y, t: Date.now()});
    }
    // bricks
    for (let i = bricks.length - 1; i >= 0; i--){
      const b = bricks[i];
      if (b.destroyed) continue;
      const closestX = Math.max(b.x, Math.min(this.x, b.x + b.w));
      const closestY = Math.max(b.y, Math.min(this.y, b.y + b.h));
      const dx = this.x - closestX;
      const dy = this.y - closestY;
      const distSq = dx * dx + dy * dy;
      if (distSq < this.r * this.r){
        const dist = Math.sqrt(distSq);
        if (dist > 0){
          const nx = dx / dist, ny = dy / dist;
          this.x += nx * (this.r - dist + 0.5);
          this.y += ny * (this.r - dist + 0.5);
          const dot = this.vx * nx + this.vy * ny;
          if (dot < 0){
            this.vx -= 2 * dot * nx;
            this.vy -= 2 * dot * ny;
          }
        } else {
          this.vy = -Math.abs(this.vy);
        }
        b.hit();
        spawnBreakParticles(b.x + b.w / 2, b.y + b.h / 2, b.color);
        if (b.destroyed){
          addScore(b.points, this.x, this.y, b.color);
          addToCombo(this.x, this.y);
          bricks.splice(i, 1);
          playSound(880, 0.1, 'square', 0.1, true, [880, 220]);
        } else {
          playSound(440, 0.08, 'square', 0.06, true, [440, 220]);
        }
        break;
      }
    }
    // bottom (lose life)
    if (this.y - this.r > H + 50){
      this.launched = false;
      ballLost = true;
    }
  }
  draw(ctx){
    const now = Date.now();
    // trail
    for (let i = 0; i < this.trail.length; i++){
      const p = this.trail[i];
      const age = (now - p.t) / 1000;
      if (age > 0.3) continue;
      const a = (1 - age / 0.3) * 0.5;
      const s = this.r * (i / this.trail.length) * 0.7;
      ctx.beginPath();
      ctx.arc(p.x, p.y, s, 0, Math.PI * 2);
      ctx.fillStyle = hexToRgba('#fff', a);
      ctx.fill();
    }
    // ball
    const grad = ctx.createRadialGradient(
      this.x - this.r / 3, this.y - this.r / 3, 1,
      this.x, this.y, this.r
    );
    grad.addColorStop(0, '#fff');
    grad.addColorStop(0.4, '#81f0fc');
    grad.addColorStop(1, '#1e88e5');
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    // highlight
    ctx.beginPath();
    ctx.arc(this.x - this.r / 4, this.y - this.r / 4, this.r * 0.3, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(255,255,255,0.6)';
    ctx.fill();
  }
}

// ===== BRICK =====
const BRICK_DATA = {
  r: {hp:1, color:'#e74c3c', points:10},
  o: {hp:2, color:'#f39c12', points:20},
  y: {hp:3, color:'#f1c40f', points:30},
  g: {hp:4, color:'#2ecc71', points:40},
};
class Brick{
  constructor(x, y, w, h, type){
    this.x=x; this.y=y; this.w=w; this.h=h;
    this.type=type;
    const d = BRICK_DATA[type];
    this.maxHp = d.hp;
    this.hp = d.hp;
    this.color = d.color;
    this.points = d.points * d.hp;
    this.destroyed = false;
    this.glow = 0;
  }
  hit(){
    this.hp--;
    this.glow = 0.8;
    if (this.hp <= 0) this.destroyed = true;
  }
  update(dt){
    if (this.glow > 0) this.glow -= dt * 10;
  }
  draw(ctx){
    if (this.destroyed) return;
    ctx.save();
    if (this.glow > 0){
      ctx.shadowColor = hexToRgba(this.color, 0.8);
      ctx.shadowBlur = 30 * this.glow;
    }
    const damage = (this.maxHp - this.hp) / this.maxHp;
    const c = adjustBrightness(this.color, -damage * 30);
    const grad = ctx.createLinearGradient(this.x, this.y, this.x, this.y + this.h);
    grad.addColorStop(0, adjustBrightness(c, 40));
    grad.addColorStop(1, c);
    const r = Math.min(6, this.h * 0.25);
    roundedRect(this.x, this.y, this.w, this.h, r, true, false);
    ctx.fillStyle = grad;
    ctx.fill();
    // cracks for damaged bricks
    if (damage > 0){
      ctx.strokeStyle = hexToRgba('#000', 0.4 + damage * 0.3);
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(this.x + this.w * 0.15, this.y + this.h * 0.15);
      ctx.lineTo(this.x + this.w * 0.85, this.y + this.h * 0.85);
      ctx.stroke();
    }
    // hp indicator
    if (this.maxHp > 1){
      ctx.fillStyle = '#fff';
      ctx.font = `${Math.min(this.h * 0.45, 14)}px monospace`;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(this.hp, this.x + this.w / 2, this.y + this.h / 2);
    }
    ctx.restore();
  }
}

// ===== LEVELS =====
const LEVELS = [
  {
    name: "First Strike",
    color: '#e74c3c',
    layout: [
      "rrrrrrrr",
      "rrrrrrrr",
      "rrrrrrrr",
      "rrrrrrrr",
      "rrrrrrrr",
    ]
  },
  {
    name: "Layered Defense",
    color: '#f39c12',
    layout: [
      "oooooooo",
      "oooooooo",
      "oooooooo",
      "rrrrrrrr",
      "rrrrrrrr",
      "rrrrrrrr",
    ]
  },
  {
    name: "The Summit",
    color: '#f1c40f',
    layout: [
      "yyyyyyyyyy",
      "oooooooooo",
      "oooooooooo",
      "rrrrrrrrrr",
      "rrrrrrrrrr",
    ]
  }
];

// ===== GAME STATE =====
let gameState = 'menu';
let score = 0;
let lives = CONFIG.lives;
let levelIndex = 0;
let combo = 0;
let comboTimer = 0;
let ballLost = false;
let screenShake = 0;
let flashAlpha = 0;
let time = 0;
let bricks = [];
let paddle, ball;
let highScore = 0;

// ===== GAME FUNCTIONS =====
function resetLevel(){
  bricks = [];
  const level = LEVELS[levelIndex];
  const layout = level.layout;
  const rows = layout.length;
  const cols = layout[0].length;
  const padding = W * 0.04;
  const totalW = W - padding * 2 - (cols - 1) * CONFIG.brickGap;
  const brickW = totalW / cols;
  const brickH = Math.max(20, H * 0.035);
  const offsetY = H * 0.12;
  for (let row = 0; row < rows; row++){
    for (let col = 0; col < cols; col++){
      const t = layout[row][col];
      if (!t || t === ' ') continue;
      const x = padding + col * (brickW + CONFIG.brickGap);
      const y = offsetY + row * (brickH + CONFIG.brickGap);
      bricks.push(new Brick(x, y, brickW, brickH, t));
    }
  }
}
function launchBall(){
  if (gameState !== 'playing' || ball.launched) return;
  ball.reset();
  ball.launch();
  spawnLaunchEffect();
}
function spawnLaunchEffect(){
  for (let i = 0; i < 20; i++){
    const angle = (i / 20) * Math.PI * 2;
    const p = new Particle(paddle.x + paddle.w / 2, paddle.y, '#fff');
    p.vx = Math.cos(angle) * W * 0.03;
    p.vy = Math.sin(angle) * W * 0.03;
    p.s = 3 + Math.random() * 3;
    p.life = 0.5 + Math.random() * 0.3;
    particles.push(p);
  }
}
function addScore(points, x, y, color){
  const bonus = combo >= 3 ? combo * 10 : 0;
  score += points + bonus;
  if (combo >= 3){
    floatText(`+${points + bonus} COMBO x${combo}!`, x, y, '#ffeb3b', 18);
  } else {
    floatText(`+${points}`, x, y, color || '#fff', 18);
  }
}
function addToCombo(x, y){
  combo++;
  comboTimer = CONFIG.comboTime;
  if (combo >= 3){
    floatText(`COMBO x${combo}!`, x, y - 30, '#ffeb3b', 22);
    playChord([261, 329, 392, 523].slice(0, Math.min(combo, 4)), 0.2, 'sawtooth', 0.1);
  }
}
function startGame(){
  score = 0;
  lives = CONFIG.lives;
  levelIndex = 0;
  combo = 0;
  comboTimer = 0;
  gameState = 'playing';
  paddle = new Paddle();
  ball = new Ball();
  ballLost = false;
  screenShake = 0;
  flashAlpha = 0;
  resetLevel();
  setTimeout(() => launchBall(), 300);
}
function nextLevel(){
  if (levelIndex < LEVELS.length - 1){
    levelIndex++;
    combo = 0;
    comboTimer = 0;
    resetLevel();
    gameState = 'playing';
    ball.reset();
    setTimeout(() => launchBall(), 500);
  } else {
    gameState = 'victory';
    saveHighScore();
  }
}
function saveHighScore(){
  try {
    const prev = parseInt(localStorage.getItem('breakout-hs') || '0');
    if (score > prev) localStorage.setItem('breakout-hs', score.toString());
  } catch(e){}
}
function loadHighScore(){
  try {
    highScore = parseInt(localStorage.getItem('breakout-hs') || '0');
  } catch(e){}
}
function gameOver(){
  gameState = 'gameOver';
  saveHighScore();
}
function applyScreenShake(intensity){
  screenShake = intensity;
  flashAlpha = 0.3;
}
function handleInteraction(){
  ensureAudio();
  if (gameState === 'menu'){
    startGame();
  } else if (gameState === 'levelComplete'){
    nextLevel();
  } else if (gameState === 'gameOver' || gameState === 'victory'){
    startGame();
  }
}

// ===== UPDATE =====
function update(dt){
  time += dt;
  // combo timer
  if (comboTimer > 0){
    comboTimer -= dt * 1000;
    if (comboTimer <= 0) combo = 0;
  }
  // screen shake
  if (screenShake > 0) screenShake -= dt * 20;
  // flash
  flashAlpha *= (1 - dt * 3);
  // stars
  for (const s of stars) s.update(dt);
  // particles
  for (let i = particles.length - 1; i >= 0; i--){
    if (!particles[i].update(dt)) particles.splice(i, 1);
  }
  if (particles.length > CONFIG.maxParticles){
    particles.splice(0, particles.length - CONFIG.maxParticles);
  }
  // texts
  for (let i = texts.length - 1; i >= 0; i--){
    if (!texts[i].update(dt)) texts.splice(i, 1);
  }
  // bricks
  for (const b of bricks) b.update(dt);
  if (gameState === 'playing') updatePlaying(dt);
}
function updatePlaying(dt){
  if (ballLost){
    ballLost = false;
    lives--;
    if (lives <= 0){
      gameOver();
      playChord([523, 392, 330, 261], 1, 'sine', 0.15);
      playNoise(0.5, 0.2);
      return;
    }
    ball.reset();
    setTimeout(() => launchBall(), 300);
  }
  paddle.update(dt);
  ball.update(dt);
  if (bricks.length === 0){
    gameState = 'levelComplete';
    applyScreenShake(15);
    playChord([329, 392, 523, 659], 0.5, 'sine', 0.15);
    playNoise(0.3, 0.2);
    addScore(1000, W/2, H/2, '#fff');
  }
}

// ===== DRAW =====
function draw(){
  // Background
  const bgGrad = ctx.createLinearGradient(0, 0, 0, H);
  bgGrad.addColorStop(0, '#0a0f20');
  bgGrad.addColorStop(0.5, '#080c18');
  bgGrad.addColorStop(1, '#050814');
  ctx.fillStyle = bgGrad;
  ctx.fillRect(0, 0, W, H);
  // Stars
  for (const s of stars) s.draw(ctx);
  // Vignette
  const vg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, W);
  vg.addColorStop(0, 'rgba(0,0,0,0)');
  vg.addColorStop(1, 'rgba(0,0,0,0.5)');
  ctx.fillStyle = vg;
  ctx.fillRect(0, 0, W, H);
  // Screen shake
  if (screenShake > 0){
    ctx.save();
    ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake);
  }
  // Game objects (shaken)
  for (const b of bricks) b.draw(ctx);
  for (const p of particles) p.draw(ctx);
  paddle.draw(ctx);
  ball.draw(ctx);
  for (const t of texts) t.draw(ctx);
  if (screenShake > 0) ctx.restore();
  // Flash overlay (not shaken)
  if (flashAlpha > 0.01){
    ctx.fillStyle = hexToRgba('#fff', flashAlpha);
    ctx.fillRect(0, 0, W, H);
  }
  // UI
  drawUI();
  // State screens
  if (gameState === 'menu') drawMenu();
  else if (gameState === 'levelComplete') drawLevelComplete();
  else if (gameState === 'gameOver') drawGameOver();
  else if (gameState === 'victory') drawVictory();
  // Reset shadow/text
  ctx.shadowBlur = 0;
  ctx.shadowColor = 'transparent';
}
function drawUI(){
  ctx.fillStyle = '#fff';
  ctx.font = `bold ${Math.min(W * 0.024, 22)}px monospace`;
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.fillText(`SCORE: ${score}`, 20, 15);
  if (highScore > 0 && gameState !== 'menu'){
    ctx.font = `bold ${Math.min(W * 0.016, 16)}px monospace`;
    ctx.fillStyle = '#ffeb3b';
    ctx.fillText(`BEST: ${highScore}`, 20, 40);
  }
  // Lives
  ctx.fillStyle = '#fff';
  ctx.font = `bold ${Math.min(W * 0.018, 18)}px monospace`;
  ctx.textAlign = 'left';
  ctx.fillText('LIVES:', 20, H - 30);
  for (let i = 0; i < lives; i++){
    ctx.fillStyle = '#e74c3c';
    ctx.beginPath();
    ctx.arc(80 + i * 22, H - 24, 8, 0, Math.PI * 2);
    ctx.fill();
  }
  // Level
  ctx.fillStyle = '#fff';
  ctx.font = `bold ${Math.min(W * 0.022, 20)}px monospace`;
  ctx.textAlign = 'right';
  ctx.fillText(`LEVEL ${levelIndex + 1} / ${LEVELS.length}`, W - 20, 15);
  ctx.fillStyle = LEVELS[levelIndex].color;
  ctx.fillText(LEVELS[levelIndex].name.toUpperCase(), W - 20, 40);
  // Combo
  if (combo >= 3){
    ctx.fillStyle = hexToRgba('#ffeb3b', 0.9);
    ctx.font = `bold ${Math.min(W * 0.06, 64)}px monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    const pulse = Math.sin(time * 8) * 4;
    ctx.shadowColor = '#ffeb3b';
    ctx.shadowBlur = 25 + pulse;
    ctx.fillText(`x${combo}`, W / 2, H / 2 - 60);
    ctx.shadowBlur = 0;
    ctx.font = `bold ${Math.min(W * 0.025, 24)}px monospace`;
    ctx.fillText(`COMBO`, W / 2, H / 2 - 30);
  }
  // Ready prompt
  if (gameState === 'playing' && !ball.launched){
    ctx.fillStyle = hexToRgba('#4fc3f7', 0.8 + Math.sin(time * 5) * 0.2);
    ctx.font = `bold ${Math.min(W * 0.028, 26)}px monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('READY', W / 2, CONFIG.paddleY - 60);
  }
}
function drawCenteredText(text, y, size, color='#fff', subText=null, subSize=null, subColor='#aaa'){
  ctx.fillStyle = color;
  ctx.font = `bold ${size}px monospace`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.shadowColor = color;
  ctx.shadowBlur = size / 8;
  ctx.fillText(text, W / 2, y);
  ctx.shadowBlur = 0;
  ctx.shadowColor = 'transparent';
  if (subText){
    ctx.fillStyle = subColor;
    ctx.font = `bold ${subSize || size * 0.5}px monospace`;
    ctx.fillText(subText, W / 2, y + size * 0.9);
  }
}
function drawMenu(){
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);
  drawCenteredText('BREAKOUT', H * 0.35, Math.min(W * 0.1, 100), '#4fc3f7');
  drawCenteredText('mouse or arrow keys / A-D', H * 0.48, Math.min(W * 0.025, 22), '#888');
  drawCenteredText('click or SPACE to launch', H * 0.53, Math.min(W * 0.022, 20), '#aaa');
  drawCenteredText('click or SPACE to start', H * 0.59, Math.min(W * 0.022, 20), '#aaa');
  if (highScore > 0){
    ctx.fillStyle = '#ffeb3b';
    ctx.font = `bold ${Math.min(W * 0.018, 18)}px monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`HIGH SCORE: ${highScore}`, W / 2, H * 0.72);
  }
  // Demo ball on paddle
  if (ball && paddle){
    paddle.w = CONFIG.paddleW; paddle.h = CONFIG.paddleH; paddle.y = CONFIG.paddleY;
    paddle.x = W / 2;
    ball.r = CONFIG.ballR;
    ball.x = paddle.x + paddle.w / 2;
    ball.y = CONFIG.paddleY - ball.r - Math.sin(time * 4) * 3;
    paddle.draw(ctx);
    ball.draw(ctx);
  }
}
function drawLevelComplete(){
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);
  drawCenteredText('LEVEL CLEAR!', H * 0.35, Math.min(W * 0.09, 90), '#2ecc71',
    `SCORE: ${score}`, Math.min(W * 0.025, 22), '#fff');
  drawCenteredText(`LEVEL ${levelIndex + 1} COMPLETE`, H * 0.48, Math.min(W * 0.022, 20), '#aaa');
  drawCenteredText('click or SPACE', H * 0.58, Math.min(W * 0.02, 18), '#aaa');
}
function drawGameOver(){
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);
  drawCenteredText('GAME OVER', H * 0.35, Math.min(W * 0.1, 100), '#e74c3c',
    `FINAL SCORE: ${score}`, Math.min(W * 0.025, 22), '#fff');
  drawCenteredText('click or SPACE to restart', H * 0.52, Math.min(W * 0.022, 20), '#aaa');
}
function drawVictory(){
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);
  drawCenteredText('VICTORY!', H * 0.3, Math.min(W * 0.11, 110), '#2ecc71',
    `FINAL SCORE: ${score}`, Math.min(W * 0.025, 22), '#fff');
  drawCenteredText('all levels cleared', H * 0.45, Math.min(W * 0.02, 18), '#aaa');
  drawCenteredText('click or SPACE to play again', H * 0.55, Math.min(W * 0.022, 20), '#aaa');
}

// ===== LOOP =====
let lastTime = 0;
function loop(timestamp){
  if (!lastTime) lastTime = timestamp;
  let dt = (timestamp - lastTime) / 1000;
  if (dt > 0.1) dt = 0.1;
  lastTime = timestamp;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}

// Init
loadHighScore();
paddle = new Paddle();
ball = new Ball();
resetLevel();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Compare with

Laguna S 2.1: other attempts