Game of Life widget

Powers the Life board on Games. Embed a board on any page with a games.life() expression.

It runs inside a sandboxed iframe (see Library/Games/Purrvim for the other game). The board wraps around at the edges, colours cells by how long they have been alive, and leaves fading ghosts where cells die. It pauses itself when scrolled out of view.

The Lua below reads the widget's markup and script from the html and js blocks further down this page, so each stays plain, highlighted code.

-- priority: 10
games = games or {}

-- Returns the body of the first ```<lang> block on a page.
function games.block(page, lang)
  local text = space.readPage(page)
  local open = "```" .. lang .. "\n"
  local s = string.find(text, open, 1, true)
  if not s then error("No " .. lang .. " block on " .. page) end
  local from = s + #open
  local e = string.find(text, "\n```", from, true)
  return string.sub(text, from, e - 1)
end

function games.life()
  local page = "Library/Games/Life"
  return widget.sandbox {
    markdown = "Conway's Game of Life (interactive widget, see Library/Games/Life)",
    html = games.block(page, "html"),
    script = games.block(page, "js"),
  }
end

Markup

<style>
  html, body { margin: 0; padding: 0; background: transparent; }
  * { box-sizing: border-box; }
  :root {
    --fg: #e7e9f3; --muted: #9aa0b8; --line: rgba(255,255,255,0.09); --chip: rgba(255,255,255,0.06);
    --chip-h: rgba(255,255,255,0.12); --accent: #a78bfa; --card: #0c1024;
  }
  html[data-theme="light"] {
    --fg: #2a2d3e; --muted: #6b6f85; --line: rgba(20,20,60,0.12); --chip: rgba(20,20,60,0.05);
    --chip-h: rgba(20,20,60,0.10); --accent: #6d5bd0; --card: #fbf8f1;
  }
  body { font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); }
  .life { border-radius: 16px; overflow: hidden; border: 1px solid var(--line); background: var(--card);
          box-shadow: 0 10px 30px -18px rgba(0,0,0,0.6); margin: 4px 2px 10px; }
  .stage { position: relative; width: 100%; height: 320px; transition: background 0.6s ease; }
  .stage canvas { position: absolute; inset: 0; display: block; }
  #glow { filter: blur(7px) saturate(1.4); opacity: 0.8; mix-blend-mode: screen; pointer-events: none; }
  html[data-theme="light"] #glow { mix-blend-mode: multiply; opacity: 0.25; }
  #cv { cursor: crosshair; touch-action: pan-y pinch-zoom; }
  .hud { position: absolute; left: 12px; top: 10px; display: flex; gap: 10px; align-items: center;
         font: 11px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.04em;
         color: var(--hud, rgba(255,255,255,0.7)); pointer-events: none;
         background: var(--hud-bg, rgba(0,0,0,0.28)); padding: 6px 9px; border-radius: 999px;
         backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
  .hud b { font-weight: 600; color: var(--hud-strong, #fff); }
  #spark { width: 90px; height: 16px; display: block; }
  .rule { position: absolute; right: 12px; top: 10px; font: 11px/1 ui-monospace, "SF Mono", Menlo, monospace;
          color: var(--hud, rgba(255,255,255,0.6)); pointer-events: none; padding: 6px 9px; border-radius: 999px;
          background: var(--hud-bg, rgba(0,0,0,0.28)); }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 12px; border-top: 1px solid var(--line); }
  .btn { appearance: none; border: 1px solid var(--line); background: var(--chip); color: var(--fg);
         font: inherit; font-weight: 550; padding: 6px 11px; border-radius: 10px; cursor: pointer;
         display: inline-flex; align-items: center; gap: 6px; transition: background 0.15s, transform 0.08s; }
  .btn:hover { background: var(--chip-h); }
  .btn:active { transform: translateY(1px); }
  .btn.primary { background: var(--accent); border-color: transparent; color: #fff; min-width: 40px; justify-content: center; }
  .btn svg { width: 14px; height: 14px; fill: currentColor; }
  .field { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-weight: 500; }
  select { font: inherit; color: var(--fg); background: var(--chip); border: 1px solid var(--line); border-radius: 9px; padding: 5px 8px; }
  select option { color: #222; }
  input[type=range] { width: 92px; accent-color: var(--accent); }
  .grow { flex: 1; }
  .pals { display: inline-flex; gap: 6px; }
  .pal { width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; }
  .pal.on { border-color: var(--fg); }
  .tip { padding: 0 12px 10px; color: var(--muted); font-size: 12px; }
  kbd { font: 11px ui-monospace, "SF Mono", Menlo, monospace; padding: 1px 5px; border-radius: 5px;
        border: 1px solid var(--line); background: var(--chip); color: var(--fg); }
</style>
<div class="life" id="root" tabindex="0">
  <div class="stage" id="stage">
    <canvas id="glow"></canvas>
    <canvas id="cv"></canvas>
    <div class="hud"><span>gen <b id="gen">0</b></span><span><b id="pop">0</b> alive</span><canvas id="spark"></canvas></div>
    <div class="rule" id="ruleTag">B3/S23</div>
  </div>
  <div class="bar">
    <button class="btn primary" id="play" title="Play / pause (space)"></button>
    <button class="btn" id="step" title="One generation (s)">Step</button>
    <button class="btn" id="soup" title="Random soup (r)">Soup</button>
    <button class="btn" id="clear" title="Clear the board (c)">Clear</button>
    <label class="field">Brush
      <select id="brush">
        <option value="">Draw</option>
        <option value="glider">Glider</option>
        <option value="lwss">Spaceship</option>
        <option value="pulsar">Pulsar</option>
        <option value="penta">Pentadecathlon</option>
        <option value="gun">Glider gun</option>
        <option value="rpent">R-pentomino</option>
        <option value="acorn">Acorn</option>
        <option value="diehard">Diehard</option>
      </select>
    </label>
    <label class="field">Rule
      <select id="rule">
        <option value="B3/S23">Life</option>
        <option value="B36/S23">HighLife</option>
        <option value="B3678/S34678">Day &amp; Night</option>
        <option value="B368/S245">Morley</option>
        <option value="B2/S">Seeds</option>
      </select>
    </label>
    <label class="field">Speed <input type="range" id="speed" min="1" max="40" value="12"></label>
    <span class="grow"></span>
    <span class="pals" id="pals"></span>
  </div>
  <div class="tip">Click or drag to draw, or pick a brush and click to stamp it. <kbd>Space</kbd> play · <kbd>S</kbd> step · <kbd>R</kbd> soup · <kbd>C</kbd> clear</div>
</div>

Script

(() => {
  const $ = (id) => document.getElementById(id);
  const stage = $("stage"), cv = $("cv"), glow = $("glow"), spark = $("spark");
  const ctx = cv.getContext("2d"), gctx = glow.getContext("2d"), sctx = spark.getContext("2d");
  const CELL = 10;
  const DPR = Math.min(2, window.devicePixelRatio || 1);

  // Colours run from newborn (index 0) to old and settled (index 5).
  const PALETTES = {
    aurora: { name: "Aurora", dark: true, swatch: "#5eead4",
      bg: "radial-gradient(120% 95% at 25% 15%, #1b2550 0%, #0b1026 55%, #05060f 100%)",
      cells: ["#f4fbff", "#9ef0ff", "#4fd8d0", "#7f8cff", "#b089ff", "#f0a6ff"], ghost: "#3fb5ff", ghostA: 0.32 },
    ember: { name: "Ember", dark: true, swatch: "#fb923c",
      bg: "radial-gradient(120% 95% at 70% 10%, #2a120c 0%, #120706 60%, #060202 100%)",
      cells: ["#fff6d8", "#ffe066", "#ffa043", "#ff5d5d", "#d6336c", "#8c2f5a"], ghost: "#ff7b2e", ghostA: 0.3 },
    moss: { name: "Moss", dark: true, swatch: "#4ade80",
      bg: "radial-gradient(120% 95% at 30% 80%, #11291c 0%, #08150f 60%, #030806 100%)",
      cells: ["#f3ffe0", "#c6f56c", "#5fe08a", "#22c3a0", "#1a8f8f", "#2d6a6a"], ghost: "#36d07a", ghostA: 0.28 },
    ink: { name: "Ink", dark: false, swatch: "#4338ca",
      bg: "radial-gradient(130% 100% at 20% 0%, #fffdf8 0%, #f6f0e4 60%, #efe6d4 100%)",
      cells: ["#e0457b", "#7c3aed", "#4338ca", "#3b4fa8", "#2f4a73", "#24364f"], ghost: "#b9a6e8", ghostA: 0.45 }
  };

  const PATTERNS = {
    glider: [".O.", "..O", "OOO"],
    lwss: [".O..O", "O....", "O...O", "OOOO."],
    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.."],
    penta: ["..O....O..", "OO.OOOO.OO", "..O....O.."],
    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......................"],
    rpent: [".OO", "OO.", ".O."],
    acorn: [".O.....", "...O...", "OO..OOO"],
    diehard: ["......O.", "OO......", ".O...OOO"]
  };

  let palKey = "aurora", pal = PALETTES.aurora, userPicked = false;
  let cols = 0, rows = 0, ox = 0, oy = 0, W = 0, H = 0;
  let cells = new Uint8Array(0), nxt = new Uint8Array(0), age = new Uint16Array(0), ghost = new Float32Array(0);
  let gen = 0, pop = 0, running = true, speed = 12, acc = 0, last = performance.now(), visible = true;
  let born = [], surv = [], hist = [];
  let brush = null, hover = null, painting = false, paintVal = 1, lastPaint = null;

  function setRule(r) {
    const m = /^B(\d*)\/S(\d*)$/.exec(r);
    born = new Array(9).fill(0); surv = new Array(9).fill(0);
    for (const d of m[1]) born[+d] = 1;
    for (const d of m[2]) surv[+d] = 1;
    $("ruleTag").textContent = r;
  }

  function stampCells(name) {
    const rowsP = PATTERNS[name], out = [];
    for (let y = 0; y < rowsP.length; y++)
      for (let x = 0; x < rowsP[y].length; x++) if (rowsP[y][x] === "O") out.push([x, y]);
    return { cells: out, w: rowsP[0].length, h: rowsP.length };
  }

  function idx(x, y) { return ((y + rows) % rows) * cols + ((x + cols) % cols); }

  function setCell(x, y, v) {
    const i = idx(x, y);
    if (v && !cells[i]) { cells[i] = 1; age[i] = 1; ghost[i] = 0; }
    else if (!v && cells[i]) { cells[i] = 0; age[i] = 0; ghost[i] = 1; }
  }

  function countPop() { let n = 0; for (let i = 0; i < cells.length; i++) n += cells[i]; pop = n; }

  function resize() {
    const w = Math.floor(stage.clientWidth);
    if (!w || w === W) return;
    const h = Math.round(Math.max(260, Math.min(460, w * 0.56)));
    stage.style.height = h + "px";
    for (const c of [cv, glow]) {
      c.width = Math.round(w * DPR); c.height = Math.round(h * DPR);
      c.style.width = w + "px"; c.style.height = h + "px";
    }
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    spark.width = 90 * DPR; spark.height = 16 * DPR; sctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    const nc = Math.max(12, Math.floor(w / CELL)), nr = Math.max(12, Math.floor(h / CELL));
    if (nc !== cols || nr !== rows) {
      const old = cells, oldAge = age, oc = cols, orr = rows;
      cols = nc; rows = nr;
      cells = new Uint8Array(cols * rows); nxt = new Uint8Array(cols * rows);
      age = new Uint16Array(cols * rows); ghost = new Float32Array(cols * rows);
      if (oc) {
        const dx = Math.floor((cols - oc) / 2), dy = Math.floor((rows - orr) / 2);
        for (let y = 0; y < orr; y++) for (let x = 0; x < oc; x++) {
          if (!old[y * oc + x]) continue;
          const X = x + dx, Y = y + dy;
          if (X >= 0 && X < cols && Y >= 0 && Y < rows) { cells[Y * cols + X] = 1; age[Y * cols + X] = oldAge[y * oc + x]; }
        }
      }
      countPop();
    }
    W = w; H = h;
    ox = Math.floor((w - cols * CELL) / 2); oy = Math.floor((h - rows * CELL) / 2);
    draw();
    remeasure();
  }

  function remeasure() { try { heightChecks = 0; updateHeight(); } catch (e) {} }

  function step() {
    const C = cols, R = rows;
    let n = 0;
    for (let y = 0; y < R; y++) {
      const up = ((y - 1 + R) % R) * C, mid = y * C, dn = ((y + 1) % R) * C;
      for (let x = 0; x < C; x++) {
        const l = (x - 1 + C) % C, r = (x + 1) % C;
        const k = cells[up + l] + cells[up + x] + cells[up + r] + cells[mid + l] + cells[mid + r] +
                  cells[dn + l] + cells[dn + x] + cells[dn + r];
        const i = mid + x, a = cells[i];
        const live = a ? surv[k] : born[k];
        nxt[i] = live;
        if (live) { age[i] = a ? (age[i] < 60000 ? age[i] + 1 : age[i]) : 1; n++; }
        else { if (a) ghost[i] = 1; age[i] = 0; }
      }
    }
    const t = cells; cells = nxt; nxt = t;
    gen++; pop = n;
    hist.push(n); if (hist.length > 90) hist.shift();
  }

  function soup() {
    for (let i = 0; i < cells.length; i++) {
      if (cells[i]) ghost[i] = 1;
      const v = Math.random() < 0.24 ? 1 : 0;
      cells[i] = v; age[i] = v ? 1 + Math.floor(Math.random() * 3) : 0;
    }
    gen = 0; hist = []; countPop();
  }

  function clearAll() {
    for (let i = 0; i < cells.length; i++) { if (cells[i]) ghost[i] = 1; cells[i] = 0; age[i] = 0; }
    gen = 0; pop = 0; hist = [];
  }

  function bucket(a) { return a <= 1 ? 0 : a <= 2 ? 1 : a <= 5 ? 2 : a <= 12 ? 3 : a <= 40 ? 4 : 5; }

  function addCell(p, x, y) {
    if (p.roundRect) p.roundRect(x + 1, y + 1, CELL - 2, CELL - 2, 3); else p.rect(x + 1, y + 1, CELL - 2, CELL - 2);
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);
    // Ghosts of recently dead cells
    ctx.fillStyle = pal.ghost;
    for (let i = 0; i < ghost.length; i++) {
      const g = ghost[i];
      if (g <= 0.02 || cells[i]) continue;
      ctx.globalAlpha = g * pal.ghostA;
      const x = ox + (i % cols) * CELL, y = oy + ((i / cols) | 0) * CELL;
      const s = 2 + (1 - g) * 2;
      ctx.fillRect(x + s, y + s, CELL - 2 * s, CELL - 2 * s);
    }
    ctx.globalAlpha = 1;
    // Live cells, batched by age
    const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D()];
    for (let i = 0; i < cells.length; i++) {
      if (!cells[i]) continue;
      addCell(paths[bucket(age[i])], ox + (i % cols) * CELL, oy + ((i / cols) | 0) * CELL);
    }
    for (let b = 5; b >= 0; b--) { ctx.fillStyle = pal.cells[b]; ctx.fill(paths[b]); }
    // Brush preview
    if (brush && hover) {
      const p = new Path2D();
      const bx = hover.x - Math.floor(brush.w / 2), by = hover.y - Math.floor(brush.h / 2);
      for (const [dx, dy] of brush.cells) {
        const X = (bx + dx + cols) % cols, Y = (by + dy + rows) % rows;
        addCell(p, ox + X * CELL, oy + Y * CELL);
      }
      ctx.globalAlpha = 0.45; ctx.fillStyle = pal.cells[0]; ctx.fill(p); ctx.globalAlpha = 1;
    } else if (hover && !painting) {
      ctx.strokeStyle = pal.cells[1]; ctx.globalAlpha = 0.5; ctx.lineWidth = 1;
      ctx.strokeRect(ox + hover.x * CELL + 0.5, oy + hover.y * CELL + 0.5, CELL - 1, CELL - 1);
      ctx.globalAlpha = 1;
    }
    gctx.clearRect(0, 0, glow.width, glow.height);
    gctx.drawImage(cv, 0, 0);
  }

  function decay(dt) {
    const f = Math.pow(0.5, dt / 260);
    for (let i = 0; i < ghost.length; i++) if (ghost[i] > 0) { ghost[i] *= f; if (ghost[i] < 0.02) ghost[i] = 0; }
  }

  function drawSpark() {
    sctx.clearRect(0, 0, 90, 16);
    if (hist.length < 2) return;
    let mx = 1; for (const v of hist) if (v > mx) mx = v;
    sctx.beginPath();
    for (let i = 0; i < hist.length; i++) {
      const x = (i / 89) * 90, y = 15 - (hist[i] / mx) * 13;
      if (i) sctx.lineTo(x, y); else sctx.moveTo(x, y);
    }
    sctx.strokeStyle = pal.cells[2]; sctx.lineWidth = 1.4; sctx.stroke();
  }

  function hud() {
    $("gen").textContent = gen.toLocaleString();
    $("pop").textContent = pop.toLocaleString();
  }

  const ICON_PLAY = '<svg viewBox="0 0 16 16"><path d="M4 2.5v11l9-5.5z"/></svg>';
  const ICON_PAUSE = '<svg viewBox="0 0 16 16"><path d="M3.5 2.5h3v11h-3zM9.5 2.5h3v11h-3z"/></svg>';
  function syncPlay() { $("play").innerHTML = running ? ICON_PAUSE : ICON_PLAY; }

  function applyPalette(key) {
    palKey = key; pal = PALETTES[key];
    stage.style.background = pal.bg;
    const r = document.documentElement.style;
    if (pal.dark) {
      r.setProperty("--hud", "rgba(255,255,255,0.72)"); r.setProperty("--hud-bg", "rgba(0,0,0,0.3)"); r.setProperty("--hud-strong", "#fff");
      glow.style.mixBlendMode = "screen"; glow.style.opacity = "0.8";
    } else {
      r.setProperty("--hud", "rgba(30,30,60,0.7)"); r.setProperty("--hud-bg", "rgba(255,255,255,0.6)"); r.setProperty("--hud-strong", "#1e1b4b");
      glow.style.mixBlendMode = "multiply"; glow.style.opacity = "0.22";
    }
    for (const b of document.querySelectorAll(".pal")) b.classList.toggle("on", b.dataset.k === key);
  }

  function themePalette() {
    if (userPicked) return;
    applyPalette(document.documentElement.getAttribute("data-theme") === "light" ? "ink" : "aurora");
  }

  // Pointer drawing
  function cellAt(e) {
    const rect = cv.getBoundingClientRect();
    const x = Math.floor((e.clientX - rect.left - ox) / CELL), y = Math.floor((e.clientY - rect.top - oy) / CELL);
    if (x < 0 || y < 0 || x >= cols || y >= rows) return null;
    return { x, y };
  }
  function paintLine(a, b) {
    let x0 = a.x, y0 = a.y; const x1 = b.x, y1 = b.y;
    const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
    let err = dx + dy;
    for (;;) {
      setCell(x0, y0, paintVal);
      if (x0 === x1 && y0 === y1) break;
      const e2 = 2 * err;
      if (e2 >= dy) { err += dy; x0 += sx; }
      if (e2 <= dx) { err += dx; y0 += sy; }
    }
  }
  cv.addEventListener("pointerdown", (e) => {
    $("root").focus({ preventScroll: true });
    const p = cellAt(e); if (!p) return;
    if (brush) {
      const bx = p.x - Math.floor(brush.w / 2), by = p.y - Math.floor(brush.h / 2);
      for (const [dx, dy] of brush.cells) setCell(bx + dx, by + dy, 1);
      countPop(); return;
    }
    try { cv.setPointerCapture(e.pointerId); } catch (_) {}
    painting = true; paintVal = cells[idx(p.x, p.y)] ? 0 : 1; lastPaint = p;
    setCell(p.x, p.y, paintVal); countPop();
  });
  cv.addEventListener("pointermove", (e) => {
    const p = cellAt(e); hover = p;
    if (painting && p) { paintLine(lastPaint, p); lastPaint = p; countPop(); }
  });
  const stop = () => { painting = false; lastPaint = null; };
  cv.addEventListener("pointerup", stop);
  cv.addEventListener("pointercancel", stop);
  cv.addEventListener("pointerleave", () => { hover = null; });

  // Controls
  $("play").onclick = () => { running = !running; syncPlay(); };
  $("step").onclick = () => { running = false; syncPlay(); step(); };
  $("soup").onclick = () => soup();
  $("clear").onclick = () => clearAll();
  $("speed").oninput = (e) => { speed = +e.target.value; };
  $("brush").onchange = (e) => { brush = e.target.value ? stampCells(e.target.value) : null; };
  $("rule").onchange = (e) => setRule(e.target.value);
  for (const k of Object.keys(PALETTES)) {
    const b = document.createElement("button");
    b.className = "pal"; b.dataset.k = k; b.title = PALETTES[k].name;
    b.style.background = "linear-gradient(135deg," + PALETTES[k].cells[1] + "," + PALETTES[k].cells[4] + ")";
    b.onclick = () => { userPicked = true; applyPalette(k); };
    $("pals").appendChild(b);
  }

  document.addEventListener("keydown", (e) => {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    if (e.target && e.target.tagName === "SELECT") return;
    const k = e.key.toLowerCase();
    if (k === " ") { running = !running; syncPlay(); }
    else if (k === "s" || k === "n") { running = false; syncPlay(); step(); }
    else if (k === "r") soup();
    else if (k === "c") clearAll();
    else return;
    e.preventDefault();
  });

  function frame(now) {
    const dt = Math.min(100, now - last); last = now;
    if (visible && cols) {
      if (running) {
        acc += dt;
        const per = 1000 / speed;
        let n = 0;
        while (acc >= per && n < 4) { step(); acc -= per; n++; }
        if (n === 4) acc = 0;
      }
      decay(dt); draw(); drawSpark(); hud();
    }
    requestAnimationFrame(frame);
  }

  if ("IntersectionObserver" in window) {
    new IntersectionObserver((es) => { visible = es[es.length - 1].isIntersecting; }).observe(stage);
  }
  new MutationObserver(themePalette).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
  if ("ResizeObserver" in window) new ResizeObserver(() => resize()).observe(document.body);
  else window.addEventListener("resize", resize);

  setRule("B3/S23");
  themePalette();
  syncPlay();
  resize();
  soup();
  requestAnimationFrame(frame);
})();