$sloprun.dev

Game of Life

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

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

▶ 11 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

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.

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/game-of-life" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Game of Life — 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/game-of-life

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1082 lines 43.9 KB index.html
demos/game-of-life/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>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 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