DEV Community

SEN LLC
SEN LLC

Posted on

Writing Conway's Game of Life With Uint8Array and Canvas, Including a Gosper Glider Gun

Writing Conway's Game of Life With Uint8Array and Canvas, Including a Gosper Glider Gun

Conway's rules fit in three lines. Writing them and watching a glider chase itself across the grid — still a what have I just witnessed moment every single time. This version uses a typed array for the grid, canvas for rendering, and ships eight preset patterns so you can jump straight to the Gosper Glider Gun.

Everyone's written Life at some point. I wrote it again because I wanted to do it carefully — typed arrays, toroidal wrap, a proper render loop — and bundle the classic patterns so you don't have to hand-place cells to see something interesting.

🔗 Live demo: https://sen.ltd/portfolio/game-of-life/
📦 GitHub: https://github.com/sen-ltd/game-of-life

Screenshot

Canvas in the middle, control panel below. Eight presets (Glider, Blinker, Toad, Beacon, Pulsar, LWSS, R-pentomino, Gosper Glider Gun), click-to-edit cells, 1–60 steps/sec, generation + live-cell counters, play/pause/step/reset/clear. Vanilla JS, zero dependencies, no build.

Why a Uint8Array instead of nested arrays

You have three reasonable grid representations:

  1. Array of arrays (grid[y][x])
  2. Uint8Array flat (cells[y * w + x])
  3. Bit-packed Uint32Array (1 bit per cell)

I went with option 2. Typed arrays give you:

  • GC-friendly memory: one allocation instead of h+1 arrays of references
  • Fast snapshotting: next.set(current) copies an entire grid in one call
  • Cache locality: rows sit contiguous in memory, which matters for the nested neighbor loop
export function createGrid(width, height) {
  return {
    width,
    height,
    cells: new Uint8Array(width * height),
  }
}
Enter fullscreen mode Exit fullscreen mode

Bit-packing is faster still, but the index math gets noisier (cells[i >> 5] |= 1 << (i & 31)), which hurts readability for something that's supposed to be educational. An 80×50 grid is 4000 bytes as a Uint8Array, 500 as bit-packed — not worth the trouble unless your grids are enormous.

Toroidal wrap so small patterns run forever

On a finite grid, a glider marches off the edge and disappears. The classic fix: treat the grid as a torus so the right edge continues into the left edge and the bottom continues into the top.

export function index(grid, x, y) {
  const w = grid.width
  const h = grid.height
  // Toroidal wrap. Double-modulo to handle negatives correctly.
  const xx = ((x % w) + w) % w
  const yy = ((y % h) + h) % h
  return yy * w + xx
}
Enter fullscreen mode Exit fullscreen mode

The ((x % w) + w) % w dance is a common JS gotcha: the % operator preserves sign, so -1 % 80 === -1, which produces an out-of-range index. Adding w before the second % forces a non-negative result and gets you the proper mathematical modulo.

With this in place, a glider exits the right edge and re-enters from the left a few steps later. Small grids stop feeling small.

The step() function

The rules themselves are three lines in the middle of a double loop:

export function step(grid) {
  const { width: w, height: h, cells } = grid
  const next = new Uint8Array(cells.length)
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const n = countNeighbors(cells, w, h, x, y)
      const i = y * w + x
      const alive = cells[i] === 1
      if (alive && (n === 2 || n === 3)) next[i] = 1
      else if (!alive && n === 3) next[i] = 1
      // else stays 0
    }
  }
  return { width: w, height: h, cells: next }
}
Enter fullscreen mode Exit fullscreen mode

Critically, this allocates a new Uint8Array for the next generation instead of updating in place. The rule is "everything transitions simultaneously based on the previous state" — if you update in place, cells that have already moved to the next state contaminate the neighbor counts of cells that haven't. Debugging that is miserable; skip the trap and allocate.

It also makes history trivial: push each returned grid onto a stack and you get undo for free.

Gosper Glider Gun as a coordinate list

The most fun preset is the Gosper Glider Gun — the pattern that Bill Gosper constructed in 1970 to answer "is there a finite pattern whose population grows without bound?" (previously a $50 open problem). It cycles with period 30, spitting out a glider each cycle, forever.

I store patterns as lists of live-cell coordinates:

{
  id: 'gosper',
  name: 'Gosper Glider Gun',
  pattern: [
    [0, 4], [0, 5], [1, 4], [1, 5],
    [10, 4], [10, 5], [10, 6], [11, 3], [11, 7],
    [12, 2], [12, 8], [13, 2], [13, 8], [14, 5],
    // ... 36 cells total, spans ~40 columns
  ],
}
Enter fullscreen mode Exit fullscreen mode

And a tiny stamp function places them at an origin:

export function stamp(grid, pattern, originX, originY) {
  for (const [x, y] of pattern) {
    set(grid, originX + x, originY + y, 1)
  }
}
Enter fullscreen mode Exit fullscreen mode

Coordinate-list format has the nice property that sparse and dense patterns use the same code path. For something like the Gosper gun, which is 36 cells spread over a wide area, it reads much better than a dense 2D array.

Play loop: requestAnimationFrame, not setInterval

To run at N steps/sec, the naive approach is setInterval(step, 1000/N). This breaks with browser throttling when the tab is backgrounded — you get inconsistent timing and dropped frames. Much better to drive everything from requestAnimationFrame and compare timestamps:

let lastStepAt = 0
function loop(now) {
  if (running) {
    const stepMs = 1000 / speed
    if (now - lastStepAt >= stepMs) {
      grid = step(grid)
      render()
      lastStepAt = now
    }
    requestAnimationFrame(loop)
  }
}
Enter fullscreen mode Exit fullscreen mode

At speed = 60 it advances every frame; at speed = 1 it polls every frame but only advances once per second. The render is always in sync with the display refresh, so movement looks smooth.

Tests

10 cases on node --test, verifying the classic patterns behave as documented:

  • Block (2×2) — still life, should be identical after step()
  • Blinker (3 in a row) — period 2
  • Glider — diagonal translation after 4 generations
  • Pulsar — period 3
  • R-pentomino — goes through 1103 chaotic generations before stabilizing (I only check the first few, since running the full settlement in a unit test is slow)
npm test
Enter fullscreen mode Exit fullscreen mode

Series

This is entry #9 in my 100+ public portfolio series.

PRs for new presets welcome.

Top comments (0)