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.
// 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,
};
