Loading
Fixed time, deterministic seeds, a toroidal world, and the spatial hash that lets two thousand particles remain an experiment.
Launch the simulation
0303 / 09On screen, Particle Life is a cloud of colored points.
Inside the engine, it is a sequence of promises.
The same amount of simulated time should produce the same physical result regardless of display refresh rate. The same seed and matrix should reproduce the same opening. A particle near the right edge should interact correctly with one near the left. Two thousand particles should not require four million expensive distance checks every step. A tab returning from the background should not try to simulate every missed second at once.
None of those promises creates emergence. They create the stable stage on which an emergent result can mean something.
Without them, a beautiful pattern might be a frame-rate bug, a random-number accident, a boundary artifact, or a machine-specific timing difference. The engine's job is to remove those alternative explanations.
The app first clamps a browser frame gap to 100 ms, then the particle system keeps at most four 1/60-second steps. Dropped time never enters the simulated clock.
The browser's animation frame drives a SimulationLoop. On each tick, the loop performs four conceptually separate jobs:
real elapsed time
↓
fixed physics steps → live analysis → render current state → publish UI statsThe ParticleSystem owns positions, velocities, types, the force matrix, and the physics step. Analysis reads the state and derives clusters, speed, and stability. A renderer consumes a narrow view of particle buffers. React owns controls and presentation but does not reach into the inner force loop.
The boundaries let tests run physics without React and let rendering fail without erasing the world.
That separation is practical. The same physics can run headlessly in tests and in the Discover search without a canvas or React tree. The renderer can change from WebGPU to WebGL2 without changing a trajectory. The UI can pause or load a preset through a small public API instead of mutating particle arrays.
Architecture rule: The simulation state has one owner. Everything else observes it or asks it to perform an explicit operation.
Browsers do not promise sixty animation frames per second. A 60 Hz display, a 144 Hz display, a busy CPU, a resized window, and a background tab all deliver different frame intervals.
If the engine used each real frame duration directly in the force equations, those machines would not run the same experiment. Large frames would take large integration jumps. Small frames would take gentler ones. Collision spacing, stability, and even qualitative patterns could diverge.
Particle Life instead accumulates real elapsed time and consumes it in fixed steps of:
h = 1 / 60 secondAt 120 display frames per second, some render frames contain no physics step and simply draw the current state. After a slow frame, the accumulator may run several fixed steps before rendering. The equations always see the same h.
The speed control respects the same contract. At 2×, it does not double h; it supplies twice as much simulated time to the accumulator. Sixty frames at 2× therefore take the same physics steps as one hundred twenty frames at 1×. A test verifies that the resulting trajectories are float-for-float identical.
There is one deliberate limit. A tick may advance at most four fixed steps. If a tab sleeps for ten seconds, the engine drops the surplus instead of entering a “spiral of death” while trying to catch up. The simulation slows gracefully under overload, and the elapsed-time display counts only steps actually performed. The clock reports simulated truth, not wall-clock aspiration.
Every reset scatters particle positions and assigns types. If that initialization used ambient Math.random(), a shared matrix would describe the rules but not the world in which those rules began.
The engine uses a deterministic Mulberry32 generator. A 32-bit seed produces the same sequence of numbers, and the reset method consumes them in a fixed order:
particle 0: x, y, type
particle 1: x, y, type
particle 2: x, y, type
...That call order is part of the data format. Reordering it would silently change every existing shared opening even if the seed stayed the same.
Determinism turns several product features into the same engineering feature:
The current share contract reproduces the opening exactly. It does not snapshot positions and velocities from an arbitrary later moment. This distinction matters: a link says “start the same experiment,” not “teleport to the frame I was viewing.”
A hard boundary would add a rule unrelated to the interaction matrix. Particles would collect against walls, reflect, or experience an artificial edge density. Any structure near that boundary would become difficult to interpret.
Particle Life uses a toroidal world: leaving the right side enters from the left; leaving the bottom enters from the top. Topologically, the rectangle behaves like the surface of a donut, even though it is rendered flat.
The world wraps. Physics uses the minimum-image distance, so particles near opposite edges can still be close neighbors.
Wrapping position is only half the implementation. Force distance must also use the shorter wrapped route. For horizontal separation dx, the engine applies the minimum-image convention:
let dx = neighborX - particleX;
if (dx > width / 2) dx -= width;
else if (dx < -width / 2) dx += width;The same calculation applies vertically. A particle at x = 2 and one at x = width - 3 are five pixels apart across the seam, not almost one canvas width apart.
This creates a world with consistent local physics everywhere. The visible rectangle still has a seam, but the simulation does not have a privileged center or a physical edge.
For each particle, a naive engine could inspect every other particle. With N = 2,000, the directed force loop starts near:
N × (N - 1) = 2,000 × 1,999 = 3,998,000 checks per stepAt sixty steps per simulated second, most of those checks are wasted because the force is zero beyond R = 100 px.
The spatial hash divides the toroidal world into a uniform grid. Each cell is at least as wide and tall as the interaction radius. Particles are inserted into flat linked lists by cell. To find possible neighbors, a particle scans only its own cell and the eight surrounding cells; the caller then performs the exact radius test.
At 2,000 particles, all-pairs starts near four million directed checks per step.
A cell is at least as wide as the interaction radius. The current cell plus its eight neighbors therefore contain every possible interaction candidate.
This changes the typical workload from “compare with the world” to “compare with the local population.” Under the intended distribution, rebuilding and querying the grid behaves near linearly with particle count. That is an operating characteristic, not a universal complexity guarantee: if every particle occupies one cell, the candidate set can still become quadratic.
The implementation includes details that only become visible when they fail:
Each of these behaviors has a regression test. Performance optimization without correctness tests would make the simulation faster and less trustworthy.
The first intuitive representation is an array of particle objects:
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
type: number;
};The shipped system instead uses a structure of arrays:
x = Float32Array
y = Float32Array
vx = Float32Array
vy = Float32Array
type = Uint8ArrayThe force loop reads contiguous numeric storage without allocating temporary objects. The renderer can stream the same position and type data into GPU buffers. Arrays grow only during explicit reset or spawn operations, not inside the per-frame hot path.
This representation is less convenient for asking for “particle 127 as an object.” That is the tradeoff. The engine is shaped around the work it performs most often: iterate one field across many particles, calculate local forces, and upload compact buffers.
The spatial hash follows the same principle. Cell heads and next pointers are Int32Arrays. A rebuild clears and refills existing storage. Neighbor results are written into reusable scratch buffers. The renderer's trail history and connection data also reuse fixed buffers.
“Zero allocation” here refers to steady-state hot paths, not the entire application. User actions are allowed to allocate. The goal is to keep garbage collection from becoming an invisible force that changes frame pacing.
After forces modify velocity, every particle follows the same integration order:
Changing that order changes the model. Applying friction before the clamp produces a different result under violent matrices. Wrapping before integration changes seam behavior. In a simulation, “refactoring” arithmetic is often a physics change wearing a code-style disguise.
The engine tests therefore cover invariants rather than implementation lines: velocities remain finite under ±0.9 interactions, speed stays clamped, irregular frame sequences produce identical trajectories, and resize operations wrap every position into the new world.
The full-screen canvas is mounted and unmounted through normal site navigation. The loop must cancel its animation frame, disconnect its resize observer, and destroy its GPU or WebGL resources. A late renderer initialization after unmount is destroyed as soon as it resolves.
GPU device loss has a stranger constraint: a canvas configured for WebGPU cannot simply become a WebGL2 canvas. Recovery remounts a fresh canvas element and rebinds the existing SimulationLoop to it. The particles, velocities, elapsed time, and matrix survive; only the renderer changes.
This boundary is why rendering can fail without erasing the experiment. The engine owns the world. The canvas is one view of it.
The force law from Part 1 and the matrix from Part 2 are the intellectually interesting rules. The engine makes claims about them inspectable.
Fixed time lets two speeds be compared. Seeds let an opening be replayed. Toroidal distance removes wall artifacts. The spatial hash makes the intended scale practical. Typed arrays and buffer reuse protect frame pacing. Explicit ownership lets the renderer degrade without changing physics.
Together, those choices turn a visual toy into a reproducible simulation—not because the code is complicated, but because it is strict about which differences are allowed to matter.
Open the simulation, press Share, and open the copied link in a second tab. The two openings should match: same rules, same count, same seed. After that, their frame schedules may diverge, but they begin as the same experiment.
The next part will follow the state in the other direction—from typed arrays into WebGPU buffers, bloom textures, trails, connection lines, and a WebGL2 fallback that keeps the world visible when the preferred renderer cannot.
More to read
The matrix is not a palette or a control panel. It is a directional language for attraction, repulsion, pursuit, and structure.
The renderer is not decoration. It makes distance, motion, relationships, and failure legible without changing the physics underneath.
The first version made only two things: singular clumps and expanding dust. One repulsion zone changed everything.