Learn Creative Coding (#47) - Cellular Automata: Wolfram's Elementary Rules

avatar

Learn Creative Coding (#47) - Cellular Automata: Wolfram's Elementary Rules

cc-banner

We've spent the last fifteen episodes in shader land. SDFs, noise, raymarching, fractals, post-processing, textures, compute shaders -- a full arc of GPU techniques for making pixels do interesting things. And it was amazing, honestly. But today we shift gears completely.

This episode starts a new arc: emergent systems. Simulations where you define some dead simple rules, press play, and watch complexity emerge that you never explicitly programmed. The rules say nothing about the patterns you see. The patterns just.. happen. It's one of the most fascinating ideas in all of computer science, and it makes for incredible creative coding material.

We start with the simplest possible version: one-dimensional cellular automata. A single row of cells, each either on or off, updating according to a rule. That's it. And from that setup, a guy named Stephen Wolfram discovered that some of these rules produce output that looks random, some produce perfect symmetry, and at least one is literally Turing complete -- meaning it can compute anything a computer can. From a row of bits and a lookup table.

What is a cellular automaton

A cellular automaton (CA for short) is a grid of cells. Each cell has a state -- for now, just 0 or 1. At every time step, each cell looks at itself and its neighbors, consults a rule table, and updates its state. All cells update simultaneously. Then you do it again. And again. And again.

The 1D version is literally a line. One row of cells. Each cell has exactly two neighbors: left and right. So when a cell needs to decide its next state, it looks at three things: the cell to its left, itself, and the cell to its right. Three cells, each either 0 or 1 -- that gives 2^3 = 8 possible neighborhood patterns.

The rule table maps each of those 8 patterns to an output: 0 or 1. Since there are 8 patterns and each can map to either 0 or 1, there are 2^8 = 256 possible rule tables. That's all 256 possible 1D cellular automata with 2 states and a 3-cell neighborhood. Wolfram numbered them 0 through 255.

Wolfram's naming scheme

This is the clever part. The rule number IS the rule table, encoded in binary.

Take Rule 30. In binary, 30 is 00011110. Those 8 bits map directly to the 8 possible neighborhoods:

Neighborhood:  111  110  101  100  011  010  001  000
Rule 30 bits:   0    0    0    1    1    1    1    0

Reading right to left: neighborhood 000 (all off) maps to 0. Neighborhood 001 (right neighbor on) maps to 1. Neighborhood 010 (only self on) maps to 1. And so on. The binary representation of 30 is literally the lookup table.

So if you want Rule 90, convert 90 to binary: 01011010. That's your rule table. Rule 110: 01101110. Rule 0: all zeros (everything dies). Rule 255: all ones (everything turns on). The naming scheme is the encoding. Elegant.

Rendering: time flows downward

Here's how you visualize a 1D CA. The initial state is one row of cells across the top of the canvas. To show the next generation, draw it on the row below. Generation after that, the row below that. Time flows downward. The entire history of the automaton is visible as a 2D image, even though the automaton itself is one-dimensional at any given moment.

This is different from everything we've done before. Shaders compute each pixel independently. A CA computes each row from the row above. It's inherently sequential in the vertical direction -- you can't know what row 50 looks like without computing rows 1 through 49 first. So we're back to JavaScript and the Canvas 2D API for this one. (You could do it in a compute shader with a ping-pong buffer if you wanted, using the techniques from episode 46. But for learning the concept, plain JavaScript is clearer.)

Let's build the simplest possible implementation:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

// rule number -> lookup table
function makeRuleTable(ruleNum) {
  const table = [];
  for (let i = 0; i < 8; i++) {
    table[i] = (ruleNum >> i) & 1;
  }
  return table;
}

// initial state: single cell in the center
let cells = new Uint8Array(W);
cells[Math.floor(W / 2)] = 1;

const rule = makeRuleTable(30);

// draw each generation as one row
for (let y = 0; y < H; y++) {
  // draw current generation
  for (let x = 0; x < W; x++) {
    if (cells[x]) {
      ctx.fillStyle = '#000';
      ctx.fillRect(x, y, 1, 1);
    }
  }

  // compute next generation
  const next = new Uint8Array(W);
  for (let x = 0; x < W; x++) {
    const left = cells[(x - 1 + W) % W];
    const center = cells[x];
    const right = cells[(x + 1) % W];
    const index = (left << 2) | (center << 1) | right;
    next[x] = rule[index];
  }
  cells = next;
}

That's the whole thing. makeRuleTable converts a rule number to its 8-entry lookup table using bit shifting. The main loop draws the current row and computes the next one. The neighborhood index is constructed by shifting left, center, and right into a 3-bit number: (left << 2) | (center << 1) | right. This maps directly to the binary encoding -- neighborhood 101 becomes index 5, which looks up rule[5].

The (x - 1 + W) % W handles wrapping at the edges -- the leftmost cell's left neighbor is the rightmost cell, and vice versa. This makes the space a circle topologically. You could also use zero-padding (edges are always 0) or reflection (edges mirror back). Wrapping is the most common choice because it avoids edge artifacts.

Rule 30: chaos from simplicity

Run the code above and you get Rule 30. From a single dot in the center, it produces a pattern that looks genuinley random on the left side and has a nested triangular structure on the right side. The left edge is so irregular that Wolfram's company (Wolfram Research) used it as the random number generator in Mathematica for years.

Think about that. A lookup table with 8 entries, processing a row of bits left to right, produces output that passes statistical randomness tests. No shuffle algorithm, no seed, no entropy source. Just 00011110 applied repeatedly.

The visual output has this distinctive asymmetric triangle pattern. From the single center cell, it expands outward with a sharp triangular boundary on the right and a jagged, unpredictable boundary on the left. The interior is a mix of triangular voids and chaotic fill. It looks different from any noise function or fractal we've generated -- it has a character that's uniquely cellular-automaton.

Rule 110: Turing complete

Rule 110 (01101110) is even more remarkable. In 2004, Matthew Cook proved that Rule 110 is Turing complete. Given an appropriate initial condition and enough space, it can simulate any computation that any computer can perform. A universal computer encoded in a one-bit lookup table.

// just change the rule number
const rule = makeRuleTable(110);

// start with random initial state (more interesting for Rule 110)
let cells = new Uint8Array(W);
for (let i = 0; i < W; i++) {
  cells[i] = Math.random() < 0.5 ? 1 : 0;
}

Rule 110 with random initialization produces these moving structures -- triangular regions of repetition separated by irregular boundaries. The boundaries interact with each other, merge, split, and propagate. Computer scientists call these "gliders" and "background patterns" by analogy with Conway's Game of Life (which we'll get to soon). The gliders are the computation -- they carry information through the space, and their collisions implement logic gates.

For creative coding purposes, Rule 110 is visually interesting because it has both order and chaos coexisting. There are recognizable repeating structures (stripes, triangles) alongside unpredictable interactions. That tension between order and chaos is exactly what makes generative art compelling.

Rule 90: Sierpinski's triangle from bits

Rule 90 (01011010) is the mathematician's favorite. From a single center cell, it produces a perfect Sierpinski triangle. The fractal we drew with recursive subdivision in episode 41 -- except here it emerges from the most basic possible mechanism. No recursion, no coordinates, no fractal formula. Just a 3-cell neighborhood rule applied repeatedly.

const rule = makeRuleTable(90);

let cells = new Uint8Array(W);
cells[Math.floor(W / 2)] = 1;

The connection between Rule 90 and the Sierpinski triangle isn't a coincidence. Rule 90 is equivalent to XOR: the output is 1 when the left and right neighbors differ, 0 when they're the same (the center cell is actually ignored). XOR applied to a row is exactly Pascal's triangle modulo 2 -- and Pascal's triangle mod 2 IS the Sierpinski triangle. The CA, the algebra, and the fractal are all the same object seen from different angles.

This is the kind of thing that makes cellular automata so fascinating for creative coders. The visual output isn't designed -- it's discovered. You pick a rule number, run it, and the mathematics produces something beautiful (or boring, or chaotic) without any aesthetic intent from the programmer. The art is in the selection and presentation.

Color mapping: beyond black and white

Black and white cells are fine for understanding the dynamics, but for creative output we want more. The simplest upgrade: instead of just drawing alive/dead, track how long each cell has been alive and map that age to a color.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

function makeRuleTable(ruleNum) {
  const table = [];
  for (let i = 0; i < 8; i++) {
    table[i] = (ruleNum >> i) & 1;
  }
  return table;
}

// track cell ages instead of just on/off
let cells = new Uint8Array(W);
let ages = new Float32Array(W);
cells[Math.floor(W / 2)] = 1;
ages[Math.floor(W / 2)] = 1;

const rule = makeRuleTable(30);

// cosine palette (from episode 37 / our shader arc)
function cosPalette(t, a, b, c, d) {
  return [
    a[0] + b[0] * Math.cos(6.28318 * (c[0] * t + d[0])),
    a[1] + b[1] * Math.cos(6.28318 * (c[1] * t + d[1])),
    a[2] + b[2] * Math.cos(6.28318 * (c[2] * t + d[2])),
  ];
}

const imageData = ctx.createImageData(W, H);

for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const idx = (y * W + x) * 4;

    if (cells[x]) {
      const t = ages[x] / 50.0;  // normalize age
      const col = cosPalette(t,
        [0.5, 0.4, 0.3],
        [0.5, 0.4, 0.4],
        [1.0, 0.7, 0.4],
        [0.0, 0.15, 0.35]
      );
      imageData.data[idx + 0] = Math.floor(col[0] * 255);
      imageData.data[idx + 1] = Math.floor(col[1] * 255);
      imageData.data[idx + 2] = Math.floor(col[2] * 255);
      imageData.data[idx + 3] = 255;
    } else {
      imageData.data[idx + 0] = 10;
      imageData.data[idx + 1] = 10;
      imageData.data[idx + 2] = 15;
      imageData.data[idx + 3] = 255;
    }
  }

  // compute next generation
  const nextCells = new Uint8Array(W);
  const nextAges = new Float32Array(W);

  for (let x = 0; x < W; x++) {
    const left = cells[(x - 1 + W) % W];
    const center = cells[x];
    const right = cells[(x + 1) % W];
    const index = (left << 2) | (center << 1) | right;
    nextCells[x] = rule[index];

    if (nextCells[x] && cells[x]) {
      nextAges[x] = ages[x] + 1;  // survived: age increases
    } else if (nextCells[x]) {
      nextAges[x] = 1;  // just born
    }
  }

  cells = nextCells;
  ages = nextAges;
}

ctx.putImageData(imageData, 0, 0);

Now living cells shift through the cosine palette as they age. Newborn cells start at the beginning of the palette (warm amber with our settings) and shift toward teal as they survive more generations. Cells that flicker on and off stay amber. Cells in stable structures gradually shift to cool tones. The age coloring reveals the dynamics -- you can see which regions are stable and which are chaotic just from the colors.

The palette parameters are the same amber-to-teal we used in the shader mini-project (episode 45). Reusing palettes across different techniques gives your creative coding portfolio a cohesive look. Or swap them out for something completely different -- the palette is independent of the CA logic.

Interactive rule explorer

The real fun starts when you can change the rule number and instantly see the result. Here's a minimal interactive version:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

let ruleNum = 30;

function makeRuleTable(ruleNum) {
  const table = [];
  for (let i = 0; i < 8; i++) {
    table[i] = (ruleNum >> i) & 1;
  }
  return table;
}

function drawCA(ruleNum) {
  ctx.fillStyle = '#0a0a0f';
  ctx.fillRect(0, 0, W, H);

  let cells = new Uint8Array(W);
  cells[Math.floor(W / 2)] = 1;

  const rule = makeRuleTable(ruleNum);

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      if (cells[x]) {
        ctx.fillStyle = '#e8a040';
        ctx.fillRect(x, y, 1, 1);
      }
    }

    const next = new Uint8Array(W);
    for (let x = 0; x < W; x++) {
      const left = cells[(x - 1 + W) % W];
      const center = cells[x];
      const right = cells[(x + 1) % W];
      const idx = (left << 2) | (center << 1) | right;
      next[x] = rule[idx];
    }
    cells = next;
  }

  // show rule number
  ctx.fillStyle = '#fff';
  ctx.font = '16px monospace';
  ctx.fillText('Rule ' + ruleNum, 10, 20);
}

drawCA(ruleNum);

// arrow keys to change rule
document.addEventListener('keydown', function(e) {
  if (e.key === 'ArrowRight') {
    ruleNum = (ruleNum + 1) % 256;
    drawCA(ruleNum);
  } else if (e.key === 'ArrowLeft') {
    ruleNum = (ruleNum - 1 + 256) % 256;
    drawCA(ruleNum);
  }
});

Arrow left and right to cycle through all 256 rules. Most of them are boring -- they die out immediately (Rule 0, Rule 4), or they fill everything solid (Rule 255, Rule 252), or they produce uniform stripes (Rule 204 -- every cell just keeps its current state). But scattered among the boring ones are gems. Rule 30, 90, 110 we already know. Try Rule 45 -- complex triangular patterns. Rule 73 -- nested structures on a dark background. Rule 150 -- another Sierpinski variant. Rule 105 -- beautiful symmetric chaos.

There are Wolfram's four classes:

  1. Class 1: evolves to a uniform state (all cells the same). Boring.
  2. Class 2: evolves to repeating structures (stripes, blocks). Stable but predictable.
  3. Class 3: appears chaotic, no repeating structures. Pseudo-random. (Rule 30)
  4. Class 4: complex behavior with local structures that interact. (Rule 110)

Class 3 and 4 are the creative goldmines. Class 3 gives you texture generators -- the output works as a pattern for other projects. Class 4 gives you living, evolving structures that feel like they have intent.

Random initial conditions

A single center cell reveals the rule's inherent structure. But random initial conditions reveal how the rule behaves as a system -- how it processes information, whether structures survive, whether order emerges from chaos.

function randomInit(width, density) {
  const cells = new Uint8Array(width);
  for (let i = 0; i < width; i++) {
    cells[i] = Math.random() < density ? 1 : 0;
  }
  return cells;
}

// Rule 110 with 50% random fill
let cells = randomInit(W, 0.5);
const rule = makeRuleTable(110);

Rule 30 with random initialization: the chaotic behavior takes over quickly, erasing any trace of the initial pattern within ~50 generations. The rule imposes its own character regardless of input.

Rule 110 with random initialization: you get those moving triangular structures (gliders) that propagate and interact. The initial randomness provides the raw material, and the rule organizes it into structured behavior. Give it a few hundred generations and you see persistent patterns emerging from the noise.

Rule 90 with random initialization: the XOR dynamics mean the pattern has a specific relationship to the input -- it's actually computing the running XOR of all cells. Dense initial conditions produce dense patterns. Sparse initial conditions produce sparse results with fractal structure in between.

The density parameter matters. Try 5% fill vs 50% fill vs 95% fill. Some rules behave completely differently at different densities. Rule 184 at ~50% density models traffic flow -- the 1s are cars, the 0s are empty road, and you can watch traffic jams form and disolve.

Totalistic rules: more than two states

Elementary CAs are limited to 2 states (on/off). Totalistic rules relax this. Instead of looking at the exact pattern of neighbors (left=1, center=0, right=1), you look at the SUM of the neighbors. With 3-cell neighborhoods and 2 states, the sum can be 0, 1, 2, or 3. This gives fewer inputs to the rule table but allows for more states in the output.

A 3-color totalistic rule looks at the sum of 3 cells (each 0, 1, or 2), giving sums from 0 to 6. Each sum maps to an output of 0, 1, or 2. That's 3^7 = 2187 possible totalistic rules with 3 colors. Some of them produce absolutely gorgeous patterns -- nested triangles in three colors, spiral-like structures, complex tilings.

function totalisticCA(ruleNum, numStates, width, height) {
  const maxSum = (numStates - 1) * 3;
  const ruleTable = [];

  let n = ruleNum;
  for (let i = 0; i <= maxSum; i++) {
    ruleTable[i] = n % numStates;
    n = Math.floor(n / numStates);
  }

  let cells = new Uint8Array(width);
  cells[Math.floor(width / 2)] = 1;

  const imageData = ctx.createImageData(width, height);
  const colors = [
    [10, 10, 15],       // state 0: dark
    [230, 160, 60],     // state 1: amber
    [60, 180, 200],     // state 2: teal
  ];

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const state = cells[x];
      const idx = (y * width + x) * 4;
      imageData.data[idx + 0] = colors[state][0];
      imageData.data[idx + 1] = colors[state][1];
      imageData.data[idx + 2] = colors[state][2];
      imageData.data[idx + 3] = 255;
    }

    const next = new Uint8Array(width);
    for (let x = 0; x < width; x++) {
      const sum = cells[(x - 1 + width) % width]
                + cells[x]
                + cells[(x + 1) % width];
      next[x] = ruleTable[sum];
    }
    cells = next;
  }

  ctx.putImageData(imageData, 0, 0);
}

// try rule 600 with 3 states -- nested triangles
totalisticCA(600, 3, W, H);

Totalistic rule 600 with 3 states produces nested triangular patterns in three colors that look like they belong on a textile or mosaic. Rule 1599 produces spiraling structures. Rule 912 creates a complex tiling. These patterns have a different aesthetic from the 2-state rules -- they're richer, more layered, and often more symmetrically complex.

The trade-off is parameter space. With 2187 possible 3-color totalistic rules, manually exploring them takes forever. Write a grid view that tiles 16 or 25 rules on screen at once and scan through them in batches. Or write a fitness function based on entropy and visual complexity to auto-select interesting ones. That's a whole creative project on its own.

CA output as texture

Here's a practical creative coding application: use a CA's output as a texture or mask for other work. Run a Rule 30 CA for 500 generations, capture the result as an array, and use it to drive color, opacity, distortion, or particle placement in another piece.

function generateCAtexture(ruleNum, width, height) {
  let cells = new Uint8Array(width);
  cells[Math.floor(width / 2)] = 1;
  const rule = makeRuleTable(ruleNum);

  // store full grid
  const grid = new Uint8Array(width * height);

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      grid[y * width + x] = cells[x];
    }

    const next = new Uint8Array(width);
    for (let x = 0; x < width; x++) {
      const left = cells[(x - 1 + width) % width];
      const center = cells[x];
      const right = cells[(x + 1) % width];
      const idx = (left << 2) | (center << 1) | right;
      next[x] = rule[idx];
    }
    cells = next;
  }

  return grid;
}

// generate a Rule 30 texture
const caTexture = generateCAtexture(30, 512, 512);

// use it as opacity mask for a gradient
const imageData = ctx.createImageData(512, 512);
for (let y = 0; y < 512; y++) {
  for (let x = 0; x < 512; x++) {
    const i = (y * 512 + x) * 4;
    const ca = caTexture[y * 512 + x];

    // gradient from warm to cool
    const t = x / 512;
    imageData.data[i + 0] = Math.floor((1 - t) * 230 + t * 60);
    imageData.data[i + 1] = Math.floor((1 - t) * 160 + t * 180);
    imageData.data[i + 2] = Math.floor((1 - t) * 60 + t * 200);
    imageData.data[i + 3] = ca ? 255 : 25;  // CA drives opacity
  }
}
ctx.putImageData(imageData, 0, 0);

The CA pattern masks the gradient -- living cells show the full gradient, dead cells show almost nothing. The result is the CA's complex structure rendered in a smooth color gradient instead of stark black and white. It's a simple compositing trick but the visual result is much more sophisticated than either element alone.

You could also use the CA grid to drive noise lookup in a shader (remember data textures from episode 44?), or to place particles (one particle per living cell, with position from the grid coordinates), or as a height map for a 3D terrain. The CA becomes a design tool rather than a standalone visualization.

The Perlin noise connection

Remember episode 12 where we implemented Perlin noise from scratch? One of the things that makes noise useful is that it's deterministic -- same input, same output. Elementary CAs are the same. Given the same rule and the same initial condition, you get the exact same output every time. Rule 30 is deterministic chaos, not randomness. The output looks random but is completely reproducible.

In fact, you can seed a CA with noise. Instead of starting with a single cell or random init, use a row of Perlin noise values thresholded to 0/1:

// seed CA initial state with noise (using our noise from ep12)
function noiseInit(width, seed, threshold) {
  const cells = new Uint8Array(width);
  for (let i = 0; i < width; i++) {
    // simple hash-based noise, seeded
    const n = Math.abs(Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1;
    cells[i] = n > threshold ? 1 : 0;
  }
  return cells;
}

let cells = noiseInit(W, 42, 0.6);
const rule = makeRuleTable(30);

Different noise seeds give different CA evolutions, but each seed is reproducible. Combine this with the seed-based variation from episode 24 and you've got parametric cellular automata -- one seed number controls the entire piece.

What's coming

We've covered 1D cellular automata today -- the simplest possible emergent system. One dimension, two states, three-cell neighborhoods, 256 rules. And even in this tiny space, we found chaos (Rule 30), computation (Rule 110), fractals (Rule 90), and enough visual variety for a whole portfolio of creative pieces.

The next step is adding a dimension. Two-dimensional cellular automata are grids where each cell looks at its 8 neighbors (or 4, depending on the neighborhood type). The dynamics are richer because cells can interact in all directions. Structures can move across the grid, rotate, reproduce, and interact in ways that feel genuinely alive. If you thought Rule 110's gliders were interesting, wait until you see what happens when the grid goes 2D.

And after that, we'll push beyond discrete states entirely -- continuous cellular automata where cells can take any value between 0 and 1. The visual output shifts from blocky pixel grids to smooth, organic, almost biological patterns. The same local-update principle, but with floating-point state instead of bits.

But that's later. For now, go explore the 256 elementary rules. Build the interactive explorer, find the ones that speak to you, and try using them as textures in your other projects. The combination of CA structure with shader coloring and compositing techniques is a rabbit hole you'll happily fall into :-)

That's the first episode of the emergent systems arc. We've gone from "I define every pixel" in the shader episodes to "I define a rule, and the system draws itself." The vibe is completely different and I honestly think this is where creative coding gets most interesting.

't Komt erop neer...

  • A cellular automaton is a row of cells (0 or 1) that update every step based on their neighborhood. Each cell looks at its left neighbor, itself, and its right neighbor -- 8 possible patterns, each mapping to a 0 or 1 output
  • Wolfram's naming: the rule number in binary IS the lookup table. Rule 30 = 00011110. There are exactly 256 elementary CA rules (2^8 = 256 possible lookup tables)
  • Rendering: draw each generation as one row of pixels, top to bottom. Time flows downward. The 1D automaton's full history becomes a 2D image
  • Rule 30 produces chaotic, pseudo-random output from a single starting cell. Good enough for random number generation. Visually asymmetric with complex internal structure
  • Rule 110 is Turing complete -- it can simulate any computation given enough space. Random initialization reveals moving structures (gliders) that carry information
  • Rule 90 produces a perfect Sierpinski triangle. It's equivalent to XOR, which is equivalent to Pascal's triangle mod 2. Same fractal from three different perspectives
  • The bit manipulation trick: neighborhood index = (left << 2) | (center << 1) | right. This converts a 3-cell pattern to a number 0-7 for the rule table lookup
  • Color mapping via cell age: track how many generations a cell has been alive, map age to a cosine palette. Reveals dynamics -- stable regions shift color, flickering regions stay at the newborn color
  • Totalistic rules: sum the neighbor values instead of looking at exact patterns. Allows more than 2 states (3-color totalistic rules produce stunning nested and spiral patterns). 2187 possible 3-color totalistic rules
  • CA output as texture: generate a grid and use it as an opacity mask, height map, particle placement guide, or shader data texture input. CAs become design tools when combined with other techniques

Sallukes! Thanks for reading.

X

@femdev



0
0
0.000
0 comments