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
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:
-
Arrayof arrays (grid[y][x]) -
Uint8Arrayflat (cells[y * w + x]) - Bit-packed
Uint32Array(1 bit per cell)
I went with option 2. Typed arrays give you:
-
GC-friendly memory: one allocation instead of
h+1arrays 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),
}
}
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
}
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 }
}
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
],
}
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)
}
}
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)
}
}
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
Series
This is entry #9 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/game-of-life
- 🌐 Live: https://sen.ltd/portfolio/game-of-life/
- 🏢 Company: https://sen.ltd/
PRs for new presets welcome.

Top comments (0)