The simulation below is running thirty thousand colored dots by default and can comfortably handle two hundred thousand, all updating sixty times a second on your GPU. Each dot follows the same dead-simple rule against everyone in its neighborhood — and out of that, you get cells that nucleate, chains that wriggle, swarms that fold past each other, and organisms that look unsettlingly alive. Drag inside it. Hit Randomize all a few times. Click any cell in the matrix and watch the entire population reorganize around the change.
Force Matrix
Row attracts column. Green = attract, red = repel. Click a cell to cycle. Shift-click to randomize one cell.
Physics
Your saves
Click an empty slot or "Save current" to store the current setup. Click a filled slot to load it. Right-click a slot to clear it. Slots persist in your browser.
Eight colors, an eight-by-eight matrix that decides who likes whom, and an interaction radius that says "particles only care about their close neighbors." That is the entire model. The cells, the chains, the orbits, the slow drifting organisms — all of it falls out of that one rule, applied to a few tens of thousands of dots in parallel.
What one particle is doing
Pick any single dot in the box above. Sixty times a second, it wakes up and asks one question: who is close to me? It looks out to a small radius — just a fraction of the window — and finds maybe a few dozen neighbors. Anything farther away does not exist as far as it is concerned.
Then, for each neighbor, it does a tiny lookup: what color am I, what color are they, and what does the matrix say one should feel about the other? A positive number means attraction, negative means repulsion. There is also a hard short-range push so dots cannot occupy the same point. It adds up all those little nudges into one direction, takes a step that way, loses a bit of speed to friction, and goes back to sleep until the next frame.
That is the whole life of a particle. There is no awareness of the cells it is part of, no notion of a chain or a swarm. The cell exists only because thousands of dots, each running that same little routine, happen to want to sit at the same equilibrium distance from each other. The chain exists because color A likes color B and B likes C and C likes A. The eye assembles a creature; the creature is not in the code.
The rules, in one paragraph
For each particle, look at every other particle within a small
radius r. Compute two separate forces. The first
is a collision force: always repulsive, peaks at zero
distance, falls linearly to zero at β · r.
This stops particles piling on top of each other. The second
is an interaction force whose sign and strength come
from a matrix entry indexed by the two particles' colors;
it peaks at zero distance and falls linearly to zero at
r. Add them together, sum over every neighbor,
apply friction so the system can settle, integrate. Repeat.
That is it. Here is the force function in WGSL, the shading
language WebGPU runs. It takes a normalized distance
d in [0, 1] (distance divided by
r), the matrix entry a, the
collision-radius ratio β, and the global
collision strength repel:
fn forceFn(d: f32, a: f32, beta: f32, repel: f32) -> f32 {
var f = 0.0;
// Interaction triangle, peak at d=0, zero at d=1.
f = f + a * max(0.0, 1.0 - d);
// Collision triangle, peak at d=0, zero at d=beta. Always repels.
if (d < beta) {
f = f - repel * (1.0 - d / beta);
}
return f;
}
Two triangles stacked on top of each other. The collision
triangle dominates inside β, so the net force
at very short range is always strongly negative. The interaction
triangle decides what happens further out. When the matrix
entry is attractive and the collision is strong, the sum has a
stable equilibrium distance where particles want to sit
— that is what gives the simulation its real-cell,
ring-shell, orbit-knot character.
Why this scales to two hundred thousand
The naive way to do this is brutal. Each particle compared
against every other particle is O(N²). Ten
thousand particles is a hundred million comparisons per frame.
Two hundred thousand is forty billion. You will not be doing
that sixty times a second on anything, ever.
The first trick is a uniform grid. Cut the
window into square cells the size of the interaction radius.
Drop each particle into its cell. To find a particle's
neighbors, only check the nine cells around it (its own plus
the eight adjacent ones). If particles are spread out roughly
evenly, the work per particle becomes constant, so the total
work is O(N). The cost is rebuilding the grid
every frame, because everyone is moving.
The second trick is doing all of this on the GPU. A modern GPU has thousands of execution units sitting around looking for work. The hard part has always been getting the work to them — especially from a browser. WebGL had no real compute model; you had to abuse the rasterizer or pack data into textures. WebGPU finally gives the browser real compute pipelines, real storage buffers, and real atomics so multiple threads can coordinate.
With those two pieces, the per-frame work splits into four tiny shaders that each run across the whole population at once. The CPU's only job is to schedule them:
function frame() {
uploadParams(dt); // 96 bytes
const enc = device.createCommandEncoder();
const cp = enc.beginComputePass();
// 1. Zero the per-cell counters
cp.setPipeline(clearPipeline);
cp.dispatchWorkgroups(ceil(numCells / 64));
// 2. Each particle inserts itself into its cell
cp.setPipeline(buildPipeline);
cp.dispatchWorkgroups(ceil(N / 64));
// 3. Each particle sums forces, integrates, writes new state
cp.setPipeline(updatePipeline);
cp.dispatchWorkgroups(ceil(N / 64));
cp.end();
// 4. Draw the new positions as instanced quads
const rp = enc.beginRenderPass(attachment);
rp.setPipeline(renderPipeline);
rp.draw(6, N);
rp.end();
device.queue.submit([enc.finish()]);
requestAnimationFrame(frame);
}
Four GPU dispatches and one draw call. No matter how many particles are running, the CPU's per-frame work stays the same handful of commands. The GPU absorbs the rest.
One subtlety: the new positions and velocities are written to
a second particle buffer, not back into the one being
read. If we wrote in place, particles updated early in the
dispatch would be seen at their new positions by particles
updated later, and the simulation would tear. So the buffers
ping−pong: read from A, write to
B, then next frame read from B,
write to A. The render pass always uses whichever
buffer was just written.
Whose idea this was
This family of simulations has been around for years. The original is Clusters, built by Jeffrey Ventrella — an algorithmic artist and artificial-life researcher who has been making asymmetric particle systems since long before the rest of us noticed they were beautiful. Clusters is the original asymmetric attraction-repulsion model: the rule that one species can love another that does not love it back.
Tom Mohr took that idea, simplified the math, gave it a clean Java framework with the spatial grid that makes scale possible, and renamed it Particle Life. His implementation is what most people have actually run.
Jonas Tyroller's video ("How Particle Life emerges from simplicity") is what put this in front of a wider audience. If you have ever seen a clip of colored dots forming organisms and thought "I should look into that," there is a very good chance the clip was his. Earlier, Code Parade's video introduced a lot of programmers to the idea too.
The two-force shape used here — the collision triangle stacked on the interaction triangle — comes from lisyarus's WebGPU port, which was the first version I saw running this many particles in a browser tab. The rendering approach here — HDR offscreen target, soft glow halos, ACES tonemap on composite — is borrowed from that post too.
None of the math here is mine. What is new is the platform.
Five years ago putting this on a web page would have meant
shipping a fifty-megabyte WebAssembly binary or asking people
to download a desktop app. Today it is a .html
file, a script tag, and a few hundred lines of WGSL. The
browser draws the rest.
Source for this page is particle-life.js. Have at it.