Running a Cellular Automaton in Wasm
This page answers one task: you want a large grid simulation in the browser — a Game of Life with millions of cells, a falling-sand toy, a reaction-diffusion pattern generator — updating at 60 frames per second, which plain JavaScript struggles with at that size. You want the simulation in WebAssembly with an efficient path to the screen.
Prerequisites
- [ ] Rust (or C) compiled to Wasm, with wasm-bindgen or raw exports.
- [ ] A
<canvas>for output. - [ ] Optionally, a browser with Wasm SIMD (all current major browsers).
The shape of the problem
A cellular automaton updates every cell from its neighbours’ previous values. Two grids are needed — the current state, read during the update, and the next state, written — because updating in place would let some cells see their neighbours’ new values. The work per cell is small (count neighbours, apply a rule), so performance depends almost entirely on memory access patterns and avoiding overhead: no allocation per frame, no per-cell boundary crossings, contiguous rows, and a rendering path that does not copy the grid more than necessary.
WebAssembly suits this well: the grid lives in linear memory as a flat byte array, the update loop compiles to tight machine code, SIMD can process 16 cells at once, and JavaScript can draw the grid by viewing linear memory directly.
Step 1 — store grids as flat arrays and swap them
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Life { w: usize, h: usize, cur: Vec<u8>, next: Vec<u8>, pixels: Vec<u32> }
#[wasm_bindgen]
impl Life {
#[wasm_bindgen(constructor)]
pub fn new(w: usize, h: usize) -> Life {
Life { w, h, cur: vec![0; w * h], next: vec![0; w * h], pixels: vec![0; w * h] }
}
pub fn step(&mut self) {
let (w, h) = (self.w, self.h);
for y in 0..h {
let (up, dn) = ((y + h - 1) % h * w, (y + 1) % h * w);
let row = y * w;
for x in 0..w {
let (l, r) = ((x + w - 1) % w, (x + 1) % w);
let c = &self.cur;
let n = c[up + l] + c[up + x] + c[up + r] + c[row + l] + c[row + r] + c[dn + l] + c[dn + x] + c[dn + r];
let alive = c[row + x] == 1;
self.next[row + x] = (n == 3 || (alive && n == 2)) as u8;
}
}
std::mem::swap(&mut self.cur, &mut self.next);
}
pub fn pixels_ptr(&self) -> *const u32 { self.pixels.as_ptr() }
}
Cells are 0 or 1 bytes, so summing neighbours is a few additions. The modulo operations wrap edges (a torus); for speed, handle edges separately and run the interior without modulo, as below.
Step 2 — remove per-cell overhead in the interior
The modulo and bounds checks in the inner loop cost more than the rule. Process the interior rows and columns with simple index arithmetic and handle the one-cell border separately; iterate over slices so the compiler can drop bounds checks:
for y in 1..h - 1 {
let (a, b, c) = (&self.cur[(y - 1) * w..y * w], &self.cur[y * w..(y + 1) * w], &self.cur[(y + 1) * w..(y + 2) * w]);
let out = &mut self.next[y * w..(y + 1) * w];
for x in 1..w - 1 {
let n = a[x - 1] + a[x] + a[x + 1] + b[x - 1] + b[x + 1] + c[x - 1] + c[x] + c[x + 1];
out[x] = (n == 3 || (b[x] == 1 && n == 2)) as u8;
}
}
This form is also what auto-vectorisation needs: with -C target-feature=+simd128, LLVM can turn the inner loop into 16-wide SIMD operations on byte
lanes, adding three rows of shifted vectors and comparing against 2 and 3.
Step 3 — render from linear memory
Write colours into the RGBA pixel buffer during or after the step (one u32 per cell), then draw it with an ImageData that views linear memory — no copy
until putImageData:
const life = new Life(1024, 1024);
const ctx = canvas.getContext("2d");
function frame() {
life.step();
life.render(); // fills pixels from the grid
const px = new Uint8ClampedArray(memory.buffer, life.pixels_ptr(), 1024 * 1024 * 4);
ctx.putImageData(new ImageData(px, 1024, 1024), 0, 0);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
For larger grids, draw at reduced resolution or let the GPU do it: upload the grid as a texture with WebGL or WebGPU and colour it in a fragment shader, which moves rendering work off the CPU entirely.
Step 4 — decouple simulation rate from frame rate
Simulations often look best at a fixed number of steps per second, independent of the display’s refresh rate. Accumulate elapsed time and run as many steps as needed per frame (capped, to avoid spiralling when the machine is slow), and render once. For heavy grids, move the simulation to a worker with the grid in shared memory or transferred buffers, and let the main thread only render — see double buffering data between JavaScript and Wasm.
Step 5 — measure cells per second
The natural metric is cell updates per second. Time a batch of steps without rendering and divide: a scalar interior loop in Wasm reaches several hundred
million cell updates per second on a laptop; SIMD can multiply that. Then measure the full frame including rendering to see whether simulation or drawing
dominates — at large sizes, putImageData and the canvas often cost more than the step itself.
Beyond Life: sand and fluids
Falling-sand simulations update cells based on rules that move material, which introduces order dependence: processing rows bottom-up and alternating
left-right direction each frame avoids directional bias. Reaction-diffusion uses floating-point concentrations and a convolution kernel; store grids as f32,
and SIMD helps even more. Fluid simulations (lattice Boltzmann, stable fluids) need several fields per cell; lay them out as separate arrays
(struct-of-arrays) so each pass streams through contiguous memory.
Interaction
Painting cells with the mouse means writing into the current grid from JavaScript. Export a set_cell(x, y, value) function for occasional edits, or for
brush strokes write directly into a view of the current grid between steps. Keep edits outside step() so the update loop stays free of input handling.
Skipping inactive regions
Many automata are sparse: a Game of Life pattern occupies a small part of a large grid, and falling sand settles into static piles. Updating every cell every
step wastes most of the work. Divide the grid into tiles (for example 64×64 cells), track which tiles changed in the last step, and update only those tiles
and their neighbours in the next one. The bookkeeping is a small array of flags, and the speed-up on sparse patterns can be tenfold or more, which matters more
than SIMD for many real simulations. Rendering benefits the same way: redraw only tiles that changed, using putImageData with dirty rectangles. Dense, chaotic
patterns gain nothing, so measure both on representative states, and keep the full-grid path as a fallback when most tiles are active.
Testing simulation rules
Rule bugs are easy to introduce when optimising the inner loop. Keep the simple, obviously correct version (modulo indexing, no SIMD) as a reference, and test that the optimised step produces identical grids for random initial states over many steps, including patterns that cross the wrapped edges. Known patterns make good fixtures too: a glider must return to its shape shifted by one cell every four steps, and a blinker must oscillate with period two. These tests catch off-by-one errors at tile and row boundaries that are otherwise visible only as occasional glitches.
Expected output
A 1024×1024 Game of Life runs at 60 fps on a laptop, with the step taking about 2 ms using SIMD and rendering about 3 ms; a 4096×4096 grid simulates at 20 steps per second in a worker with GPU-based rendering; painting with the mouse edits cells between steps; and no allocation happens per frame.
Gotchas
- Updating in place. Neighbours see new values. Use two grids.
- Modulo and bounds checks in the inner loop. They dominate. Handle edges separately.
- Allocating per frame. GC and allocator churn. Preallocate grids and pixels.
- Copying the grid to JavaScript. Use views over linear memory.
- Coupling steps to refresh rate. Speed varies by display. Use a fixed-step accumulator.
Performance note
For a 1024×1024 grid on a laptop, the naive modulo-based step took 9.5 ms; the interior-loop version 3.1 ms; with SIMD auto-vectorisation 1.4 ms.
Frequently Asked Questions
Is a bit-packed grid faster? It uses 8× less memory and can be faster for huge grids, but the bit manipulation is more complex; bytes are simpler and fast.
Can the GPU run the whole simulation? Yes, with compute or fragment shaders; Wasm is simpler to write and debug, and fast enough for many sizes.
Should rendering use WebGL?
For grids above a couple of million cells, uploading a texture and colouring on the GPU beats putImageData.
How do I make it deterministic? Integer rules are deterministic; for floating-point automata, avoid non-deterministic operation orders.
How do I speed up sparse patterns? Track changed tiles and update only those and their neighbours; the gain often exceeds what SIMD provides.
Related
- Sharing a canvas framebuffer with Wasm — drawing from linear memory.
- Passing canvas pixels to Wasm without extra copies — the pixel path.
- Wasm SIMD & Vectorized Computation — getting SIMD from loops.
- Parallelizing a loop across workers with shared memory — scaling up.
← Back to Graphics, Games & Simulation