Last episode we explored one-dimensional cellular automata -- a single row of cells updating based on a 3-cell neighborhood rule. We saw Rule 30 produce chaos, Rule 90 produce Sierpinski triangles, and Rule 110 turn out to be Turing complete. All from a one-bit lookup table. Pretty wild for something so simple.
Today we go 2D. Conway's Game of Life is the most famous cellular automaton ever devised, and probably the single most studied system in the history of computer science outside of actual computers. John Conway designed it in 1970 -- four rules applied to a grid of cells, and from those four rules emerge gliders that move across the grid, oscillators that pulse forever, still lifes that never change, and structures complex enough to build a working computer inside the simulation itself.
If the 1D automata from episode 47 were a gateway drug, this is the full trip.
The setup: a 2D grid of cells. Each cell is either alive (1) or dead (0). Each cell has eight neighbors -- the four cardinal directions plus the four diagonals. This is called a Moore neighborhood. Every generation, all cells update simultaneously based on these four rules:
That's it. Four rules. You can simplify it even further: a cell is alive next generation if it has exactly 3 neighbors, OR if it's currently alive and has exactly 2 neighbors. Everything else dies or stays dead.
The shorthand notation for Life is B3/S23 -- birth on 3 neighbors, survival on 2 or 3. This notation becomes useful later when we look at variant rules.
Same principle as the 1D case from episode 47, but now it's a 2D grid instead of a row. You need two copies of the grid -- the current state and the next state. Read from current, write to next. After processing every cell, swap them.
Why not update in place? Because the rules require simultaneous update. If you modify cell (5, 3) before reading cell (5, 4), and cell (5, 4) needs to know the OLD state of cell (5, 3) to count its neighbors correctly, you get wrong results. The double buffer guarantees every cell sees the same generation when counting neighbors.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const cellSize = 4;
const cols = Math.floor(canvas.width / cellSize);
const rows = Math.floor(canvas.height / cellSize);
// two grids for double buffering
let current = new Uint8Array(cols * rows);
let next = new Uint8Array(cols * rows);
// random initialization
for (let i = 0; i < current.length; i++) {
current[i] = Math.random() < 0.3 ? 1 : 0;
}
function index(x, y) {
return y * cols + x;
}
30% density is a good starting point for random init. Too dense and the grid chokes immediately -- everything overpopulates and dies. Too sparse and nothing interesting happens because cells can't reach each other. 20-40% gives you the best balance of activity.
The core of the simulation: counting live neighbors for each cell. Eight neighbors, wrapping at edges (toroidal topology -- same approach as the 1D case).
function countNeighbors(grid, x, y) {
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue; // skip self
const nx = (x + dx + cols) % cols;
const ny = (y + dy + rows) % rows;
count += grid[index(nx, ny)];
}
}
return count;
}
The nested loop checks all 9 cells in the 3x3 neighborhood and skips the center (self). The (x + dx + cols) % cols wrapping handles edges -- the left neighbor of column 0 is the last column, and vice versa. Same for rows.
This is O(1) per cell since we always check exactly 8 neighbors. The full grid update is O(n) where n is the number of cells. For a 200x150 grid that's 30,000 cells, each checking 8 neighbors -- trivial for JavaScript even at 60fps.
Now the actual Game of Life logic. For each cell, count neighbors and apply the rules:
function step() {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const neighbors = countNeighbors(current, x, y);
const alive = current[index(x, y)];
const i = index(x, y);
if (alive) {
// survival: 2 or 3 neighbors
next[i] = (neighbors === 2 || neighbors === 3) ? 1 : 0;
} else {
// birth: exactly 3 neighbors
next[i] = (neighbors === 3) ? 1 : 0;
}
}
}
// swap buffers
[current, next] = [next, current];
}
Or even shorter, using the simplified rule:
function step() {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const n = countNeighbors(current, x, y);
const i = index(x, y);
next[i] = (n === 3 || (n === 2 && current[i])) ? 1 : 0;
}
}
[current, next] = [next, current];
}
Same logic, one line. A cell lives if it has 3 neighbors (regardless of current state) or if it has 2 neighbors and is currently alive. That single expression captures all four of Conway's rules.
The simplest renderer: draw each alive cell as a filled rectangle.
function draw() {
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#e8a040';
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (current[index(x, y)]) {
ctx.fillRect(x * cellSize, y * cellSize, cellSize - 1, cellSize - 1);
}
}
}
}
function loop() {
step();
draw();
requestAnimationFrame(loop);
}
loop();
The cellSize - 1 leaves a 1px gap between cells, making the grid visible. Without the gap, adjacent alive cells merge into a solid blob and you lose the grid structure. The gap also makes it easier to see individual patterns like gliders and oscillators.
Press play and you'll see the random soup evolve rapidly. Within the first 20-30 generations, most of the initial chaos settles into stable structures. Some regions go completely dead. Others form oscillators that pulse forever. And if you're lucky, a glider or two will emerge from the noise and start drifting across the grid.
Conway's Game of Life has been studied for over fifty years. People have cataloged thousands of named patterns. Here are the ones every creative coder should know:
Still lifes -- stable patterns that never change:
Oscillators -- patterns that repeat after N generations:
Spaceships -- patterns that move across the grid:
Glider gun -- a pattern that produces gliders forever:
Let's add a way to place some of these patterns:
// pattern definitions (relative coordinates of alive cells)
const patterns = {
glider: [[0,0], [1,0], [2,0], [2,1], [1,2]],
blinker: [[0,0], [1,0], [2,0]],
lwss: [[0,0], [3,0], [4,1], [0,2], [4,2], [1,3], [2,3], [3,3], [4,3]],
gliderGun: [
[24,0], [22,1], [24,1], [12,2], [13,2], [20,2], [21,2], [34,2], [35,2],
[11,3], [15,3], [20,3], [21,3], [34,3], [35,3], [0,4], [1,4], [10,4],
[16,4], [20,4], [21,4], [0,5], [1,5], [10,5], [14,5], [16,5], [17,5],
[22,5], [24,5], [10,6], [16,6], [24,6], [11,7], [15,7], [12,8], [13,8]
],
};
function placePattern(name, startX, startY) {
const cells = patterns[name];
if (!cells) return;
for (const [dx, dy] of cells) {
const x = (startX + dx) % cols;
const y = (startY + dy) % rows;
current[index(x, y)] = 1;
}
}
// place a glider gun in the top-left
placePattern('gliderGun', 2, 2);
Clear the grid first (fill with zeros), place a glider gun, and run it. Watch it pump out gliders every 30 generations. The gliders march diagonally toward the bottom-right, wrapping around and eventually coliding with the gun itself (or each other) if you leave it running long enough. Those collisions produce debris -- new still lifes, oscillators, and occasionally more gliders.
Half the fun of Life is designing initial conditions. Click to toggle cells, press a button to run:
let running = false;
canvas.addEventListener('click', function(e) {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / cellSize);
const y = Math.floor((e.clientY - rect.top) / cellSize);
if (x >= 0 && x < cols && y >= 0 && y < rows) {
current[index(x, y)] = current[index(x, y)] ? 0 : 1;
draw();
}
});
// drag to paint
let painting = false;
canvas.addEventListener('mousedown', function() { painting = true; });
canvas.addEventListener('mouseup', function() { painting = false; });
canvas.addEventListener('mousemove', function(e) {
if (!painting) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / cellSize);
const y = Math.floor((e.clientY - rect.top) / cellSize);
if (x >= 0 && x < cols && y >= 0 && y < rows) {
current[index(x, y)] = 1;
draw();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === ' ') {
running = !running;
if (running) loop();
}
if (e.key === 's') {
step();
draw();
}
if (e.key === 'c') {
current.fill(0);
draw();
}
});
function loop() {
if (!running) return;
step();
draw();
requestAnimationFrame(loop);
}
Spacebar toggles run/pause, 's' steps one generation at a time (super useful for understanding what the rules actually do), 'c' clears the grid. Click toggles individual cells, drag paints them alive. Single-step mode is where you really learn Life -- you can place a pattern, step once, see what changed, step again, trace the logic manually.
Try drawing a line of 10 cells and stepping through it. The ends die (only 1 neighbor each), the cells one step in die too (only 1 neighbor after wrapping adjustments), but cells near the middle survive and new cells are born at specific positions. Within a few steps, the line transforms into something you didn't design. That's emergence.
Same technique we used in episode 47 for 1D CAs -- track how long each cell has been alive and map age to a color palette. In 2D, this is even more revealing because you can see stable regions (old cells, cool colors) surrounded by active boundaries (young cells, warm colors).
let ages = new Float32Array(cols * rows);
function stepWithAge() {
const nextAges = new Float32Array(cols * rows);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const n = countNeighbors(current, x, y);
const i = index(x, y);
next[i] = (n === 3 || (n === 2 && current[i])) ? 1 : 0;
if (next[i] && current[i]) {
nextAges[i] = ages[i] + 1; // survived
} else if (next[i]) {
nextAges[i] = 1; // just born
}
// dead cells stay at 0
}
}
[current, next] = [next, current];
ages = nextAges;
}
function drawWithAge() {
const imageData = ctx.createImageData(canvas.width, canvas.height);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = index(x, y);
const alive = current[i];
// fill the cell rectangle in imageData
for (let py = 0; py < cellSize - 1; py++) {
for (let px = 0; px < cellSize - 1; px++) {
const sx = x * cellSize + px;
const sy = y * cellSize + py;
if (sx >= canvas.width || sy >= canvas.height) continue;
const pi = (sy * canvas.width + sx) * 4;
if (alive) {
const t = Math.min(ages[i] / 80.0, 1.0);
// warm to cool: amber -> teal
imageData.data[pi + 0] = Math.floor(230 * (1 - t) + 60 * t);
imageData.data[pi + 1] = Math.floor(160 * (1 - t) + 180 * t);
imageData.data[pi + 2] = Math.floor(60 * (1 - t) + 200 * t);
imageData.data[pi + 3] = 255;
} else {
imageData.data[pi + 0] = 10;
imageData.data[pi + 1] = 10;
imageData.data[pi + 2] = 15;
imageData.data[pi + 3] = 255;
}
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
Newly born cells appear amber. Cells that have survived for many generations shift toward teal. Still lifes (blocks, beehives) turn deep teal after a few hundred generations -- they never change, so their age keeps climbing. Oscillator cells flicker between amber (newly re-born) and slightly warmer tones. Glider cells are always young because they constantly move to new positions. The age coloring makes the dynamics immediately visible -- you can glance at the grid and tell which regions are stable, which are active, and which are dead without watching the animation.
Conway's Life is B3/S23. But the B/S notation lets you define any rule using the same neighbor-counting mechanism. Some interesting variants:
function parseRule(ruleStr) {
// parse "B36/S23" format
const parts = ruleStr.split('/');
const birthStr = parts[0].replace('B', '');
const surviveStr = parts[1].replace('S', '');
const birth = new Set(birthStr.split('').map(Number));
const survive = new Set(surviveStr.split('').map(Number));
return { birth, survive };
}
function stepWithRule(rule) {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const n = countNeighbors(current, x, y);
const i = index(x, y);
if (current[i]) {
next[i] = rule.survive.has(n) ? 1 : 0;
} else {
next[i] = rule.birth.has(n) ? 1 : 0;
}
}
}
[current, next] = [next, current];
}
const rule = parseRule('B36/S23'); // HighLife
// or parseRule('B2/S') // Seeds
// or parseRule('B3678/S34678') // Day & Night
The rule parser is generic -- you can try any combination. There are hundreds of named variants in the Life community. Some produce crystalline growth patterns, some produce smooth blob-like structures, some produce complete chaos. The parameter space is smaller than the 256 elementary CAs from episode 47 (each B/S rule is defined by which of the 9 possible neighbor counts 0-8 trigger birth and survival -- that's 2^9 * 2^9 = 262,144 possible rules) but the visual variety is enormous because the 2D grid allows so much more structural complexity.
Try B368/S245 -- it produces slowly growing organic blobs that look like coral. Or B3/S12345 -- cells survive almost anything, creating dense expanding regions with thin tendrils at the borders. Exploring rulesets is a creative rabbit hole.
For a 200x150 grid, JavaScript is more than fast enough. But if you want a 1920x1080 grid where every pixel is a cell -- over 2 million cells -- you need the GPU. And the technique is clean: use a fragment shader with ping-pong framebuffers, exactly like the feedback loops from episode 36.
The state is stored in a texture. Each pixel's red channel holds the cell state (0.0 or 1.0). The fragment shader reads the 8 neighbors from the texture, counts them, applies the rules, and writes the new state to a second texture. Next frame, swap textures. The same ping-pong pattern we used for feedback effects, but now the feedback implements a simulation.
precision mediump float;
uniform sampler2D u_state;
uniform vec2 u_resolution;
void main() {
vec2 pixel = 1.0 / u_resolution;
vec2 uv = gl_FragCoord.xy / u_resolution;
// count 8 neighbors
float neighbors = 0.0;
neighbors += texture2D(u_state, uv + vec2(-1.0, -1.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2( 0.0, -1.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2( 1.0, -1.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2(-1.0, 0.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2( 1.0, 0.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2(-1.0, 1.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2( 0.0, 1.0) * pixel).r;
neighbors += texture2D(u_state, uv + vec2( 1.0, 1.0) * pixel).r;
float alive = texture2D(u_state, uv).r;
// B3/S23 rule
float next = 0.0;
if (alive > 0.5 && (neighbors > 1.5 && neighbors < 3.5)) {
next = 1.0; // survival
}
if (neighbors > 2.5 && neighbors < 3.5) {
next = 1.0; // birth (also covers survival with 3)
}
gl_FragColor = vec4(next, next, next, 1.0);
}
We use > 1.5 and < 3.5 instead of == 2 and == 3 because floating point equality is unreliable. The texture might store 0.999 instead of 1.0, so the neighbor sum might be 2.997 instead of 3.0. Threshold comparisons are safer.
The nice thing about this approach: you don't need WebGPU or compute shaders. Plain WebGL 1 with two framebuffers and one shader handles the whole simulation. You could combine this with the cosine palette from episode 37 for the rendering pass -- one shader does the Life step (writing state to a framebuffer), another shader reads that state and renders it with color mapping.
At 1920x1080, the GPU updates over 2 million cells per frame at 60fps without breaking a sweat. That's a lot of Game of Life. Patterns at this scale develop large-scale structure you never see on small grids -- highways of gliders crossing the grid, collision debris creating new guns, emergent "ecologies" of interacting pattern types.
There's an algorithm called HashLife, invented by Bill Gosper in 1984, that can compute Life generations astronomically fast. Not just faster than brute-force -- exponentially faster. It can jump to generation 2^64 of a pattern in seconds.
The idea: if two regions of the grid are identical, they'll produce identical futures. So cache the results. Represent the grid as a quadtree where each node stores a square region. Hash the node to check if you've already computed its future. If yes, return the cached result instead of recomputing.
The recursion goes: to compute the future of a 16x16 region, you need the futures of its four overlapping 8x8 subregions. Each 8x8 region's future depends on its four 4x4 subregions. And so on down to 2x2 base cases where you apply the rules directly. Because many subregions repeat across the grid, the cache hit rate is enormous for patterns with any regularity.
I'm not going to implement HashLife here -- it's a complex data structure and the payoff is mainly for extremely large or long-running simulations. But it's worth knowing about because it demonstrates something profound: the regularity in Life's output (the fact that it produces repeating structures) can be exploited computationally. The same patterns that make Life visually interesting (gliders, oscillators, still lifes) make it algorithmically compressible.
For creative coding, the brute-force GPU approach is almost always sufficient. You want real-time interaction, not jumping to generation 10^18.
Build an interactive Game of Life with these features:
If you want to push it further:
The trail effect is especially nice. Instead of hard 0/1 states, dead cells fade from 1.0 to 0.0 over several frames. Gliders leave contrails. Oscillators create pulsing auras. The grid stops looking like a binary grid and starts looking like a living ecosystem. Trust me, once you see it you won't want to go back to hard on/off rendering :-)
Conway's Game of Life is where 2D cellular automata begin, but it's far from where they end. The B/S notation opens up 262,144 possible rulesets. The fixed neighborhood (Moore, 8 cells) could be swapped for a von Neumann neighborhood (4 cells, cardinal only) or larger neighborhoods (24 cells in a 5x5 region). Each choice produces fundamentally different dynamics.
And we haven't even left the world of discrete states yet. Every cell in Life is either 0 or 1. What happens when cells can take any value between 0 and 1? When the rules are smooth functions instead of integer thresholds? The patterns shift from blocky pixel grids to smooth, organic, almost biological structures. Continuous automata blur the line between simulation and art in a way that the discrete version only hints at.
But that's the next step. For now, explore Life. Draw patterns, watch them evolve, try diferent rulesets, build the interactive version. The combination of extreme simplicity (four rules) and extreme complexity (Turing completeness, self-replicating structures, emergent computation) is what makes Life such a perfect creative coding subject. You define almost nothing, and the system gives you almost everything.
That's two episodes into the emergent systems arc now. We went from 1D rules in a row (episode 47) to 2D rules in a grid (this one). The pattern should be clear by now -- simple local rules, complex global behavior, endlessly creative output.
Sallukes! Thanks for reading.
X