• Updated 11 Nov, 12:43 UTC
  • Next run 12 Nov, 05:17 UTCNext 12 Nov 05:17
  • Last 7 days: 10 new models · 61 runs7 d: 10 models · 61 runs
Playable Breakout

Fugu Ultra v2Sakana AI

Network offOpen alone (new tab)

Run details

Model
sakana/fugu-ultra-v2
Route
OpenRouter API, one user message
Served by
Sakana AI
Effort
xhigh, OpenRouter's default for this model
Limits
60 min · 66,666 output tokens (the model's limit)
Date
9 Nov 2026
Time
22 min 51 s
Size
63 kB
Tokens
93,387 in, 139,823 out (9,526 of them thinking)
List price
$4.66 at today's price ($5.00 in / $30.00 out per M)
Paid
$4.64

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

63 kB 2,337 lines Raw

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Neon Breakout</title>
<style>
  * { box-sizing: border-box; }
  html, body {
    width: 100%;
    height: 100%;
    margin: 0;
    overflow: hidden;
    background: radial-gradient(1100px 800px at 50% -10%, #172452 0%, #090d20 55%, #03040b 100%);
    color: #dff5ff;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Courier New", monospace;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
  }
  #wrap {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  canvas {
    display: block;
    border-radius: 14px;
    touch-action: none;
    outline: none;
    box-shadow:
      0 0 0 1px rgba(125, 210, 255, .2),
      0 0 55px rgba(50, 125, 255, .25),
      0 25px 85px rgba(0, 0, 0, .75);
  }
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game" width="900" height="680" tabindex="0" aria-label="Neon Breakout game"></canvas>
</div>
<p class="sr-only">
  Neon Breakout. Move with the mouse, the left and right arrow keys, or A and D.
  Press Space, Enter, or click to launch the ball and to fire lasers.
  Press P or Escape to pause, M to mute, R to restart.
</p>

<script>
(function () {
'use strict';

/* ============================== CONSTANTS ============================== */

var W = 900, H = 680;
var HUD_H = 68, WALL = 14;
var FL = WALL, FR = W - WALL, FT = HUD_H + 8;

var COLS = 12, MAX_ROWS = 10, GRID_PAD = 10;
var BRICK_GAP = 4;
var BRICK_W = (FR - FL - GRID_PAD * 2) / COLS;
var BRICK_H = 26, BRICK_TOP = FT + 46;

var BALL_R = 8, MAX_BALLS = 8, MAX_SPEED = 880;
var PADDLE_Y = H - 58, PADDLE_H = 16;
var PADDLE_BASE = 112, PADDLE_WIDE = 178, PADDLE_SMALL = 72;
var MAX_BOUNCE = 1.05;

var MAX_DT = 0.05;
var MAX_PARTICLES = 760;

var FONT_DISPLAY = '"Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif';
var FONT_MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Courier New", monospace';

var BULLET = '\u2022';
var ARROW_L = '\u2190';
var ARROW_R = '\u2192';
var STAR = '\u2605';

var HP_COLORS = {
  1: { a: '#5ff7e6', b: '#0f9b8c' },
  2: { a: '#a6f759', b: '#3f9418' },
  3: { a: '#ff79dc', b: '#a1198a' },
  4: { a: '#ffd36b', b: '#bf7710' },
  S: { a: '#c3d2e2', b: '#58677a' }
};

/* Level grammar: '.' empty, '1'-'4' hit points, 'S' indestructible steel. */
var LEVELS = [
  { name: 'FIRST SERVE', rows: [
    '............',
    '111111111111',
    '111111111111',
    '222222222222'
  ] },
  { name: 'PYRAMID', rows: [
    '.....11.....',
    '....1221....',
    '...122221...',
    '..12233221..',
    '.1223333221.'
  ] },
  { name: 'FORTRESS', rows: [
    'SS........SS',
    '.2222222222.',
    '.2SSSSSSSS2.',
    '.2333333332.',
    '.2222222222.'
  ] },
  { name: 'CHECKPOINT', rows: [
    '1.1.1.1.1.1.',
    '.2.2.2.2.2.2',
    '3.3.3.3.3.3.',
    '.2.2.2.2.2.2',
    '1.1.1.1.1.1.'
  ] },
  { name: 'ZIGZAG', rows: [
    '..3......3..',
    '.333....333.',
    '33333..33333',
    '.222....222.',
    '..1......1..'
  ] },
  { name: 'THE VAULT', rows: [
    '222222222222',
    '2..........2',
    '2.SS3333SS.2',
    '2.S333333S.2',
    '2..........2',
    '333333333333'
  ] },
  { name: 'DOWNPOUR', rows: [
    '4.4.4.4.4.4.',
    '.S.S.S.S.S.S',
    '3.3.3.3.3.3.',
    '.S.S.S.S.S.S',
    '2.2.2.2.2.2.',
    '.1.1.1.1.1.1'
  ] },
  { name: 'CORE MELTDOWN', rows: [
    'SS44444444SS',
    '.3333333333.',
    '..S333333S..',
    '...333333...',
    '....3333....',
    '.....22.....'
  ] }
];

var POWER_TYPES = [
  { key: 'wide',   weight: 16, color: '#4ad7ff', icon: 'W', label: 'WIDE PADDLE',   duration: 14 },
  { key: 'multi',  weight: 14, color: '#ff79dc', icon: 'M', label: 'MULTI BALL',    duration: 0  },
  { key: 'slow',   weight: 12, color: '#a6f759', icon: 'S', label: 'SLOW MOTION',   duration: 10 },
  { key: 'laser',  weight: 12, color: '#ffd36b', icon: 'L', label: 'LASERS',        duration: 13 },
  { key: 'sticky', weight: 10, color: '#c9a3ff', icon: 'C', label: 'STICKY PADDLE', duration: 13 },
  { key: 'life',   weight: 6,  color: '#ff6b7a', icon: '+', label: 'EXTRA LIFE',    duration: 0  },
  { key: 'small',  weight: 7,  color: '#ff8a3d', icon: '-', label: 'SHRINK!',       duration: 9  }
];

var POWER_BY_KEY = {};
(function () {
  for (var i = 0; i < POWER_TYPES.length; i++) {
    POWER_BY_KEY[POWER_TYPES[i].key] = POWER_TYPES[i];
  }
})();

var TIMED_EFFECTS = ['wide', 'small', 'slow', 'laser', 'sticky'];

/* =============================== HELPERS =============================== */

function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function rand(a, b) { return a + Math.random() * (b - a); }
function pick(list) { return list[(Math.random() * list.length) | 0]; }

function pad(n, length) {
  var s = String(Math.max(0, Math.floor(n)));
  while (s.length < length) s = '0' + s;
  return s;
}

function roundedRect(c, x, y, w, h, r) {
  r = Math.min(r, Math.abs(w) / 2, Math.abs(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();
}

var Store = {
  get: function (key, fallback) {
    try {
      var value = localStorage.getItem(key);
      return value === null ? fallback : value;
    } catch (e) {
      return fallback;
    }
  },
  set: function (key, value) {
    try { localStorage.setItem(key, value); } catch (e) {}
  }
};

/* ================================ AUDIO ================================ */

var muted = Store.get('neon_breakout_muted', '0') === '1';

var Sound = (function () {
  var audio = null;
  var master = null;
  var broken = false;

  function ready() {
    if (muted || broken) return null;

    if (!audio) {
      try {
        var Ctor = window.AudioContext || window.webkitAudioContext;
        if (!Ctor) { broken = true; return null; }
        audio = new Ctor();
        master = audio.createGain();
        master.gain.value = 0.24;
        master.connect(audio.destination);
      } catch (e) {
        broken = true;
        audio = null;
        return null;
      }
    }

    if (audio.state === 'suspended') {
      try { audio.resume(); } catch (e) {}
    }
    return audio;
  }

  function envelope(node, start, duration, volume, attack) {
    var gain = audio.createGain();
    attack = attack || 0.005;
    gain.gain.setValueAtTime(0.0001, start);
    gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, volume), start + attack);
    gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
    node.connect(gain);
    gain.connect(master);
    return gain;
  }

  function tone(frequency, duration, type, volume, slide, delay) {
    var a = ready();
    if (!a) return;
    try {
      var start = a.currentTime + (delay || 0);
      var osc = a.createOscillator();
      osc.type = type || 'square';
      osc.frequency.setValueAtTime(frequency, start);
      if (slide) {
        osc.frequency.exponentialRampToValueAtTime(Math.max(20, slide), start + duration);
      }
      envelope(osc, start, duration, volume == null ? 0.3 : volume);
      osc.start(start);
      osc.stop(start + duration + 0.03);
    } catch (e) {}
  }

  function noise(duration, volume, filterFrequency, slide) {
    var a = ready();
    if (!a) return;
    try {
      var start = a.currentTime;
      var count = Math.max(1, Math.floor(a.sampleRate * duration));
      var buffer = a.createBuffer(1, count, a.sampleRate);
      var data = buffer.getChannelData(0);
      for (var i = 0; i < count; i++) {
        data[i] = (Math.random() * 2 - 1) * (1 - i / count);
      }
      var source = a.createBufferSource();
      source.buffer = buffer;
      var filter = a.createBiquadFilter();
      filter.type = 'lowpass';
      filter.frequency.setValueAtTime(filterFrequency || 1200, start);
      if (slide) {
        filter.frequency.exponentialRampToValueAtTime(Math.max(60, slide), start + duration);
      }
      source.connect(filter);
      envelope(filter, start, duration, volume == null ? 0.25 : volume, 0.004);
      source.start(start);
    } catch (e) {}
  }

  function sequence(base, steps, duration, type, volume, gap) {
    for (var i = 0; i < steps.length; i++) {
      tone(base * Math.pow(2, steps[i] / 12), duration, type, volume, null, i * gap);
    }
  }

  return {
    unlock: function () { ready(); },
    paddle: function () { tone(300, 0.07, 'square', 0.22, 470); },
    wall: function () { tone(195, 0.05, 'sine', 0.14, 175); },
    brick: function (combo) {
      var f = 240 * Math.pow(1.0595, Math.min(combo || 0, 26));
      tone(f, 0.08, 'triangle', 0.26, f * 1.5);
    },
    hard: function () { tone(130, 0.1, 'sawtooth', 0.17, 72); noise(0.07, 0.12, 900); },
    steel: function () { noise(0.09, 0.2, 2800, 600); tone(540, 0.05, 'square', 0.07, 380); },
    power: function () { sequence(440, [0, 4, 7], 0.1, 'square', 0.18, 0.06); },
    life: function () { sequence(523, [0, 7, 12], 0.14, 'sine', 0.22, 0.07); },
    laser: function () { tone(900, 0.08, 'sawtooth', 0.1, 180); },
    lose: function () { tone(300, 0.5, 'sawtooth', 0.28, 60); noise(0.4, 0.16, 800, 100); },
    clear: function () { sequence(330, [0, 2, 4, 7, 12], 0.22, 'triangle', 0.2, 0.08); },
    over: function () { sequence(300, [0, -3, -7, -12], 0.4, 'sawtooth', 0.2, 0.15); },
    win: function () { sequence(392, [0, 4, 7, 12, 16, 19, 24], 0.3, 'square', 0.15, 0.09); },
    ui: function () { tone(660, 0.06, 'square', 0.13, 880); }
  };
})();

/* ================================ STATE ================================ */

var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var highScore = parseInt(Store.get('neon_breakout_high', '0'), 10) || 0;

var game = {
  state: 'title',
  previousState: 'serve',
  score: 0,
  lives: 3,
  level: 0,
  loop: 0,
  combo: 0,
  bestCombo: 0,
  bricksLeft: 0,
  bricksTotal: 1,
  timer: 0,
  intro: 0,
  levelBonus: 0,
  trauma: 0,
  hitStop: 0,
  flash: 0,
  flashColor: '255,255,255',
  time: 0,
  newRecord: false,
  error: null
};

var paddle = {
  cx: W / 2, w: PADDLE_BASE, h: PADDLE_H, y: PADDLE_Y,
  vx: 0, squash: 0, flash: 0, cooldown: 0
};

var effects = { wide: 0, small: 0, slow: 0, laser: 0, sticky: 0 };

var balls = [];
var bricks = [];
var powerups = [];
var bolts = [];
var particles = [];
var floatingTexts = [];
var waves = [];

var keys = Object.create(null);
var pointerMode = false;
var pointerX = W / 2;
var fireHeld = false;
var lastWallSound = -1;

var demoBall = { x: 260, y: 380, vx: 250, vy: -215, r: 8, trail: [] };

/* ========================== BRICK SPRITE CACHE ========================= */

var brickSprites = {};

(function buildBrickSprites() {
  var names = ['1', '2', '3', '4', 'S'];

  for (var i = 0; i < names.length; i++) {
    var name = names[i];
    var colors = HP_COLORS[name] || HP_COLORS[1];
    var scale = 2;
    var width = BRICK_W - BRICK_GAP;
    var height = BRICK_H;

    var sprite = document.createElement('canvas');
    sprite.width = Math.round(width * scale);
    sprite.height = Math.round(height * scale);

    var g = sprite.getContext('2d');
    g.scale(scale, scale);

    var gradient = g.createLinearGradient(0, 0, 0, height);
    gradient.addColorStop(0, colors.a);
    gradient.addColorStop(0.55, colors.b);
    gradient.addColorStop(1, colors.a);

    roundedRect(g, 1, 1, width - 2, height - 2, 6);
    g.fillStyle = gradient;
    g.fill();

    g.globalAlpha = 0.45;
    roundedRect(g, 3.5, 3, width - 7, height * 0.36, 4);
    g.fillStyle = '#ffffff';
    g.fill();
    g.globalAlpha = 1;

    roundedRect(g, 1, 1, width - 2, height - 2, 6);
    g.lineWidth = 1.4;
    g.strokeStyle = 'rgba(255,255,255,.4)';
    g.stroke();

    if (name === 'S') {
      g.fillStyle = 'rgba(255,255,255,.55)';
      var rivets = [[7, height / 2], [width - 7, height / 2]];
      for (var r = 0; r < rivets.length; r++) {
        g.beginPath();
        g.arc(rivets[r][0], rivets[r][1], 2.2, 0, Math.PI * 2);
        g.fill();
      }
      g.strokeStyle = 'rgba(0,0,0,.25)';
      g.lineWidth = 1;
      g.beginPath();
      g.moveTo(10, height - 4);
      g.lineTo(width - 10, 4);
      g.stroke();
    }

    brickSprites[name] = sprite;
  }
})();

/* =============================== LEVELS ================================ */

function baseSpeed() {
  return clamp(395 + game.level * 16 + game.loop * 55, 320, MAX_SPEED);
}

function loadLevel(index) {
  var normalized = ((index % LEVELS.length) + LEVELS.length) % LEVELS.length;
  var definition = LEVELS[normalized];

  bricks.length = 0;
  powerups.length = 0;
  bolts.length = 0;
  waves.length = 0;

  var count = 0;
  var rows = (definition && definition.rows) || [];

  for (var rowIndex = 0; rowIndex < rows.length && rowIndex < MAX_ROWS; rowIndex++) {
    var row = String(rows[rowIndex] || '');
    while (row.length < COLS) row += '.';
    row = row.slice(0, COLS);

    for (var column = 0; column < COLS; column++) {
      var character = row.charAt(column);
      if (character === '.' || character === ' ') continue;

      var steel = (character === 'S' || character === 's');
      var hp = steel ? 1 : clamp(parseInt(character, 10) || 1, 1, 4);

      bricks.push({
        x: FL + GRID_PAD + BRICK_GAP / 2 + column * BRICK_W,
        y: BRICK_TOP + rowIndex * (BRICK_H + BRICK_GAP),
        w: BRICK_W - BRICK_GAP,
        h: BRICK_H,
        hp: hp,
        maxHp: hp,
        steel: steel,
        alive: true,
        flash: 0,
        shake: 0,
        offY: -(260 + rowIndex * 22 + rand(0, 70))
      });

      if (!steel) count++;
    }
  }

  game.bricksLeft = count;
  game.bricksTotal = Math.max(1, count);
  return count;
}

function resetEffects() {
  for (var i = 0; i < TIMED_EFFECTS.length; i++) {
    effects[TIMED_EFFECTS[i]] = 0;
  }
  powerups.length = 0;
  bolts.length = 0;
  paddle.cooldown = 0;
}

function startLevel() {
  loadLevel(game.level);
  resetEffects();

  paddle.w = PADDLE_BASE;
  paddle.cx = clamp(paddle.cx, FL + PADDLE_BASE / 2, FR - PADDLE_BASE / 2);
  paddle.vx = 0;

  serveBall();

  game.combo = 0;
  game.intro = 1.7;
  game.state = 'serve';
  game.flash = 0.45;
  game.flashColor = '140,210,255';
}

function startGame() {
  game.error = null;
  game.score = 0;
  game.lives = 3;
  game.level = 0;
  game.loop = 0;
  game.combo = 0;
  game.bestCombo = 0;
  game.newRecord = false;
  game.hitStop = 0;
  game.trauma = 0;

  particles.length = 0;
  floatingTexts.length = 0;

  startLevel();
  Sound.ui();
}

function nextLoop() {
  game.loop++;
  game.level = 0;
  game.lives = Math.max(game.lives, 3);
  startLevel();
  Sound.ui();
}

function advanceLevel() {
  if (game.level + 1 >= LEVELS.length) {
    game.state = 'win';
    saveHighScore();
    Sound.win();
    return;
  }
  game.level++;
  startLevel();
}

function saveHighScore() {
  if (game.score > highScore) {
    highScore = game.score;
    game.newRecord = true;
  }
  Store.set('neon_breakout_high', String(highScore));
}

/* ================================ BALLS ================================ */

function makeBall(x, y, vx, vy, speed) {
  return {
    x: x, y: y, vx: vx, vy: vy, r: BALL_R, speed: speed,
    trail: [], stuck: false, offset: 0, dead: false, idle: 0
  };
}

function serveBall() {
  balls.length = 0;
  var ball = makeBall(paddle.cx, paddle.y - BALL_R - 2, 0, 0, baseSpeed());
  ball.stuck = true;
  balls.push(ball);
}

function normalizeBall(ball) {
  var vx = ball.vx;
  var vy = ball.vy;
  var length = Math.sqrt(vx * vx + vy * vy);

  if (!(length > 0.0001)) { vx = 0; vy = -1; length = 1; }

  vx /= length;
  vy /= length;

  var minY = 0.26;
  if (Math.abs(vy) < minY) {
    vy = (vy > 0 ? 1 : -1) * minY;
    vx = (vx >= 0 ? 1 : -1) * Math.sqrt(Math.max(0, 1 - minY * minY));
  }

  var minX = 0.08;
  if (Math.abs(vx) < minX) {
    vx = (vx >= 0 ? 1 : -1) * minX;
    vy = (vy > 0 ? 1 : -1) * Math.sqrt(Math.max(0, 1 - minX * minX));
  }

  var speed = ball.speed * (effects.slow > 0 ? 0.66 : 1);
  ball.vx = vx * speed;
  ball.vy = vy * speed;
}

function releaseBall(ball) {
  if (!ball.stuck) return;
  ball.stuck = false;

  var angle = -Math.PI / 2 +
    clamp(ball.offset / Math.max(1, paddle.w / 2), -1, 1) * 0.6 +
    rand(-0.06, 0.06);

  ball.vx = Math.cos(angle) * ball.speed;
  ball.vy = Math.sin(angle) * ball.speed;
  normalizeBall(ball);
}

function launchBalls() {
  var launched = false;

  for (var i = 0; i < balls.length; i++) {
    if (balls[i].stuck) {
      releaseBall(balls[i]);
      launched = true;
    }
  }

  if (launched) {
    Sound.paddle();
    paddle.squash = 1;
    if (game.state === 'serve') game.state = 'play';
  }

  return launched;
}

function multiball() {
  var originals = balls.slice();

  for (var i = 0; i < originals.length; i++) {
    if (balls.length >= MAX_BALLS) break;

    var source = originals[i];
    if (source.stuck) releaseBall(source);

    var baseAngle = Math.atan2(source.vy, source.vx);
    var offsets = [-0.42, 0.42];

    for (var j = 0; j < offsets.length; j++) {
      if (balls.length >= MAX_BALLS) break;
      var angle = baseAngle + offsets[j];
      balls.push(makeBall(
        source.x, source.y,
        Math.cos(angle) * source.speed,
        Math.sin(angle) * source.speed,
        source.speed
      ));
    }
  }

  if (game.state === 'serve') game.state = 'play';
}

/* =============================== SCORING =============================== */

function addScore(points, x, y, color, size) {
  game.score += points;

  if (game.score > highScore) {
    highScore = game.score;
    game.newRecord = true;
  }

  if (x != null) {
    floatingTexts.push({
      x: x, y: y, text: '+' + points,
      life: 0.9, maxLife: 0.9,
      color: color || '#ffffff', size: size || 16, vy: -48
    });
  }
}

function addFloatingText(text, x, y, color, size) {
  floatingTexts.push({
    x: x, y: y, text: text,
    life: 1.1, maxLife: 1.1,
    color: color || '#ffffff', size: size || 16, vy: -36
  });
}

/* ============================== PARTICLES ============================== */

function spawnParticle(x, y, color, speed, options) {
  if (particles.length > MAX_PARTICLES) particles.shift();
  options = options || {};

  var angle = options.angle == null
    ? rand(0, Math.PI * 2)
    : options.angle + rand(-0.6, 0.6);

  var velocity = speed * rand(0.35, 1);

  var particle = {
    x: x, y: y,
    vx: Math.cos(angle) * velocity,
    vy: Math.sin(angle) * velocity,
    life: options.life || rand(0.35, 0.9),
    maxLife: 1,
    size: options.size || rand(2, 5),
    color: color,
    gravity: options.gravity == null ? 540 : options.gravity,
    rotation: rand(0, Math.PI * 2),
    rotationSpeed: rand(-10, 10),
    shape: options.shape || 'rect',
    drag: options.drag || 0.88
  };

  particle.maxLife = particle.life;
  particles.push(particle);
}

function sparks(x, y, color, count, speed) {
  for (var i = 0; i < count; i++) {
    spawnParticle(x, y, color, speed, {
      size: rand(1.5, 3), life: rand(0.18, 0.45),
      gravity: 170, shape: 'spark', drag: 0.9
    });
  }
}

function shockwave(x, y, radius, maxRadius, life, color) {
  waves.push({
    x: x, y: y, radius: radius, maxRadius: maxRadius,
    life: life, maxLife: life, color: color
  });
}

function explodeBrick(brick) {
  var colors = HP_COLORS[brick.steel ? 'S' : brick.maxHp] || HP_COLORS[1];
  var drawY = brick.y + brick.offY;
  var centerX = brick.x + brick.w / 2;
  var centerY = drawY + brick.h / 2;

  for (var i = 0; i < 14; i++) {
    spawnParticle(
      rand(brick.x, brick.x + brick.w),
      rand(drawY, drawY + brick.h),
      i % 4 === 0 ? '#ffffff' : (i % 2 ? colors.a : colors.b),
      rand(90, 340),
      { size: rand(2.5, 6.5), life: rand(0.4, 1.05) }
    );
  }

  shockwave(centerX, centerY, 4, 62 + Math.min(game.combo, 12) * 6, 0.35, colors.a);
}

/* =============================== BRICKS ================================ */

function damageBrick(brick, damage, hitX, hitY) {
  if (!brick || !brick.alive) return false;

  if (brick.steel) {
    brick.flash = 1;
    brick.shake = 1;
    Sound.steel();
    sparks(hitX, hitY, '#dbe7f5', 6, 170);
    game.trauma = Math.min(1, game.trauma + 0.05);
    return false;
  }

  brick.hp -= damage;
  brick.flash = 1;
  brick.shake = 1;

  if (brick.hp > 0) {
    Sound.hard();
    sparks(hitX, hitY, (HP_COLORS[brick.hp] || HP_COLORS[1]).a, 7, 190);
    addScore(10, null);
    game.trauma = Math.min(1, game.trauma + 0.05);
    return false;
  }

  brick.alive = false;
  game.bricksLeft = Math.max(0, game.bricksLeft - 1);
  game.combo++;

  if (game.combo > game.bestCombo) game.bestCombo = game.combo;

  var multiplier = Math.min(1 + 0.1 * (game.combo - 1), 3);
  var points = Math.round((50 + (brick.maxHp - 1) * 30) * multiplier);
  var colors = HP_COLORS[brick.maxHp] || HP_COLORS[1];

  addScore(
    points,
    brick.x + brick.w / 2,
    brick.y + brick.offY + brick.h / 2,
    colors.a,
    game.combo >= 5 ? 20 : 16
  );

  Sound.brick(game.combo);
  explodeBrick(brick);

  game.trauma = Math.min(1.1, game.trauma + 0.12 + Math.min(game.combo, 10) * 0.012);
  if (game.combo >= 6) game.hitStop = Math.max(game.hitStop, 0.035);

  if (Math.random() < 0.135 + Math.min(game.combo, 10) * 0.004) {
    spawnPowerup(brick.x + brick.w / 2, brick.y + brick.offY + brick.h / 2);
  }

  return true;
}

/* ============================== POWER-UPS ============================== */

function rollPowerup() {
  var total = 0;
  var i;

  for (i = 0; i < POWER_TYPES.length; i++) total += POWER_TYPES[i].weight;

  var roll = Math.random() * total;

  for (i = 0; i < POWER_TYPES.length; i++) {
    roll -= POWER_TYPES[i].weight;
    if (roll <= 0) return POWER_TYPES[i];
  }

  return POWER_TYPES[0];
}

function spawnPowerup(x, y) {
  powerups.push({
    x: clamp(x, FL + 18, FR - 18), y: y,
    vy: 95, w: 30, h: 26,
    definition: rollPowerup(),
    time: 0
  });
}

function applyPowerup(powerup) {
  var definition = powerup.definition;

  switch (definition.key) {
    case 'wide':
      effects.small = 0;
      effects.wide = definition.duration;
      break;
    case 'small':
      effects.wide = 0;
      effects.small = definition.duration;
      break;
    case 'slow':
      effects.slow = definition.duration;
      break;
    case 'laser':
      effects.laser = definition.duration;
      break;
    case 'sticky':
      effects.sticky = definition.duration;
      break;
    case 'multi':
      multiball();
      break;
    case 'life':
      game.lives = Math.min(game.lives + 1, 6);
      Sound.life();
      break;
  }

  if (definition.key !== 'life') Sound.power();

  addScore(75, powerup.x, powerup.y - 10, definition.color, 16);
  addFloatingText(definition.label, powerup.x, powerup.y - 34, definition.color, 17);
  shockwave(powerup.x, powerup.y, 6, 90, 0.4, definition.color);

  for (var i = 0; i < 16; i++) {
    spawnParticle(powerup.x, powerup.y, definition.color, rand(70, 260), { size: rand(2, 5) });
  }

  game.trauma = Math.min(1, game.trauma + 0.12);
  paddle.flash = 1;
}

function updatePowerups(dt, canCollect) {
  var left = paddle.cx - paddle.w / 2;
  var right = paddle.cx + paddle.w / 2;

  for (var i = powerups.length - 1; i >= 0; i--) {
    var powerup = powerups[i];

    powerup.vy = Math.min(powerup.vy + 130 * dt, 330);
    powerup.y += powerup.vy * dt;
    powerup.time += dt;

    if (canCollect &&
        powerup.y + powerup.h / 2 >= paddle.y &&
        powerup.y - powerup.h / 2 <= paddle.y + paddle.h &&
        powerup.x + powerup.w / 2 >= left &&
        powerup.x - powerup.w / 2 <= right) {
      applyPowerup(powerup);
      powerups.splice(i, 1);
      continue;
    }

    if (powerup.y - powerup.h > H) powerups.splice(i, 1);
  }
}

function updateTimedEffects(dt) {
  for (var i = 0; i < TIMED_EFFECTS.length; i++) {
    var name = TIMED_EFFECTS[i];
    if (effects[name] <= 0) continue;

    effects[name] -= dt;

    if (effects[name] <= 0) {
      effects[name] = 0;
      Sound.ui();

      if (name === 'sticky') {
        for (var j = 0; j < balls.length; j++) {
          if (balls[j].stuck) releaseBall(balls[j]);
        }
      }
    }
  }
}

/* =============================== LASERS ================================ */

function updateBolts(dt) {
  paddle.cooldown = Math.max(0, paddle.cooldown - dt);

  var firing = fireHeld || keys.Space || keys.KeyW || keys.ArrowUp;

  if (effects.laser > 0 && game.state === 'play' && firing && paddle.cooldown <= 0) {
    paddle.cooldown = 0.19;

    var startY = paddle.y - 4;
    bolts.push({ x: paddle.cx - paddle.w / 2 + 9, y: startY, previousY: startY, vy: -980 });
    bolts.push({ x: paddle.cx + paddle.w / 2 - 9, y: startY, previousY: startY, vy: -980 });

    Sound.laser();
    paddle.squash = Math.max(paddle.squash, 0.45);
  }

  for (var i = bolts.length - 1; i >= 0; i--) {
    var bolt = bolts[i];

    bolt.previousY = bolt.y;
    bolt.y += bolt.vy * dt;

    if (bolt.y < FT - 6) {
      sparks(bolt.x, FT, '#ffd36b', 4, 120);
      bolts.splice(i, 1);
      continue;
    }

    var sweptTop = Math.min(bolt.y, bolt.previousY);
    var sweptBottom = Math.max(bolt.y, bolt.previousY);
    var hitBrick = null;
    var hitY = -Infinity;

    /* Bolts fly upward, so of every brick the swept segment touches, the one
       with the lowest bottom edge is the first the bolt would actually reach. */
    for (var j = 0; j < bricks.length; j++) {
      var brick = bricks[j];
      if (!brick.alive) continue;

      var drawY = brick.y + brick.offY;
      var lowerEdge = drawY + brick.h;

      if (bolt.x >= brick.x && bolt.x <= brick.x + brick.w &&
          sweptTop <= lowerEdge && sweptBottom >= drawY &&
          lowerEdge > hitY) {
        hitBrick = brick;
        hitY = lowerEdge;
      }
    }

    if (hitBrick) {
      damageBrick(hitBrick, 1, bolt.x, hitY);
      bolts.splice(i, 1);
    }
  }
}

/* =============================== PADDLE ================================ */

function updatePaddle(dt) {
  var movingLeft = keys.ArrowLeft || keys.KeyA;
  var movingRight = keys.ArrowRight || keys.KeyD;

  if (movingLeft || movingRight) pointerMode = false;

  var previousX = paddle.cx;

  if (pointerMode) {
    paddle.cx = lerp(paddle.cx, pointerX, 1 - Math.exp(-34 * dt));
  } else {
    var direction = (movingRight ? 1 : 0) - (movingLeft ? 1 : 0);
    paddle.vx = lerp(paddle.vx, direction * 820, 1 - Math.exp(-22 * dt));
    paddle.cx += paddle.vx * dt;
  }

  var targetWidth = effects.wide > 0
    ? PADDLE_WIDE
    : (effects.small > 0 ? PADDLE_SMALL : PADDLE_BASE);

  paddle.w = lerp(paddle.w, targetWidth, 1 - Math.exp(-12 * dt));
  paddle.cx = clamp(paddle.cx, FL + paddle.w / 2, FR - paddle.w / 2);
  paddle.vx = (paddle.cx - previousX) / Math.max(dt, 0.0001);

  paddle.squash = Math.max(0, paddle.squash - dt * 4);
  paddle.flash = Math.max(0, paddle.flash - dt * 3);
}

function onPaddleHit(ball) {
  game.combo = 0;
  paddle.squash = 1;
  paddle.flash = 1;

  Sound.paddle();
  sparks(ball.x, paddle.y, '#9fe8ff', 8, 220);
  shockwave(ball.x, paddle.y, 4, 46, 0.26, '#7fd8ff');

  game.trauma = Math.min(1, game.trauma + 0.07);
  ball.idle = 0;
}

/* ============================ BALL PHYSICS ============================= */

function stepBall(ball, dt) {
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;

  var wallHit = false;

  if (ball.x - ball.r < FL) {
    ball.x = FL + ball.r;
    ball.vx = Math.abs(ball.vx);
    wallHit = true;
  } else if (ball.x + ball.r > FR) {
    ball.x = FR - ball.r;
    ball.vx = -Math.abs(ball.vx);
    wallHit = true;
  }

  if (ball.y - ball.r < FT) {
    ball.y = FT + ball.r;
    ball.vy = Math.abs(ball.vy);
    wallHit = true;
  }

  if (wallHit) {
    if (game.time - lastWallSound > 0.03) {
      Sound.wall();
      lastWallSound = game.time;
    }
    sparks(ball.x, ball.y, '#9fd8ff', 3, 130);
    game.trauma = Math.min(1, game.trauma + 0.025);
  }

  if (ball.y - ball.r > H) {
    ball.dead = true;
    return;
  }

  /* --- paddle --- */
  var paddleLeft = paddle.cx - paddle.w / 2;
  var paddleRight = paddle.cx + paddle.w / 2;
  var paddleTop = paddle.y;
  var paddleBottom = paddle.y + paddle.h;

  if (ball.vy > 0 &&
      ball.y + ball.r >= paddleTop && ball.y - ball.r <= paddleBottom &&
      ball.x + ball.r >= paddleLeft && ball.x - ball.r <= paddleRight) {

    if (ball.y <= paddleTop + paddle.h * 0.85) {
      ball.y = paddleTop - ball.r - 0.01;

      var relative = clamp((ball.x - paddle.cx) / Math.max(1, paddle.w / 2), -1, 1);
      ball.speed = clamp(ball.speed * 1.012 + 2, baseSpeed() * 0.85, MAX_SPEED);

      var angle = -Math.PI / 2 + relative * MAX_BOUNCE;
      ball.vx = Math.cos(angle) * ball.speed + paddle.vx * 0.13;
      ball.vy = Math.sin(angle) * ball.speed;
      normalizeBall(ball);

      if (effects.sticky > 0 && game.state === 'play') {
        ball.stuck = true;
        ball.offset = clamp(ball.x - paddle.cx, -paddle.w / 2 + ball.r, paddle.w / 2 - ball.r);
      }

      onPaddleHit(ball);
    } else {
      var direction = ball.x < paddle.cx ? -1 : 1;

      ball.x = clamp(
        direction < 0 ? paddleLeft - ball.r - 0.01 : paddleRight + ball.r + 0.01,
        FL + ball.r, FR - ball.r
      );

      ball.vx = Math.abs(ball.vx) * direction;
      normalizeBall(ball);
      onPaddleHit(ball);
    }

    if (ball.stuck) return;
  }

  /* --- bricks (penetration-depth resolution, one brick per substep) --- */
  for (var i = 0; i < bricks.length; i++) {
    var brick = bricks[i];
    if (!brick.alive) continue;

    var drawY = brick.y + brick.offY;
    var deltaX = ball.x - (brick.x + brick.w / 2);
    var deltaY = ball.y - (drawY + brick.h / 2);

    var penX = ball.r + brick.w / 2 - Math.abs(deltaX);
    var penY = ball.r + brick.h / 2 - Math.abs(deltaY);

    if (penX > 0 && penY > 0) {
      if (penX < penY) {
        ball.vx = (deltaX < 0 ? -1 : 1) * Math.abs(ball.vx);
        ball.x += deltaX < 0 ? -penX : penX;
      } else {
        ball.vy = (deltaY < 0 ? -1 : 1) * Math.abs(ball.vy);
        ball.y += deltaY < 0 ? -penY : penY;
      }

      normalizeBall(ball);
      damageBrick(brick, 1, ball.x, ball.y);
      ball.idle = 0;
      break;
    }
  }
}

function updateBalls(dt) {
  for (var i = balls.length - 1; i >= 0; i--) {
    var ball = balls[i];

    if (ball.stuck) {
      ball.x = clamp(paddle.cx + ball.offset, FL + ball.r, FR - ball.r);
      ball.y = paddle.y - ball.r - 1;
      ball.trail.push({ x: ball.x, y: ball.y });
      if (ball.trail.length > 12) ball.trail.shift();
      continue;
    }

    normalizeBall(ball);
    ball.idle += dt;

    /* Anti-stall: nothing hit for a while, so nudge the angle and speed. */
    if (ball.idle > 9) {
      ball.idle = 0;
      ball.speed = Math.min(ball.speed + 32, MAX_SPEED);

      var angle = Math.atan2(ball.vy, ball.vx) + rand(-0.25, 0.25);
      ball.vx = Math.cos(angle) * ball.speed;
      ball.vy = Math.sin(angle) * ball.speed;
      normalizeBall(ball);

      addFloatingText('SPEED+', ball.x, ball.y - 18, '#ffd36b', 14);
    }

    var velocity = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
    var steps = clamp(Math.ceil(velocity * dt / (ball.r * 0.6)), 1, 16);
    var stepTime = dt / steps;

    for (var step = 0; step < steps; step++) {
      stepBall(ball, stepTime);
      if (ball.dead) break;
    }

    ball.trail.push({ x: ball.x, y: ball.y });
    if (ball.trail.length > 12) ball.trail.shift();

    if (ball.dead) {
      for (var p = 0; p < 14; p++) {
        spawnParticle(
          ball.x, Math.min(ball.y, H - 4),
          p % 2 ? '#ff6b7a' : '#ffd0d6',
          rand(80, 300), { size: rand(2, 5) }
        );
      }
      shockwave(ball.x, H - 6, 6, 120, 0.4, '#ff6b7a');
      balls.splice(i, 1);
    }
  }

  if (balls.length === 0 && (game.state === 'play' || game.state === 'serve')) {
    /* If the last brick and the last ball vanish on the same frame,
       award the clear rather than charging a life. */
    if (game.bricksLeft <= 0) return;
    loseLife();
  }
}

function loseLife() {
  game.lives--;
  game.combo = 0;

  Sound.lose();

  game.trauma = Math.min(1.3, game.trauma + 0.9);
  game.flash = 1;
  game.flashColor = '255,90,100';
  game.hitStop = 0.12;

  resetEffects();
  paddle.w = PADDLE_BASE;

  if (game.lives <= 0) {
    game.lives = 0;
    game.state = 'over';
    saveHighScore();
    Sound.over();
  } else {
    serveBall();
    game.state = 'serve';
    game.intro = 0.9;
  }
}

function checkLevelClear() {
  if (game.bricksLeft > 0) return;

  game.state = 'clear';
  game.timer = 2.4;

  var bonus = 1000 + game.lives * 250 + game.level * 100 + game.loop * 500;
  game.levelBonus = bonus;
  game.score += bonus;

  saveHighScore();
  Sound.clear();

  game.trauma = Math.min(1, game.trauma + 0.55);
  game.flash = 0.85;
  game.flashColor = '170,255,230';

  for (var i = 0; i < 70; i++) {
    spawnParticle(
      rand(FL, FR), rand(FT, H * 0.72),
      pick(['#5ff7e6', '#ff79dc', '#ffd36b', '#a6f759', '#ffffff']),
      rand(60, 340), { size: rand(2, 6), life: rand(0.6, 1.4) }
    );
  }

  for (var w = 0; w < 3; w++) {
    shockwave(W / 2, H * 0.44, 10 + w * 20, 620, 1, '#9ef7ff');
  }
}

/* ================================ INPUT ================================ */

function confirmAction() {
  Sound.unlock();

  if (game.error) { restartGame(); return; }

  switch (game.state) {
    case 'title':  startGame(); break;
    case 'serve':
    case 'play':   launchBalls(); break;
    case 'paused': togglePause(); break;
    case 'clear':  game.timer = Math.min(game.timer, 0.05); break;
    case 'over':   startGame(); break;
    case 'win':    nextLoop(); break;
  }
}

function togglePause() {
  if (game.state === 'play' || game.state === 'serve') {
    game.previousState = game.state;
    game.state = 'paused';
    Sound.ui();
  } else if (game.state === 'paused') {
    game.state = game.previousState || 'serve';
    Sound.ui();
  }
}

function toggleMute() {
  muted = !muted;
  Store.set('neon_breakout_muted', muted ? '1' : '0');
  if (!muted) Sound.ui();
}

function restartGame() {
  if (game.state === 'title' && !game.error) return;
  if (game.error) { game.error = null; resize(); }
  startGame();
}

window.addEventListener('keydown', function (event) {
  var code = event.code;
  var key = (event.key || '').toLowerCase();

  if (code === 'Space' || code === 'ArrowLeft' || code === 'ArrowRight' ||
      code === 'ArrowUp' || code === 'ArrowDown') {
    event.preventDefault();
  }

  keys[code] = true;
  Sound.unlock();

  if (event.repeat) return;

  if (code === 'Space' || code === 'Enter' || code === 'NumpadEnter') {
    confirmAction();
  } else if (key === 'p' || code === 'Escape') {
    togglePause();
  } else if (key === 'm') {
    toggleMute();
  } else if (key === 'r') {
    restartGame();
  }
}, { passive: false });

window.addEventListener('keyup', function (event) {
  keys[event.code] = false;
});

function pointerToLocalX(event) {
  var rect = canvas.getBoundingClientRect();
  return clamp((event.clientX - rect.left) / Math.max(1, rect.width) * W, 0, W);
}

canvas.addEventListener('pointermove', function (event) {
  pointerMode = true;
  pointerX = pointerToLocalX(event);
});

canvas.addEventListener('pointerdown', function (event) {
  event.preventDefault();

  try { canvas.focus({ preventScroll: true }); } catch (e) {
    try { canvas.focus(); } catch (e2) {}
  }
  try {
    if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId);
  } catch (e) {}

  pointerMode = true;
  pointerX = pointerToLocalX(event);
  fireHeld = true;
  confirmAction();
});

window.addEventListener('pointerup', function () { fireHeld = false; });
window.addEventListener('pointercancel', function () { fireHeld = false; });
canvas.addEventListener('contextmenu', function (event) { event.preventDefault(); });

window.addEventListener('blur', function () {
  fireHeld = false;
  for (var key in keys) keys[key] = false;
  if (game.state === 'play' || game.state === 'serve') togglePause();
});

document.addEventListener('visibilitychange', function () {
  if (document.hidden && (game.state === 'play' || game.state === 'serve')) togglePause();
});

/* =============================== UPDATE ================================ */

function updateParticles(dt) {
  for (var i = particles.length - 1; i >= 0; i--) {
    var particle = particles[i];
    particle.life -= dt;

    if (particle.life <= 0) { particles.splice(i, 1); continue; }

    particle.vy += particle.gravity * dt;

    var drag = Math.pow(particle.drag, dt * 60);
    particle.vx *= drag;
    particle.vy *= drag;

    particle.x += particle.vx * dt;
    particle.y += particle.vy * dt;
    particle.rotation += particle.rotationSpeed * dt;

    if (particle.y > H + 30) particles.splice(i, 1);
  }
}

function updateFloatingTexts(dt) {
  for (var i = floatingTexts.length - 1; i >= 0; i--) {
    var text = floatingTexts[i];
    text.life -= dt;
    text.y += text.vy * dt;
    text.vy *= Math.pow(0.9, dt * 60);
    if (text.life <= 0) floatingTexts.splice(i, 1);
  }
}

function updateWaves(dt) {
  for (var i = waves.length - 1; i >= 0; i--) {
    waves[i].life -= dt;
    if (waves[i].life <= 0) waves.splice(i, 1);
  }
}

function updateBrickAnimations(dt) {
  for (var i = 0; i < bricks.length; i++) {
    var brick = bricks[i];

    brick.flash = Math.max(0, brick.flash - dt * 4.5);
    brick.shake = Math.max(0, brick.shake - dt * 5);

    if (brick.offY !== 0) {
      brick.offY = lerp(brick.offY, 0, 1 - Math.exp(-9 * dt));
      if (Math.abs(brick.offY) < 0.4) brick.offY = 0;
    }
  }
}

function updateTitleBackground(dt) {
  demoBall.x += demoBall.vx * dt;
  demoBall.y += demoBall.vy * dt;

  if (demoBall.x - demoBall.r < FL) { demoBall.x = FL + demoBall.r; demoBall.vx = Math.abs(demoBall.vx); }
  if (demoBall.x + demoBall.r > FR) { demoBall.x = FR - demoBall.r; demoBall.vx = -Math.abs(demoBall.vx); }
  if (demoBall.y - demoBall.r < FT) { demoBall.y = FT + demoBall.r; demoBall.vy = Math.abs(demoBall.vy); }
  if (demoBall.y + demoBall.r > H - 20) { demoBall.y = H - 20 - demoBall.r; demoBall.vy = -Math.abs(demoBall.vy); }

  demoBall.trail.push({ x: demoBall.x, y: demoBall.y });
  if (demoBall.trail.length > 14) demoBall.trail.shift();

  if (Math.random() < 0.5) {
    spawnParticle(rand(FL, FR), H - 10, pick(['#5ff7e6', '#4ad7ff', '#ff79dc']), rand(20, 60), {
      gravity: -40, life: rand(0.8, 1.6), size: rand(1.5, 3), shape: 'spark', drag: 0.99
    });
  }
}

function update(dt) {
  game.time += dt;
  game.trauma = Math.max(0, game.trauma - dt * 1.6);
  game.flash = Math.max(0, game.flash - dt * 3.1);

  updateParticles(dt);
  updateFloatingTexts(dt);
  updateWaves(dt);
  updateBrickAnimations(dt);

  if (game.state === 'title') {
    updatePaddle(dt);
    updateTitleBackground(dt);
    return;
  }

  if (game.state === 'paused') return;

  if (game.state === 'over' || game.state === 'win') {
    updatePowerups(dt, false);
    return;
  }

  if (game.state === 'clear') {
    updatePowerups(dt, false);
    game.timer -= dt;
    if (game.timer <= 0) advanceLevel();
    return;
  }

  if (game.intro > 0) game.intro = Math.max(0, game.intro - dt);

  if (game.hitStop > 0) { game.hitStop -= dt; return; }

  updatePaddle(dt);
  updateTimedEffects(dt);
  updateBalls(dt);

  if (game.state !== 'play' && game.state !== 'serve') return;

  updatePowerups(dt, true);
  updateBolts(dt);
  checkLevelClear();
}

/* =============================== RENDER ================================ */

var backgroundGradient = null;
var vignetteGradient = null;

function makeGradients() {
  backgroundGradient = ctx.createLinearGradient(0, 0, 0, H);
  backgroundGradient.addColorStop(0, '#0b1130');
  backgroundGradient.addColorStop(0.55, '#070a1c');
  backgroundGradient.addColorStop(1, '#04060f');

  vignetteGradient = ctx.createRadialGradient(W / 2, H * 0.42, 80, W / 2, H * 0.5, H * 0.95);
  vignetteGradient.addColorStop(0, 'rgba(0,0,0,0)');
  vignetteGradient.addColorStop(1, 'rgba(0,0,0,0.65)');
}

function drawText(text, x, y, options) {
  options = options || {};

  ctx.save();
  ctx.font = (options.weight || '700') + ' ' + (options.size || 16) + 'px ' +
    (options.mono ? FONT_MONO : FONT_DISPLAY);
  ctx.textBaseline = options.baseline || 'alphabetic';
  ctx.globalAlpha = options.alpha == null ? 1 : options.alpha;
  ctx.fillStyle = options.color || '#eaf6ff';

  if (options.glow) {
    ctx.shadowColor = options.glow;
    ctx.shadowBlur = options.blur || 14;
  }

  if (options.letter) {
    ctx.textAlign = 'left';

    var characters = String(text).split('');
    var totalWidth = 0;
    var i;

    for (i = 0; i < characters.length; i++) {
      totalWidth += ctx.measureText(characters[i]).width + options.letter;
    }
    totalWidth -= options.letter;

    var cursor = options.align === 'center'
      ? x - totalWidth / 2
      : (options.align === 'right' ? x - totalWidth : x);

    for (i = 0; i < characters.length; i++) {
      ctx.fillText(characters[i], cursor, y);
      cursor += ctx.measureText(characters[i]).width + options.letter;
    }
  } else {
    ctx.textAlign = options.align || 'left';
    ctx.fillText(String(text), x, y);
  }

  ctx.restore();
}

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

  ctx.save();
  ctx.beginPath();
  ctx.rect(FL, FT, FR - FL, H - FT);
  ctx.clip();

  ctx.strokeStyle = 'rgba(90,160,255,0.07)';
  ctx.lineWidth = 1;

  var offset = (game.time * 22) % 44;
  var x, y;

  for (x = FL - 44 + offset; x < FR + 44; x += 44) {
    ctx.beginPath();
    ctx.moveTo(x, FT);
    ctx.lineTo(x, H);
    ctx.stroke();
  }

  for (y = FT + offset; y < H; y += 44) {
    ctx.beginPath();
    ctx.moveTo(FL, y);
    ctx.lineTo(FR, y);
    ctx.stroke();
  }

  var pulse = 0.05 + Math.sin(game.time * 1.6) * 0.02;
  ctx.fillStyle = 'rgba(80,150,255,' + pulse + ')';
  ctx.fillRect(FL, FT, FR - FL, H - FT);

  ctx.restore();

  ctx.fillStyle = vignetteGradient;
  ctx.fillRect(0, 0, W, H);
}

function drawField() {
  ctx.save();
  ctx.strokeStyle = 'rgba(120,200,255,.35)';
  ctx.lineWidth = 2;
  ctx.shadowColor = 'rgba(90,170,255,.8)';
  ctx.shadowBlur = 16;

  ctx.beginPath();
  ctx.moveTo(FL - 1, H);
  ctx.lineTo(FL - 1, FT - 1);
  ctx.lineTo(FR + 1, FT - 1);
  ctx.lineTo(FR + 1, H);
  ctx.stroke();
  ctx.restore();

  var danger = ctx.createLinearGradient(0, H - 70, 0, H);
  danger.addColorStop(0, 'rgba(255,80,110,0)');
  danger.addColorStop(1, 'rgba(255,70,100,.16)');
  ctx.fillStyle = danger;
  ctx.fillRect(FL, H - 70, FR - FL, 70);
}

function drawBricks(dimmed) {
  for (var i = 0; i < bricks.length; i++) {
    var brick = bricks[i];
    if (!brick.alive) continue;

    var shakeX = brick.shake > 0 ? rand(-2, 2) * brick.shake : 0;
    var shakeY = brick.shake > 0 ? rand(-2, 2) * brick.shake : 0;
    var x = brick.x + shakeX;
    var y = brick.y + brick.offY + shakeY;
    var spriteKey = brick.steel ? 'S' : String(clamp(brick.hp, 1, 4));

    ctx.save();
    if (dimmed) ctx.globalAlpha = 0.32;

    ctx.drawImage(brickSprites[spriteKey], x, y, brick.w, brick.h);

    if (!brick.steel && brick.hp < brick.maxHp) {
      ctx.globalAlpha = (dimmed ? 0.32 : 1) * (1 - brick.hp / brick.maxHp) * 0.3;
      roundedRect(ctx, x, y, brick.w, brick.h, 6);
      ctx.fillStyle = '#000000';
      ctx.fill();
      ctx.globalAlpha = dimmed ? 0.32 : 1;
    }

    if (brick.flash > 0) {
      ctx.globalCompositeOperation = 'lighter';
      ctx.globalAlpha = brick.flash * 0.75 * (dimmed ? 0.4 : 1);
      roundedRect(ctx, x, y, brick.w, brick.h, 6);
      ctx.fillStyle = '#ffffff';
      ctx.fill();
    }

    ctx.restore();
  }
}

function drawPaddle() {
  var width = paddle.w * (1 + paddle.squash * 0.1);
  var height = paddle.h * (1 - paddle.squash * 0.28);
  var x = paddle.cx - width / 2;
  var y = paddle.y + (paddle.h - height) / 2;

  ctx.save();
  ctx.shadowColor = 'rgba(90,200,255,.85)';
  ctx.shadowBlur = 18 + paddle.flash * 22;

  var gradient = ctx.createLinearGradient(0, y, 0, y + height);
  gradient.addColorStop(0, '#ddf6ff');
  gradient.addColorStop(0.45, '#51c6ff');
  gradient.addColorStop(1, '#1763b8');

  roundedRect(ctx, x, y, width, height, height / 2);
  ctx.fillStyle = gradient;
  ctx.fill();

  ctx.shadowBlur = 0;

  roundedRect(ctx, x + 3, y + 2.5, width - 6, height * 0.34, height * 0.2);
  ctx.fillStyle = 'rgba(255,255,255,.5)';
  ctx.fill();

  if (effects.sticky > 0) {
    ctx.fillStyle = 'rgba(201,163,255,.95)';
    for (var dot = -width / 2 + 8; dot < width / 2 - 6; dot += 11) {
      ctx.beginPath();
      ctx.arc(paddle.cx + dot, y + 2.5, 1.8, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  if (effects.laser > 0) {
    ctx.fillStyle = '#ffd36b';
    ctx.shadowColor = 'rgba(255,210,100,.9)';
    ctx.shadowBlur = 12;

    var cannons = [paddle.cx - width / 2 + 9, paddle.cx + width / 2 - 9];
    for (var i = 0; i < cannons.length; i++) {
      ctx.beginPath();
      ctx.moveTo(cannons[i] - 4, y);
      ctx.lineTo(cannons[i] + 4, y);
      ctx.lineTo(cannons[i], y - 9);
      ctx.closePath();
      ctx.fill();
    }

    ctx.shadowBlur = 0;
  }

  if (paddle.flash > 0) {
    ctx.globalCompositeOperation = 'lighter';
    ctx.globalAlpha = paddle.flash * 0.6;
    roundedRect(ctx, x, y, width, height, height / 2);
    ctx.fillStyle = '#ffffff';
    ctx.fill();
  }

  ctx.restore();
}

function drawBallShape(x, y, radius, lightColor, darkColor) {
  var gradient = ctx.createRadialGradient(
    x - radius * 0.35, y - radius * 0.35, radius * 0.15, x, y, radius
  );
  gradient.addColorStop(0, '#ffffff');
  gradient.addColorStop(0.45, lightColor);
  gradient.addColorStop(1, darkColor);

  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fillStyle = gradient;
  ctx.fill();
}

function drawBalls(list, trailColor) {
  ctx.save();

  for (var i = 0; i < list.length; i++) {
    var ball = list[i];

    for (var j = 0; j < ball.trail.length; j++) {
      var amount = (j + 1) / ball.trail.length;
      ctx.globalAlpha = amount * 0.33;
      ctx.beginPath();
      ctx.arc(ball.trail[j].x, ball.trail[j].y, ball.r * amount * 0.95, 0, Math.PI * 2);
      ctx.fillStyle = trailColor || '#7fe9ff';
      ctx.fill();
    }

    ctx.globalAlpha = 1;
    ctx.shadowColor = 'rgba(120,230,255,.95)';
    ctx.shadowBlur = 18;
    drawBallShape(ball.x, ball.y, ball.r, '#9ff2ff', '#2aa7e0');
    ctx.shadowBlur = 0;
  }

  ctx.restore();
}

function drawBolts() {
  ctx.save();
  ctx.shadowColor = 'rgba(255,210,100,.9)';
  ctx.shadowBlur = 14;

  for (var i = 0; i < bolts.length; i++) {
    var bolt = bolts[i];
    var gradient = ctx.createLinearGradient(0, bolt.y - 14, 0, bolt.y + 6);
    gradient.addColorStop(0, 'rgba(255,230,160,0)');
    gradient.addColorStop(0.5, '#fff3c4');
    gradient.addColorStop(1, '#ff9f43');
    ctx.fillStyle = gradient;
    ctx.fillRect(bolt.x - 2, bolt.y - 14, 4, 20);
  }

  ctx.restore();
}

function drawPowerups() {
  for (var i = 0; i < powerups.length; i++) {
    var powerup = powerups[i];
    var definition = powerup.definition;
    var bob = Math.sin(powerup.time * 7) * 2;
    var scale = 1 + Math.sin(powerup.time * 9) * 0.05;

    ctx.save();
    ctx.translate(powerup.x, powerup.y + bob);
    ctx.scale(scale, scale);

    ctx.shadowColor = definition.color;
    ctx.shadowBlur = 18;

    roundedRect(ctx, -15, -11, 30, 22, 8);

    var gradient = ctx.createLinearGradient(0, -11, 0, 11);
    gradient.addColorStop(0, 'rgba(255,255,255,.95)');
    gradient.addColorStop(0.35, definition.color);
    gradient.addColorStop(1, 'rgba(0,0,0,.45)');

    ctx.fillStyle = gradient;
    ctx.fill();

    ctx.shadowBlur = 0;
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = 'rgba(255,255,255,.75)';
    ctx.stroke();

    ctx.fillStyle = '#07101f';
    ctx.font = '900 15px ' + FONT_DISPLAY;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(definition.icon, 0, 1);

    ctx.restore();
  }
}

function drawParticles() {
  ctx.save();

  for (var i = 0; i < particles.length; i++) {
    var particle = particles[i];
    ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
    ctx.fillStyle = particle.color;

    if (particle.shape === 'spark') {
      ctx.fillRect(
        particle.x - particle.size / 2, particle.y - particle.size / 2,
        particle.size, particle.size
      );
    } else {
      ctx.save();
      ctx.translate(particle.x, particle.y);
      ctx.rotate(particle.rotation);
      ctx.fillRect(-particle.size / 2, -particle.size / 2, particle.size, particle.size * 0.85);
      ctx.restore();
    }
  }

  ctx.restore();
}

function drawWaves() {
  ctx.save();

  for (var i = 0; i < waves.length; i++) {
    var wave = waves[i];
    var progress = 1 - wave.life / wave.maxLife;
    var radius = lerp(wave.radius, wave.maxRadius, 1 - Math.pow(1 - progress, 2));

    ctx.globalAlpha = (1 - progress) * 0.6;
    ctx.strokeStyle = wave.color;
    ctx.lineWidth = 1 + 3 * (1 - progress);

    ctx.beginPath();
    ctx.arc(wave.x, wave.y, radius, 0, Math.PI * 2);
    ctx.stroke();
  }

  ctx.restore();
}

function drawFloatingTexts() {
  for (var i = 0; i < floatingTexts.length; i++) {
    var text = floatingTexts[i];
    drawText(text.text, text.x, text.y, {
      size: text.size,
      align: 'center',
      color: text.color,
      alpha: clamp(text.life / text.maxLife, 0, 1),
      glow: text.color,
      blur: 10,
      weight: '900'
    });
  }
}

function drawEffectIndicators() {
  var x = FL + 4;
  var y = H - 36;

  for (var i = 0; i < TIMED_EFFECTS.length; i++) {
    var name = TIMED_EFFECTS[i];
    if (effects[name] <= 0) continue;

    var definition = POWER_BY_KEY[name];
    if (!definition) continue;

    var width = 92;
    var remaining = effects[name];
    var fraction = clamp(remaining / Math.max(1, definition.duration), 0, 1);

    ctx.save();

    roundedRect(ctx, x, y, width, 20, 10);
    ctx.fillStyle = 'rgba(8,14,32,.8)';
    ctx.fill();

    ctx.strokeStyle = definition.color;
    ctx.globalAlpha = 0.85;
    ctx.lineWidth = 1.2;
    ctx.stroke();
    ctx.globalAlpha = 1;

    roundedRect(ctx, x + 2, y + 15, (width - 4) * fraction, 3, 1.5);
    ctx.fillStyle = definition.color;
    ctx.fill();

    ctx.restore();

    drawText(name.toUpperCase() + ' ' + remaining.toFixed(1), x + width / 2, y + 13, {
      size: 11, align: 'center', color: definition.color, mono: true, weight: '700'
    });

    x += width + 8;
  }
}

function drawHUD() {
  ctx.save();

  var gradient = ctx.createLinearGradient(0, 0, 0, HUD_H);
  gradient.addColorStop(0, 'rgba(9,15,38,.96)');
  gradient.addColorStop(1, 'rgba(9,15,38,.55)');
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, W, HUD_H);

  ctx.strokeStyle = 'rgba(120,200,255,.3)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, HUD_H + 0.5);
  ctx.lineTo(W, HUD_H + 0.5);
  ctx.stroke();

  ctx.restore();

  drawText('SCORE', 24, 24, { size: 11, color: '#6f9fd0', letter: 2, weight: '700' });
  drawText(pad(game.score, 6), 24, 52, {
    size: 26, color: '#eaf6ff', mono: true, weight: '700',
    glow: 'rgba(120,200,255,.6)', blur: 10
  });

  drawText('HIGH', 196, 24, { size: 11, color: '#6f9fd0', letter: 2, weight: '700' });
  drawText(pad(Math.max(highScore, game.score), 6), 196, 52, {
    size: 20, color: '#8fd3ff', mono: true, weight: '700'
  });

  var level = LEVELS[game.level % LEVELS.length];
  var levelTitle = 'LEVEL ' + (game.level + 1) + '/' + LEVELS.length +
    (game.loop > 0 ? ('  ' + BULLET + '  LOOP ' + (game.loop + 1)) : '');

  drawText(levelTitle, W / 2, 24, {
    size: 11, color: '#6f9fd0', align: 'center', letter: 2, weight: '700'
  });
  drawText(level.name, W / 2, 46, {
    size: 19, color: '#ffffff', align: 'center', weight: '900',
    glow: 'rgba(120,200,255,.5)', blur: 10
  });

  var barWidth = 230;
  var barX = W / 2 - barWidth / 2;
  var barY = 55;
  var progress = 1 - clamp(game.bricksLeft / game.bricksTotal, 0, 1);

  ctx.save();

  roundedRect(ctx, barX, barY, barWidth, 5, 2.5);
  ctx.fillStyle = 'rgba(255,255,255,.12)';
  ctx.fill();

  var barGradient = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);
  barGradient.addColorStop(0, '#5ff7e6');
  barGradient.addColorStop(1, '#ff79dc');

  roundedRect(ctx, barX, barY, Math.max(2, barWidth * progress), 5, 2.5);
  ctx.fillStyle = barGradient;
  ctx.fill();

  ctx.restore();

  var livesX = W - 24;
  drawText('LIVES', livesX, 24, {
    size: 11, color: '#6f9fd0', align: 'right', letter: 2, weight: '700'
  });

  ctx.save();
  for (var i = 0; i < game.lives; i++) {
    ctx.shadowColor = 'rgba(120,230,255,.9)';
    ctx.shadowBlur = 10;
    drawBallShape(livesX - 9 - i * 22, 44, 7, '#9ff2ff', '#2aa7e0');
  }
  ctx.restore();

  if (game.lives === 0) {
    drawText('--', livesX, 50, { size: 18, align: 'right', color: '#ff6b7a', mono: true });
  }

  if (game.combo >= 2) {
    var multiplier = Math.min(1 + 0.1 * (game.combo - 1), 3);
    var pulse = 1 + Math.sin(game.time * 14) * 0.06;

    ctx.save();
    ctx.translate(396, 46);
    ctx.scale(pulse, pulse);
    drawText('x' + multiplier.toFixed(1) + ' COMBO', 0, 0, {
      size: 17, color: '#ffd36b', weight: '900',
      glow: 'rgba(255,200,80,.8)', blur: 14, mono: true
    });
    ctx.restore();
  }

  if (muted) {
    drawText('MUTED (M)', W - 24, HUD_H - 4, {
      size: 10, align: 'right', color: '#ff8a3d', mono: true
    });
  }
}

function drawHints() {
  drawText(
    ARROW_L + ' ' + ARROW_R + ' / A D / MOUSE  ' + BULLET +
    '  SPACE or CLICK: launch & fire  ' + BULLET +
    '  P pause  ' + BULLET + '  M mute  ' + BULLET + '  R restart',
    W / 2, H - 10,
    { size: 11, align: 'center', color: 'rgba(150,190,235,.55)', mono: true, weight: '400' }
  );
}

function drawPanel(x, y, width, height) {
  ctx.save();
  roundedRect(ctx, x, y, width, height, 18);
  ctx.fillStyle = 'rgba(6,10,26,.9)';
  ctx.fill();
  ctx.lineWidth = 1.5;
  ctx.strokeStyle = 'rgba(130,210,255,.45)';
  ctx.shadowColor = 'rgba(80,170,255,.55)';
  ctx.shadowBlur = 26;
  ctx.stroke();
  ctx.restore();
}

function drawBigTitle(text, y, size) {
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'alphabetic';
  ctx.font = '900 ' + size + 'px ' + FONT_DISPLAY;

  var gradient = ctx.createLinearGradient(0, y - size * 0.8, 0, y + size * 0.15);
  gradient.addColorStop(0, '#aefcff');
  gradient.addColorStop(0.5, '#59b6ff');
  gradient.addColorStop(1, '#ff79dc');

  ctx.fillStyle = gradient;
  ctx.shadowColor = 'rgba(90,180,255,.9)';
  ctx.shadowBlur = 34;
  ctx.fillText(text, W / 2, y);
  ctx.restore();
}

function drawOverlays() {
  var pulse = 0.55 + 0.45 * Math.sin(game.time * 4);
  var i;

  if (game.state === 'title') {
    drawPanel(W / 2 - 320, 118, 640, 430);
    drawBigTitle('NEON BREAKOUT', 212, 62);

    drawText(
      LEVELS.length + ' LEVELS  ' + BULLET + '  POWER-UPS  ' + BULLET + '  COMBO SCORING',
      W / 2, 244,
      { size: 13, align: 'center', color: '#8fd3ff', mono: true, letter: 1 }
    );

    var rows = [
      ['MOVE', ARROW_L + ' ' + ARROW_R + '   or   A D   or   MOUSE'],
      ['LAUNCH / FIRE', 'SPACE  or  CLICK'],
      ['PAUSE', 'P  or  ESC'],
      ['MUTE / RESTART', 'M  /  R']
    ];

    for (i = 0; i < rows.length; i++) {
      drawText(rows[i][0], W / 2 - 250, 300 + i * 34, {
        size: 13, color: '#6f9fd0', mono: true
      });
      drawText(rows[i][1], W / 2 + 250, 300 + i * 34, {
        size: 14, color: '#eaf6ff', align: 'right', mono: true
      });
    }

    drawText('HIGH SCORE  ' + pad(highScore, 6), W / 2, 462, {
      size: 16, align: 'center', color: '#ffd36b', mono: true, glow: 'rgba(255,200,80,.5)'
    });

    drawText('PRESS SPACE OR CLICK TO PLAY', W / 2, 514, {
      size: 19, align: 'center', color: '#ffffff', weight: '900',
      alpha: pulse, glow: 'rgba(140,220,255,.9)', blur: 18
    });

  } else if (game.state === 'paused') {
    ctx.fillStyle = 'rgba(3,6,16,.68)';
    ctx.fillRect(0, 0, W, H);

    drawPanel(W / 2 - 220, H / 2 - 130, 440, 260);
    drawBigTitle('PAUSED', H / 2 - 50, 52);

    drawText('P / ESC / CLICK  to resume', W / 2, H / 2 + 6, {
      size: 15, align: 'center', color: '#8fd3ff', mono: true
    });
    drawText('M  mute  ' + BULLET + '  R  restart', W / 2, H / 2 + 38, {
      size: 13, align: 'center', color: '#6f9fd0', mono: true
    });
    drawText('SCORE ' + pad(game.score, 6), W / 2, H / 2 + 84, {
      size: 16, align: 'center', color: '#eaf6ff', mono: true
    });

  } else if (game.state === 'clear') {
    drawPanel(W / 2 - 250, H / 2 - 130, 500, 260);
    drawBigTitle('LEVEL CLEAR', H / 2 - 52, 46);

    drawText('BONUS  +' + game.levelBonus, W / 2, H / 2 + 4, {
      size: 22, align: 'center', color: '#5ff7e6', mono: true, weight: '900',
      glow: 'rgba(90,250,230,.7)', blur: 16
    });
    drawText('SCORE  ' + pad(game.score, 6), W / 2, H / 2 + 42, {
      size: 16, align: 'center', color: '#eaf6ff', mono: true
    });
    drawText('BEST COMBO  x' + game.bestCombo, W / 2, H / 2 + 68, {
      size: 13, align: 'center', color: '#ffd36b', mono: true
    });
    drawText(
      'NEXT LEVEL IN ' + Math.max(0, game.timer).toFixed(1) + 's  (SPACE to skip)',
      W / 2, H / 2 + 104,
      { size: 12, align: 'center', color: '#6f9fd0', mono: true, alpha: pulse }
    );

  } else if (game.state === 'over') {
    ctx.fillStyle = 'rgba(30,4,10,.6)';
    ctx.fillRect(0, 0, W, H);

    drawPanel(W / 2 - 260, H / 2 - 150, 520, 300);
    drawBigTitle('GAME OVER', H / 2 - 62, 54);

    drawText('FINAL SCORE  ' + pad(game.score, 6), W / 2, H / 2 - 4, {
      size: 22, align: 'center', color: '#ffffff', mono: true, weight: '900'
    });
    drawText('HIGH SCORE  ' + pad(highScore, 6), W / 2, H / 2 + 30, {
      size: 15, align: 'center', color: '#8fd3ff', mono: true
    });
    drawText(
      'REACHED LEVEL ' + (game.level + 1) + '  ' + BULLET + '  BEST COMBO x' + game.bestCombo,
      W / 2, H / 2 + 56,
      { size: 13, align: 'center', color: '#6f9fd0', mono: true }
    );

    if (game.newRecord) {
      drawText(STAR + ' NEW RECORD ' + STAR, W / 2, H / 2 + 90, {
        size: 17, align: 'center', color: '#ffd36b', weight: '900',
        alpha: pulse, glow: 'rgba(255,200,80,.9)', blur: 16
      });
    }

    drawText('SPACE OR CLICK TO PLAY AGAIN', W / 2, H / 2 + 124, {
      size: 16, align: 'center', color: '#ffffff', alpha: pulse, weight: '900'
    });

  } else if (game.state === 'win') {
    drawPanel(W / 2 - 290, H / 2 - 160, 580, 320);
    drawBigTitle('YOU WIN!', H / 2 - 74, 58);

    drawText('ALL ' + LEVELS.length + ' LEVELS CLEARED', W / 2, H / 2 - 28, {
      size: 16, align: 'center', color: '#5ff7e6', mono: true, letter: 1
    });
    drawText('SCORE  ' + pad(game.score, 6), W / 2, H / 2 + 12, {
      size: 24, align: 'center', color: '#ffffff', mono: true, weight: '900',
      glow: 'rgba(140,220,255,.7)', blur: 16
    });
    drawText(
      'HIGH  ' + pad(highScore, 6) + '   BEST COMBO  x' + game.bestCombo,
      W / 2, H / 2 + 44,
      { size: 13, align: 'center', color: '#8fd3ff', mono: true }
    );
    drawText(
      'SPACE: LOOP ' + (game.loop + 2) + ' (faster, score kept)',
      W / 2, H / 2 + 88,
      { size: 15, align: 'center', color: '#ffd36b', mono: true, alpha: pulse }
    );
    drawText('R: fresh game', W / 2, H / 2 + 114, {
      size: 13, align: 'center', color: '#6f9fd0', mono: true
    });

  } else {
    if (game.intro > 0 && game.state === 'serve') {
      var alpha = clamp(game.intro / 0.6, 0, 1);
      var introLevel = LEVELS[game.level % LEVELS.length];

      ctx.save();
      ctx.globalAlpha = alpha;
      drawBigTitle('LEVEL ' + (game.level + 1), H * 0.42, 56);
      drawText(introLevel.name, W / 2, H * 0.42 + 38, {
        size: 20, align: 'center', color: '#8fd3ff', mono: true, letter: 3, alpha: alpha
      });
      ctx.restore();
    }

    var hasHeldBall = false;
    for (i = 0; i < balls.length; i++) {
      if (balls[i].stuck) { hasHeldBall = true; break; }
    }

    if (hasHeldBall && game.intro <= 0.9) {
      drawText('PRESS SPACE OR CLICK TO LAUNCH', W / 2, paddle.y - 52, {
        size: 15, align: 'center', color: '#ffffff', alpha: pulse,
        weight: '900', glow: 'rgba(140,220,255,.8)', blur: 14
      });
    }
  }
}

function render() {
  ctx.setTransform(canvas.width / W, 0, 0, canvas.height / H, 0, 0);

  drawBackground();

  ctx.save();

  var shake = game.trauma * game.trauma;
  if (shake > 0.001) {
    ctx.translate((Math.random() * 2 - 1) * 15 * shake, (Math.random() * 2 - 1) * 15 * shake);
  }

  drawField();
  drawBricks(game.state === 'title');
  drawWaves();
  drawPowerups();

  if (game.state === 'title') {
    drawBalls([demoBall], '#7fe9ff');
    drawPaddle();
  } else {
    drawPaddle();
    drawBolts();
    drawBalls(balls, '#7fe9ff');
  }

  drawParticles();
  drawFloatingTexts();

  ctx.restore();

  drawHUD();
  if (game.state !== 'title') drawEffectIndicators();
  drawHints();
  drawOverlays();

  if (game.flash > 0.001) {
    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    ctx.fillStyle = 'rgba(' + game.flashColor + ',' + (game.flash * 0.33) + ')';
    ctx.fillRect(0, 0, W, H);
    ctx.restore();
  }
}

function drawErrorScreen() {
  ctx.setTransform(canvas.width / W, 0, 0, canvas.height / H, 0, 0);
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = 'source-over';
  ctx.shadowBlur = 0;

  ctx.fillStyle = 'rgba(4,6,16,.94)';
  ctx.fillRect(0, 0, W, H);

  drawText('SOMETHING WENT WRONG', W / 2, H / 2 - 26, {
    size: 20, align: 'center', color: '#ff8a8a', weight: '900'
  });
  drawText(String(game.error).slice(0, 72), W / 2, H / 2 + 4, {
    size: 13, align: 'center', color: '#ffd0d6', mono: true
  });
  drawText('Press R or click to restart', W / 2, H / 2 + 40, {
    size: 14, align: 'center', color: '#8fd3ff', mono: true
  });
}

/* ================================= BOOT ================================ */

function resize() {
  var dpr = Math.min(window.devicePixelRatio || 1, 2);
  var availableWidth = Math.max(1, window.innerWidth - 20);
  var availableHeight = Math.max(1, window.innerHeight - 20);
  var scale = Math.min(availableWidth / W, availableHeight / H);

  canvas.style.width = (W * scale) + 'px';
  canvas.style.height = (H * scale) + 'px';
  canvas.width = Math.max(1, Math.round(W * scale * dpr));
  canvas.height = Math.max(1, Math.round(H * scale * dpr));

  ctx.setTransform(canvas.width / W, 0, 0, canvas.height / H, 0, 0);
  ctx.imageSmoothingEnabled = true;

  makeGradients();
}

window.addEventListener('resize', resize);
window.addEventListener('orientationchange', function () { setTimeout(resize, 150); });

resize();
loadLevel(0);

for (var initial = 0; initial < bricks.length; initial++) {
  bricks[initial].offY = 0;
}

game.state = 'title';

var lastTime = (window.performance && performance.now) ? performance.now() : Date.now();

function frame(now) {
  var dt = clamp((now - lastTime) / 1000, 0, MAX_DT);
  lastTime = now;

  if (game.error) {
    try { drawErrorScreen(); } catch (e) {}
  } else {
    try {
      update(dt);
      render();
    } catch (error) {
      game.error = (error && error.message) ? error.message : String(error);
      try { drawErrorScreen(); } catch (e) {}
    }
  }

  var hidePointer = pointerMode && (game.state === 'play' || game.state === 'serve');
  canvas.style.cursor = hidePointer ? 'none' : 'default';

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

})();
</script>
</body>
</html>

Compare with

Fugu Ultra v2: other attempts

  • Fugu Ultra v2, Pelican: Not run yet
  • Fugu Ultra v2, Watch: Not run yet
  • Fugu Ultra v2, Landing: Not run yet
  • Fugu Ultra v2, Logo: Not run yet
  • Fugu Ultra v2, About: Not run yet