Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import 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 */
8export 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.