Back to Conway's Game of Life
SOURCE / PINNED RELEASE

Made of little things.

Conway's Game of Life

Release
230955df1900…
Author-recorded commit
c08196c2b050…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-644c45e22c7

Archive hash verified: 2627ce678935a213…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
// Game of Life entry point (Phase 2: board render + tick loop; Phase 3:
// draw/erase input; Phase 4: simulation controls, spec §5). Capability-demo
// wiring was replaced per docs/napplet-visual-design.md; Phase 7: save wire.
import './napplet-settings.js';
// Guidance tests pin this import + a runtimeHasDomain() use: optional-domain
// absence must stay a gated normal state even when the game owns the frame.
import { runtimeHasDomain } from './domain-availability.js';
import { attachBoardInput } from './input.js';
import { decodeRle, encodeRle, GameOfLife, registerPattern, type DecodedPattern } from './engine.js';
import { GUN_SEED_X, GUN_SEED_Y, GOSPER_GUN, PATTERNS } from './patterns.js';
import { PALETTE, cellSizeFor, drawBoard, type GhostSpec } from './render.js';
import {
  loadSavedBoard,
  saveBoard,
  SPEED_MAX,
  SPEED_MIN,
  storageAvailable,
} from './savewire.js';
import './styles.css';

/** Default generation rate per ratified default D3 (spec §4.1). */
export const DEFAULT_GEN_PER_SEC = 10;
/** Accumulator catch-up cap so a backgrounded tab never burst-steps. */
const MAX_STEPS_PER_FRAME = 5;

const elements = {
  board: requireElement<HTMLDivElement>('#board'),
  canvas: requireElement<HTMLCanvasElement>('#cells'),
  generation: requireElement<HTMLSpanElement>('#generation'),
  population: requireElement<HTMLSpanElement>('#population'),
  status: requireElement<HTMLOutputElement>('#status'),
  playPause: requireElement<HTMLButtonElement>('#play-pause'),
  step: requireElement<HTMLButtonElement>('#step'),
  clear: requireElement<HTMLButtonElement>('#clear'),
  random: requireElement<HTMLButtonElement>('#random'),
  speed: requireElement<HTMLInputElement>('#speed'),
  speedValue: requireElement<HTMLOutputElement>('#speed-value'),
  saveStatus: requireElement<HTMLOutputElement>('#save-status'),
  placeStatus: requireElement<HTMLOutputElement>('#place-status'),
  patterns: requireElement<HTMLButtonElement>('#patterns'),
  patternMenu: requireElement<HTMLDivElement>('#pattern-menu'),
  patternList: requireElement<HTMLDivElement>('#pattern-list'),
  menuClose: requireElement<HTMLButtonElement>('#menu-close'),
  welcome: requireElement<HTMLDivElement>('#welcome'),
  welcomeStart: requireElement<HTMLButtonElement>('#welcome-start'),
};

function requireElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) {
    throw new Error(`Missing required element: ${selector}`);
  }
  return element;
}

// --- Engine state: default board is the reference's Gosper gun at (3,3) ----
const game = new GameOfLife();
for (const def of PATTERNS) registerPattern(def.id, def.rle);
game.patternAt(GOSPER_GUN, GUN_SEED_X, GUN_SEED_Y);

// Boots PAUSED behind the welcome gate (spec §5 welcome rule, player ruling
// 2026-10-30): Start calls setRunning(true) through the same path as the
// play/pause button. Static control bar remains the loop owner afterwards.
let running = false;
let genPerSec = DEFAULT_GEN_PER_SEC;
/** Random-fill density per spec §5 (~15%, sandbox: Random always replaces). */
export const RANDOM_DENSITY = 0.15;
let stable = false;
let dirty = true;
let cellPx = 8;

const ctx = requireContext(elements.canvas);

function requireContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
  const context = canvas.getContext('2d');
  if (!context) {
    throw new Error('Canvas 2D context unavailable');
  }
  return context;
}

// --- Layout: cell size derives from viewport (D2); letterbox on tiny frames -
function layout(): void {
  const availW = elements.board.clientWidth;
  const availH = elements.board.clientHeight;
  if (availW === 0 || availH === 0) return;
  cellPx = cellSizeFor(availW, availH, game);
  const dpr = Math.max(1, window.devicePixelRatio || 1);
  elements.canvas.width = game.w * cellPx * dpr;
  elements.canvas.height = game.h * cellPx * dpr;
  // The 4px floor can overflow a very small frame: fit the CSS box down,
  // never up, so every cell stays on screen (visual-design layout rule).
  const fit = Math.min(1, availW / (game.w * cellPx), availH / (game.h * cellPx));
  elements.canvas.style.width = `${Math.floor(game.w * cellPx * fit)}px`;
  elements.canvas.style.height = `${Math.floor(game.h * cellPx * fit)}px`;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  dirty = true;
}
new ResizeObserver(layout).observe(elements.board);
layout();

// --- Pattern placement state (spec §6, rework ruling 2026-10-30) -----------
// Placement law: a menu item ARMS a pattern (select first, never stamp on
// select). The next primary button/pointer board tap stamps it centered on
// that exact cell; a right-click or Escape cancels the arming. While armed,
// a translucent ghost previews the pattern under the pointer (mouse/pen);
// on touch the first tap simply places. Declared BEFORE the input attach so
// the intercept/hover closures read live values; closures fire only after
// module evaluation.
const patternById = new Map(PATTERNS.map((def) => [def.id, def]));
let pendingPatternId: string | null = null;
let pendingGhost: DecodedPattern | null = null;
let hoverCell: { x: number; y: number } | null = null;

function setPendingPattern(id: string | null): void {
  const def = id === null ? undefined : patternById.get(id);
  if (id !== null && !def) throw new RangeError(`unknown pattern: ${id}`);
  pendingPatternId = def ? def.id : null;
  pendingGhost = def ? decodeRle(def.rle) : null;
  hoverCell = null;
  if (def) {
    elements.placeStatus.textContent = `Placing ${def.name} — tap the board · Esc cancels`;
    elements.placeStatus.hidden = false;
  } else {
    elements.placeStatus.hidden = true;
  }
  dirty = true;
}

// --- Draw/erase input (Phase 3, spec §10) ------------------------------------
// Direct engine writes: live while the loop runs AND while paused. Paint
// flips a live cell's survival math on the very next step.
attachBoardInput(elements.canvas, game, {
  requestRedraw: () => {
    dirty = true;
  },
  onPaint: () => {
    stable = game.stable();
    syncHud();
    markChanged();
  },
  interceptPointerDown: (cell, button) => {
    if (!pendingPatternId) return false;
    const id = pendingPatternId;
    if (button === 2) {
      setPendingPattern(null); // right-click = cancel, never erase mid-arming
    } else {
      setPendingPattern(null);
      stampPatternCentered(id, cell.x, cell.y); // centered on the placing tap
    }
    return true; // gesture consumed: no single-cell paint rides along
  },
  onHover: (cell) => {
    hoverCell = cell;
    if (pendingGhost) dirty = true; // ghost follows the pointer
  },
});

// --- HUD (Phase 8 counter cues: spec §12) -----------------------------------
// Transient CSS classes on the existing HUD number spans, fired ONLY on value
// change. Zero canvas/rAF cost; color/glow only, never layout shift. Each cue
// self-removes on animationend via ONE persistent listener per element (no
// per-flash listener pile-up at 30 gen/s); restarting an already-live cue is
// remove + reflow + re-add, so rapid generations re-trigger cleanly.
const CUE_GEN = 'cue-gen';
const CUE_POP = 'cue-pop';
let lastShownGen = -1;
let lastShownPop = -1;

function flashCue(el: HTMLElement, cls: string): void {
  el.classList.remove(cls);
  void el.offsetWidth; // force reflow so the animation restarts
  el.classList.add(cls);
}

elements.generation.addEventListener('animationend', () => elements.generation.classList.remove(CUE_GEN));
elements.population.addEventListener('animationend', () => elements.population.classList.remove(CUE_POP));

function syncHud(): void {
  elements.generation.textContent = String(game.generation);
  elements.population.textContent = String(game.aliveCount());
  if (game.generation !== lastShownGen) {
    flashCue(elements.generation, CUE_GEN);
    lastShownGen = game.generation;
  }
  const pop = game.aliveCount();
  if (pop !== lastShownPop) {
    flashCue(elements.population, CUE_POP);
    lastShownPop = pop;
  }
  // Quiet stable notice only: never auto-pause, the player may edit (Phase 3).
  elements.status.hidden = !stable;
}

// --- Tick loop: rAF + accumulator at D3 rate --------------------------------
let lastMs = performance.now();
let accMs = 0;

function frame(now: number): void {
  const dt = Math.min(now - lastMs, 250);
  lastMs = now;
  if (running) {
    accMs += dt;
    const interval = 1000 / genPerSec;
    let steps = 0;
    while (accMs >= interval && steps < MAX_STEPS_PER_FRAME) {
      game.step();
      accMs -= interval;
      steps++;
    }
    if (accMs > interval) accMs = 0; // dropped after catch-up cap
    if (steps > 0) {
      stable = game.stable();
      syncHud();
      dirty = true;
      markChanged(); // running: persists ~1/s, not per generation
    }
  }
  if (dirty) {
    // Ghost preview rides the existing dirty-flag repaint: zero rAF cost,
    // only exists while a pattern is armed AND a hover cell is known.
    let ghost: GhostSpec | null = null;
    if (pendingGhost && hoverCell) {
      ghost = {
        cells: pendingGhost.cells,
        w: pendingGhost.w,
        h: pendingGhost.h,
        x: hoverCell.x - Math.floor(pendingGhost.w / 2),
        y: hoverCell.y - Math.floor(pendingGhost.h / 2),
      };
    }
    drawBoard(ctx, game, cellPx, PALETTE, ghost);
    dirty = false;
  }
  requestAnimationFrame(frame);
}

syncHud();
requestAnimationFrame(frame);

// --- Simulation controls (Phase 4, spec §5) ---------------------------------
// Static chrome OUTSIDE any state machine: buttons/keys read and write the
// loop flags directly, and painting (spec §10) stays legal in every state.
const PLAY_GLYPH = '\u23F5';  // ⏵
const PAUSE_GLYPH = '\u23F8'; // ⏸

function syncRunButton(): void {
  elements.playPause.textContent = running ? PAUSE_GLYPH : PLAY_GLYPH;
  elements.playPause.setAttribute('aria-label', running ? 'Pause' : 'Play');
}

function setRunning(value: boolean): void {
  running = value;
  lastMs = performance.now(); // never burst-step on resume
  accMs = 0;
  syncRunButton();
}

function stepOnce(): void {
  game.step();
  stable = game.stable();
  syncHud();
  dirty = true;
  markChanged();
}

function clearBoard(): void {
  game.clear(); // also resets the generation counter (spec §2.3)
  stable = game.stable();
  syncHud();
  dirty = true;
  markChanged();
}

function randomBoard(): void {
  game.random(RANDOM_DENSITY);
  stable = game.stable();
  syncHud();
  dirty = true;
  markChanged();
}

function setSpeedFromSlider(): void {
  const v = Math.round(Number(elements.speed.value));
  genPerSec = Number.isFinite(v) ? Math.min(SPEED_MAX, Math.max(SPEED_MIN, v)) : DEFAULT_GEN_PER_SEC;
  elements.speedValue.textContent = `${genPerSec}/s`;
  markChanged();
}

elements.playPause.addEventListener('click', () => setRunning(!running));
elements.step.addEventListener('click', stepOnce);
elements.clear.addEventListener('click', clearBoard);
elements.random.addEventListener('click', randomBoard);
elements.speed.addEventListener('input', setSpeedFromSlider);

// --- Pattern library menu (Phase 5, spec §6) --------------------------------
// Static overlay riding OUTSIDE any state machine: open/close toggles only
// `hidden` + aria-expanded. running, generation, paint legality — untouched.
function stampPatternCentered(id: string, cx: number, cy: number): number {
  const def = patternById.get(id);
  if (!def) throw new RangeError(`unknown pattern: ${id}`);
  const written = game.patternAt(id, cx - Math.floor(def.w / 2), cy - Math.floor(def.h / 2));
  stable = game.stable();
  syncHud();
  dirty = true;
  markChanged();
  return written;
}

function setMenuOpen(value: boolean): void {
  menuOpen = value;
  elements.patternMenu.hidden = !value;
  elements.patterns.setAttribute('aria-expanded', String(value));
  if (value) elements.menuClose.focus();
}
let menuOpen = false;

// Welcome gate (spec §5 welcome rule, player ruling 2026-10-30): the primer
// card covers the board at boot; the sim boots paused (running = false) and
// Start enters it via setRunning(true) — the exact play/pause path, so the
// accumulator reset law (§5) holds and the button label never desyncs.
// Unlike the pattern menu, this overlay is a boot GATE: while it is open the
// global primer keys (Space/ArrowRight/C) stay inert; Space/Enter on the
// focused Start button is native button activation, untouched by the gate.
let welcomeOpen = true;

function setWelcomeOpen(value: boolean): void {
  welcomeOpen = value;
  elements.welcome.hidden = !value;
  if (value) elements.welcomeStart.focus();
}

for (const def of PATTERNS) {
  const item = document.createElement('button');
  item.type = 'button';
  item.className = 'menu-item';
  item.dataset.pattern = def.id;
  item.textContent = def.name;
  elements.patternList.appendChild(item);
}

elements.patterns.addEventListener('click', () => setMenuOpen(!menuOpen));
elements.menuClose.addEventListener('click', () => setMenuOpen(false));
elements.welcomeStart.addEventListener('click', () => {
  setWelcomeOpen(false);
  setRunning(true); // same path as the play/pause button: no burst-step
});
elements.welcomeStart.focus(); // boot focus: Space/Enter activate Start natively
// Menu items ARM placement (spec §6 rework ruling 2026-10-30): selecting a
// pattern never stamps — the player places it with a board tap afterwards.
// Re-opening the menu and picking another item replaces the armed pattern;
// Back keeps the arming (only Escape/right-click/board-tap resolve it).
elements.patternList.addEventListener('click', (e: Event) => {
  const target = e.target instanceof HTMLElement ? e.target.closest<HTMLButtonElement>('button[data-pattern]') : null;
  if (!target?.dataset.pattern) return;
  setPendingPattern(target.dataset.pattern);
  setMenuOpen(false);
});

// Keyboard law (spec §5): Space play/pause, ArrowRight step, C clear.
// Escape priority: armed placement cancels first, then an open menu owns it
// (static-overlay loop rule). When a control has focus the key belongs to
// that control (native button activation already fires its click; arrow keys
// drive the slider) — the global handler stays out, which also prevents a
// focused-button double-toggle on Space.
document.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.key === 'Escape') {
    if (pendingPatternId) {
      e.preventDefault();
      setPendingPattern(null);
      return;
    }
    if (menuOpen) {
      // Menu owns Escape first, even when its own button holds focus
      // (Escape has no native button activation to conflict with).
      e.preventDefault();
      setMenuOpen(false);
      return;
    }
  }
  const tag = e.target instanceof HTMLElement ? e.target.tagName : '';
  if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  // Welcome gate (spec §5): primer keys stay inert until Start. This sits
  // AFTER the tag check so Space/Enter on the focused Start button still
  // activates it natively (that path never reaches here).
  if (welcomeOpen) return;
  if (e.key === ' ') {
    e.preventDefault();
    setRunning(!running);
  } else if (e.key === 'ArrowRight') {
    e.preventDefault();
    stepOnce();
  } else if (e.key === 'c' || e.key === 'C') {
    e.preventDefault();
    clearBoard();
  }
});
syncRunButton();

// --- Save wire (Phase 7, spec §7 / D6) --------------------------------------
// Board, speed setting and generation counter persist through the host
// NAP-STORAGE capability (keys in savewire.ts). Bare-host contract: storage
// absent is a gated normal state — defaults play, an honest notice shows,
// and nothing throws at window level (tested pair, tests/browser.test.mjs).

function setSaveUnavailable(): void {
  elements.saveStatus.hidden = false;
}

/** Write the current state through the storage wire right now. */
async function saveNow(): Promise<boolean> {
  const ok = await saveBoard(encodeRle(game.grid, game.w, game.h), genPerSec, game.generation);
  if (!ok && storageAvailable()) setSaveUnavailable(); // namespace present, serving broken
  return ok;
}

// Autosave law: any board/speed change schedules at most ONE pending write
// (~1s out). A running simulation persists once per second, not once per
// generation, and each flush captures every change made since the last one.
const AUTOSAVE_DELAY_MS = 1000;
let saveTimer: number | undefined;
function markChanged(): void {
  if (!storageAvailable() || saveTimer !== undefined) return;
  saveTimer = window.setTimeout(() => {
    saveTimer = undefined;
    void saveNow();
  }, AUTOSAVE_DELAY_MS);
}

// Flush when the frame goes away or hides (best effort: the wire call may
// not complete before unload; the debounce covers normal play).
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') void saveNow();
});
window.addEventListener('pagehide', () => void saveNow());

// Boot load (plan Phase 7: "read + render on boot, not after first step"):
// the synchronously seeded gun board stays unless a valid save replaces it
// wholesale. A corrupt save keeps defaults too — the load never throws.
if (!storageAvailable()) {
  setSaveUnavailable();
} else {
  void loadSavedBoard(game.w, game.h, DEFAULT_GEN_PER_SEC).then((saved) => {
    if (!saved) return;
    game.clear();
    game.stampRle(saved.boardRle, 0, 0);
    game.generation = saved.generation;
    genPerSec = saved.speed;
    elements.speed.value = String(saved.speed);
    elements.speedValue.textContent = `${saved.speed}/s`;
    stable = game.stable();
    syncHud();
    dirty = true;
  });
}

// QA hook (build plan Phase 3/6 affordance, added early for the Phase 2
// headless smoke test). Exposes engine + UI state for page.evaluate only;
// never referenced by player-facing code or listing copy.
export interface QaHandle {
  engine: GameOfLife;
  isRunning(): boolean;
  setRunning(value: boolean): void;
  genPerSec(): number;
  isStable(): boolean;
  patternIds(): string[];
  /** Direct programmatic stamp (fixture path); player placement arms instead. */
  stampPattern(id: string, cx: number, cy: number): number;
  menuIsOpen(): boolean;
  setMenuOpen(value: boolean): void;
  /** Armed pattern id while placement is pending (spec §6), else null. */
  pendingPattern(): string | null;
  /** Arm/cancel placement through the same path as the menu items (spec §6). */
  setPendingPattern(id: string | null): void;
  placeHintVisible(): boolean;
  /** Welcome gate (spec §5 welcome rule): primer open / sim not yet entered. */
  welcomeVisible(): boolean;
  domains: { storage: boolean };
  /** Flush the save wire through the host storage right now (Phase 7). */
  saveNow(): Promise<boolean>;
  saveStatusVisible(): boolean;
}

declare global {
  interface Window {
    game_of_life: QaHandle;
  }
}

window.game_of_life = {
  engine: game,
  isRunning: () => running,
  setRunning: (value: boolean) => setRunning(value), // same path as the button
  genPerSec: () => genPerSec,
  isStable: () => stable,
  patternIds: () => PATTERNS.map((def) => def.id),
  stampPattern: (id: string, cx: number, cy: number) => stampPatternCentered(id, cx, cy),
  menuIsOpen: () => menuOpen,
  setMenuOpen: (value: boolean) => setMenuOpen(value),
  pendingPattern: () => pendingPatternId,
  setPendingPattern: (id: string | null) => setPendingPattern(id),
  placeHintVisible: () => !elements.placeStatus.hidden,
  welcomeVisible: () => welcomeOpen,
  domains: { storage: runtimeHasDomain('storage') },
  saveNow: () => saveNow(),
  saveStatusVisible: () => !elements.saveStatus.hidden,
};