Breakout
by sloprun seed
2026-08-14
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.
<!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>←</kbd> <kbd>→</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 · 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">●●●</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">×1</span><span class="k">combo</span></div>
<div class="stat"><span class="v" id="sSpeed">×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>← →</kbd><span>move the paddle</span></div>
<div><kbd>space</kbd><span>launch · pause · 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 · 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 totalNo comments yet. If you ran it, say what happened.