• Updated 30 Oct, 21:11 UTC
  • Next run 31 Oct, 05:17 UTCNext 31 Oct 05:17
  • Last 7 days: 4 new models · 475 runs7 d: 4 models · 475 runs
Playable Breakout

Particle-1Particle AI

Network offOpen alone (new tab)

Run details

Model
particle-ai/particle-1
Route
OpenRouter API, one user message
Served by
Particle AI
Effort
high, OpenRouter's default for this model
Limits
60 min · 131,072 output tokens (the model's limit)
Date
28 Oct 2026
Time
2 min 10 s
Size
34 kB
Tokens
158 in, 30,859 out (18,168 of them thinking)
List price
$0.015 at today's price ($0.05 in / $0.5 out per M)
Paid
$0.031

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). Reply with the file in a single ```html code block.

Source

34 kB 1,295 lines Raw

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>Neon Breakout</title>
<style>
  html, body {
    margin: 0;
    padding: 0;
    height: 100%;
    background: #05070d;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  }
  body {
    display: flex;
    align-items: center;
    justify-content: center;
  }
  canvas {
    display: block;
    border-radius: 14px;
    cursor: crosshair;
    touch-action: none;
    box-shadow:
      0 0 70px rgba(70, 230, 255, 0.16),
      0 0 0 1px rgba(70, 230, 255, 0.16),
      0 30px 80px rgba(0, 0, 0, 0.8);
  }
</style>
</head>
<body>
<canvas id="game" width="900" height="640"></canvas>
<script>
(function () {
  "use strict";

  // ---------------------------------------------------------------- setup
  const canvas = document.getElementById("game");
  const ctx = canvas.getContext("2d");
  const W = 900, H = 640;
  const TAU = Math.PI * 2;
  const HUD_H = 56;
  const PADDLE_Y = 596;
  const PADDLE_H = 14;
  const BALL_R = 8;

  const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
  const rand = (a, b) => a + Math.random() * (b - a);

  function roundRect(c, x, y, w, h, r) {
    r = Math.min(r, w / 2, h / 2);
    c.beginPath();
    c.moveTo(x + r, y);
    c.lineTo(x + w - r, y);
    c.quadraticCurveTo(x + w, y, x + w, y + r);
    c.lineTo(x + w, y + h - r);
    c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    c.lineTo(x + r, y + h);
    c.quadraticCurveTo(x, y + h, x, y + h - r);
    c.lineTo(x, y + r);
    c.quadraticCurveTo(x, y, x + r, y);
    c.closePath();
  }

  // responsive scale
  function resize() {
    const pad = 28;
    const s = Math.min(
      (window.innerWidth - pad * 2) / W,
      (window.innerHeight - pad * 2) / H,
      1.35
    );
    canvas.style.width = Math.round(W * s) + "px";
    canvas.style.height = Math.round(H * s) + "px";
  }
  window.addEventListener("resize", resize);
  resize();

  // ---------------------------------------------------------------- audio
  let actx = null, masterGain = null, muted = false;
  let lastBlip = 0;

  function initAudio() {
    if (actx) {
      if (actx.state === "suspended") actx.resume();
      return;
    }
    try {
      actx = new (window.AudioContext || window.webkitAudioContext)();
      masterGain = actx.createGain();
      masterGain.gain.value = 0.32;
      masterGain.connect(actx.destination);
    } catch (e) {
      actx = null;
    }
  }

  function tone(freq, dur, type, vol, when, slideTo) {
    if (!actx || muted) return;
    const t0 = actx.currentTime + (when || 0);
    const o = actx.createOscillator();
    const g = actx.createGain();
    o.type = type || "sine";
    o.frequency.setValueAtTime(freq, t0);
    if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t0 + dur);
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.linearRampToValueAtTime(vol, t0 + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    o.connect(g);
    g.connect(masterGain);
    o.start(t0);
    o.stop(t0 + dur + 0.03);
  }

  const sfx = {
    wall() {
      const now = performance.now();
      if (now - lastBlip < 24) return;
      lastBlip = now;
      tone(300, 0.045, "square", 0.05, 0, 240);
    },
    paddle() { tone(230, 0.09, "square", 0.11, 0, 430); },
    hit(hp) { tone(500 + hp * 60, 0.06, "square", 0.07, 0, 380); },
    brk(hp) {
      tone(360 + hp * 130, 0.12, "triangle", 0.12, 0, 900);
      tone(720 + hp * 90, 0.07, "square", 0.05, 0.01, 1300);
    },
    clang() { tone(160, 0.09, "sawtooth", 0.06, 0, 120); },
    death() {
      tone(320, 0.55, "sawtooth", 0.16, 0, 55);
      tone(160, 0.55, "square", 0.08, 0.02, 40);
    },
    power() {
      tone(520, 0.09, "square", 0.10, 0);
      tone(700, 0.09, "square", 0.10, 0.07);
      tone(980, 0.12, "square", 0.10, 0.14);
    },
    level() {
      [523, 659, 784, 1046].forEach((f, i) =>
        tone(f, 0.16, "triangle", 0.12, i * 0.09)
      );
    },
    win() {
      [523, 659, 784, 1046, 1318].forEach((f, i) =>
        tone(f, 0.22, "triangle", 0.13, i * 0.11)
      );
    }
  };

  // ---------------------------------------------------------------- levels
  const COLS = 12;
  const BRICK_LEFT = 36, BRICK_RIGHT = 36, BRICK_TOP = 92;
  const BRICK_GAP = 6, BRICK_H = 26;
  const BRICK_W = (W - BRICK_LEFT - BRICK_RIGHT - BRICK_GAP * (COLS - 1)) / COLS;

  const LEVELS = [
    {
      name: "WARM UP",
      rows: [
        "111111111111",
        "111111111111",
        "222222222222",
        "222222222222"
      ]
    },
    {
      name: "CHECKER",
      rows: [
        "1.1.1.1.1.1.",
        ".2.2.2.2.2.2",
        "3.3.3.3.3.3.",
        ".1.1.1.1.1.1",
        "2.2.2.2.2.2."
      ]
    },
    {
      name: "FORTRESS",
      rows: [
        "X2222222222X",
        "X1111111111X",
        "X.22222222.X",
        "X..333333..X",
        "X...1111...X"
      ]
    },
    {
      name: "PYRAMID",
      rows: [
        ".....33.....",
        "....3223....",
        "...322223...",
        "..32222223..",
        ".3222222223.",
        "111111111111"
      ]
    },
    {
      name: "CROSSFIRE",
      rows: [
        "2..111111..2",
        "22.222222.22",
        "322.3333.223",
        "3222.33.2223",
        "322.3333.223",
        "22.222222.22",
        "2..111111..2"
      ]
    },
    {
      name: "THE VAULT",
      rows: [
        "333333333333",
        "3XX333333XX3",
        "3X11111111X3",
        "3X12222221X3",
        "3X11111111X3",
        "3XX333333XX3",
        "333333333333"
      ]
    }
  ];

  const BRICK_COLORS = {
    1: ["#2ad4ff", "#0a4f6b"],
    2: ["#7bff6a", "#1c6b16"],
    3: ["#ff5fd2", "#7a1858"],
    X: ["#7d879e", "#333a4a"]
  };

  // ---------------------------------------------------------------- state
  const paddle = {
    x: W / 2,
    y: PADDLE_Y,
    w: 118,
    h: PADDLE_H,
    baseW: 118,
    wideW: 182,
    vx: 0,
    squash: 0
  };

  const G = {
    mode: "title",
    score: 0,
    best: 0,
    lives: 3,
    levelIndex: 0,
    bricks: [],
    balls: [],
    particles: [],
    powerups: [],
    popups: [],
    banner: null,
    shake: 0,
    flash: 0,
    combo: 0,
    ballSpeed: 330,
    effects: { wide: 0, slow: 0 },
    clearTimer: 0,
    time: 0
  };

  try {
    const b = localStorage.getItem("neonBreakoutBest");
    if (b) G.best = parseInt(b, 10) || 0;
  } catch (e) {}

  function saveBest() {
    try {
      localStorage.setItem("neonBreakoutBest", String(G.best));
    } catch (e) {}
  }

  // ---------------------------------------------------------------- input
  const keys = { left: false, right: false };
  let mouseX = W / 2;
  let usingMouse = false;

  window.addEventListener("keydown", (e) => {
    const k = e.key.toLowerCase();
    if (
      k === "arrowleft" || k === "arrowright" || k === "arrowup" ||
      k === "arrowdown" || k === " "
    ) {
      e.preventDefault();
    }
    if (k === "arrowleft" || k === "a") { keys.left = true; usingMouse = false; }
    if (k === "arrowright" || k === "d") { keys.right = true; usingMouse = false; }
    if (k === " " || k === "enter") { action(); }
    if (k === "p" || k === "escape") { togglePause(); }
    if (k === "m") {
      muted = !muted;
      toast(muted ? "SOUND OFF" : "SOUND ON");
    }
    if (k === "r" && (G.mode === "playing" || G.mode === "paused")) {
      startGame();
    }
  });

  window.addEventListener("keyup", (e) => {
    const k = e.key.toLowerCase();
    if (k === "arrowleft" || k === "a") keys.left = false;
    if (k === "arrowright" || k === "d") keys.right = false;
  });

  function pointerToX(clientX) {
    const r = canvas.getBoundingClientRect();
    return ((clientX - r.left) / r.width) * W;
  }

  canvas.addEventListener("mousemove", (e) => {
    mouseX = pointerToX(e.clientX);
    usingMouse = true;
  });
  canvas.addEventListener("mousedown", (e) => {
    e.preventDefault();
    initAudio();
    action();
  });
  canvas.addEventListener("touchstart", (e) => {
    e.preventDefault();
    initAudio();
    mouseX = pointerToX(e.touches[0].clientX);
    usingMouse = true;
    action();
  }, { passive: false });
  canvas.addEventListener("touchmove", (e) => {
    e.preventDefault();
    mouseX = pointerToX(e.touches[0].clientX);
    usingMouse = true;
  }, { passive: false });

  function togglePause() {
    if (G.mode === "playing") G.mode = "paused";
    else if (G.mode === "paused") G.mode = "playing";
  }

  function action() {
    initAudio();
    if (G.mode === "title") startGame();
    else if (G.mode === "playing") launch();
    else if (G.mode === "paused") G.mode = "playing";
    else if (G.mode === "levelclear") G.clearTimer = 0;
    else if (G.mode === "gameover" || G.mode === "win") startGame();
  }

  // ---------------------------------------------------------------- game flow
  function startGame() {
    initAudio();
    G.score = 0;
    G.lives = 3;
    G.combo = 0;
    G.particles.length = 0;
    G.popups.length = 0;
    G.powerups.length = 0;
    G.banner = null;
    G.shake = 0;
    G.flash = 0;
    G.effects.wide = 0;
    G.effects.slow = 0;
    paddle.x = W / 2;
    paddle.w = paddle.baseW;
    loadLevel(0);
    G.mode = "playing";
  }

  function loadLevel(i) {
    G.levelIndex = i;
    const lv = LEVELS[i];
    G.bricks = [];
    for (let r = 0; r < lv.rows.length; r++) {
      const row = lv.rows[r];
      for (let c = 0; c < COLS; c++) {
        const ch = row[c];
        if (!ch || ch === ".") continue;
        const hp = ch === "X" ? Infinity : parseInt(ch, 10);
        G.bricks.push({
          x: BRICK_LEFT + c * (BRICK_W + BRICK_GAP),
          y: BRICK_TOP + r * (BRICK_H + BRICK_GAP),
          w: BRICK_W,
          h: BRICK_H,
          hp: hp,
          maxHp: hp,
          flash: 0,
          dead: false
        });
      }
    }
    G.ballSpeed = 330 + i * 22;
    G.effects.wide = 0;
    G.effects.slow = 0;
    G.combo = 0;
    G.powerups.length = 0;
    paddle.x = W / 2;
    paddle.w = paddle.baseW;
    resetBall();
    toast("LEVEL " + (i + 1) + " — " + lv.name);
  }

  function resetBall() {
    G.balls = [{
      x: paddle.x,
      y: paddle.y - BALL_R - 1,
      vx: 0,
      vy: 0,
      r: BALL_R,
      stuck: true,
      trail: []
    }];
  }

  function speedForBall() {
    return G.ballSpeed * (G.effects.slow > 0 ? 0.72 : 1);
  }

  function launch() {
    let launched = false;
    for (const b of G.balls) {
      if (b.stuck) {
        b.stuck = false;
        const ang = rand(-0.3, 0.3);
        const sp = speedForBall();
        b.vx = Math.sin(ang) * sp;
        b.vy = -Math.cos(ang) * sp;
        launched = true;
      }
    }
    if (launched) sfx.paddle();
  }

  function levelClear() {
    G.mode = "levelclear";
    G.clearTimer = 2.2;
    const bonus = 500 + G.lives * 150;
    G.score += bonus;
    G.popups.push({
      x: W / 2, y: H / 2 + 60,
      text: "+" + bonus + " BONUS",
      life: 1.6, maxLife: 1.6, color: "#ffd166", size: 22
    });
    sfx.level();
    addShake(8);
  }

  function nextLevel() {
    if (G.levelIndex + 1 >= LEVELS.length) {
      G.mode = "win";
      G.best = Math.max(G.best, G.score);
      saveBest();
      sfx.win();
    } else {
      loadLevel(G.levelIndex + 1);
      G.mode = "playing";
    }
  }

  function loseLife() {
    G.lives--;
    G.combo = 0;
    G.shake = 16;
    G.flash = 0.55;
    sfx.death();
    if (G.lives <= 0) {
      G.mode = "gameover";
      G.best = Math.max(G.best, G.score);
      saveBest();
      G.balls = [];
    } else {
      resetBall();
    }
  }

  // ---------------------------------------------------------------- update
  function update(dt) {
    G.time += dt;

    if (G.effects.wide > 0) G.effects.wide = Math.max(0, G.effects.wide - dt);
    if (G.effects.slow > 0) G.effects.slow = Math.max(0, G.effects.slow - dt);

    const targetW = G.effects.wide > 0 ? paddle.wideW : paddle.baseW;
    paddle.w += (targetW - paddle.w) * Math.min(1, dt * 10);
    paddle.squash = Math.max(0, paddle.squash - dt * 6);

    if (G.banner) {
      G.banner.life -= dt;
      if (G.banner.life <= 0) G.banner = null;
    }

    if (G.mode === "playing") {
      updatePaddle(dt);
      updateBalls(dt);
      updatePowerups(dt);
      updatePopups(dt);
      if (!G.bricks.some((b) => !b.dead && b.hp !== Infinity)) levelClear();
    } else if (G.mode === "levelclear") {
      G.clearTimer -= dt;
      updatePopups(dt);
      if (G.clearTimer <= 0) nextLevel();
    } else if (G.mode === "gameover" || G.mode === "win") {
      updatePopups(dt);
    }

    updateParticles(dt);

    for (const b of G.bricks) {
      if (b.flash > 0) b.flash = Math.max(0, b.flash - dt * 5);
    }

    G.shake *= Math.pow(0.0018, dt);
    if (G.shake < 0.06) G.shake = 0;
    G.flash = Math.max(0, G.flash - dt * 2.4);
  }

  function updatePaddle(dt) {
    const prevX = paddle.x;
    if (usingMouse) {
      const target = clamp(mouseX, paddle.w / 2, W - paddle.w / 2);
      paddle.x += (target - paddle.x) * Math.min(1, dt * 26);
    } else {
      let dir = 0;
      if (keys.left) dir -= 1;
      if (keys.right) dir += 1;
      paddle.x += dir * 760 * dt;
    }
    paddle.x = clamp(paddle.x, paddle.w / 2, W - paddle.w / 2);
    paddle.vx = (paddle.x - prevX) / Math.max(dt, 1e-4);
  }

  function updateBalls(dt) {
    const balls = G.balls;

    for (let i = balls.length - 1; i >= 0; i--) {
      const b = balls[i];

      if (b.stuck) {
        b.x = paddle.x;
        b.y = paddle.y - b.r - 1;
        b.trail.length = 0;
        continue;
      }

      // smooth speed correction (handles slow powerup + level changes)
      const target = speedForBall();
      const cur = Math.hypot(b.vx, b.vy);
      if (cur > 0.5) {
        const k = 1 + (target / cur - 1) * Math.min(1, dt * 7);
        b.vx *= k;
        b.vy *= k;
      }

      // avoid near-horizontal crawling
      const sp = Math.hypot(b.vx, b.vy);
      if (sp > 1 && Math.abs(b.vy) < sp * 0.22) {
        const sign = b.vy <= 0 ? -1 : 1;
        b.vy = sign * sp * 0.22;
        const m = Math.hypot(b.vx, b.vy);
        b.vx = (b.vx / m) * sp;
        b.vy = (b.vy / m) * sp;
      }

      // substep movement for reliable collisions
      const dist = Math.hypot(b.vx, b.vy) * dt;
      const steps = Math.max(1, Math.ceil(dist / (b.r * 0.85)));
      const sdt = dt / steps;
      let alive = true;

      for (let s = 0; s < steps && alive; s++) {
        b.x += b.vx * sdt;
        b.y += b.vy * sdt;
        collideWalls(b);
        collidePaddle(b);
        collideBricks(b);
        if (b.y - b.r > H + 30) alive = false;
      }

      if (!alive) {
        balls.splice(i, 1);
      } else {
        b.trail.push({ x: b.x, y: b.y });
        if (b.trail.length > 9) b.trail.shift();
      }
    }

    if (balls.length === 0 && G.mode === "playing") loseLife();
  }

  function collideWalls(b) {
    if (b.x - b.r < 0) {
      b.x = b.r;
      b.vx = Math.abs(b.vx);
      sfx.wall();
      spawnParticles(0, b.y, "#7fe9ff", 3, 80);
    } else if (b.x + b.r > W) {
      b.x = W - b.r;
      b.vx = -Math.abs(b.vx);
      sfx.wall();
      spawnParticles(W, b.y, "#7fe9ff", 3, 80);
    }
    if (b.y - b.r < HUD_H) {
      b.y = HUD_H + b.r;
      b.vy = Math.abs(b.vy);
      sfx.wall();
      spawnParticles(b.x, HUD_H, "#7fe9ff", 3, 80);
    }
  }

  function collidePaddle(b) {
    if (b.vy <= 0) return;
    const px = paddle.x - paddle.w / 2;
    if (
      b.y + b.r >= paddle.y &&
      b.y - b.r <= paddle.y + paddle.h &&
      b.x + b.r >= px &&
      b.x - b.r <= px + paddle.w
    ) {
      b.y = paddle.y - b.r - 0.01;
      const rel = clamp((b.x - paddle.x) / (paddle.w / 2), -1, 1);
      let ang = clamp(rel * 1.05 + paddle.vx * 0.00011, -1.2, 1.2);
      const sp = speedForBall();
      b.vx = Math.sin(ang) * sp;
      b.vy = -Math.cos(ang) * sp;
      paddle.squash = 1;
      G.combo = 0;
      addShake(2);
      sfx.paddle();
      spawnParticles(b.x, paddle.y, "#46e6ff", 6, 150);
    }
  }

  function collideBricks(b) {
    for (const br of G.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
      ) continue;

      const cx = clamp(b.x, br.x, br.x + br.w);
      const cy = clamp(b.y, br.y, br.y + br.h);
      const dx = b.x - cx, dy = b.y - cy;
      if (dx * dx + dy * dy > b.r * b.r) continue;

      const bcx = br.x + br.w / 2;
      const bcy = br.y + br.h / 2;
      const ox = br.w / 2 + b.r - Math.abs(b.x - bcx);
      const oy = br.h / 2 + b.r - Math.abs(b.y - bcy);

      if (ox < oy) {
        b.vx = -b.vx;
        b.x += b.x < bcx ? -ox : ox;
      } else {
        b.vy = -b.vy;
        b.y += b.y < bcy ? -oy : oy;
      }

      hitBrick(br, b);
      break;
    }
  }

  function hitBrick(br, ball) {
    if (br.hp === Infinity) {
      br.flash = 0.2;
      addShake(1.6);
      sfx.clang();
      spawnParticles(ball.x, ball.y, "#c8d2e6", 4, 110);
      return;
    }

    br.hp--;
    br.flash = 0.24;
    const base = br.maxHp;
    const col = BRICK_COLORS[base] ? BRICK_COLORS[base][0] : "#2ad4ff";

    if (br.hp <= 0) {
      br.dead = true;
      G.combo++;
      const bonus = Math.min(G.combo, 12) * 10;
      const pts = 50 * base + bonus;
      G.score += pts;
      spawnParticles(br.x + br.w / 2, br.y + br.h / 2, col, 12 + base * 5, 250);
      G.popups.push({
        x: br.x + br.w / 2, y: br.y + br.h / 2,
        text: "+" + pts, life: 0.85, maxLife: 0.85,
        color: col, size: 13
      });
      addShake(2.5 + base);
      maybeDropPowerup(br);
      sfx.brk(base);
    } else {
      G.score += 10;
      spawnParticles(ball.x, ball.y, col, 4, 130);
      sfx.hit(base);
    }
  }

  function maybeDropPowerup(br) {
    if (Math.random() > 0.17) return;
    const pool = ["wide", "multi", "slow", "wide", "multi", "slow", "life"];
    const kind = pool[Math.floor(Math.random() * pool.length)];
    G.powerups.push({
      x: br.x + br.w / 2,
      y: br.y + br.h / 2,
      vy: 135,
      kind: kind,
      t: 0,
      w: 30,
      h: 22
    });
  }

  function updatePowerups(dt) {
    const px = paddle.x - paddle.w / 2;
    for (let i = G.powerups.length - 1; i >= 0; i--) {
      const p = G.powerups[i];
      p.y += p.vy * dt;
      p.t += dt;
      if (
        p.y + p.h / 2 > paddle.y &&
        p.y - p.h / 2 < paddle.y + paddle.h &&
        p.x + p.w / 2 > px &&
        p.x - p.w / 2 < px + paddle.w
      ) {
        applyPowerup(p.kind);
        G.powerups.splice(i, 1);
        continue;
      }
      if (p.y - p.h > H) G.powerups.splice(i, 1);
    }
  }

  function applyPowerup(kind) {
    sfx.power();
    addShake(4);
    if (kind === "wide") {
      G.effects.wide = 12;
      toast("WIDE PADDLE");
    } else if (kind === "slow") {
      G.effects.slow = 8;
      toast("SLOW BALL");
    } else if (kind === "life") {
      G.lives = Math.min(G.lives + 1, 6);
      toast("EXTRA LIFE");
    } else if (kind === "multi") {
      const active = G.balls.filter((b) => !b.stuck);
      if (!active.length) {
        toast("MULTIBALL");
        return;
      }
      const added = [];
      for (const b of active) {
        if (G.balls.length + added.length >= 8) break;
        const sp = Math.hypot(b.vx, b.vy) || speedForBall();
        const baseAng = Math.atan2(b.vy, b.vx);
        for (const off of [-0.45, 0.45]) {
          const a = baseAng + off;
          added.push({
            x: b.x, y: b.y,
            vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
            r: BALL_R, stuck: false, trail: []
          });
        }
      }
      G.balls.push.apply(G.balls, added);
      toast("MULTIBALL");
    }
  }

  function spawnParticles(x, y, color, n, spread) {
    if (G.particles.length > 520) n = Math.min(n, 3);
    for (let i = 0; i < n; i++) {
      const a = Math.random() * TAU;
      const sp = rand(30, spread);
      const life = rand(0.28, 0.7);
      G.particles.push({
        x: x, y: y,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - 40,
        life: life,
        maxLife: life,
        size: rand(1.6, 4.2),
        color: color
      });
    }
  }

  function updateParticles(dt) {
    for (let i = G.particles.length - 1; i >= 0; i--) {
      const p = G.particles[i];
      p.life -= dt;
      if (p.life <= 0) {
        G.particles.splice(i, 1);
        continue;
      }
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.vy += 620 * dt;
      p.vx *= Math.pow(0.12, dt);
      p.vy *= Math.pow(0.55, dt);
    }
  }

  function updatePopups(dt) {
    for (let i = G.popups.length - 1; i >= 0; i--) {
      const p = G.popups[i];
      p.life -= dt;
      p.y -= 42 * dt;
      if (p.life <= 0) G.popups.splice(i, 1);
    }
  }

  function addShake(v) {
    G.shake = Math.min(26, G.shake + v);
  }

  function toast(text) {
    G.banner = { text: text, life: 1.5 };
  }

  // ---------------------------------------------------------------- drawing
  const bgGrad = ctx.createLinearGradient(0, 0, 0, H);
  bgGrad.addColorStop(0, "#0b1224");
  bgGrad.addColorStop(0.55, "#070b16");
  bgGrad.addColorStop(1, "#04060c");

  const stars = [];
  for (let i = 0; i < 95; i++) {
    stars.push({
      x: Math.random() * W,
      y: Math.random() * H,
      r: rand(0.4, 1.7),
      p: Math.random() * TAU,
      s: rand(0.2, 0.8)
    });
  }

  function drawBackground() {
    ctx.fillStyle = bgGrad;
    ctx.fillRect(0, 0, W, H);

    // faint grid
    ctx.strokeStyle = "rgba(70,180,255,0.045)";
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let x = 0; x <= W; x += 45) {
      ctx.moveTo(x + 0.5, HUD_H);
      ctx.lineTo(x + 0.5, H);
    }
    for (let y = HUD_H; y <= H; y += 45) {
      ctx.moveTo(0, y + 0.5);
      ctx.lineTo(W, y + 0.5);
    }
    ctx.stroke();

    // stars
    for (const s of stars) {
      const a = 0.18 + 0.32 * (0.5 + 0.5 * Math.sin(G.time * s.s + s.p));
      ctx.fillStyle = "rgba(150,215,255," + a.toFixed(3) + ")";
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, TAU);
      ctx.fill();
    }
  }

  function drawFieldEdges() {
    ctx.save();
    ctx.strokeStyle = "rgba(70,230,255,0.22)";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(1, HUD_H);
    ctx.lineTo(1, H);
    ctx.moveTo(W - 1, HUD_H);
    ctx.lineTo(W - 1, H);
    ctx.stroke();
    ctx.restore();
  }

  function drawBricks() {
    for (const br of G.bricks) {
      if (br.dead) continue;
      const key = br.maxHp === Infinity ? "X" : br.maxHp;
      const col = BRICK_COLORS[key] || BRICK_COLORS[1];
      const main = col[0], dark = col[1];

      ctx.save();
      if (br.flash > 0) {
        ctx.shadowColor = main;
        ctx.shadowBlur = 20;
      }
      ctx.fillStyle = dark;
      roundRect(ctx, br.x, br.y, br.w, br.h, 5);
      ctx.fill();

      ctx.fillStyle = main;
      roundRect(ctx, br.x + 1.5, br.y + 1.5, br.w - 3, br.h - 4, 4);
      ctx.fill();
      ctx.restore();

      // top highlight
      ctx.fillStyle = "rgba(255,255,255,0.30)";
      roundRect(ctx, br.x + 4, br.y + 3.5, br.w - 8, 3.5, 1.8);
      ctx.fill();

      // damage hint
      if (br.hp !== Infinity && br.hp < br.maxHp) {
        ctx.fillStyle = "rgba(0,0,0,0.22)";
        roundRect(ctx, br.x + 1.5, br.y + 1.5, br.w - 3, br.h - 4, 4);
        ctx.fill();
      }

      if (br.maxHp === Infinity) {
        ctx.strokeStyle = "rgba(255,255,255,0.25)";
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(br.x + 8, br.y + br.h - 8);
        ctx.lineTo(br.x + br.w - 8, br.y + 8);
        ctx.moveTo(br.x + 8, br.y + 8);
        ctx.lineTo(br.x + br.w - 8, br.y + br.h - 8);
        ctx.stroke();
      }

      if (br.flash > 0) {
        ctx.fillStyle = "rgba(255,255,255," + (br.flash * 3.2).toFixed(3) + ")";
        roundRect(ctx, br.x, br.y, br.w, br.h, 5);
        ctx.fill();
      }
    }
  }

  function drawParticles() {
    for (const p of G.particles) {
      const a = Math.max(0, p.life / p.maxLife);
      ctx.globalAlpha = a;
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * (0.35 + a * 0.75), 0, TAU);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
  }

  const PU_STYLE = {
    wide: { c: "#5cff9d", t: "W" },
    multi: { c: "#ffb545", t: "M" },
    slow: { c: "#5cb8ff", t: "S" },
    life: { c: "#ff6bd6", t: "+" }
  };

  function drawPowerups() {
    for (const p of G.powerups) {
      const st = PU_STYLE[p.kind] || PU_STYLE.wide;
      const bob = Math.sin(p.t * 8) * 1.6;
      ctx.save();
      ctx.translate(p.x, p.y + bob);
      ctx.shadowColor = st.c;
      ctx.shadowBlur = 16;
      ctx.fillStyle = "rgba(6,12,22,0.9)";
      roundRect(ctx, -p.w / 2, -p.h / 2, p.w, p.h, 6);
      ctx.fill();
      ctx.strokeStyle = st.c;
      ctx.lineWidth = 2;
      roundRect(ctx, -p.w / 2, -p.h / 2, p.w, p.h, 6);
      ctx.stroke();
      ctx.shadowBlur = 0;
      ctx.fillStyle = st.c;
      ctx.font = "bold 15px ui-monospace, Menlo, Consolas, monospace";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillText(st.t, 0, 0.5);
      ctx.restore();
    }
  }

  function drawPaddle() {
    const sq = paddle.squash;
    const h = paddle.h * (1 - 0.45 * sq);
    const w = paddle.w;
    const x = paddle.x - w / 2;
    const y = paddle.y + (paddle.h - h) / 2;

    ctx.save();
    ctx.shadowColor = "#46e6ff";
    ctx.shadowBlur = 20;
    const g = ctx.createLinearGradient(0, y, 0, y + h);
    g.addColorStop(0, "#ccfaff");
    g.addColorStop(0.45, "#46e6ff");
    g.addColorStop(1, "#0d6f92");
    ctx.fillStyle = g;
    roundRect(ctx, x, y, w, h, h / 2);
    ctx.fill();
    ctx.restore();

    ctx.fillStyle = "rgba(255,255,255,0.55)";
    roundRect(ctx, x + 6, y + 2, w - 12, 2.5, 1.2);
    ctx.fill();
  }

  function drawBalls() {
    for (const b of G.balls) {
      // trail
      for (let i = 0; i < b.trail.length; i++) {
        const t = b.trail[i];
        const a = (i / b.trail.length) * 0.28;
        ctx.fillStyle = "rgba(120,235,255," + a.toFixed(3) + ")";
        ctx.beginPath();
        ctx.arc(t.x, t.y, b.r * (0.3 + 0.7 * (i / b.trail.length)), 0, TAU);
        ctx.fill();
      }

      ctx.save();
      ctx.shadowColor = "#7ef0ff";
      ctx.shadowBlur = 18;
      const g = ctx.createRadialGradient(
        b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.15,
        b.x, b.y, b.r
      );
      g.addColorStop(0, "#ffffff");
      g.addColorStop(0.45, "#b6f4ff");
      g.addColorStop(1, "#2aa9d6");
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.r, 0, TAU);
      ctx.fill();
      ctx.restore();
    }
  }

  function drawPopups() {
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    for (const p of G.popups) {
      const a = Math.max(0, p.life / p.maxLife);
      ctx.globalAlpha = a;
      ctx.fillStyle = p.color;
      ctx.font = "bold " + (p.size || 14) + "px ui-monospace, Menlo, Consolas, monospace";
      ctx.fillText(p.text, p.x, p.y);
    }
    ctx.globalAlpha = 1;
  }

  function drawBanner() {
    if (!G.banner) return;
    const a = Math.min(1, G.banner.life * 2.2);
    ctx.globalAlpha = a;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.font = "bold 20px ui-monospace, Menlo, Consolas, monospace";
    ctx.fillStyle = "rgba(4,10,20,0.7)";
    const w = ctx.measureText(G.banner.text).width + 40;
    roundRect(ctx, W / 2 - w / 2, 400, w, 38, 19);
    ctx.fill();
    ctx.fillStyle = "#8ff4ff";
    ctx.shadowColor = "#46e6ff";
    ctx.shadowBlur = 14;
    ctx.fillText(G.banner.text, W / 2, 420);
    ctx.shadowBlur = 0;
    ctx.globalAlpha = 1;
  }

  function drawHUD() {
    ctx.fillStyle = "rgba(6,11,22,0.92)";
    ctx.fillRect(0, 0, W, HUD_H);
    ctx.strokeStyle = "rgba(70,230,255,0.28)";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, HUD_H - 1);
    ctx.lineTo(W, HUD_H - 1);
    ctx.stroke();

    const mono = "ui-monospace, Menlo, Consolas, monospace";
    ctx.textBaseline = "alphabetic";

    // score
    ctx.textAlign = "left";
    ctx.fillStyle = "rgba(140,200,230,0.65)";
    ctx.font = "600 11px " + mono;
    ctx.fillText("SCORE", 24, 22);
    ctx.fillStyle = "#e2f8ff";
    ctx.font = "bold 24px " + mono;
    ctx.fillText(String(G.score).padStart(6, "0"), 24, 46);

    // lives
    ctx.textAlign = "center";
    ctx.fillStyle = "rgba(140,200,230,0.65)";
    ctx.font = "600 11px " + mono;
    ctx.fillText("LIVES", W / 2, 22);
    const n = Math.max(0, G.lives);
    const iconW = 22, gap = 8;
    const totalW = n > 0 ? n * iconW + (n - 1) * gap : 0;
    let sx = W / 2 - totalW / 2;
    for (let i = 0; i < n; i++) {
      ctx.save();
      ctx.shadowColor = "#46e6ff";
      ctx.shadowBlur = 8;
      ctx.fillStyle = "#46e6ff";
      roundRect(ctx, sx, 33, iconW, 6, 3);
      ctx.fill();
      ctx.restore();
      sx += iconW + gap;
    }

    // level
    ctx.textAlign = "right";
    ctx.fillStyle = "rgba(140,200,230,0.65)";
    ctx.font = "600 11px " + mono;
    ctx.fillText("LEVEL", W - 24, 22);
    ctx.fillStyle = "#e2f8ff";
    ctx.font = "bold 24px " + mono;
    ctx.fillText((G.levelIndex + 1) + " / " + LEVELS.length, W - 24, 46);

    // combo indicator
    if (G.combo > 1) {
      ctx.textAlign = "center";
      ctx.fillStyle = "rgba(255,214,102," + (0.5 + 0.5 * Math.sin(G.time * 14)) + ")";
      ctx.font = "bold 13px " + mono;
      ctx.fillText("COMBO x" + G.combo, W / 2, HUD_H + 24);
    }
  }

  function drawOverlay() {
    if (G.mode === "playing") return;

    ctx.fillStyle = "rgba(3,6,14,0.74)";
    ctx.fillRect(0, 0, W, H);

    const mono = "ui-monospace, Menlo, Consolas, monospace";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    const blink = 0.55 + 0.45 * Math.sin(G.time * 4.5);

    if (G.mode === "title") {
      ctx.save();
      ctx.shadowColor = "#46e6ff";
      ctx.shadowBlur = 44;
      const g = ctx.createLinearGradient(0, 190, 0, 270);
      g.addColorStop(0, "#ffffff");
      g.addColorStop(0.5, "#8bf2ff");
      g.addColorStop(1, "#2a8bd6");
      ctx.fillStyle = g;
      ctx.font = "bold 82px " + mono;
      ctx.fillText("BREAKOUT", W / 2, 232);
      ctx.restore();

      ctx.fillStyle = "rgba(120,220,255,0.75)";
      ctx.font = "bold 16px " + mono;
      ctx.fillText("N E O N   E D I T I O N", W / 2, 288);

      ctx.fillStyle = "rgba(200,225,245,0.8)";
      ctx.font = "14px " + mono;
      ctx.fillText("MOVE:  MOUSE  or  \u2190 \u2192  /  A D", W / 2, 372);
      ctx.fillText("LAUNCH:  SPACE  or  CLICK", W / 2, 398);
      ctx.fillText("PAUSE:  P        MUTE:  M        RESTART:  R", W / 2, 424);

      ctx.globalAlpha = blink;
      ctx.fillStyle = "#ffd166";
      ctx.font = "bold 22px " + mono;
      ctx.fillText("PRESS SPACE OR CLICK TO START", W / 2, 512);
      ctx.globalAlpha = 1;

      if (G.best > 0) {
        ctx.fillStyle = "rgba(140,200,230,0.6)";
        ctx.font = "13px " + mono;
        ctx.fillText("BEST SCORE: " + G.best, W / 2, 566);
      }
    } else if (G.mode === "paused") {
      ctx.fillStyle = "#e2f8ff";
      ctx.font = "bold 56px " + mono;
      ctx.fillText("PAUSED", W / 2, H / 2 - 20);
      ctx.globalAlpha = blink;
      ctx.fillStyle = "#ffd166";
      ctx.font = "bold 18px " + mono;
      ctx.fillText("PRESS P OR SPACE TO RESUME", W / 2, H / 2 + 50);
      ctx.globalAlpha = 1;
    } else if (G.mode === "levelclear") {
      ctx.save();
      ctx.shadowColor = "#7bff6a";
      ctx.shadowBlur = 30;
      ctx.fillStyle = "#c9ffbe";
      ctx.font = "bold 54px " + mono;
      ctx.fillText("LEVEL CLEAR", W / 2, H / 2 - 30);
      ctx.restore();
      ctx.fillStyle = "#ffd166";
      ctx.font = "bold 20px " + mono;
      ctx.fillText("+" + (500 + G.lives * 150) + " BONUS", W / 2, H / 2 + 30);
      ctx.globalAlpha = blink;
      ctx.fillStyle = "rgba(200,225,245,0.9)";
      ctx.font = "bold 16px " + mono;
      ctx.fillText("NEXT LEVEL INCOMING\u2026", W / 2, H / 2 + 90);
      ctx.globalAlpha = 1;
    } else if (G.mode === "gameover") {
      ctx.save();
      ctx.shadowColor = "#ff4d6d";
      ctx.shadowBlur = 30;
      ctx.fillStyle = "#ff8fa3";
      ctx.font = "bold 62px " + mono;
      ctx.fillText("GAME OVER", W / 2, H / 2 - 60);
      ctx.restore();

      ctx.fillStyle = "#e2f8ff";
      ctx.font = "bold 26px " + mono;
      ctx.fillText("SCORE  " + G.score, W / 2, H / 2 + 10);
      ctx.fillStyle = "rgba(140,200,230,0.8)";
      ctx.font = "16px " + mono;
      ctx.fillText("BEST  " + G.best, W / 2, H / 2 + 48);

      ctx.globalAlpha = blink;
      ctx.fillStyle = "#ffd166";
      ctx.font = "bold 20px " + mono;
      ctx.fillText("PRESS SPACE OR CLICK TO PLAY AGAIN", W / 2, H / 2 + 122);
      ctx.globalAlpha = 1;
    } else if (G.mode === "win") {
      ctx.save();
      ctx.shadowColor = "#ffd166";
      ctx.shadowBlur = 36;
      const g = ctx.createLinearGradient(0, H / 2 - 110, 0, H / 2 - 40);
      g.addColorStop(0, "#ffffff");
      g.addColorStop(1, "#ffb545");
      ctx.fillStyle = g;
      ctx.font = "bold 64px " + mono;
      ctx.fillText("YOU WIN!", W / 2, H / 2 - 75);
      ctx.restore();

      ctx.fillStyle = "#e2f8ff";
      ctx.font = "bold 26px " + mono;
      ctx.fillText("FINAL SCORE  " + G.score, W / 2, H / 2 + 10);
      ctx.fillStyle = "rgba(140,200,230,0.8)";
      ctx.font = "16px " + mono;
      ctx.fillText("ALL " + LEVELS.length + " LEVELS CLEARED", W / 2, H / 2 + 48);

      ctx.globalAlpha = blink;
      ctx.fillStyle = "#ffd166";
      ctx.font = "bold 20px " + mono;
      ctx.fillText("PRESS SPACE OR CLICK TO PLAY AGAIN", W / 2, H / 2 + 122);
      ctx.globalAlpha = 1;
    }
  }

  function render() {
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    drawBackground();

    ctx.save();
    if (G.shake > 0.1) {
      ctx.translate(
        (Math.random() - 0.5) * G.shake,
        (Math.random() - 0.5) * G.shake
      );
    }
    drawFieldEdges();
    drawBricks();
    drawParticles();
    drawPowerups();
    drawPaddle();
    drawBalls();
    drawPopups();
    drawBanner();
    ctx.restore();

    drawHUD();

    if (G.flash > 0.001) {
      ctx.fillStyle = "rgba(255,70,100," + (G.flash * 0.45).toFixed(3) + ")";
      ctx.fillRect(0, 0, W, H);
    }

    drawOverlay();
  }

  // ---------------------------------------------------------------- loop
  let lastT = performance.now();

  function frame(now) {
    let dt = (now - lastT) / 1000;
    lastT = now;
    if (dt > 1 / 30) dt = 1 / 30;
    if (dt < 0) dt = 0;

    update(dt);
    render();
    requestAnimationFrame(frame);
  }

  // start with a placeholder level so the title screen has something behind it
  loadLevel(0);
  G.mode = "title";
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>

Compare with

Particle-1: other attempts