Conway’s Game of Life
Complexity from four rules.
Beginner / cellular-automaton / emergence
A grid of cells lives or dies by neighbor counts alone: birth with three, survival with two or three, otherwise death. No cell knows anything beyond its eight neighbors, yet gliders, oscillators, and working computers emerge. It is the standard demonstration that simple local rules can produce irreversible global behavior.
Tagged: cellular automaton, emergence, zero-player
Live simulation
Drag on the grid to toggle cells. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Drag on the grid to toggle cells.
Mathematics
The Game of Life is a totalistic cellular automaton: each cell's next state depends only on how many of its eight neighbors are alive.
B3/S23: cell becomes alive with exactly 3 neighbors
survives with 2 or 3; otherwise dies or stays empty
n'(c) = 1 ⇔ Σ neighbors = 3 ∨ ( Σ neighbors = 2 ∧ n(c) = 1 )
Despite the trivial update rule, the automaton is Turing complete: there are known configurations that implement logic gates, registers, and even a universal constructor. No closed-form shortcut is known for stepping the rule, which is exactly why it must be simulated.
How it works
The grid lives in a flat Uint8Array with a second buffer for the next generation. Each tick scans every cell, sums the eight neighbors with edge handling for wrap or closed boundaries, applies B3/S23, and swaps the buffers. Births and deaths are counted during the same pass and reported as metrics.
Dragging on the canvas toggles cells directly in the live buffer: the very next generation is computed from your edit. Try dropping a 5-cell glider and stepping one generation at a time.
Implementation
src/engine/experiments/game-of-life.ts · 167 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Conway's Game of Life: B3/S23 on a bounded or wrapping grid. |
| 6 | * Uint8Array double buffer; one tick is exactly one generation. |
| 7 | */ |
| 8 | export class GameOfLifeExperiment extends BaseExperiment { |
| 9 | readonly id = "game-of-life"; |
| 10 | |
| 11 | private cols = 0; |
| 12 | private rows = 0; |
| 13 | private offsetX = 0; |
| 14 | private offsetY = 0; |
| 15 | private cells = new Uint8Array(0); |
| 16 | private next = new Uint8Array(0); |
| 17 | private generation = 0; |
| 18 | private population = 0; |
| 19 | private births = 0; |
| 20 | private deaths = 0; |
| 21 | private paintValue = 1; |
| 22 | private pointerDown = false; |
| 23 | |
| 24 | protected params(): ParameterDef[] { |
| 25 | return [ |
| 26 | { key: "cell", label: "Cell size", min: 5, max: 16, step: 1, defaultValue: 8, unit: "px" }, |
| 27 | { key: "density", label: "Initial density", min: 5, max: 60, step: 1, defaultValue: 24, unit: "%" }, |
| 28 | { |
| 29 | key: "edges", |
| 30 | label: "Edges", |
| 31 | options: [ |
| 32 | { value: "wrap", label: "Wrap (torus)" }, |
| 33 | { value: "closed", label: "Closed" }, |
| 34 | ], |
| 35 | defaultValue: "wrap", |
| 36 | }, |
| 37 | ]; |
| 38 | } |
| 39 | |
| 40 | protected onReset(): void { |
| 41 | this.cols = Math.max(8, Math.floor(this.width / this.num("cell"))); |
| 42 | this.rows = Math.max(8, Math.floor(this.height / this.num("cell"))); |
| 43 | this.offsetX = Math.floor((this.width - this.cols * this.num("cell")) / 2); |
| 44 | this.offsetY = Math.floor((this.height - this.rows * this.num("cell")) / 2); |
| 45 | this.cells = new Uint8Array(this.cols * this.rows); |
| 46 | this.next = new Uint8Array(this.cols * this.rows); |
| 47 | const density = this.num("density") / 100; |
| 48 | for (let i = 0; i < this.cells.length; i++) { |
| 49 | this.cells[i] = this.rng() < density ? 1 : 0; |
| 50 | } |
| 51 | this.generation = 0; |
| 52 | this.population = this.countPopulation(); |
| 53 | this.births = 0; |
| 54 | this.deaths = 0; |
| 55 | } |
| 56 | |
| 57 | protected onParameterChange(key: string): void { |
| 58 | if (key === "cell" || key === "density" || key === "edges") this.reset(); |
| 59 | } |
| 60 | |
| 61 | resize(width: number, height: number): void { |
| 62 | this.width = width; |
| 63 | this.height = height; |
| 64 | this.reset(); |
| 65 | } |
| 66 | |
| 67 | private countPopulation(): number { |
| 68 | let sum = 0; |
| 69 | for (let i = 0; i < this.cells.length; i++) sum += this.cells[i]; |
| 70 | return sum; |
| 71 | } |
| 72 | |
| 73 | protected onUpdate(): void { |
| 74 | if (this.cells.length === 0) return; |
| 75 | const { cols, rows, cells, next } = this; |
| 76 | const wrap = this.str("edges") === "wrap"; |
| 77 | let population = 0; |
| 78 | let births = 0; |
| 79 | let deaths = 0; |
| 80 | for (let y = 0; y < rows; y++) { |
| 81 | const up = wrap ? (y - 1 + rows) % rows : y - 1; |
| 82 | const down = wrap ? (y + 1) % rows : y + 1; |
| 83 | for (let x = 0; x < cols; x++) { |
| 84 | const left = wrap ? (x - 1 + cols) % cols : x - 1; |
| 85 | const right = wrap ? (x + 1) % cols : x + 1; |
| 86 | let n = 0; |
| 87 | if (up >= 0) { |
| 88 | if (left >= 0) n += cells[up * cols + left]; |
| 89 | n += cells[up * cols + x]; |
| 90 | if (right < cols) n += cells[up * cols + right]; |
| 91 | } |
| 92 | if (left >= 0) n += cells[y * cols + left]; |
| 93 | if (right < cols) n += cells[y * cols + right]; |
| 94 | if (down < rows) { |
| 95 | if (left >= 0) n += cells[down * cols + left]; |
| 96 | n += cells[down * cols + x]; |
| 97 | if (right < cols) n += cells[down * cols + right]; |
| 98 | } |
| 99 | const alive = cells[y * cols + x]; |
| 100 | const live = alive === 1 ? n === 2 || n === 3 : n === 3; |
| 101 | next[y * cols + x] = live ? 1 : 0; |
| 102 | if (live && !alive) births++; |
| 103 | if (!live && alive) deaths++; |
| 104 | if (live) population++; |
| 105 | } |
| 106 | } |
| 107 | this.cells.set(next); |
| 108 | this.generation += 1; |
| 109 | this.population = population; |
| 110 | this.births = births; |
| 111 | this.deaths = deaths; |
| 112 | } |
| 113 | |
| 114 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 115 | const { cols, rows, offsetX, offsetY } = this; |
| 116 | const cell = this.num("cell"); |
| 117 | ctx.fillStyle = theme.bg; |
| 118 | ctx.fillRect(0, 0, this.width, this.height); |
| 119 | ctx.fillStyle = theme.fg; |
| 120 | for (let y = 0; y < rows; y++) { |
| 121 | for (let x = 0; x < cols; x++) { |
| 122 | if (this.cells[y * cols + x] === 1) { |
| 123 | ctx.fillRect(offsetX + x * cell, offsetY + y * cell, cell - 1, cell - 1); |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | onPointer(state: PointerState): void { |
| 130 | if (!state.inside || this.cells.length === 0) return; |
| 131 | if (!state.down) { |
| 132 | this.pointerDown = false; |
| 133 | return; |
| 134 | } |
| 135 | const cell = this.num("cell"); |
| 136 | const x = Math.floor((state.x - this.offsetX) / cell); |
| 137 | const y = Math.floor((state.y - this.offsetY) / cell); |
| 138 | if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return; |
| 139 | const idx = y * this.cols + x; |
| 140 | if (!this.pointerDown) { |
| 141 | this.pointerDown = true; |
| 142 | this.paintValue = this.cells[idx] === 1 ? 0 : 1; |
| 143 | } |
| 144 | if (this.cells[idx] !== this.paintValue) { |
| 145 | this.cells[idx] = this.paintValue; |
| 146 | this.population += this.paintValue === 1 ? 1 : -1; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | getMetrics() { |
| 151 | return { |
| 152 | generation: this.generation, |
| 153 | population: this.population, |
| 154 | births: this.births, |
| 155 | deaths: this.deaths, |
| 156 | }; |
| 157 | } |
| 158 | |
| 159 | describe(): string { |
| 160 | return `Generation ${this.generation.toLocaleString("en-US")}, population ${this.population.toLocaleString("en-US")} live cells.`; |
| 161 | } |
| 162 | |
| 163 | entities(): number { |
| 164 | return this.cols * this.rows; |
| 165 | } |
| 166 | } |
| 167 | |
Performance
- Time per tick
- O(cells) per generation, branch-free inner loop
- Space
- Two Uint8Array buffers: 2 bytes per cell
- Notes
- Boundary mode changes the physics: wrap edges let gliders circle the torus, closed edges kill them at the wall.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.