Game of Life
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Conway's Life on a canvas you can draw on: one rule about eight neighbours, no plan and no randomness, and out of it come blinkers, still shapes, and gliders that walk diagonally forever. The page opens 150 generations into a Gosper glider gun, so a stream of gliders is already crossing the field while the generation and population counters tick. Drag to draw cells (drag over live ones to erase), hit Step to walk exactly one generation and see which squares flip, then load the R-pentomino — five cells that erupt and keep throwing off debris for hundreds of generations.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Game of Life — 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,.9); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --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: 1140px; 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; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 296px; gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { display: flex; flex-direction: column; gap: 11px; min-width: 0; }
/* the field */
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow);
aspect-ratio: 16 / 10;
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
/* absolutely positioned so the canvas never feeds its own pinned pixel size back
into the field's layout — the field's box stays driven purely by aspect-ratio */
canvas#c { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
canvas#c:focus { outline: none; }
canvas#c:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.hint {
position: absolute; left: 10px; bottom: 10px; 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 .45s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
.hint.gone { opacity: 0; }
.overlay {
position: absolute; inset: 0; display: grid; place-items: center;
background: var(--scrim); padding: 18px; text-align: center;
}
.overlay[hidden] { display: none; }
.overlay .box { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 34ch; }
.overlay p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--muted); }
/* toolbar under the field */
.toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 9px 10px; box-shadow: var(--shadow);
}
.speed { display: flex; align-items: center; gap: 9px; flex: 1 1 210px; min-width: 170px; padding-left: 4px; }
.speed label { font-size: 13px; color: var(--muted); flex: none; }
.speed input { flex: 1 1 auto; min-width: 70px; }
.speed .val {
font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
font-variant-numeric: tabular-nums; flex: none; min-width: 8.5ch; text-align: right;
}
.keys {
margin: 0; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
letter-spacing: .04em; padding-left: 2px;
}
/* panel */
.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.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: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-size: 20px; line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted);
}
.stat .v.up { color: var(--accent); }
.stat .v.flat { color: var(--muted); }
canvas#spark { display: block; width: 100%; height: 40px; margin-top: 11px; }
.pill {
display: inline-flex; align-items: center; gap: 6px; margin-top: 10px;
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.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }
.pill.note { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.note .dot { background: var(--accent); }
/* controls */
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
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; min-width: 84px; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.big { font-size: 14px; padding: 9px 18px; }
.presets { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.presets button { text-align: left; padding: 7px 9px; }
.presets button[aria-pressed="true"] {
background: var(--accent-soft); color: var(--accent); border-color: var(--accent);
}
.presets button .sub {
display: block; font-family: var(--font-mono); font-size: 9.5px;
letter-spacing: .05em; color: var(--muted); margin-top: 2px;
}
.presets button[aria-pressed="true"] .sub { color: var(--accent); }
.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(--muted); transition: transform .15s ease, background .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); background: var(--surface); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
/* rules list */
.rules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.rules li { display: flex; align-items: baseline; gap: 9px; font-size: 12.5px; line-height: 1.4; color: var(--muted); }
.rules .n {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
color: var(--accent); background: var(--accent-soft);
border-radius: 5px; padding: 2px 6px; flex: none; min-width: 5.5ch; text-align: center;
}
.rules li b { color: var(--ink); font-weight: 600; }
.note { margin: 10px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
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 (max-width: 560px) {
.lede { font-size: 13.5px; }
}
@media (max-width: 420px) {
.wrap { padding: 18px 12px 22px; }
button.primary { min-width: 76px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Game of Life</h1>
<p class="lede">
One rule about eight neighbours, applied to every square at once — no plan, no goal, no randomness.
Out of it come blinkers, gliders that walk across the screen, and the thing on the grid right now:
a <b>glider gun</b> firing a fresh glider every 30 generations. <b>Drag on the grid</b> to draw your own.
</p>
</header>
<main class="stage">
<div class="left">
<div class="field" id="field">
<canvas id="c" tabindex="0" role="application"
aria-label="Life grid. Drag to draw cells. When focused, arrow keys move the cursor and Enter toggles a cell.">
A grid of cells running Conway's Game of Life. Your browser does not support canvas.
</canvas>
<div class="hint" id="hint">drag on the grid to draw cells</div>
<div class="overlay" id="overlay" hidden>
<div class="box">
<button class="primary big" id="overlayPlay" type="button">Play</button>
<p id="overlayNote">Paused.</p>
</div>
</div>
</div>
<div class="toolbar">
<button type="button" class="primary" id="playBtn">Play</button>
<button type="button" id="stepBtn">Step</button>
<button type="button" id="clearBtn">Clear</button>
<div class="speed">
<label for="speed">Speed</label>
<input type="range" id="speed" min="1" max="60" step="1" value="12"
aria-label="Speed in generations per second" aria-valuetext="12 generations per second">
<span class="val" id="speedV">12 gen/s</span>
</div>
</div>
<p class="keys">space play · s step · c clear · 1–4 patterns · arrows + enter to draw</p>
</div>
<div class="panel">
<section class="card" aria-label="Readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sGen">0</span><span class="k">generation</span></div>
<div class="stat"><span class="v" id="sPop">0</span><span class="k">live cells</span></div>
<div class="stat"><span class="v flat" id="sDelta">0</span><span class="k">change</span></div>
</div>
<canvas id="spark" aria-hidden="true"></canvas>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">idle</span></span>
</section>
<section class="card" aria-label="Patterns">
<h2>Patterns</h2>
<div class="presets" id="presets" role="group" aria-label="Starting patterns">
<button type="button" data-p="0" aria-pressed="true">Glider gun<span class="sub">period 30</span></button>
<button type="button" data-p="1" aria-pressed="false">Pulsar<span class="sub">period 3</span></button>
<button type="button" data-p="2" aria-pressed="false">R-pentomino<span class="sub">5 cells, chaos</span></button>
<button type="button" data-p="3" aria-pressed="false">Random soup<span class="sub">new every time</span></button>
</div>
<label class="switch" for="wrap">
<input type="checkbox" id="wrap">
<span class="track" aria-hidden="true"></span>
<span>Edges wrap around</span>
</label>
</section>
<section class="card" aria-label="The rules">
<h2>The rules</h2>
<ul class="rules">
<li><span class="n">3</span><span>live neighbours and an empty square <b>comes alive</b></span></li>
<li><span class="n">2–3</span><span>live neighbours and a live square <b>stays alive</b></span></li>
<li><span class="n">else</span><span>the square <b>goes empty</b> — too crowded, or too alone</span></li>
</ul>
<p class="note">Colour is age: a cell born this generation is bright, one that has held on for a while fades toward grey.</p>
</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(); draw(); drawSpark();
}
});
if (mqDark) {
var onScheme = function () { readTheme(); draw(); drawSpark(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── dom ────────────────────────────────────────────────────────────────── */
var field = document.getElementById("field");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var spark = document.getElementById("spark");
var sctx = spark.getContext("2d");
var hint = document.getElementById("hint");
var overlay = document.getElementById("overlay");
var overlayPlay = document.getElementById("overlayPlay");
var overlayNote = document.getElementById("overlayNote");
var sGen = document.getElementById("sGen");
var sPop = document.getElementById("sPop");
var sDelta = document.getElementById("sDelta");
var pill = document.getElementById("pill");
var pillText = document.getElementById("pillText");
var playBtn = document.getElementById("playBtn");
var stepBtn = document.getElementById("stepBtn");
var clearBtn = document.getElementById("clearBtn");
var speedEl = document.getElementById("speed");
var speedV = document.getElementById("speedV");
var presetsEl = document.getElementById("presets");
var wrapEl = document.getElementById("wrap");
var live = document.getElementById("live");
/* ── colours read from the tokens ───────────────────────────────────────── */
var COL = {
surface: [255,255,255], ink: [26,29,33], muted: [90,96,104],
accent: [192,86,0], line: [227,226,220]
};
var AGE_BK = 6, aliveCss = [], ghostCss = [], gridCss = "", surfCss = "", curCss = "", sparkLine = "", sparkFill = "";
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);
var n = parseInt(h, 16);
if (isNaN(n) || h.length !== 6) 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 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)];
}
function readTheme() {
COL.surface = tok("--surface", COL.surface);
COL.ink = tok("--ink", COL.ink);
COL.muted = tok("--muted", COL.muted);
COL.accent = tok("--accent", COL.accent);
COL.line = tok("--line", COL.line);
aliveCss.length = 0;
var old = mix(COL.muted, COL.ink, 0.25);
for (var k = 0; k < AGE_BK; k++) {
var t = k / (AGE_BK - 1);
aliveCss.push(rgba(mix(COL.accent, old, t), 1));
}
ghostCss = [rgba(COL.muted, .10), rgba(COL.muted, .18), rgba(COL.muted, .28)];
gridCss = rgba(COL.line, .6);
surfCss = rgba(COL.surface, 1);
curCss = rgba(COL.accent, .55);
sparkLine = rgba(COL.accent, 1);
sparkFill = rgba(COL.accent, .16);
}
/* ── grid state ─────────────────────────────────────────────────────────── */
var cols = 0, rows = 0, N = 0;
var cur = null, nxt = null, age = null, ghost = null;
var rowUp = null, rowDn = null, colL = null, colR = null;
var buckets = [], bcount = [];
var gen = 0, pop = 0, delta = 0;
var hist = [], HIST_MAX = 240;
var pristine = true; /* true while the grid is still exactly a preset + N generations */
function buildOffsets() {
var wrapOn = wrapEl.checked;
rowUp = new Int32Array(rows); rowDn = new Int32Array(rows);
colL = new Int32Array(cols); colR = new Int32Array(cols);
for (var y = 0; y < rows; y++) {
rowUp[y] = y > 0 ? (y - 1) * cols : (wrapOn ? (rows - 1) * cols : -1);
rowDn[y] = y < rows - 1 ? (y + 1) * cols : (wrapOn ? 0 : -1);
}
for (var x = 0; x < cols; x++) {
colL[x] = x > 0 ? x - 1 : (wrapOn ? cols - 1 : -1);
colR[x] = x < cols - 1 ? x + 1 : (wrapOn ? 0 : -1);
}
}
function makeGrid(nc, nr) {
var oldCur = cur, oc = cols, orr = rows;
cols = nc; rows = nr; N = nc * nr;
cur = new Uint8Array(N); nxt = new Uint8Array(N);
age = new Uint8Array(N); ghost = new Uint8Array(N);
buckets = []; bcount = [];
for (var k = 0; k < AGE_BK + 3; k++) { buckets.push(new Int32Array(N)); bcount.push(0); }
if (oldCur) {
var dx = (nc - oc) >> 1, dy = (nr - orr) >> 1;
for (var y = 0; y < orr; y++) {
var ty = y + dy; if (ty < 0 || ty >= nr) continue;
for (var x = 0; x < oc; x++) {
var tx = x + dx; if (tx < 0 || tx >= nc) continue;
if (oldCur[y * oc + x]) cur[ty * nc + tx] = 1;
}
}
}
buildOffsets();
countPop();
delta = 0;
}
function countPop() {
var p = 0;
for (var i = 0; i < N; i++) if (cur[i]) p++;
pop = p;
return p;
}
function pushHist(p) {
hist.push(p);
if (hist.length > HIST_MAX) hist.shift();
}
/* ── one generation ─────────────────────────────────────────────────────── */
function step() {
var same = true, p = 0;
for (var y = 0; y < rows; y++) {
var u = rowUp[y], d = rowDn[y], c = y * cols;
for (var x = 0; x < cols; x++) {
var l = colL[x], r = colR[x], n = 0;
if (u >= 0) { if (l >= 0) n += cur[u + l]; n += cur[u + x]; if (r >= 0) n += cur[u + r]; }
if (l >= 0) n += cur[c + l];
if (r >= 0) n += cur[c + r];
if (d >= 0) { if (l >= 0) n += cur[d + l]; n += cur[d + x]; if (r >= 0) n += cur[d + r]; }
var i = c + x, a = cur[i];
var alive = a ? (n === 2 || n === 3) : (n === 3);
if (alive) {
p++; nxt[i] = 1;
if (a) { if (age[i] < 250) age[i]++; } else { age[i] = 0; }
ghost[i] = 0;
} else {
nxt[i] = 0;
if (a) { ghost[i] = 3; age[i] = 0; }
else if (ghost[i]) ghost[i]--;
}
if (nxt[i] !== a) same = false;
}
}
var t = cur; cur = nxt; nxt = t;
delta = p - pop; pop = p; gen++;
pushHist(p);
return same;
}
/* ── drawing ────────────────────────────────────────────────────────────── */
var cssW = 0, cssH = 0, dpr = 1;
var hover = null, kcur = null, showCur = false;
var xs = null, ys = null; /* integer cell edges — keeps every cell crisp */
function buildEdges() {
if (!cols || !rows || !cssW || !cssH) { xs = ys = null; return; }
xs = new Int32Array(cols + 1);
ys = new Int32Array(rows + 1);
for (var i = 0; i <= cols; i++) xs[i] = Math.round(i * cssW / cols);
for (var j = 0; j <= rows; j++) ys[j] = Math.round(j * cssH / rows);
}
function draw() {
if (!cur || !cssW || !xs) return;
var cw = cssW / cols, ch = cssH / rows;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = surfCss;
ctx.fillRect(0, 0, cssW, cssH);
if (cw >= 7 && ch >= 7) {
ctx.beginPath();
for (var gx = 1; gx < cols; gx++) {
var px = xs[gx] + 0.5;
ctx.moveTo(px, 0); ctx.lineTo(px, cssH);
}
for (var gy = 1; gy < rows; gy++) {
var py = ys[gy] + 0.5;
ctx.moveTo(0, py); ctx.lineTo(cssW, py);
}
ctx.lineWidth = 1; ctx.strokeStyle = gridCss; ctx.stroke();
}
for (var k = 0; k < bcount.length; k++) bcount[k] = 0;
for (var i = 0; i < N; i++) {
var b;
if (cur[i]) {
b = age[i] >> 1; if (b > AGE_BK - 1) b = AGE_BK - 1;
} else if (ghost[i]) {
b = AGE_BK + (ghost[i] - 1);
} else continue;
buckets[b][bcount[b]++] = i;
}
var gap = (cw >= 6 && ch >= 6) ? 1 : 0;
for (var bk = bcount.length - 1; bk >= 0; bk--) {
var n = bcount[bk]; if (!n) continue;
ctx.fillStyle = bk < AGE_BK ? aliveCss[bk] : ghostCss[bk - AGE_BK];
var arr = buckets[bk];
for (var j = 0; j < n; j++) {
var idx = arr[j], cx = idx % cols, cy = (idx / cols) | 0;
var px0 = xs[cx], py0 = ys[cy];
ctx.fillRect(px0, py0, Math.max(1, xs[cx + 1] - px0 - gap), Math.max(1, ys[cy + 1] - py0 - gap));
}
}
var mark = showCur && kcur ? kcur : hover;
if (mark && mark.x < cols && mark.y < rows) {
ctx.lineWidth = 2;
ctx.strokeStyle = curCss;
ctx.strokeRect(
xs[mark.x] + 1, ys[mark.y] + 1,
Math.max(2, xs[mark.x + 1] - xs[mark.x] - 2),
Math.max(2, ys[mark.y + 1] - ys[mark.y] - 2)
);
}
}
function sizeSpark() {
var w = Math.max(40, Math.round(spark.clientWidth || 260));
var h = Math.max(20, Math.round(spark.clientHeight || 40));
spark.width = Math.round(w * dpr); spark.height = Math.round(h * dpr);
spark._w = w; spark._h = h;
}
function drawSpark() {
if (!spark._w) return;
var w = spark._w, h = spark._h;
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, w, h);
sctx.beginPath();
sctx.moveTo(0, h - 0.5); sctx.lineTo(w, h - 0.5);
sctx.lineWidth = 1; sctx.strokeStyle = gridCss; sctx.stroke();
var n = hist.length;
if (n < 2) return;
var max = 1;
for (var i = 0; i < n; i++) if (hist[i] > max) max = hist[i];
var stepX = w / (Math.max(36, n) - 1);
var x0 = w - (n - 1) * stepX;
sctx.beginPath();
for (var j = 0; j < n; j++) {
var x = x0 + j * stepX, y = h - 2 - (hist[j] / max) * (h - 5);
if (j === 0) sctx.moveTo(x, y); else sctx.lineTo(x, y);
}
sctx.lineWidth = 1.5; sctx.strokeStyle = sparkLine; sctx.lineJoin = "round"; sctx.stroke();
sctx.lineTo(x0 + (n - 1) * stepX, h - 1); sctx.lineTo(x0, h - 1); sctx.closePath();
sctx.fillStyle = sparkFill; sctx.fill();
}
/* ── readouts ───────────────────────────────────────────────────────────── */
function fmt(v) { return String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
function updateStats() {
sGen.textContent = fmt(gen);
sPop.textContent = fmt(pop);
var d = delta;
sDelta.textContent = d > 0 ? "+" + fmt(d) : (d < 0 ? "−" + fmt(-d) : "0");
sDelta.className = "v " + (d === 0 ? "flat" : "up");
}
function setPill(state, text) {
pill.className = "pill" + (state === "run" ? " on" : state === "note" ? " note" : "");
pillText.textContent = text;
}
/* ── patterns ───────────────────────────────────────────────────────────── */
var GUN = [
"........................O...........",
"......................O.O...........",
"............OO......OO............OO",
"...........O...O....OO............OO",
"OO........O.....O...OO..............",
"OO........O...O.OO....O.O...........",
"..........O.....O.......O...........",
"...........O...O....................",
"............OO......................"
];
var PULSAR = [
"..OOO...OOO..",
".............",
"O....O.O....O",
"O....O.O....O",
"O....O.O....O",
"..OOO...OOO..",
".............",
"..OOO...OOO..",
"O....O.O....O",
"O....O.O....O",
"O....O.O....O",
".............",
"..OOO...OOO.."
];
var RPENT = [
".OO",
"OO.",
".O."
];
function clearGrid() {
cur.fill(0); nxt.fill(0); age.fill(0); ghost.fill(0);
pop = 0; delta = 0; gen = 0; hist.length = 0;
}
function stamp(pat, ox, oy) {
for (var y = 0; y < pat.length; y++) {
var row = pat[y], ty = oy + y;
if (ty < 0 || ty >= rows) continue;
for (var x = 0; x < row.length; x++) {
if (row.charAt(x) !== "O") continue;
var tx = ox + x;
if (tx < 0 || tx >= cols) continue;
cur[ty * cols + tx] = 1;
}
}
}
var PRESET_NAMES = ["Glider gun", "Pulsar", "R-pentomino", "Random soup"];
var activePreset = 0;
function applyPreset(p, announce, warm) {
clearGrid();
if (p === 0) {
var ox = Math.max(0, Math.min(2, cols - GUN[0].length));
stamp(GUN, ox, Math.max(1, Math.min(2, rows - GUN.length)));
} else if (p === 1) {
stamp(PULSAR, ((cols - 13) / 2) | 0, ((rows - 13) / 2) | 0);
} else if (p === 2) {
stamp(RPENT, ((cols - 3) / 2) | 0, ((rows - 3) / 2) | 0);
} else {
var mw = Math.max(8, (cols * 0.62) | 0), mh = Math.max(8, (rows * 0.62) | 0);
var sx = ((cols - mw) / 2) | 0, sy = ((rows - mh) / 2) | 0;
for (var y = 0; y < mh; y++) {
for (var x = 0; x < mw; x++) {
if (Math.random() < 0.34) cur[(sy + y) * cols + sx + x] = 1;
}
}
}
countPop();
pushHist(pop);
if (warm) {
for (var w = 0; w < warm; w++) {
if (pop === 0 || step()) break;
}
}
activePreset = p;
pristine = true;
var btns = presetsEl.querySelectorAll("button");
for (var k = 0; k < btns.length; k++) {
btns[k].setAttribute("aria-pressed", String(Number(btns[k].getAttribute("data-p")) === p));
}
updateStats(); draw(); drawSpark();
if (announce) say(PRESET_NAMES[p] + " loaded, " + pop + " live cells.");
}
/* ── the loop ───────────────────────────────────────────────────────────── */
var running = false, rafId = 0, lastT = 0, acc = 0, gps = 12;
function setRunning(on, reason) {
if (on === running) return;
running = on;
/* the label itself carries the state, so no aria-pressed: "Pause, pressed"
reads as though pausing were already in effect */
playBtn.textContent = on ? "Pause" : "Play";
if (on) {
setPill("run", "running · " + gps + " gen/s");
lastT = 0; acc = 0;
if (!rafId) rafId = requestAnimationFrame(loop);
hideOverlay();
} else {
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
setPill(reason ? "note" : "idle", reason || "paused");
}
}
function loop(t) {
rafId = requestAnimationFrame(loop);
if (!lastT) lastT = t;
var dt = t - lastT; lastT = t;
if (dt > 250) dt = 250;
acc += dt;
var interval = 1000 / gps, steps = 0;
while (acc >= interval && steps < 8) {
acc -= interval; steps++;
var same = step();
if (pop === 0) { finish("everything died out"); break; }
if (same) { finish("settled — nothing changes now"); break; }
}
if (steps) { updateStats(); draw(); drawSpark(); }
}
function finish(msg) {
setRunning(false, msg);
say(msg + " at generation " + gen + ".");
}
function doStep() {
setRunning(false);
if (pop === 0) { /* nothing to advance — don't tick the counter over an empty grid */
setPill("note", "grid is empty — draw or pick a pattern");
return false;
}
step();
updateStats(); draw(); drawSpark();
if (pop === 0) setPill("note", "everything died out");
else setPill("idle", "paused · gen " + gen);
return true;
}
/* ── painting ───────────────────────────────────────────────────────────── */
var painting = false, paintVal = 1, lastCell = null;
function cellAt(clientX, clientY) {
var r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return null;
var x = Math.floor((clientX - r.left) / (r.width / cols));
var y = Math.floor((clientY - r.top) / (r.height / rows));
if (x < 0 || y < 0 || x >= cols || y >= rows) return null;
return { x: x, y: y, i: y * cols + x };
}
function setCell(x, y, v) {
if (x < 0 || y < 0 || x >= cols || y >= rows) return;
var i = y * cols + x;
if (cur[i] === v) return;
cur[i] = v;
pristine = false;
age[i] = 0;
ghost[i] = v ? 0 : 2;
pop += v ? 1 : -1;
}
function paintLine(a, b, v) {
var x0 = a.x, y0 = a.y, x1 = b.x, y1 = b.y;
var dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
var dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
var err = dx + dy, guard = 0;
while (guard++ < 4000) {
setCell(x0, y0, v);
if (x0 === x1 && y0 === y1) break;
var e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
canvas.addEventListener("pointerdown", function (e) {
var p = cellAt(e.clientX, e.clientY);
if (!p) return;
e.preventDefault();
if (canvas.setPointerCapture) { try { canvas.setPointerCapture(e.pointerId); } catch (err) {} }
painting = true;
paintVal = cur[p.i] ? 0 : 1;
setCell(p.x, p.y, paintVal);
lastCell = p; hover = p; kcur = { x: p.x, y: p.y, i: p.i };
afterEdit();
dismissHint();
hideOverlay();
canvas.focus();
/* focus() fires synchronously; clear the keyboard caret it just turned on so a
mouse user isn't left with a stray outline parked on the cell they clicked */
showCur = false;
draw();
});
canvas.addEventListener("pointermove", function (e) {
var p = cellAt(e.clientX, e.clientY);
if (painting) {
if (!p) { lastCell = null; return; }
if (lastCell) paintLine(lastCell, p, paintVal); else setCell(p.x, p.y, paintVal);
lastCell = p; hover = p;
afterEdit();
} else if (e.pointerType === "mouse") {
var changed = (!!p !== !!hover) || (p && hover && (p.x !== hover.x || p.y !== hover.y));
hover = p;
if (changed && !running) draw();
}
});
function endPaint() {
if (!painting) return;
painting = false; lastCell = null;
say(pop + " live cells.");
}
canvas.addEventListener("pointerup", endPaint);
canvas.addEventListener("pointercancel", endPaint);
window.addEventListener("pointerup", endPaint); /* if capture ever fails, don't stay stuck painting */
canvas.addEventListener("pointerleave", function () {
if (!painting && hover) { hover = null; if (!running) draw(); }
});
function afterEdit() {
delta = 0;
/* Keep the status honest while the visitor edits. This used to refresh only when
the pill was in the "note" state, so the "empty · draw some cells" line Clear
leaves behind sat there contradicting a grid the visitor had just drawn on. */
if (!running) setPill("idle", pop > 0 ? "paused · gen " + gen : "empty · draw some cells");
updateStats();
draw();
}
/* ── keyboard on the grid ───────────────────────────────────────────────── */
canvas.addEventListener("focus", function () { ensureCursor(); draw(); });
canvas.addEventListener("blur", function () { showCur = false; draw(); });
function ensureCursor() {
if (!kcur || kcur.x >= cols || kcur.y >= rows) kcur = { x: cols >> 1, y: rows >> 1, i: 0 };
showCur = true;
return kcur;
}
canvas.addEventListener("keydown", function (e) {
var k = e.key, dx = 0, dy = 0;
if (k === "ArrowLeft") dx = -1;
else if (k === "ArrowRight") dx = 1;
else if (k === "ArrowUp") dy = -1;
else if (k === "ArrowDown") dy = 1;
else if (k === "Enter" || k === " " || k === "Spacebar") {
e.preventDefault(); e.stopPropagation();
ensureCursor();
var i = kcur.y * cols + kcur.x;
setCell(kcur.x, kcur.y, cur[i] ? 0 : 1);
afterEdit(); dismissHint();
say((cur[i] ? "cell on" : "cell off") + " at column " + (kcur.x + 1) + ", row " + (kcur.y + 1));
return;
} else return;
e.preventDefault();
ensureCursor();
var step2 = e.shiftKey ? 5 : 1;
kcur = {
x: Math.max(0, Math.min(cols - 1, kcur.x + dx * step2)),
y: Math.max(0, Math.min(rows - 1, kcur.y + dy * step2)),
i: 0
};
showCur = true;
dismissHint();
draw();
});
/* ── controls ───────────────────────────────────────────────────────────── */
playBtn.addEventListener("click", function () {
if (running) { setRunning(false); say("paused at generation " + gen + "."); }
else if (pop === 0) { setPill("note", "grid is empty — draw or pick a pattern"); say("Grid is empty. Draw cells or pick a pattern."); }
else { setRunning(true); say("running at " + gps + " generations per second."); }
});
overlayPlay.addEventListener("click", function () { setRunning(true); canvas.focus(); });
overlay.addEventListener("pointerdown", function (e) { if (e.target === overlay) hideOverlay(); });
stepBtn.addEventListener("click", function () {
hideOverlay();
if (doStep()) say("generation " + gen + ", " + pop + " live cells.");
else say("Grid is empty. Draw cells or pick a pattern.");
});
clearBtn.addEventListener("click", function () {
setRunning(false);
hideOverlay();
clearGrid();
pristine = false;
var btns = presetsEl.querySelectorAll("button");
for (var k = 0; k < btns.length; k++) btns[k].setAttribute("aria-pressed", "false");
activePreset = -1;
updateStats(); draw(); drawSpark();
setPill("idle", "empty · draw some cells");
say("Grid cleared.");
dismissHint();
});
speedEl.addEventListener("input", function () {
gps = Math.max(1, Math.min(60, Number(speedEl.value) || 12));
speedV.textContent = gps + " gen/s";
speedEl.setAttribute("aria-valuetext", gps + " generations per second");
if (running) setPill("run", "running · " + gps + " gen/s");
acc = 0;
});
presetsEl.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button[data-p]") : null;
if (!b) return;
var p = Number(b.getAttribute("data-p"));
hideOverlay();
applyPreset(p, true);
dismissHint();
if (reduced) { setRunning(false); setPill("idle", "paused · " + PRESET_NAMES[p]); }
else { setRunning(false); setRunning(true); }
});
wrapEl.addEventListener("change", function () {
buildOffsets();
say(wrapEl.checked ? "Edges wrap around." : "Edges are walls.");
});
/* ── global shortcuts ───────────────────────────────────────────────────── */
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target, tag = t && t.tagName ? t.tagName.toLowerCase() : "";
var k = e.key;
/* never hijack keys aimed at a form control — the speed slider and the wrap
switch both live on this page, and "c" must not wipe the grid from there */
if (tag === "input" || tag === "select" || tag === "textarea") return;
if (k === " " || k === "Spacebar") {
if (tag === "button" || t === canvas) return;
e.preventDefault();
playBtn.click();
return;
}
if (k === "s" || k === "S") { e.preventDefault(); stepBtn.click(); }
else if (k === "c" || k === "C") { e.preventDefault(); clearBtn.click(); }
else if (k === "p" || k === "P") { e.preventDefault(); playBtn.click(); }
else if (k >= "1" && k <= "4") {
e.preventDefault();
var b = presetsEl.querySelector('button[data-p="' + (Number(k) - 1) + '"]');
if (b) b.click();
}
});
/* ── hint + live region ─────────────────────────────────────────────────── */
var hintTimer = 0;
function dismissHint() {
if (hint.hidden || hint.classList.contains("gone")) return;
hint.classList.add("gone");
clearTimeout(hintTimer);
setTimeout(function () { hint.hidden = true; }, 500);
}
function hideOverlay() { if (!overlay.hidden) overlay.hidden = true; }
var sayTimer = 0;
function say(msg) {
clearTimeout(sayTimer);
sayTimer = setTimeout(function () { live.textContent = msg; }, 140);
}
/* ── sizing ─────────────────────────────────────────────────────────────── */
var MIN_COLS = GUN[0].length + 4; /* the widest preset must always fit whole */
function idealDims(w, h) {
var target = w < 560 ? 8 : (w < 900 ? 9.5 : 11);
var nc = Math.round(w / target), nr = Math.round(h / target);
nc = Math.max(MIN_COLS, Math.min(200, nc));
nr = Math.max(26, Math.min(160, nr));
return [nc, nr];
}
function resize(force) {
/* clientWidth/Height are the field's CONTENT box — i.e. exactly the space the
canvas fills. Measuring the border box instead would size the backing store
2px larger than the element it is painted into, and the browser would rescale
every frame, softening the integer cell edges buildEdges() works to keep. */
var rw = field.clientWidth, rh = field.clientHeight;
/* The field is sized by aspect-ratio inside a grid, so the very first
measurement can land before layout has settled and come back ~0. Building a
grid from that gives a cramped 40x26 board — the gun would be warmed up on
the wrong field and then re-centred as debris. Wait for a real box instead. */
if ((rw < 40 || rh < 40) && !force) return false;
var w = Math.max(160, rw), h = Math.max(140, rh);
dpr = Math.min(window.devicePixelRatio || 1, 2);
if (w !== cssW || h !== cssH || canvas.width !== Math.round(w * dpr)) {
cssW = w; cssH = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
/* pin the CSS box to the same integer size so backing store and element map
1:1 — cell edges land on device pixels, and cellAt() hit-tests the very
same geometry that draw() paints */
canvas.style.width = w + "px";
canvas.style.height = h + "px";
}
var d = idealDims(cssW, cssH);
if (!cur || Math.abs(d[0] - cols) >= 3 || Math.abs(d[1] - rows) >= 3) {
/* if nothing has been drawn yet and we are still near the opening state,
re-stamp the pattern onto the new board rather than re-centring the old one.
Random soup is excluded: re-stamping it would roll fresh dice and throw away
the board the visitor is actually watching, so that one just gets re-centred. */
var refit = !!cur && pristine && activePreset >= 0 && activePreset !== 3 && gen <= 400;
var atGen = gen;
makeGrid(d[0], d[1]);
if (kcur) kcur = { x: Math.min(kcur.x, cols - 1), y: Math.min(kcur.y, rows - 1), i: 0 };
if (refit) { buildEdges(); applyPreset(activePreset, false, atGen); }
}
buildEdges();
sizeSpark();
updateStats();
draw(); drawSpark();
return true;
}
document.addEventListener("visibilitychange", function () {
if (document.hidden && running && rafId) { cancelAnimationFrame(rafId); rafId = 0; }
else if (!document.hidden && running && !rafId) { lastT = 0; acc = 0; rafId = requestAnimationFrame(loop); }
});
if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(field);
else window.addEventListener("resize", resize);
/* ── boot ───────────────────────────────────────────────────────────────── */
var booted = false;
function boot() {
if (booted) return;
booted = true;
applyPreset(0, false, 150); /* warm the gun up so the grid arrives mid-stream */
if (reduced) {
setPill("idle", "paused · reduced motion");
overlayNote.textContent = "Your system asks for reduced motion, so the grid starts paused. The glider gun is loaded and ready.";
overlay.hidden = false;
updateStats(); draw(); drawSpark();
} else {
setRunning(true);
}
hintTimer = setTimeout(dismissHint, 8000);
}
readTheme();
gps = Number(speedEl.value) || 12;
speedV.textContent = gps + " gen/s";
speedEl.setAttribute("aria-valuetext", gps + " generations per second");
if (resize()) boot();
else requestAnimationFrame(function () { if (!resize()) resize(true); boot(); });
})();
</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.