← code

game of life

Conway's Game of Life running in a canvas, wrapping at the edges. Click or drag to draw — it pauses while you do.

the rules

Conway's Game of Life has no player and only one decision, made once per cell per generation. Each cell counts its eight neighbors: a live cell with two or three of them survives, a dead cell with exactly three becomes alive, and everything else dies of isolation or overcrowding. That is the complete specification — no goal, no scoring, and no randomness at all after the first frame is seeded. The grid here is a torus, with the left edge glued to the right and the top to the bottom, so a pattern that runs off one side reappears on the other rather than hitting a wall.

the emergent zoo

The reason it has held people's attention since 1970 is that those rules turn out to describe a whole zoo of behavior nobody put there deliberately. Patterns that never change at all are still lifes; patterns that return to their starting shape after a fixed number of generations are oscillators; and a handful of patterns cycle and shift position while doing it, so they appear to travel. The smallest of those, the glider, was spotted by Richard K. Guy in 1969 and moves one cell diagonally every four generations. Draw a few by hand and you can watch them fly across the wrapped edges indefinitely.

turing completeness

The deeper result is that Life is Turing-complete — anything a computer can compute, some arrangement of cells here can compute too. The hinge of that argument was Bill Gosper's discovery in 1970 of a glider gun, a pattern that does not merely persist but manufactures a stream of gliders forever, emitting its first at generation 15 and another every 30 after that. It won him a $50 prize from Conway, and it mattered because an unbounded supply of gliders is what lets you build signals, logic gates, and eventually arbitrary computation out of a rule set with four clauses.

in one dimension

Life is a two-dimensional member of the same family as the elementary cellular automaton on this site. The one-dimensional version gives each cell two neighbors and a rule number; this gives each cell eight and a survival count. Both reach universality, which is the surprising part — rule 110 gets there with dramatically less machinery than Life needs.

view source
import { useEffect, useRef, useState } from 'react';

const CELL = 12;
const LIVE = '#7cf29c';

// 5x7 pixel glyphs, one string per row, '#' = alive.
const BLANK_GLYPH = [
  '.....',
  '.....',
  '.....',
  '.....',
  '.....',
  '.....',
  '.....',
];
const GLYPHS: Record<string, string[]> = {
  w: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
  r: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
  k: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
  s: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
  p: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
  c: ['.####', '#....', '#....', '#....', '#....', '#....', '.####'],
};
const GLYPH_HEIGHT = 7;
const GLYPH_GAP = 1;

function textPattern(word: string): {
  cols: number;
  rows: number;
  cells: boolean[][];
} {
  const letters = word.split('').map((ch) => GLYPHS[ch] ?? BLANK_GLYPH);
  const widths = letters.map((rows) => rows[0]?.length ?? 0);
  const cols =
    widths.reduce((a, b) => a + b, 0) + GLYPH_GAP * (letters.length - 1);
  const cells = Array.from({ length: GLYPH_HEIGHT }, () =>
    Array<boolean>(cols).fill(false),
  );

  let offset = 0;
  letters.forEach((glyph, i) => {
    const width = widths[i];
    for (let y = 0; y < GLYPH_HEIGHT; y++) {
      for (let x = 0; x < width; x++) {
        if (glyph[y]?.[x] === '#') cells[y][offset + x] = true;
      }
    }
    offset += width + GLYPH_GAP;
  });

  return { cols, rows: GLYPH_HEIGHT, cells };
}

// Gosper glider gun: the first known pattern that grows forever, discovered
// by Bill Gosper in 1970. These coordinates were pulled from a search
// rather than trusted from memory, then verified standalone -- run through
// this exact same step() function -- before being wired in: the engine
// region repeats exactly every 30 generations, and total population climbs
// by exactly 5 cells every 30 generations as each new glider escapes.
const GOSPER_GUN_COLS = 36;
const GOSPER_GUN_ROWS = 9;
const GOSPER_GUN_CELLS: [number, number][] = [
  [24, 0],
  [22, 1],
  [24, 1],
  [12, 2],
  [13, 2],
  [20, 2],
  [21, 2],
  [34, 2],
  [35, 2],
  [11, 3],
  [15, 3],
  [20, 3],
  [21, 3],
  [34, 3],
  [35, 3],
  [0, 4],
  [1, 4],
  [10, 4],
  [16, 4],
  [20, 4],
  [21, 4],
  [0, 5],
  [1, 5],
  [10, 5],
  [14, 5],
  [16, 5],
  [17, 5],
  [22, 5],
  [24, 5],
  [10, 6],
  [16, 6],
  [24, 6],
  [11, 7],
  [15, 7],
  [12, 8],
  [13, 8],
];

function makeGunGrid(cols: number, rows: number): boolean[][] | null {
  const margin = 4;
  if (
    cols < GOSPER_GUN_COLS + margin * 2 ||
    rows < GOSPER_GUN_ROWS + margin * 2
  ) {
    return null;
  }
  const grid = Array.from({ length: rows }, () =>
    Array<boolean>(cols).fill(false),
  );
  const offsetX = margin;
  const offsetY = Math.floor((rows - GOSPER_GUN_ROWS) / 2);
  for (const [c, r] of GOSPER_GUN_CELLS) {
    grid[offsetY + r][offsetX + c] = true;
  }
  return grid;
}

function makeGrid(cols: number, rows: number, density = 0.18): boolean[][] {
  return Array.from({ length: rows }, () =>
    Array.from({ length: cols }, () => Math.random() < density),
  );
}

function makeTextGrid(
  cols: number,
  rows: number,
  word: string,
): boolean[][] | null {
  const pattern = textPattern(word);
  if (cols < pattern.cols + 2 || rows < pattern.rows + 2) return null;

  const grid = Array.from({ length: rows }, () =>
    Array<boolean>(cols).fill(false),
  );
  const offsetX = Math.floor((cols - pattern.cols) / 2);
  const offsetY = Math.floor((rows - pattern.rows) / 2);
  for (let y = 0; y < pattern.rows; y++) {
    for (let x = 0; x < pattern.cols; x++) {
      if (pattern.cells[y][x]) grid[offsetY + y][offsetX + x] = true;
    }
  }
  return grid;
}

function step(grid: boolean[][]): boolean[][] {
  const rows = grid.length;
  const cols = grid[0]?.length ?? 0;
  return grid.map((row, y) =>
    row.map((alive, x) => {
      let neighbors = 0;
      for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -1; dx <= 1; dx++) {
          if (dx === 0 && dy === 0) continue;
          const ny = (y + dy + rows) % rows;
          const nx = (x + dx + cols) % cols;
          if (grid[ny][nx]) neighbors++;
        }
      }
      if (alive) return neighbors === 2 || neighbors === 3;
      return neighbors === 3;
    }),
  );
}

export default function GameOfLife() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reseedRef = useRef<() => void>(() => {});
  const toggleRunningRef = useRef<() => void>(() => {});
  const gunRef = useRef<() => void>(() => {});
  const [running, setRunning] = useState(true);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    let cols = 0;
    let rows = 0;
    let grid: boolean[][] = [];
    let frame = 0;
    let raf = 0;
    let isRunning = true;
    let isPainting = false;
    let paintValue = true;
    let firstSeed = true;
    const playAt = performance.now() + 2000;

    const seed = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cols = Math.floor(rect.width / CELL);
      rows = Math.floor(rect.height / CELL);
      grid =
        (firstSeed ? makeTextGrid(cols, rows, 'wrkspc') : null) ??
        makeGrid(cols, rows);
      firstSeed = false;
    };

    const draw = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = LIVE;
      for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
          if (grid[y][x]) {
            ctx.fillRect(x * CELL, y * CELL, CELL - 1, CELL - 1);
          }
        }
      }
    };

    const tick = () => {
      frame++;
      if (isRunning && frame % 6 === 0 && performance.now() >= playAt) {
        grid = step(grid);
        draw();
      }
      raf = requestAnimationFrame(tick);
    };

    const cellAt = (clientX: number, clientY: number) => {
      const rect = canvas.getBoundingClientRect();
      const x = Math.floor((clientX - rect.left) / CELL);
      const y = Math.floor((clientY - rect.top) / CELL);
      if (x < 0 || y < 0 || x >= cols || y >= rows) return null;
      return { x, y };
    };

    const paint = (clientX: number, clientY: number) => {
      const cell = cellAt(clientX, clientY);
      if (!cell) return;
      grid[cell.y][cell.x] = paintValue;
      draw();
    };

    const pause = () => {
      if (!isRunning) return;
      isRunning = false;
      setRunning(false);
    };

    const onPointerDown = (e: PointerEvent) => {
      const cell = cellAt(e.clientX, e.clientY);
      if (!cell) return;
      pause();
      isPainting = true;
      paintValue = !grid[cell.y][cell.x];
      grid[cell.y][cell.x] = paintValue;
      draw();
    };
    const onPointerMove = (e: PointerEvent) => {
      if (!isPainting) return;
      paint(e.clientX, e.clientY);
    };
    const stopPainting = () => {
      isPainting = false;
    };

    seed();
    draw();
    raf = requestAnimationFrame(tick);

    reseedRef.current = () => {
      firstSeed = false;
      seed();
      draw();
      isRunning = true;
      setRunning(true);
    };
    toggleRunningRef.current = () => {
      isRunning = !isRunning;
      setRunning(isRunning);
    };
    gunRef.current = () => {
      const gunGrid = makeGunGrid(cols, rows);
      if (!gunGrid) return;
      firstSeed = false;
      grid = gunGrid;
      draw();
      isRunning = true;
      setRunning(true);
    };

    canvas.addEventListener('pointerdown', onPointerDown);
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', stopPainting);

    const onResize = () => seed();
    window.addEventListener('resize', onResize);

    // Konami code: an easter egg, so it's intentionally not surfaced in the
    // UI anywhere -- up up down down left right left right b a.
    const KONAMI = [
      'arrowup',
      'arrowup',
      'arrowdown',
      'arrowdown',
      'arrowleft',
      'arrowright',
      'arrowleft',
      'arrowright',
      'b',
      'a',
    ];
    let konamiProgress: string[] = [];
    const onKeydown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement | null;
      if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return;
      const key = e.key.toLowerCase();
      konamiProgress.push(key);
      if (konamiProgress.length > KONAMI.length) konamiProgress.shift();
      if (
        konamiProgress.length === KONAMI.length &&
        konamiProgress.every((k, i) => k === KONAMI[i])
      ) {
        konamiProgress = [];
        gunRef.current();
      }
    };
    window.addEventListener('keydown', onKeydown);

    return () => {
      cancelAnimationFrame(raf);
      canvas.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', stopPainting);
      window.removeEventListener('resize', onResize);
      window.removeEventListener('keydown', onKeydown);
    };
  }, []);

  return (
    <div className="game-of-life">
      <canvas
        ref={canvasRef}
        aria-label="Conway's Game of Life — click or drag to draw cells"
        role="img"
      />
      <div className="controls">
        <button type="button" onClick={() => toggleRunningRef.current()}>
          {running ? 'pause' : 'play'}
        </button>
        <button type="button" onClick={() => reseedRef.current()}>
          reseed
        </button>
      </div>
      <style>{`
        .game-of-life {
          position: relative;
          width: 100%;
          aspect-ratio: 16 / 7;
          border: 1px solid #2a2d31;
          overflow: hidden;
          background: #0e1113;
        }
        .game-of-life canvas {
          display: block;
          width: 100%;
          height: 100%;
          cursor: crosshair;
          touch-action: none;
        }
        .game-of-life .controls {
          position: absolute;
          bottom: 0.5rem;
          right: 0.5rem;
          display: flex;
          gap: 0.375rem;
        }
        .game-of-life button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.75);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.5rem;
          cursor: pointer;
        }
        .game-of-life button:hover {
          color: #e6e6e6;
        }
      `}</style>
    </div>
  );
}