The Grid Is the Data Structure
A falling-sand simulation looks like a physics problem and is actually a data-structure problem. Every behavior — sand piling, water flowing, walls blocking — is the same operation applied millions of times per minute: look at a cell, look at its neighbors, maybe swap two values. The entire performance question is how expensive "look at a cell" is.
The grid is a flat Uint8Array where each byte is a material id. That's the whole model:
const W = 240, H = 135; // simulation cells, not screen pixels
const EMPTY = 0, SAND = 1, WATER = 2, STONE = 3;
const grid = new Uint8Array(W * H); // 32,400 bytes for a full screen
const idx = (x, y) => y * W + x; // row-major, cache-friendly
Wrap that in the naive alternative — an array of cell objects, { type: 'sand', color: '...', density: 4 } — and you get an object per pixel, pointer-chasing through the heap on every neighbor lookup, and GC pressure if anything is re-allocated per frame. The typed array wins by roughly two orders of magnitude, and that is not an exaggeration; it is the single authoritative reason pixel simulations run at 60 FPS in a browser tab. Everything below assumes this layout: neighbor access is index ± 1 and index ± W — arithmetic on cache lines, not property lookups.
One immediate consequence of the flat array: swap is three byte assignments, no allocation, no method calls:
function swap(a, b) {
const t = grid[a];
grid[a] = grid[b];
grid[b] = t;
}
Why You Iterate Bottom-Up
Here is the bug that every first implementation ships: iterate rows top-down, move grains down, and watch the sand fall at ten cells per frame — because a grain moved from row 5 to row 6 is re-examined when the loop reaches row 6, and moves again, and again, until it hits the floor in a single frame. Physics becomes teleportation.
Iterating bottom-up fixes it structurally. When you process row y, every row below has already been processed for this frame, so a grain that moves into row y+1 will not be touched again until the next frame. One row of travel per frame — exactly the "slow, satisfying trickle" the genre is named for.
There is a second, subtler bias: scanning columns left-to-right every frame makes sand lean left (ties resolve toward a direction, water drifts). The fix is to alternate the horizontal scan direction every frame, so the asymmetry cancels out over any two consecutive frames:
let frame = 0;
function step() {
frame++;
const ltr = (frame & 1) === 1; // left-to-right this frame?
for (let y = H - 2; y >= 0; y--) { // bottom-up: one row of fall per frame
for (let i = 0; i < W; i++) {
const x = ltr ? i : W - 1 - i;
const cell = grid[y * W + x];
if (cell === SAND) stepSand(y * W + x, x, y);
else if (cell === WATER) stepWater(y * W + x, x, y, ltr);
}
}
}
Note what the bottom-up property buys for downward moves of any kind: straight down, down-left, down-right all land in already-processed rows, so no grain can move twice downward in one frame, with no bookkeeping at all. The only move that needs care is water sliding sideways in its own row: if the scan is left-to-right and water moves right, the loop will reach it again and it can skate multiple cells per frame. The clean answer without allocating anything: when scanning left-to-right, let water's sideways option be leftward only, and flip with the scan. Over two frames the behavior is symmetric, and no water ever double-moves.
Materials as Rules
Each material is a function from (cell, neighbors) to (maybe swap). Sand first — including two behaviors that separate a pleasant sim from a rigid one:
function stepSand(i, x, y) {
const below = i + W;
if (grid[below] === EMPTY || grid[below] === WATER) {
swap(i, below); // falls — and sinks through water
return;
}
const openLeft = x > 0 && isOpen(grid[below - 1]);
const openRight = x < W - 1 && isOpen(grid[below + 1]);
// Diagonal choice must be random, or piles grow lopsided
if (openLeft && openRight) swap(i, Math.random() < 0.5 ? below - 1 : below + 1);
else if (openLeft) swap(i, below - 1);
else if (openRight) swap(i, below + 1);
}
const isOpen = v => v === EMPTY || v === WATER; // sand falls through water too
Those two water tolerances — falling into it, sliding into it diagonally — produce the density behavior everyone expects from a sandbox: sand poured into a pool sinks and displaces the water upward, with no water-specific code in the sand function and no density field anywhere. Emergence for free from four comparisons.
Water is sand's rule set plus lateral flow:
function stepWater(i, x, y, ltr) {
const below = i + W;
if (grid[below] === EMPTY) { swap(i, below); return; }
const opts = [];
if (x > 0 && grid[below - 1] === EMPTY) opts.push(below - 1);
if (x < W - 1 && grid[below + 1] === EMPTY) opts.push(below + 1);
// Sideways: only against the scan direction, or water skates across the row
if (ltr && x > 0 && grid[i - 1] === EMPTY) opts.push(i - 1);
if (!ltr && x < W - 1 && grid[i + 1] === EMPTY) opts.push(i + 1);
if (opts.length) swap(i, opts[(Math.random() * opts.length) | 0]);
}
Math.random() in the hot loop is fine at this scale — tens of thousands of calls per frame are nothing. If you want determinism (replay, tests, or "same seed, same collapse") swap in a 32-bit xorshift; it is three lines and dozens of times faster, which matters only if you scale the grid up by 10×. STONE is simply a material with no rule function: the switch statement never calls anything for it, and it can never move. Static is the default state; motion is opt-in.
From Uint8Array to Pixels in One Write
The wrong renderer calls fillRect once per cell — 32,400 draw calls per frame, each with full Canvas API overhead, and the frame budget is gone before physics even runs. The right renderer writes pixels directly and lets the canvas do the scaling.
// Color lookup table, indexed by material id. Packs little-endian ABGR,
// which is the byte order of a Uint32 view over ImageData.
const rgb = (r, g, b) => ((0xff << 24) | (b << 16) | (g << 8) | r) >>> 0;
const COLORS = new Uint32Array(256);
COLORS[EMPTY] = rgb(11, 15, 20);
COLORS[SAND] = rgb(232, 176, 64);
COLORS[WATER] = rgb(64, 140, 232);
COLORS[STONE] = rgb(120, 124, 132);
// Allocate once — never per frame
const imageData = new ImageData(W, H);
const pixels = new Uint32Array(imageData.data.buffer); // view, zero copies
const simCanvas = new OffscreenCanvas(W, H);
const simCtx = simCanvas.getContext('2d');
function render(ctx, canvas) {
for (let i = 0; i < grid.length; i++) pixels[i] = COLORS[grid[i]];
simCtx.putImageData(imageData, 0, 0);
ctx.imageSmoothingEnabled = false; // crisp chunky pixels
ctx.drawImage(simCanvas, 0, 0, canvas.width, canvas.height);
}
Three things make this fast. The color LUT turns per-cell rendering into one array lookup — the loop is a single pass over two typed arrays with perfect locality. The Uint32Array over imageData.data.buffer is a view, not a copy: writing 32,400 uint32s is a memcpy-class operation measured in microseconds. And the scaled drawImage hands magnification to the compositor with smoothing disabled, so a 240×135 grid fills a 4K screen with sharp pixels at GPU cost, not JS cost. The entire render step is well under a millisecond; the genre's smoothness is bought here, not in the physics.
Resolution Strategy: Simulate Coarse, Display Sharp
The most valuable design decision in the whole project: the simulation grid has nothing to do with screen pixels. Simulating at device resolution on a 4K display means 8.3 million cells to update; simulating at 240×135 means 32,400 — a factor of 250 in work, for a visual difference most players read as aesthetic (sand grains are visible) rather than degraded. Chunky is the genre's look. Lean into it.
function sizeGrid(displayWidth, displayHeight) {
const targetCells = 34000; // tune: 34000 ~= smooth everywhere
const aspect = displayWidth / displayHeight;
const h = Math.round(Math.sqrt(targetCells / aspect));
return { w: Math.round(h * aspect), h };
}
Everything downstream handles the conversion: pointer coordinates map display→grid, and rendering maps grid→display via the single scaled drawImage. One consequence to respect: cell aspect ratio should stay close to 1 by deriving both dimensions from the real display aspect (as above), otherwise diagonal sand piles look sheared on ultrawide screens. And the display canvas itself can stay at CSS size with width/height attributes set to the grid dimensions — the browser scales it for free in the layout, and drawImage becomes optional. Two canvases, one lookup: pick whichever fits your rendering pipeline.
The Frame Loop: Fixed Steps, Capped Catch-Up
requestAnimationFrame calls you at display refresh — 60 Hz, 120 Hz, or whatever the system feels like — and occasionally not at all (background tab, OS sleep, GC pause). Coupling simulation speed to callback count means your sim runs at double speed on a gaming monitor and teleports after every stall. Decouple with the standard accumulator pattern:
const STEP_MS = 1000 / 60; // simulation time step
const MAX_DT = 100; // ignore stalls longer than this
let acc = 0, last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
let dt = now - last;
last = now;
if (dt > MAX_DT) dt = MAX_DT; // tab was hidden: don't spiral
acc += dt;
let steps = 0;
while (acc >= STEP_MS && steps < 2) { // at most 2 catch-up steps
step();
acc -= STEP_MS;
steps++;
}
if (acc >= STEP_MS) acc = 0; // backlog abandoned, not deferred
render(ctx, canvas);
}
requestAnimationFrame(frame);
Each guard in that loop prevents a specific failure. MAX_DT stops a five-minute background tab from queuing 18,000 simulation steps. The two-step cap bounds worst-case frame cost when the machine is slow — the sim runs slightly slow rather than collapsing into a death spiral where each frame's catch-up work makes the next frame later. And abandoning the backlog (acc = 0) expresses the correct priority: for an ambient toy, dropping simulation time is invisible; stuttering is not.
Add one more line-level behavior and the battery story is complete: on visibilitychange to hidden, cancel the rAF loop entirely (and reset last on resume). A background tab rendering a sandbox at 60 Hz is pure waste the user can feel on a laptop.
If you want graceful degradation instead of dropped frames, instrument it: keep an exponential moving average of step() + render() cost, and when it stays above ~12 ms, halve the grid resolution (fewer cells) and rebuild canvases. Users on weak hardware keep 60 FPS with a coarser grid — a much better trade than letting the sim crawl, and about fifteen lines of code.
Painting With the Pointer
The simulation is the frame's real deadline, so input handling must never do more than a few grid writes per event. Map display coordinates to grid coordinates, and — the one detail that makes or breaks the feel — interpolate between consecutive pointer events, because a fast stroke delivers samples far apart and painting only at sample points yields a dotted line:
let lastPoint = null;
const toGrid = (e) => {
const r = canvas.getBoundingClientRect();
return {
x: Math.floor((e.clientX - r.left) / r.width * W),
y: Math.floor((e.clientY - r.top) / r.height * H),
};
};
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId); // keep events outside the canvas
lastPoint = toGrid(e);
paintDot(lastPoint.x, lastPoint.y, current);
});
canvas.addEventListener('pointermove', (e) => {
if (!canvas.hasPointerCapture(e.pointerId)) return;
const p = toGrid(e);
paintLine(lastPoint.x, lastPoint.y, p.x, p.y, current);
lastPoint = p;
});
setPointerCapture is what makes strokes that leave the canvas and come back behave; without it, a fast drag delivers a gap, a missed pointerup, and a stuck brush. The line itself is Bresenham — integer-only, and exactly what an 8-connected grid needs (each painted step touches every cell the line passes through):
function paintLine(x0, y0, x1, y1, material, radius = 3) {
const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
for (;;) {
paintDot(x0, y0, material, radius);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
Two CSS/Layout details that only surface on phones: set touch-action: none on the canvas (otherwise the browser claims touch drags for scrolling and the brush dies mid-stroke), and let paintDot clip at the grid boundary rather than the caller — radius-3 brushes at the edge are the norm, not an edge case.
The Actual Numbers
At 240×135, a frame's work is: ~32k cells examined with 1–3 Uint8Array reads each, ~32k Uint32Array stores for rendering, one putImageData, one scaled drawImage. On hardware from the last decade, the physics pass is tens of microseconds to a low single-digit millisecond, and rendering is comparable. Against a 16.7 ms budget that is order-of-magnitude headroom — which is the point worth carrying to other projects: at this scale, the genre has no optimization problem at all, provided the data layout is right. The simulations that stutter are not victims of JavaScript's speed; they're victims of an object per pixel, a fillRect per cell, or an ImageData allocated per frame. Fix the layout and 60 FPS stops being a performance goal and becomes the default outcome.
When you do scale up — and the itch to scale up is real, since the same architecture runs at 960×540 with the loop unchanged — the first lever is not the physics, it's not scanning settled regions. Track a simple dirty-row band (rows above the lowest settled row since last frame, plus any row a brush touched) and skip the rest; piles of settled sand cost nothing. That single change buys roughly an order of magnitude on typical scenes, and it composes with everything above rather than replacing it.
Production Checklist
- Flat
Uint8Arraygrid of material ids; neighbor access as index arithmetic; no per-cell objects. - Bottom-up row iteration — one row of fall per frame, with no per-cell bookkeeping.
- Alternate column scan direction each frame; restrict same-row water movement against the scan direction to prevent multi-cell skates.
- Randomize (or parity-alternate) diagonal choices, or piles lean.
- Color LUT as
Uint32Array;ImageDataand its pixel view allocated once. - One
putImageData+ one scaleddrawImageper frame;imageSmoothingEnabled = false. - Simulate coarse (tens of thousands of cells), display at any size; derive grid dimensions from display aspect.
- Fixed timestep accumulator with a max-delta clamp and a catch-up cap; abandon backlog rather than defer it.
- Cancel the loop on tab hide; reset the clock on resume.
- Bresenham interpolation between pointer events;
setPointerCapture;touch-action: none. - Clip brush writes at the grid boundary, not at the caller.
- Instrument step+render cost; degrade resolution before dropping frames.
References
- Falling-sand Game — Wikipedia
- putImageData() — MDN
- ImageData — MDN
- requestAnimationFrame() — MDN
- JavaScript Typed Arrays — MDN
- sandspiel — a production falling-sand sandbox