$sloprun.dev

Breakout

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

It is already playing when you arrive — a small auto-player keeps the ball alive until you move the mouse or press an arrow key, and from that moment the wall is yours. Where the ball lands on the paddle decides where it leaves: the edges throw it out at up to sixty degrees, the middle sends it back near-vertical, and a paddle still moving on contact drags the ball along with it, which is most of the skill in the game. Bricks take one to four hits and each one fades a shade toward the field as it takes damage, so you can always read how close one is to going. Try this first: take over, then break three bricks before the ball comes back down to the paddle — the combo starts multiplying, twelve without a return puts you at ×5, and the ball speed readout creeps up the whole time, resetting when you lose a ball and starting nine percent higher on every new wall.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/breakout" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Breakout — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/breakout

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1274 lines 54.3 KB index.html
demos/breakout/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Breakout — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.90); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.90); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.90); --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1080px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede kbd {
  font-family: var(--font-mono); font-size: .84em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px; border: 0;
}

.stage { display: grid; grid-template-columns: minmax(0,1fr) 274px; gap: 16px; align-items: start; }
@media (max-width: 880px) { .stage { grid-template-columns: minmax(0,1fr); } }

/* the field — sized from its own width, never stretched to the panel's height,
   and capped so the paddle never sits below the fold */
.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
  aspect-ratio: 4 / 3;
  max-height: calc(100svh - 180px);
  min-height: 320px;
}
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: default; }
.field.aiming canvas { cursor: none; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

/* chip over the field */
.hint {
  position: absolute; left: 10px; bottom: 44px; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .3s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
@media (max-width: 480px) { .hint { font-size: 10px; padding: 4px 8px; left: 8px; bottom: 34px; } }

/* floating score pops */
.pops { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }

/* overlay */
.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: var(--scrim); padding: 16px; text-align: center;
}
.overlay[hidden] { display: none; }
.overlay .box { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 36ch; }
.overlay h2 { margin: 0; font-size: clamp(19px, 4.4vw, 25px); letter-spacing: -.02em; line-height: 1.1; }
.overlay p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--muted); }
.overlay .keyhint { font-family: var(--font-mono); font-size: 11px; letter-spacing: .03em; }
.overlay .final { font-family: var(--font-mono); font-size: 13px; color: var(--ink); font-variant-numeric: tabular-nums; }
.tag {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; color: var(--muted); background: var(--bg);
}
.tag.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.tag.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); }

/* panel */
.panel { display: flex; flex-direction: column; gap: 12px; }
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}

.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 11px 8px; }
@media (max-width: 880px) { .stats { grid-template-columns: repeat(3, minmax(0,1fr)); } }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 19px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
  white-space: nowrap; overflow: hidden; text-overflow: clip;
}
.stat .v.sm { font-size: 15px; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}
.stat .v.good { color: var(--run); }
.stat .v.hot { color: var(--accent); }
.stat .v .off { color: var(--muted); opacity: .38; }

.bar {
  height: 6px; border-radius: 3px; background: var(--line); margin-top: 12px; overflow: hidden;
}
.bar > i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 3px; transition: width .18s ease; }
.bar.done > i { background: var(--run); }
.barrow { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 9px; }
.barrow .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.barrow .num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }

.pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 12px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  padding: 3px 9px 3px 7px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.ok .dot { background: var(--run); }
.pill.bad { color: var(--danger); border-color: var(--danger); background: var(--surface); }
.pill.bad .dot { background: var(--danger); }
.pill.live { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.live .dot { background: var(--accent); }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.big { font-size: 14px; padding: 9px 18px; }

.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.modes { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
.modes button { padding: 7px 4px; font-size: 12.5px; }
.modes button[aria-pressed="true"] {
  background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600;
}

.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; margin-top: 11px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
  width: 34px; height: 19px; border-radius: 999px; background: var(--line);
  border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
  content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
  border-radius: 50%; background: var(--surface); transition: transform .15s ease;
  box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }

.keys { display: flex; flex-direction: column; gap: 6px; margin: 0; }
.keys div { display: flex; align-items: baseline; gap: 9px; font-size: 12.5px; color: var(--muted); }
.keys kbd {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px;
  border-radius: 5px; padding: 2px 6px; min-width: 4.5em; text-align: center; flex: none;
}

.brickkey { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 7px; }
.brickkey div { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.brickkey .sw { width: 100%; height: 13px; border-radius: 3px; border: 1px solid var(--line); }
.brickkey .n { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; color: var(--muted); text-transform: uppercase; }

footer {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  letter-spacing: .05em; padding-top: 2px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Breakout</h1>
    <p class="lede">
      A brick wall, one ball, and a paddle that turns the ball wherever you hit it.
      The boldest bricks take up to <b>four</b> hits, the faintest take <b>one</b>. Clear the wall and
      the next one arrives faster. <span id="ledeStart">It is already playing itself — <span id="ledeTake"><b>move the mouse</b>
      or press <kbd>&larr;</kbd> <kbd>&rarr;</kbd></span> to take over.</span>
    </p>
  </header>

  <main class="stage">

    <div class="field" id="field">
      <canvas id="c" tabindex="0" role="application" aria-label="Breakout play field. Left and right arrow keys move the paddle, space launches the ball and pauses.">
        A canvas game: a paddle bounces a ball into a wall of bricks. Your browser does not support canvas.
      </canvas>
      <div class="pops" id="pops" aria-hidden="true"></div>
      <div class="hint" id="hint">auto-play &middot; move the mouse to take over</div>
      <div class="overlay" id="overlay" hidden>
        <div class="box">
          <span class="tag" id="ovTag">paused</span>
          <h2 id="ovTitle">Paused</h2>
          <p id="ovNote">The ball is holding still.</p>
          <p class="final" id="ovFinal" hidden></p>
          <button class="primary big" id="ovBtn" type="button">Resume</button>
          <p class="keyhint" id="ovKey">space to resume</p>
        </div>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Game readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sScore">0</span><span class="k">score</span></div>
          <div class="stat"><span class="v" id="sBest">0</span><span class="k">best</span></div>
          <div class="stat"><span class="v sm" id="sLives" aria-hidden="true">&#9679;&#9679;&#9679;</span><span class="sr-only" id="sLivesSR">3 balls left</span><span class="k" aria-hidden="true">lives</span></div>
          <div class="stat"><span class="v" id="sLevel">1</span><span class="k">level</span></div>
          <div class="stat"><span class="v" id="sCombo">&times;1</span><span class="k">combo</span></div>
          <div class="stat"><span class="v" id="sSpeed">&times;1.00</span><span class="k">ball speed</span></div>
        </div>
        <div class="barrow">
          <span class="lbl">wall cleared</span><span class="num" id="sWall">0 / 44</span>
        </div>
        <div class="bar" id="bar"><i id="barFill"></i></div>
        <span class="pill live" id="pill"><span class="dot"></span><span id="pillText">auto-play</span></span>
      </section>

      <section class="card" aria-label="Controls">
        <h2>Controls</h2>
        <div class="row" style="margin-bottom:7px">
          <button type="button" class="primary" id="mainBtn">Take over</button>
          <button type="button" id="newBtn">New game</button>
        </div>
        <label class="switch" for="snd">
          <input type="checkbox" id="snd">
          <span class="track" aria-hidden="true"></span>
          <span>Blips</span>
        </label>
      </section>

      <section class="card" aria-label="Difficulty">
        <h2>Difficulty</h2>
        <div class="modes" id="modes" role="group" aria-label="Difficulty">
          <button type="button" data-m="0" aria-pressed="false">Easy</button>
          <button type="button" data-m="1" aria-pressed="true">Normal</button>
          <button type="button" data-m="2" aria-pressed="false">Sharp</button>
        </div>
        <p class="cap" id="modeCap">The standard wall: 3 lives and a ball that ramps up as the level empties.</p>
      </section>

      <section class="card" aria-label="Brick strength key">
        <h2>Bricks</h2>
        <div class="brickkey" aria-hidden="true">
          <div><span class="sw" id="k1"></span><span class="n">1 hit</span></div>
          <div><span class="sw" id="k2"></span><span class="n">2 hit</span></div>
          <div><span class="sw" id="k3"></span><span class="n">3 hit</span></div>
          <div><span class="sw" id="k4"></span><span class="n">4 hit</span></div>
        </div>
        <p class="cap">A brick fades toward the field each time you hit it, so you can always read how close it is to going. Break bricks without touching the paddle to build the <b>combo</b> — it multiplies every point.</p>
      </section>

      <section class="card" aria-label="Keys">
        <h2>Keys</h2>
        <div class="keys">
          <div><kbd>&larr; &rarr;</kbd><span>move the paddle</span></div>
          <div><kbd>space</kbd><span>launch &middot; pause &middot; resume</span></div>
          <div><kbd>P</kbd><span>pause</span></div>
          <div><kbd>R</kbd><span>new game</span></div>
        </div>
      </section>

    </div>
  </main>

  <footer>demo &middot; sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

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

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  var root = document.documentElement;
  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
  var reduced = !!(mqRM && mqRM.matches);

  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      readTheme(); dirty = true;
    }
  });
  function onScheme() { readTheme(); dirty = true; }
  // the platform may also flip data-theme on <html> directly, with no message —
  // the canvas caches its colours, so watch the attribute and re-read on change
  if (window.MutationObserver) {
    new MutationObserver(onScheme).observe(root, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
  }
  if (mqDark) {
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }
  if (mqRM) {
    var onRM = function () { reduced = mqRM.matches; };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  /* ── colours from tokens ────────────────────────────────────────────────── */
  var COL = {
    bg: [246,246,243], surface: [255,255,255], ink: [26,29,33], muted: [90,96,104],
    line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55]
  };
  function hexToRgb(h) {
    h = String(h).trim();
    if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    if (h.length !== 6) return null;
    var n = parseInt(h, 16);
    if (isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  function rgb(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
  function mix(a, b, t) {
    return [Math.round(a[0]+(b[0]-a[0])*t), Math.round(a[1]+(b[1]-a[1])*t), Math.round(a[2]+(b[2]-a[2])*t)];
  }
  var BRICK = [null, [0,0,0], [0,0,0], [0,0,0], [0,0,0]];
  function readTheme() {
    COL.bg      = tok("--bg", COL.bg);
    COL.surface = tok("--surface", COL.surface);
    COL.ink     = tok("--ink", COL.ink);
    COL.muted   = tok("--muted", COL.muted);
    COL.line    = tok("--line", COL.line);
    COL.accent  = tok("--accent", COL.accent);
    COL.run     = tok("--run", COL.run);
    COL.danger  = tok("--danger", COL.danger);
    BRICK[1] = mix(COL.surface, COL.muted, 0.46);
    BRICK[2] = mix(COL.surface, COL.muted, 0.82);
    BRICK[3] = COL.accent.slice();
    BRICK[4] = mix(COL.surface, COL.ink, 0.86);
    for (var i = 1; i <= 4; i++) {
      var el = document.getElementById("k" + i);
      if (el) el.style.background = rgb(BRICK[i]);
    }
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var field   = document.getElementById("field");
  var canvas  = document.getElementById("c");
  var ctx     = null;
  try { ctx = canvas.getContext("2d"); } catch (e) { ctx = null; }
  var popsEl  = document.getElementById("pops");
  var hint    = document.getElementById("hint");
  var overlay = document.getElementById("overlay");
  var ovTag   = document.getElementById("ovTag");
  var ovTitle = document.getElementById("ovTitle");
  var ovNote  = document.getElementById("ovNote");
  var ovFinal = document.getElementById("ovFinal");
  var ovBtn   = document.getElementById("ovBtn");
  var ovKey   = document.getElementById("ovKey");
  var sScore  = document.getElementById("sScore");
  var sBest   = document.getElementById("sBest");
  var sLives  = document.getElementById("sLives");
  var sLivesSR= document.getElementById("sLivesSR");
  var sLevel  = document.getElementById("sLevel");
  var sCombo  = document.getElementById("sCombo");
  var sSpeed  = document.getElementById("sSpeed");
  var sWall   = document.getElementById("sWall");
  var bar     = document.getElementById("bar");
  var barFill = document.getElementById("barFill");
  var pill    = document.getElementById("pill");
  var pillText= document.getElementById("pillText");
  var mainBtn = document.getElementById("mainBtn");
  var newBtn  = document.getElementById("newBtn");
  var modes   = document.getElementById("modes");
  var modeCap = document.getElementById("modeCap");
  var sndBox  = document.getElementById("snd");
  var live    = document.getElementById("live");

  // no 2d context (canvas disabled or blocked) — say so plainly instead of
  // throwing on every frame and leaving a blank rectangle on the page
  if (!ctx) {
    mainBtn.disabled = true; newBtn.disabled = true; sndBox.disabled = true;
    var mbtns = modes.querySelectorAll("button[data-m]");
    for (var mi = 0; mi < mbtns.length; mi++) mbtns[mi].disabled = true;
    hint.hidden = true;
    ovTag.className = "tag bad"; ovTag.textContent = "no canvas";
    ovTitle.textContent = "This browser cannot draw the field";
    ovNote.textContent = "Breakout needs a 2D canvas, and this browser did not give one. Everything else on the page is inert.";
    ovFinal.hidden = true; ovBtn.hidden = true; ovKey.hidden = true;
    overlay.hidden = false;
    live.textContent = "Canvas is unavailable, so the game cannot run.";
    return;
  }

  /* ── virtual geometry (width is always 1000 units) ──────────────────────── */
  var VW = 1000, VH = 750, SCALE = 1, DPR = 1;
  function brickH() { return Math.max(24, Math.min(64, VH * 0.055)); }
  function brickTop() { return Math.max(48, VH * 0.22); }
  var PAD_X = 44;
  function brickW() { return (VW - PAD_X * 2) / grid.cols; }
  function paddleY() { return VH - Math.max(38, VH * 0.055); }

  function resize() {
    // clientWidth/Height is the box the canvas actually fills (the border is not in it),
    // so the backing store lands 1:1 on device pixels — no resample, no soft edges.
    var w = Math.max(160, field.clientWidth);
    var h = Math.max(160, field.clientHeight);
    DPR = Math.min(2, window.devicePixelRatio || 1);
    if (canvas.width !== Math.round(w * DPR) || canvas.height !== Math.round(h * DPR)) {
      canvas.width  = Math.round(w * DPR);
      canvas.height = Math.round(h * DPR);
    }
    canvas.style.width  = w + "px";
    canvas.style.height = h + "px";
    SCALE = w / VW;
    VH = Math.round(VW * h / w);
    paddle.y = paddleY();
    // a court that just got shorter must not swallow a ball that is still in play
    if (ball.y > VH - ball.r) ball.y = VH - ball.r;
    dirty = true;
  }

  /* ── model ──────────────────────────────────────────────────────────────── */
  var MODES = [
    { name: "Easy",   pw: 200, spd: 430, lives: 4, cap: "Wide paddle, calm ball, 4 lives. Picking a difficulty starts a fresh game." },
    { name: "Normal", pw: 152, spd: 520, lives: 3, cap: "The standard wall: 3 lives and a ball that ramps up as the level empties." },
    { name: "Sharp",  pw: 108, spd: 610, lives: 3, cap: "Narrow paddle, fast ball, 3 lives. Every wall gets an extra row." }
  ];
  var mode = 1;

  var grid = { cols: 11, rows: 5, hp: [], hp0: [], left: 0 };
  var paddle = { x: VW / 2, w: 152, h: 15, y: 700, vx: 0 };
  var ball = { x: VW / 2, y: 600, vx: 0, vy: 0, r: 10 };
  var game = {
    state: "attract",   // attract | ready | playing | paused | clear | over
    score: 0, best: 0, lives: 3, level: 1,
    combo: 0, mult: 1, breaks: 0, hadBest: false,
    speed: 520, base: 520, total: 0, left: 0,
    newBest: false
  };
  var fx = [], trail = [], shake = 0, dirty = true;
  var lostFlash = 0;

  /* ── level building ─────────────────────────────────────────────────────── */
  function levelPlan(n) {
    var cols = 11;
    var rows = Math.min(7, 4 + Math.floor((n - 1) / 2) + (mode === 2 ? 1 : 0));
    var maxHp = Math.min(4, 3 + Math.floor((n - 1) / 2));
    var pat = (n - 1) % 5;
    var mid = (cols - 1) / 2;
    var hp = [], hp0 = [], count = 0;
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) {
        var on = true;
        if (pat === 1) on = ((r + c) % 2 === 0) || r === 0;
        else if (pat === 2) on = (c >= r && c <= cols - 1 - r);
        else if (pat === 3) on = (c % 3 !== 1) || r === rows - 1;
        else if (pat === 4) on = (Math.abs(c - mid) + Math.abs(r - (rows - 1) / 2) <= mid);
        var v = 0;
        if (on) {
          v = rows > 1 ? 1 + Math.round((rows - 1 - r) / (rows - 1) * (maxHp - 1)) : maxHp;
          count++;
        }
        hp.push(v); hp0.push(v);
      }
    }
    if (count < 10) {   // safety: never ship an empty wall
      hp = []; hp0 = []; count = 0;
      for (var i = 0; i < rows * cols; i++) {
        var rr = Math.floor(i / cols);
        var vv = rows > 1 ? 1 + Math.round((rows - 1 - rr) / (rows - 1) * (maxHp - 1)) : maxHp;
        hp.push(vv); hp0.push(vv); count++;
      }
    }
    grid.cols = cols; grid.rows = rows; grid.hp = hp; grid.hp0 = hp0;
    game.total = count; game.left = count;
  }

  /* ── sound (synthesised, no assets, no network) ─────────────────────────── */
  var soundOn = false, actx = null;
  function beep(freq, dur, type, vol) {
    if (!soundOn) return;
    try {
      var AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      if (!actx) actx = new AC();
      if (actx.state === "suspended" && actx.resume) actx.resume();
      var o = actx.createOscillator(), g = actx.createGain();
      o.type = type || "square";
      o.frequency.value = freq;
      o.connect(g); g.connect(actx.destination);
      var t = actx.currentTime, d = dur || 0.06;
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(vol || 0.05, t + 0.006);
      g.gain.exponentialRampToValueAtTime(0.0001, t + d);
      o.start(t); o.stop(t + d + 0.02);
    } catch (e) { /* audio unavailable — game plays on */ }
  }

  /* ── ui ─────────────────────────────────────────────────────────────────── */
  function say(msg) { live.textContent = msg; }

  function setPill(cls, text) {
    pill.className = "pill" + (cls ? " " + cls : "");
    pillText.textContent = text;
  }

  function fmt(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }

  function renderHud() {
    sScore.textContent = fmt(game.score);
    sBest.textContent = fmt(game.best);
    sBest.className = "v" + (game.newBest ? " good" : "");
    var dots = "";
    for (var i = 0; i < Math.max(game.lives, 0); i++) dots += "●";
    var off = "";
    for (var j = Math.max(game.lives, 0); j < MODES[mode].lives; j++) off += "○";
    sLives.innerHTML = dots + (off ? '<span class="off">' + off + "</span>" : "");
    sLivesSR.textContent = Math.max(game.lives, 0) + (game.lives === 1 ? " ball left" : " balls left");
    sLevel.textContent = game.level;
    sCombo.textContent = "×" + game.mult;
    sCombo.className = "v" + (game.mult > 1 ? " hot" : "");
    sSpeed.textContent = "×" + (game.speed / MODES[mode].spd).toFixed(2);
    sSpeed.className = "v" + (game.speed / MODES[mode].spd >= 1.3 ? " hot" : "");
    var done = game.total - game.left;
    sWall.textContent = done + " / " + game.total;
    var pct = game.total ? (done / game.total) * 100 : 0;
    barFill.style.width = pct.toFixed(1) + "%";
    if (game.left === 0 && game.total > 0) bar.classList.add("done"); else bar.classList.remove("done");

    var s = game.state;
    mainBtn.textContent =
      s === "attract" ? "Take over" :
      s === "ready"   ? "Launch ball" :
      s === "playing" ? "Pause" :
      s === "paused"  ? (game.score === 0 && game.level === 1 && ball.vx === 0 && ball.vy === 0 ? "Start playing" : "Resume") :
      s === "clear"   ? "Next level" : "Play again";
    if (s === "attract") setPill("live", "auto-play");
    else if (s === "playing") setPill("live", "playing");
    else if (s === "ready") setPill("", "ready · launch");
    else if (s === "paused") setPill("", "paused");
    else if (s === "clear") setPill("ok", "level " + game.level + " cleared");
    else setPill("bad", "game over");
    field.classList.toggle("aiming", usingPointer && (s === "playing" || s === "ready"));
  }

  var focusOverlay = false;
  function showOverlay(tagCls, tagText, title, note, btn, keyhint, finalText) {
    ovTag.className = "tag" + (tagCls ? " " + tagCls : "");
    ovTag.textContent = tagText;
    ovTitle.textContent = title;
    ovNote.textContent = note;
    ovBtn.textContent = btn;
    ovKey.textContent = keyhint;
    if (finalText) { ovFinal.textContent = finalText; ovFinal.hidden = false; }
    else ovFinal.hidden = true;
    overlay.hidden = false;
    if (focusOverlay) { try { ovBtn.focus(); } catch (e) {} }
  }
  function hideOverlay() {
    var wasFocused = document.activeElement === ovBtn;
    overlay.hidden = true;
    if (wasFocused) { try { canvas.focus({ preventScroll: true }); } catch (e) {} }
  }

  var touchy = !!(window.matchMedia && matchMedia("(hover: none)").matches);
  var ATTRACT_HINT = touchy ? "auto-play · tap to take over" : "auto-play · move the mouse to take over";
  if (touchy) {
    var ledeTake = document.getElementById("ledeTake");
    if (ledeTake) ledeTake.innerHTML = "<b>tap or drag</b> on the field";
  }
  var LAUNCH_HINT  = touchy ? "tap to launch" : "press space to launch";

  function setHint(text) {
    if (!text) { hint.hidden = true; return; }
    hint.hidden = false; hint.textContent = text;
  }

  /* ── score pops (html, so they read crisply at any size) ────────────────── */
  function pop(x, y, text, good) {
    if (reduced) return;
    var el = document.createElement("div");
    el.textContent = text;
    el.style.cssText =
      "position:absolute;left:" + (x / VW * 100).toFixed(2) + "%;top:" + (y / VH * 100).toFixed(2) + "%;" +
      "transform:translate(-50%,-50%);font-family:var(--font-mono);font-size:12px;font-weight:600;" +
      "color:" + (good ? "var(--run)" : "var(--accent)") + ";opacity:1;transition:transform .55s ease-out,opacity .55s ease-out;" +
      "pointer-events:none;white-space:nowrap;";
    popsEl.appendChild(el);
    requestAnimationFrame(function () {
      el.style.transform = "translate(-50%,-160%)";
      el.style.opacity = "0";
    });
    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 620);
  }

  /* ── game flow ──────────────────────────────────────────────────────────── */
  function resetBall(onPaddle) {
    trail.length = 0;
    if (onPaddle) {
      ball.x = paddle.x;
      ball.y = paddle.y - paddle.h / 2 - ball.r - 1;
      ball.vx = 0; ball.vy = 0;
    }
  }
  function launch() {
    // measured off vertical so there is no dead zone: a launch is never so close
    // to straight up that it just falls back down the same line
    var dir = Math.random() < 0.5 ? -1 : 1;
    var a = (0.20 + Math.random() * 0.28) * (Math.PI / 2);   // 18°–43° off vertical
    ball.vx = Math.sin(a) * game.speed * dir;
    ball.vy = -Math.abs(Math.cos(a) * game.speed);
    beep(520, 0.05, "square", 0.045);
  }
  function levelSpeed() {
    return MODES[mode].spd * (1 + 0.09 * (game.level - 1));
  }
  // the ceiling has to sit above this level's own starting pace, or a late level
  // would clamp the ball *slower* than the speed it launched at
  function speedCap() {
    return Math.max(MODES[mode].spd * 2.0, game.base * 1.35);
  }

  function startAttract() {
    game.state = "attract";
    game.level = 1; game.score = 0; game.combo = 0; game.mult = 1; game.breaks = 0;
    game.lives = MODES[mode].lives;
    game.base = levelSpeed(); game.speed = game.base;
    levelPlan(1);
    paddle.w = MODES[mode].pw * 1.05;
    paddle.x = VW / 2; paddle.y = paddleY();
    resetBall(true); launch();
    hideOverlay();
    setHint(ATTRACT_HINT);
    renderHud();
  }

  function newGame(announce) {
    game.state = "ready";
    game.level = 1; game.score = 0; game.combo = 0; game.mult = 1; game.breaks = 0;
    game.lives = MODES[mode].lives; game.newBest = false; game.hadBest = game.best > 0;
    game.base = levelSpeed(); game.speed = game.base;
    levelPlan(1);
    paddle.w = MODES[mode].pw;
    paddle.x = VW / 2; paddle.y = paddleY();
    resetBall(true);
    fx.length = 0; trail.length = 0; shake = 0;
    hideOverlay();
    setHint(LAUNCH_HINT);
    renderHud();
    if (announce) say(announce);
  }

  function takeOver() {
    if (game.state !== "attract") return;
    focusOverlay = true;
    newGame("Your turn. Press space to launch the ball.");
  }

  function nextLevel() {
    game.level++;
    if (game.level % 3 === 0 && game.lives < MODES[mode].lives + 2) game.lives++;
    game.base = levelSpeed(); game.speed = game.base;
    game.combo = 0; game.mult = 1; game.breaks = 0;
    levelPlan(game.level);
    resetBall(true);
    fx.length = 0;
    game.state = "ready";
    hideOverlay();
    setHint("level " + game.level + " · " + LAUNCH_HINT);
    renderHud();
  }

  function loseLife() {
    game.lives--;
    game.combo = 0; game.mult = 1;
    beep(120, 0.24, "sawtooth", 0.05);
    if (!reduced) { shake = 9; lostFlash = 0.5; }
    trail.length = 0;
    if (game.lives <= 0) {
      game.lives = 0;
      game.state = "over";
      if (game.score > game.best) { game.best = game.score; game.newBest = true; }
      setHint("");
      focusOverlay = true;
      showOverlay(
        "bad", "game over", "Out of balls",
        game.newBest ? (game.hadBest ? "That beats your best run so far." : "That is the score to beat now.")
                     : "The wall wins this round.",
        "Play again", "space or R to play again",
        "score " + fmt(game.score) + "  ·  level " + game.level + "  ·  best " + fmt(game.best)
      );
      say("Game over. Score " + game.score + ", level " + game.level + ".");
    } else {
      game.state = "ready";
      game.speed = game.base;              // a fresh ball starts at this level's pace
      resetBall(true);
      setHint(game.lives + (game.lives === 1 ? " ball left · " : " balls left · ") + LAUNCH_HINT);
      say(game.lives + " balls left.");
    }
    renderHud();
  }

  function levelCleared() {
    var bonus = 250 + game.lives * 100;
    game.score += bonus;
    if (game.score > game.best) { game.best = game.score; game.newBest = true; }
    game.state = "clear";
    beep(660, 0.09, "triangle", 0.05);
    setTimeout(function () { beep(880, 0.12, "triangle", 0.05); }, 110);
    setHint("");
    focusOverlay = true;
    showOverlay(
      "ok", "cleared", "Level " + game.level + " cleared",
      "Every brick is gone. The next wall is a new shape and the ball starts faster.",
      "Next level", "space for the next wall",
      "clear bonus +" + fmt(bonus) + "  ·  score " + fmt(game.score)
    );
    say("Level " + game.level + " cleared. Bonus " + bonus + " points.");
    renderHud();
  }

  function pause() {
    if (game.state !== "playing" && game.state !== "ready") return;
    game.state = "paused";
    setHint("");
    focusOverlay = true;
    var fresh = (game.score === 0 && game.level === 1 && ball.vx === 0 && ball.vy === 0);
    showOverlay("", "paused", "Paused",
      fresh ? "The wall is set. Nothing moves until you say so." : "Nothing moves until you say so.",
      fresh ? "Start playing" : "Resume", "space or P to resume", null);
    renderHud();
    say("Paused.");
  }
  function resume() {
    if (game.state !== "paused") return;
    game.state = ball.vx === 0 && ball.vy === 0 ? "ready" : "playing";
    hideOverlay();
    setHint(game.state === "ready" ? LAUNCH_HINT : "");
    renderHud();
    try { canvas.focus({ preventScroll: true }); } catch (e) {}
  }

  function primary() {
    var s = game.state;
    if (s === "attract") takeOver();
    else if (s === "ready") { game.state = "playing"; launch(); setHint(""); renderHud(); }
    else if (s === "playing") pause();
    else if (s === "paused") resume();
    else if (s === "clear") nextLevel();
    else if (s === "over") newGame("New game. Press space to launch.");
  }

  /* ── input ──────────────────────────────────────────────────────────────── */
  var pointerX = null, usingPointer = false, keyL = false, keyR = false;

  function fieldX(clientX) {
    var r = canvas.getBoundingClientRect();
    if (!r.width) return VW / 2;
    return (clientX - r.left) / r.width * VW;
  }
  field.addEventListener("pointermove", function (e) {
    pointerX = fieldX(e.clientX);
    usingPointer = true;
    if (game.state === "attract") takeOver();
  });
  field.addEventListener("pointerleave", function () { field.classList.remove("aiming"); });
  field.addEventListener("pointerdown", function (e) {
    if (overlay.contains(e.target)) return;
    pointerX = fieldX(e.clientX);
    usingPointer = true;
    if (game.state === "attract") { takeOver(); return; }
    if (game.state === "ready") { game.state = "playing"; launch(); setHint(""); renderHud(); }
    try { canvas.focus({ preventScroll: true }); } catch (err) {}
    if (e.pointerType === "touch") e.preventDefault();
  });

  window.addEventListener("keydown", function (e) {
    var t = e.target, tag = t && t.tagName ? t.tagName : "";
    var onControl = (tag === "BUTTON" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || tag === "A");
    var typeAttr = (t && t.type ? String(t.type).toLowerCase() : "");
    var textish  = (tag === "TEXTAREA" || (tag === "INPUT" && typeAttr !== "checkbox" && typeAttr !== "radio" && typeAttr !== "button"));
    var typing   = (textish || tag === "SELECT");
    var k = e.key;
    if (k === "ArrowLeft" || k === "a" || k === "A") {
      keyL = true; usingPointer = false;
      if (game.state === "attract") takeOver();
      if (!onControl) e.preventDefault();
    } else if (k === "ArrowRight" || k === "d" || k === "D") {
      keyR = true; usingPointer = false;
      if (game.state === "attract") takeOver();
      if (!onControl) e.preventDefault();
    } else if (k === " " || k === "Spacebar" || k === "Enter") {
      if (onControl) return;         // let focused buttons do their own thing
      e.preventDefault();
      primary();
    } else if (k === "p" || k === "P") {
      if (typing) return;            // letters are safe even with a button focused
      if (game.state === "paused") resume();
      else { if (game.state === "attract") takeOver(); pause(); }
    } else if (k === "r" || k === "R") {
      if (typing) return;
      focusOverlay = false;
      newGame("New game.");
    }
  });
  window.addEventListener("keyup", function (e) {
    var k = e.key;
    if (k === "ArrowLeft" || k === "a" || k === "A") keyL = false;
    if (k === "ArrowRight" || k === "d" || k === "D") keyR = false;
  });
  window.addEventListener("blur", function () { keyL = keyR = false; });
  document.addEventListener("visibilitychange", function () {
    if (document.hidden && game.state === "playing") pause();
  });

  mainBtn.addEventListener("click", function () { focusOverlay = false; primary(); });
  ovBtn.addEventListener("click", function () { focusOverlay = false; primary(); });
  newBtn.addEventListener("click", function () { focusOverlay = false; newGame("New game."); });
  sndBox.addEventListener("change", function () {
    soundOn = sndBox.checked;
    if (soundOn) beep(700, 0.05, "triangle", 0.04);
  });
  modes.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-m]") : null;
    if (!b) return;
    mode = parseInt(b.getAttribute("data-m"), 10) || 0;
    var all = modes.querySelectorAll("button[data-m]");
    for (var i = 0; i < all.length; i++) all[i].setAttribute("aria-pressed", all[i] === b ? "true" : "false");
    modeCap.textContent = MODES[mode].cap;
    focusOverlay = false;
    newGame(MODES[mode].name + " difficulty. New game.");
  });

  /* ── simulation ─────────────────────────────────────────────────────────── */
  var FIXED = 1 / 120;

  function paddleTop() { return paddle.y - paddle.h / 2; }

  function movePaddle(dt) {
    var prev = paddle.x;
    if (game.state === "attract") {
      var aim = ball.vy > 0 ? ball.x : (ball.x + VW / 2) / 2;
      var lag = 7.5;
      paddle.x += (aim - paddle.x) * Math.min(1, lag * dt);
    } else if (usingPointer && pointerX !== null) {
      paddle.x = pointerX;
    } else {
      var v = 980 * dt;
      if (keyL) paddle.x -= v;
      if (keyR) paddle.x += v;
    }
    var half = paddle.w / 2;
    if (paddle.x < half) paddle.x = half;
    if (paddle.x > VW - half) paddle.x = VW - half;
    paddle.vx = dt > 0 ? (paddle.x - prev) / dt : 0;
  }

  function bumpSpeed(f) {
    game.speed = Math.min(speedCap(), game.speed * f);
    var s = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
    if (s > 0.001) { ball.vx = ball.vx / s * game.speed; ball.vy = ball.vy / s * game.speed; }
  }

  function hitBrick(idx, cx, cy) {
    var was = grid.hp[idx];
    grid.hp[idx] = was - 1;
    var mult = game.mult;
    if (grid.hp[idx] > 0) {
      game.score += 4 * mult;
      beep(300 + was * 60, 0.035, "square", 0.035);
      burst(cx, cy, BRICK[was], 4, 0.6);
    } else {
      game.left--;
      game.breaks++;
      game.combo++;
      game.mult = Math.min(5, 1 + Math.floor(game.combo / 3));
      var pts = (10 + 10 * grid.hp0[idx]) * mult;
      game.score += pts;
      beep(420 + was * 90, 0.055, "square", 0.05);
      burst(cx, cy, BRICK[was], 11, 1);
      if (!reduced) shake = Math.min(6, 2.2 + was * 0.8);
      pop(cx, cy, "+" + pts, false);
      if (game.breaks % 6 === 0) bumpSpeed(1.03);
      if (game.score > game.best && game.state !== "attract") {
        if (!game.newBest) { game.newBest = true; say("New best score."); }
        game.best = game.score;
      }
    }
    dirty = true;
  }

  function brickSweep() {
    var bw = brickW(), bh = brickH(), top = brickTop(), left = PAD_X;
    if (ball.y + ball.r < top || ball.y - ball.r > top + grid.rows * bh) return false;
    var c0 = Math.floor((ball.x - ball.r - left) / bw), c1 = Math.floor((ball.x + ball.r - left) / bw);
    var r0 = Math.floor((ball.y - ball.r - top) / bh), r1 = Math.floor((ball.y + ball.r - top) / bh);
    if (c0 < 0) c0 = 0; if (c1 > grid.cols - 1) c1 = grid.cols - 1;
    if (r0 < 0) r0 = 0; if (r1 > grid.rows - 1) r1 = grid.rows - 1;
    for (var r = r0; r <= r1; r++) {
      for (var c = c0; c <= c1; c++) {
        var idx = r * grid.cols + c;
        if (!grid.hp[idx]) continue;
        var bx = left + c * bw, by = top + r * bh;
        var nx = Math.max(bx, Math.min(ball.x, bx + bw));
        var ny = Math.max(by, Math.min(ball.y, by + bh));
        var dx = ball.x - nx, dy = ball.y - ny;
        if (dx * dx + dy * dy > ball.r * ball.r) continue;
        var cx = bx + bw / 2, cy = by + bh / 2;
        var ox = (bw / 2 + ball.r) - Math.abs(ball.x - cx);
        var oy = (bh / 2 + ball.r) - Math.abs(ball.y - cy);
        if (ox < oy) {
          ball.vx = -ball.vx;
          ball.x += (ball.x < cx ? -1 : 1) * (ox + 0.01);
        } else {
          ball.vy = -ball.vy;
          ball.y += (ball.y < cy ? -1 : 1) * (oy + 0.01);
        }
        hitBrick(idx, cx, cy);
        return true;
      }
    }
    return false;
  }

  function paddleHit() {
    var top = paddleTop();
    if (ball.vy <= 0) return;
    if (ball.y + ball.r < top || ball.y - ball.r > paddle.y + paddle.h) return;
    var half = paddle.w / 2;
    if (ball.x < paddle.x - half - ball.r || ball.x > paddle.x + half + ball.r) return;
    var t = (ball.x - paddle.x) / half;
    if (t < -1) t = -1; if (t > 1) t = 1;
    t += Math.max(-0.35, Math.min(0.35, paddle.vx / 2600));   // a moving paddle pushes the ball
    if (t < -1) t = -1; if (t > 1) t = 1;
    var a = t * 1.0472;                                        // up to 60° off vertical
    // A dead-centre hit off a still paddle returns the ball perfectly vertical —
    // and that is an absorbing state: it comes straight back down to the same
    // spot, the paddle never has to move, and the ball bounces in one empty
    // column forever. Keep a few degrees of lean so the wall always gets eaten.
    if (Math.abs(a) < 0.155) {
      var side = a !== 0 ? (a < 0 ? -1 : 1)
               : paddle.vx !== 0 ? (paddle.vx < 0 ? -1 : 1)
               : ball.vx !== 0 ? (ball.vx < 0 ? -1 : 1)
               : (Math.random() < 0.5 ? -1 : 1);
      a = side * 0.155;                                        // ~9° off vertical
    }
    game.speed = Math.min(speedCap(), game.speed * 1.012);
    ball.vx = Math.sin(a) * game.speed;
    ball.vy = -Math.abs(Math.cos(a) * game.speed);
    ball.y = top - ball.r - 0.5;
    game.combo = 0; game.mult = 1;   // the readout must mean the same thing in attract
    beep(190 + Math.abs(t) * 110, 0.045, "square", 0.045);
    if (!reduced) shake = Math.max(shake, 1.4);
    dirty = true;
  }

  function stepBall(dt) {
    var dist = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) * dt;
    var steps = Math.max(1, Math.ceil(dist / (ball.r * 0.55)));
    var sdt = dt / steps;
    for (var i = 0; i < steps; i++) {
      ball.x += ball.vx * sdt;
      ball.y += ball.vy * sdt;
      if (ball.x < ball.r) { ball.x = ball.r; ball.vx = Math.abs(ball.vx); beep(240, 0.03, "square", 0.03); }
      else if (ball.x > VW - ball.r) { ball.x = VW - ball.r; ball.vx = -Math.abs(ball.vx); beep(240, 0.03, "square", 0.03); }
      if (ball.y < ball.r) { ball.y = ball.r; ball.vy = Math.abs(ball.vy); beep(260, 0.03, "square", 0.03); }
      brickSweep();
      paddleHit();
      if (ball.y - ball.r > VH) return true;
    }
    return false;
  }

  function update(dt) {
    movePaddle(dt);
    var s = game.state;
    if (s === "ready") {
      ball.x = paddle.x;
      ball.y = paddleTop() - ball.r - 1;
      return;
    }
    if (s !== "playing" && s !== "attract") return;

    var lost = stepBall(dt);

    if (game.left <= 0) {
      if (s === "attract") { levelPlan(game.level); game.speed = game.base; resetBall(true); launch(); }
      else levelCleared();
      return;
    }
    if (lost) {
      if (s === "attract") { game.speed = game.base; resetBall(true); launch(); game.combo = 0; game.mult = 1; }
      else loseLife();
    }
  }

  /* ── effects ────────────────────────────────────────────────────────────── */
  function burst(x, y, col, n, power) {
    if (reduced) return;
    if (fx.length > 220) return;
    for (var i = 0; i < n; i++) {
      var a = Math.random() * Math.PI * 2;
      var sp = (70 + Math.random() * 190) * power;
      fx.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 40, life: 1, c: col, r: 1.6 + Math.random() * 2.4 });
    }
  }
  function stepFx(dt) {
    for (var i = fx.length - 1; i >= 0; i--) {
      var p = fx[i];
      p.life -= dt * 1.9;
      if (p.life <= 0) { fx.splice(i, 1); continue; }
      p.vy += 620 * dt;
      p.x += p.vx * dt; p.y += p.vy * dt;
    }
    if (shake > 0) shake = Math.max(0, shake - dt * 26);
    if (lostFlash > 0) lostFlash = Math.max(0, lostFlash - dt * 1.4);
  }

  /* ── drawing ────────────────────────────────────────────────────────────── */
  function rr(x, y, w, h, r) {
    if (w < 2 * r) r = w / 2;
    if (h < 2 * r) r = h / 2;
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }

  function draw() {
    var sx = 0, sy = 0;
    if (shake > 0) { sx = (Math.random() - 0.5) * shake; sy = (Math.random() - 0.5) * shake; }
    ctx.setTransform(DPR * SCALE, 0, 0, DPR * SCALE, DPR * sx * SCALE, DPR * sy * SCALE);

    // field
    ctx.fillStyle = rgb(COL.surface);
    ctx.fillRect(-20, -20, VW + 40, VH + 40);

    // faint dot grid
    ctx.fillStyle = rgba(COL.line, 0.85);
    var step = 40;
    for (var gx = step / 2; gx < VW; gx += step) {
      for (var gy = step / 2; gy < VH; gy += step) ctx.fillRect(gx - 1, gy - 1, 2, 2);
    }

    // the floor line
    ctx.strokeStyle = rgba(COL.muted, lostFlash > 0 ? 0.5 : 0.22);
    ctx.lineWidth = 2;
    ctx.setLineDash([9, 9]);
    ctx.beginPath();
    ctx.moveTo(0, VH - 5); ctx.lineTo(VW, VH - 5);
    ctx.stroke();
    ctx.setLineDash([]);

    // bricks
    var bw = brickW(), bh = brickH(), top = brickTop(), gap = 3.5;
    for (var r = 0; r < grid.rows; r++) {
      for (var c = 0; c < grid.cols; c++) {
        var idx = r * grid.cols + c, hp = grid.hp[idx];
        if (!hp) continue;
        var x = PAD_X + c * bw + gap / 2, y = top + r * bh + gap / 2;
        var w = bw - gap, h = bh - gap;
        var col = BRICK[hp] || BRICK[1];
        rr(x, y, w, h, 4);
        ctx.fillStyle = rgb(col);
        ctx.fill();
        ctx.strokeStyle = rgba(COL.ink, 0.10);
        ctx.lineWidth = 1;
        ctx.stroke();
        // gloss
        ctx.fillStyle = "rgba(255,255,255,0.10)";
        ctx.fillRect(x + 3, y + 2.5, w - 6, 2);
        // damage marks
        var dmg = grid.hp0[idx] - hp;
        if (dmg > 0) {
          ctx.strokeStyle = rgba(COL.ink, 0.20);
          ctx.lineWidth = 1.4;
          for (var d = 0; d < dmg && d < 3; d++) {
            var px = x + w * (0.24 + d * 0.26);
            ctx.beginPath();
            ctx.moveTo(px, y + h * 0.24);
            ctx.lineTo(px + w * 0.09, y + h * 0.76);
            ctx.stroke();
          }
        }
      }
    }

    // particles
    for (var i = 0; i < fx.length; i++) {
      var p = fx[i];
      ctx.fillStyle = rgba(p.c, Math.max(0, Math.min(1, p.life)) * 0.9);
      ctx.fillRect(p.x - p.r, p.y - p.r, p.r * 2, p.r * 2);
    }

    // trail
    if (!reduced) {
      for (var t = 0; t < trail.length; t++) {
        var q = trail[t], f = (t + 1) / trail.length;
        ctx.fillStyle = rgba(COL.accent, 0.03 + f * 0.14);
        ctx.beginPath();
        ctx.arc(q.x, q.y, ball.r * (0.30 + f * 0.62), 0, Math.PI * 2);
        ctx.fill();
      }
    }

    // paddle
    var pw = paddle.w, ph = paddle.h, px2 = paddle.x - pw / 2, py2 = paddle.y - ph / 2;
    rr(px2, py2, pw, ph, ph / 2);
    ctx.fillStyle = rgb(COL.accent);
    ctx.fill();
    ctx.fillStyle = "rgba(255,255,255,0.16)";
    ctx.fillRect(px2 + 5, py2 + 2.5, pw - 10, 2.5);
    // aim ticks: shows which way each part of the paddle sends the ball
    ctx.strokeStyle = rgba(COL.surface, 0.55);
    ctx.lineWidth = 1.4;
    for (var k = -1; k <= 1; k++) {
      if (!k) continue;
      var tx = paddle.x + k * pw * 0.30;
      ctx.beginPath(); ctx.moveTo(tx, py2 + 4); ctx.lineTo(tx, py2 + ph - 4); ctx.stroke();
    }

    // ball
    if (game.state !== "over") {
      ctx.fillStyle = rgb(COL.ink);
      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = "rgba(255,255,255,0.22)";
      ctx.beginPath();
      ctx.arc(ball.x - ball.r * 0.3, ball.y - ball.r * 0.32, ball.r * 0.34, 0, Math.PI * 2);
      ctx.fill();
    }

    // dim while an overlay is up (paused / cleared / over) is handled in HTML
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  }

  /* ── loop ───────────────────────────────────────────────────────────────── */
  var last = 0, acc = 0, hudAcc = 0, trailAcc = 0;
  function frame(ts) {
    requestAnimationFrame(frame);
    if (!last) last = ts;
    var dt = (ts - last) / 1000;
    last = ts;
    if (dt > 0.25) dt = 0.25;

    var s = game.state;
    var sim = (s === "playing" || s === "attract" || s === "ready");

    if (sim) {
      acc += dt;
      var n = 0;
      while (acc >= FIXED && n < 10) { update(FIXED); acc -= FIXED; n++; s = game.state; }
      if (acc > FIXED * 10) acc = 0;
      trailAcc += dt;
      if (!reduced && trailAcc > 0.022 && (game.state === "playing" || game.state === "attract")) {
        trailAcc = 0;
        trail.push({ x: ball.x, y: ball.y });
        if (trail.length > 7) trail.shift();
      }
    } else { acc = 0; }

    stepFx(dt);

    hudAcc += dt;
    if (sim && hudAcc > 0.1) { hudAcc = 0; renderHud(); }

    if (sim || fx.length || shake > 0 || dirty) { draw(); dirty = false; }
  }

  /* ── boot ───────────────────────────────────────────────────────────────── */
  modeCap.textContent = MODES[mode].cap;
  readTheme();
  resize();
  if (window.ResizeObserver) new ResizeObserver(resize).observe(field);
  window.addEventListener("resize", resize);
  window.addEventListener("orientationchange", function () { setTimeout(resize, 120); });

  if (reduced) {
    // no unexpected motion: lay the wall out and wait to be asked
    var ledeStart = document.getElementById("ledeStart");
    if (ledeStart) {
      ledeStart.innerHTML = touchy
        ? "The wall is set and nothing moves until you say so — press <b>Start playing</b>."
        : "The wall is set and nothing moves until you say so — press <b>Start playing</b> or <kbd>space</kbd>.";
    }
    newGame(null);
    game.state = "paused";
    setHint("");
    showOverlay("", "ready", "Breakout",
      touchy ? "A wall of bricks, one ball, one paddle. Drag on the field to move the paddle."
             : "A wall of bricks, one ball, one paddle. Arrow keys or the mouse move the paddle.",
      "Start playing", touchy ? "tap to start" : "space to start", null);
  } else {
    startAttract();
  }
  renderHud();
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong