AI 3D Game of Life - Conway's Automata in Space

This sketch simulates Conway's Game of Life extended into three dimensions, animating a rotating 10x10x10 cube of neon-green blocks that live, die, or are born based on their 26 neighboring cells. The whole grid slowly spins in WEBGL 3D space, and clicking the canvas reshuffles the cells to start a fresh evolution.

🧪 Try This!

Experiment with the code by making these changes:

  1. Recolor the cells — Changing the fill() values instantly recolors every living cube from neon green to any other color.
  2. Slow down the rotation — Reducing the angle increment makes the cube spin much more slowly, making it easier to study individual cell patterns.
  3. Speed up evolution — Lowering the frame-count divisor makes step() run more often, so generations change much faster on screen.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch takes the classic 2D Game of Life and lifts it into three dimensions: a 10x10x10 lattice of cubes where each cell checks all 26 of its neighbors (not just 8) to decide whether it survives, dies, or is born. The grid slowly tumbles in space using p5's WEBGL renderer, rotateX/rotateY transforms, and push/pop matrix stacking, while glowing green box() shapes represent living cells against a black void.

Under the hood, the 3D grid is stored as a single flat array using a custom idx() function that converts x/y/z coordinates into one index - a common trick for performance and simplicity. You'll learn how to flatten multi-dimensional data into 1D arrays, how to implement cellular automata update rules with a triple-nested neighbor-counting loop, how to swap two arrays in one line with array destructuring, and how to mix p5's DOM functions (createP) with WEBGL 3D graphics.

⚙️ How It Works

  1. When the sketch loads, setup() creates a fullscreen WEBGL canvas, allocates two flat arrays (grid and next) big enough to hold 1000 cells, fills grid with roughly 30% randomly 'alive' cells, and creates an HTML paragraph to display the generation counter.
  2. Every frame, draw() clears the screen, nudges a rotation angle forward, rotates the whole scene around the X and Y axes, and then loops through every x/y/z coordinate in the cube - drawing a small box wherever a cell is alive.
  3. Every 15th frame, draw() calls step(), which runs the actual Game of Life logic: for each cell it counts how many of its 26 neighbors are alive, then applies survival rules (a live cell needs 4-6 live neighbors to survive) and birth rules (a dead cell needs exactly 5-6 live neighbors to be born).
  4. step() writes the new generation into the next array, then swaps grid and next in a single destructuring statement so the freshly computed generation becomes the one that gets drawn on the following frames, and increments the generation counter.
  5. Clicking anywhere on the canvas triggers mousePressed(), which re-randomizes the entire grid and resets the generation counter back to zero, letting you restart the simulation at will.
  6. If the browser window is resized, windowResized() keeps the canvas matched to the new window dimensions so the 3D scene always fills the screen.

🎓 Concepts You'll Learn

WEBGL 3D renderingCellular automata rules (survival/birth)Flattened 3D arrays with a custom index functionTriple-nested for loopsArray swapping via destructuring assignment3D transforms (rotateX, rotateY, translate, push/pop)Mixing DOM elements (createP) with canvas graphics

📝 Code Breakdown

idx()

Flattening multi-dimensional data into a single array is a common performance and simplicity trick in graphics and simulation code - it avoids the overhead of nested JavaScript arrays and makes swapping whole buffers (like grid and next) trivial.

🔬 This formula packs x, y, and z into one number using N as a base. What do you think would happen if you swapped the order to `z + N * (y + N * x)` - would the simulation still work correctly? (Hint: as long as idx() is used consistently everywhere, the mapping just needs to be unique per coordinate.)

function idx(x, y, z) { return x + N * (y + N * z); }
function idx(x, y, z) { return x + N * (y + N * z); }
Line-by-line explanation (1 lines)

🔧 Subcomponents:

calculation 3D-to-1D Index Formula return x + N * (y + N * z);

Converts a 3D coordinate (x,y,z) into a single number so it can be used as an index into the flat grid array

function idx(x, y, z) { return x + N * (y + N * z); }
Instead of a true 3D array (arrays of arrays of arrays), this sketch stores everything in one flat 1D array of length N*N*N. This formula maps any (x,y,z) coordinate to a unique slot in that array - it's the same idea as reading a 3D box row by row, then layer by layer.

setup()

setup() runs once when the sketch starts, and is the right place to configure the canvas, allocate data structures, and create any DOM elements you need before the animation loop begins.

function setup() {
  createCanvas(windowWidth, windowHeight, WEBGL);
  grid = new Array(N * N * N); next = new Array(N * N * N);
  randomize();
  counterP = createP(''); counterP.id('counter');
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight, WEBGL);
Creates a canvas that fills the browser window and switches to WEBGL mode, which enables true 3D drawing (box(), rotateX, translate in 3D, etc.) instead of flat 2D shapes.
grid = new Array(N * N * N); next = new Array(N * N * N);
Allocates two flat arrays big enough to hold every cell in the N x N x N cube (1000 slots when N=10). 'grid' holds the current generation and 'next' will hold the generation being computed.
randomize();
Calls the randomize() helper to fill the grid with a random starting pattern of alive/dead cells.
counterP = createP(''); counterP.id('counter');
Creates an empty HTML paragraph element using p5's DOM function createP(), then gives it the CSS id 'counter' so style.css can position and style it as an overlay showing the generation number.

randomize()

randomize() is the sketch's way of seeding new starting conditions. Since Game of Life outcomes are extremely sensitive to initial state, this one line effectively determines whether the automaton dies out quickly, oscillates, or grows in interesting ways.

🔬 This line controls the starting density of live cells. What happens to the pattern's survival if you drop 0.3 down to 0.05? What about pushing it up to 0.9?

  for (let i = 0; i < grid.length; i++) grid[i] = random() < 0.3;
function randomize() {
  for (let i = 0; i < grid.length; i++) grid[i] = random() < 0.3;
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

for-loop Random Cell Assignment Loop for (let i = 0; i < grid.length; i++) grid[i] = random() < 0.3;

Visits every slot in the flat grid array and sets it to true (alive) with roughly 30% probability, false otherwise

for (let i = 0; i < grid.length; i++) grid[i] = random() < 0.3;
random() returns a decimal between 0 and 1. Comparing it to 0.3 makes the expression true about 30% of the time, so roughly 30% of all cells start alive and the rest start dead.

draw()

draw() is p5's continuously running animation loop, called about 60 times per second by default. This sketch separates the fast per-frame rendering (rotation, drawing boxes) from the slower simulation update (step(), gated to every 15th frame), a common pattern for decoupling visual smoothness from logical simulation speed.

🔬 This loop draws a box for every living cell. What do you think happens visually if you remove the push()/pop() pair - would the boxes still be positioned correctly, or would every translate() stack on top of the last one?

  for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    if (grid[idx(x, y, z)]) { push(); translate(x * S, y * S, z * S); box(S * 0.8); pop(); }
  }
function draw() {
  background(0); noStroke(); fill(0, 255, 100);
  angle += 0.01; rotateX(angle * 0.7); rotateY(angle);
  translate(-S * (N - 1) / 2, -S * (N - 1) / 2, -S * (N - 1) / 2);
  for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    if (grid[idx(x, y, z)]) { push(); translate(x * S, y * S, z * S); box(S * 0.8); pop(); }
  }
  if (frameCount % 15 == 0) step();
  counterP.html('Generation: ' + gens);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop 3D Grid Render Loop for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {

Walks through every coordinate in the N x N x N cube so each alive cell can be drawn as a box

conditional Draw Alive Cell if (grid[idx(x, y, z)]) { push(); translate(x * S, y * S, z * S); box(S * 0.8); pop(); }

Only draws a box at coordinates where the cell is alive, positioning it correctly using push/pop to isolate each box's transform

conditional Simulation Step Timer if (frameCount % 15 == 0) step();

Advances the automaton to the next generation only once every 15 frames, so the simulation doesn't evolve too fast to see

background(0); noStroke(); fill(0, 255, 100);
Clears the canvas to black each frame, disables shape outlines, and sets the fill color to a neon green that will be used for every box drawn this frame.
angle += 0.01; rotateX(angle * 0.7); rotateY(angle);
Increments a rotation angle every frame and applies it around both the X and Y axes (at slightly different speeds) so the whole scene tumbles continuously rather than spinning around just one axis.
translate(-S * (N - 1) / 2, -S * (N - 1) / 2, -S * (N - 1) / 2);
Shifts the coordinate system so the cube of cells is centered on the origin (and thus on screen) instead of having its corner at the origin - without this the cube would rotate lopsidedly around one edge.
for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
Three nested loops visit every one of the N*N*N coordinates in the cube, one cell at a time.
if (grid[idx(x, y, z)]) { push(); translate(x * S, y * S, z * S); box(S * 0.8); pop(); }
Checks if the cell at this coordinate is alive using the idx() helper. If so, push() saves the current transform, translate() moves to that cell's position, box() draws a cube slightly smaller than the cell spacing (0.8 of S), and pop() restores the transform so the next cell isn't affected.
if (frameCount % 15 == 0) step();
frameCount increases by 1 every frame. Using modulo 15 means this condition is only true once every 15 frames, so step() (which advances the simulation) runs about 4 times per second instead of 60.
counterP.html('Generation: ' + gens);
Updates the text inside the HTML paragraph created in setup() to show the current generation number, using string concatenation to combine text with the gens variable.

step()

step() is the heart of the cellular automaton - it implements the classic 'count neighbors, then apply a rule' pattern used in almost all Game of Life variants, just extended from 2D's 8 neighbors to 3D's 26 neighbors. The array-swap trick at the end avoids the need to copy the entire grid every generation, which keeps the simulation efficient.

🔬 This single line defines every rule of survival and birth in this 3D universe. What happens if you make the survival range wider, like (n >= 2 && n <= 8)? What if the birth condition becomes just (n == 6)?

    next[i] = grid[i] ? (n >= 4 && n <= 6) : (n >= 5 && n <= 6);

🔬 This loop checks all 26 neighbors in every direction, including diagonals. What do you think would happen to the patterns if you restricted it to only the 6 face-touching neighbors (where only one of dx/dy/dz is nonzero at a time)?

    for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
      if (dx || dy || dz) {
function step() {
  for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    let n = 0, i = idx(x, y, z);
    for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
      if (dx || dy || dz) {
        let nx = x + dx, ny = y + dy, nz = z + dz;
        if (nx >= 0 && nx < N && ny >= 0 && ny < N && nz >= 0 && nz < N && grid[idx(nx, ny, nz)]) n++;
      }
    }
    next[i] = grid[i] ? (n >= 4 && n <= 6) : (n >= 5 && n <= 6);
  }
  [grid, next] = [next, grid]; gens++;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Cell Iteration Loop for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {

Visits every cell in the cube so its next state can be calculated

for-loop 26-Neighbor Counting Loop for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {

Checks the 3x3x3 block of cells surrounding the current cell (27 cells total, minus the cell itself equals 26 neighbors)

conditional Skip Self Check if (dx || dy || dz) {

Skips the case where dx, dy, and dz are all 0, which would be the cell itself rather than a neighbor

conditional Boundary and Alive Check if (nx >= 0 && nx < N && ny >= 0 && ny < N && nz >= 0 && nz < N && grid[idx(nx, ny, nz)]) n++;

Makes sure the neighbor coordinate is inside the cube (not off the edge) before checking if it's alive, and increments the neighbor count if so

calculation Life/Death Rule Application next[i] = grid[i] ? (n >= 4 && n <= 6) : (n >= 5 && n <= 6);

Applies survival rules if the cell is currently alive, or birth rules if it's currently dead, based on the neighbor count

for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
Loops over every cell coordinate in the cube, one at a time, so each cell's next state can be computed.
let n = 0, i = idx(x, y, z);
Sets up a neighbor counter 'n' starting at 0, and precomputes this cell's flat array index 'i' so it doesn't need to be recalculated later.
for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
Loops over offsets -1, 0, and 1 in all three dimensions, covering the 3x3x3 block of cells centered on (x,y,z) - that's 27 combinations total, including the cell itself.
if (dx || dy || dz) {
In JavaScript, 0 is falsy, so this condition is only false when dx, dy, and dz are ALL zero (the center cell). This skips counting the cell as its own neighbor.
let nx = x + dx, ny = y + dy, nz = z + dz;
Calculates the actual coordinate of the neighboring cell by adding the offset to the current cell's coordinates.
if (nx >= 0 && nx < N && ny >= 0 && ny < N && nz >= 0 && nz < N && grid[idx(nx, ny, nz)]) n++;
First checks that the neighbor coordinate is within the cube's bounds (0 to N-1 on each axis) to avoid reading outside the array, then checks if that neighbor is alive - if both are true, increments the neighbor count.
next[i] = grid[i] ? (n >= 4 && n <= 6) : (n >= 5 && n <= 6);
This is the core Game of Life rule, adapted for 3D. If the cell is currently alive (grid[i] is true), it survives only if it has 4, 5, or 6 live neighbors. If the cell is currently dead, it's born only if it has exactly 5 or 6 live neighbors. Otherwise it stays or becomes dead.
[grid, next] = [next, grid]; gens++;
This is array destructuring used to swap two variables in one line: grid becomes what next was, and next becomes what grid was. This means the newly-computed generation instantly becomes the one that gets drawn, without copying every element. Then the generation counter is incremented.

mousePressed()

mousePressed() is one of p5's built-in event functions that runs automatically in response to user input, letting you add interactivity without manually checking mouse state every frame.

function mousePressed() { randomize(); gens = 0; }
Line-by-line explanation (1 lines)
function mousePressed() { randomize(); gens = 0; }
p5.js automatically calls mousePressed() whenever the mouse is clicked anywhere on the canvas. This one calls randomize() to scramble the grid with new random cells, and resets the generation counter back to 0 so the display starts counting from the beginning again.

windowResized()

windowResized() is a built-in p5 callback useful for making sketches responsive to different screen sizes, which is especially important for full-window WEBGL scenes like this one.

function windowResized() { resizeCanvas(windowWidth, windowHeight); }
Line-by-line explanation (1 lines)
function windowResized() { resizeCanvas(windowWidth, windowHeight); }
p5.js automatically calls windowResized() whenever the browser window changes size. resizeCanvas() adjusts the canvas to match the new windowWidth and windowHeight so the 3D scene always fills the browser.

📦 Key Variables

N number

The number of cells along each axis of the cube (grid is N x N x N cells total)

let N = 10;
grid array

Flat array holding the current generation's alive/dead state for every cell, indexed via idx()

grid = new Array(N * N * N);
next array

Flat array used as scratch space to compute the next generation before it's swapped in to become the new grid

next = new Array(N * N * N);
gens number

Counts how many generations have elapsed since the last randomize(), shown in the on-screen counter

let gens = 0;
angle number

Tracks the current rotation angle of the cube, incremented every frame to animate the spin

let angle = 0;
counterP object

Reference to the HTML paragraph element (created with createP) used to display the generation counter overlay

let counterP;
S number

The pixel spacing between cells and roughly the overall scale of the cube in 3D space

const S = 30;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG step() boundary check

Cells on the edges and corners of the cube have fewer than 26 possible neighbors because out-of-bounds neighbors simply aren't counted, rather than wrapping around. This makes edge behavior noticeably different from the center of the cube, which can bias patterns toward dying out near the boundaries.

💡 Consider wrapping coordinates with modulo arithmetic (a toroidal/donut-shaped grid) so every cell always has exactly 26 neighbors, e.g. nx = (x + dx + N) % N.

PERFORMANCE step()

step() recalculates idx() for every one of the 26 neighbors on every one of the N^3 cells every generation - for N=10 that's 26,000 idx() calls per generation, which grows quickly if N is increased via the tunable range.

💡 Precompute neighbor offsets as index deltas once (since the grid is fixed size) instead of calling idx(nx, ny, nz) fresh each time, or cache y*N and z*N*N partial sums outside the innermost loop.

STYLE step() and randomize()

The survival/birth thresholds (4, 5, 6) and the initial density (0.3) are 'magic numbers' embedded directly in expressions, making them hard to find and tweak without reading closely.

💡 Extract them into named constants at the top of the file, e.g. const SURVIVE_MIN = 4, SURVIVE_MAX = 6, BIRTH_MIN = 5, BIRTH_MAX = 6, INITIAL_DENSITY = 0.3, so the rules are easy to locate and experiment with.

FEATURE draw() / mousePressed()

There's no way to pause the simulation, adjust the grid size, or control simulation speed without editing code directly.

💡 Add keyboard controls (keyPressed()) to pause/resume step(), and consider adding p5 DOM sliders for stepInterval or the density threshold so viewers can experiment live in the browser.

🔄 Code Flow

Code flow showing idx, setup, randomize, draw, step, mousepressed, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> randomize[randomize] randomize --> draw[draw loop] draw --> step-timer[Simulation Step Timer] step-timer -->|Every 15 frames| cell-iteration-loop[Cell Iteration Loop] cell-iteration-loop --> neighbor-counting-loop[26-Neighbor Counting Loop] neighbor-counting-loop --> skip-self-check[Skip Self Check] skip-self-check --> boundary-check[Boundary and Alive Check] boundary-check --> life-rule[Life/Death Rule Application] life-rule --> cell-iteration-loop draw --> render-loop[3D Grid Render Loop] render-loop --> cell-draw-conditional[Draw Alive Cell] cell-draw-conditional --> render-loop click setup href "#fn-setup" click randomize href "#fn-randomize" click draw href "#fn-draw" click step-timer href "#sub-step-timer" click cell-iteration-loop href "#sub-cell-iteration-loop" click neighbor-counting-loop href "#sub-neighbor-counting-loop" click skip-self-check href "#sub-skip-self-check" click boundary-check href "#sub-boundary-check" click life-rule href "#sub-life-rule" click render-loop href "#sub-render-loop" click cell-draw-conditional href "#sub-cell-draw-conditional"

❓ Frequently Asked Questions

What visual experience does the AI 3D Game of Life sketch offer?

The sketch creates a mesmerizing 3D display of cellular automata evolving inside a rotating cube, where neon green cells live and die based on Conway's Game of Life rules.

How can users interact with the AI 3D Game of Life sketch?

Users can click anywhere on the canvas to randomize the grid and reset the generation count, allowing for continuous exploration of cellular evolution.

What creative coding concept is showcased in this sketch?

This sketch demonstrates the principles of Conway's Game of Life, a cellular automaton, visualized in three dimensions to blend mathematics, biology, and computer graphics.

Preview

AI 3D Game of Life - Conway's Automata in Space - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI 3D Game of Life - Conway's Automata in Space - Code flow showing idx, setup, randomize, draw, step, mousepressed, windowresized
Code Flow Diagram