← 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.

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 };
}

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 [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);
    };

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

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

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

  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.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.5rem;
          cursor: pointer;
        }
        .game-of-life button:hover {
          color: #e6e6e6;
        }
      `}</style>
    </div>
  );
}