GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Falling Sand Simulator, 9 Ways (No Library)

No library, no WebGL — four rules per cell (down, down-left, down-right, sideways) make sand pile up, water flow and fire spread across every falling sand

Auto-plays · click a tile to jump to its section · all nine in one zip

Why one falling rule splits into nine materials

The nine items climb one axis: how many materials share the same grid at once. 01 and 02 hold only sand, one testing a single downward swap and the other adding a diagonal branch so a pile settles at a natural angle instead of a column. 03 introduces a second material and a density check so water can sink sand and rise past it in the same grid. 04 burns through neighbors and counts down to ash, the same consumption math that drives a fire scene that trades particles for a diffusing heat grid of its own one page over. 05 turns a water touch into new growth, closing the loop between two materials that 03 only let pass through each other. 06 hands the fill rule to a pointer instead of a spawner, the same probabilistic radius fill that a solver where velocity gets pushed through neighboring cells instead of swapped between them uses for its own brush tool. 07 reverses 05's direction — a material that erases neighbors instead of converting them — while 08 proves that walls alone, with zero new rules, can turn item 02's slide into a readable countdown. 09 finally runs every rule from every other item at once, on one grid, so nothing in it is a new idea — it's the other eight sharing a floor.

Why sand falls with cellular automata? — the four-direction rule and probabilistic spread

A cell that only reads its own neighborhood is a cellular automaton

Every item on this page is the same textbook cellular automaton underneath: a grid where each cell holds one state, updates according to a rule that only looks at its immediate neighbors, and repeats that rule across the whole grid every tick. Nothing here tracks position or velocity as a float — a "falling sand game" in the Wikipedia sense is exactly this pattern, going back to the sand and water toys built into early graphing calculators.

Bottom-to-top, alternating left-right, keeps one cell from moving twice

Scanning from the bottom row up matters because a cell that just fell into row y+1 shouldn't be re-evaluated later in the same pass as if it just arrived there naturally. Alternating which side of each row gets scanned first from one tick to the next is what keeps a symmetric pile symmetric — scan left-to-right every single tick and a subtle rightward drift creeps into every pile on this page.

Density, not identity, decides what sinks through what

03's entire water-versus-sand relationship comes down to one comparison: a falling sand cell is allowed to swap into a water cell, but a falling water cell is not allowed to swap into a sand cell. Reverse that one condition and water would float on top of sand instead of soaking underneath it.

Recomputing from a fixed pastel palette keeps nine different materials readable

Every material on this page is a small integer in a Uint8Array grid, colored from one shared demo-tokens.scss palette rather than nine separate color schemes — background $sand-night (#201c17), sand $sand-grain (#d9b98a), water $sand-water (#a9c8dd), fire $sand-fire (#ffc9ad), plants $sand-plant (#9fd6ad), acid $sand-acid (#c7dd8f). The same eleven pastel tokens carry across all nine items, so a viewer can tell material apart from material on sight without a legend.

Code per item

Every function below is copied straight out of that item's own script tag, not paraphrased into pseudocode — the same constant names, the same order of operations, and code that behaves exactly like the demo sitting above it once it's pasted into a fresh page.

01The basic fall — one rule: move down when the cell below is empty

Give every cell one instruction — swap down if the cell below is empty — and run it bottom-to-top each frame, and a sand pour off-screen above turns into heaps piled on the floor below. It's the entire mechanism behind a hero background or a game's loading-screen animation, with nothing else added.

The basic fall — one rule: move down when the cell below is empty
Open in a new tab

01 · The basic fall — one rule: move down when the cell below is empty — Measured area 33.333% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame

One rule, run bottom-to-top, is the whole pile

function update() {
  for (var y = rows - 2; y >= 0; y--) {
    for (var x = 0; x < cols; x++) {
      var i = idx(x, y);
      if (grid[i] !== 1) continue;
      var destY = y;
      for (var s = 1; s <= FALL; s++) {
        var yy = y + s;
        if (yy >= rows) break;
        if (grid[idx(x, yy)] === 0) destY = yy; else break;
      }
      if (destY !== y) {
        var di = idx(x, destY);
        grid[di] = 1; shade[di] = shade[i];
        grid[i] = 0;
      }
    }
  }
}

02Slope slide — diagonal rules produce a natural angle of repose

When the straight-down move is blocked, trying a random diagonal before giving up stops sand from freezing into a single rigid column. That one extra branch is what turns a falling sand simulation online into something that mounds up at a believable angle instead of a stack of blocks.

Slope slide — diagonal rules produce a natural angle of repose
Open in a new tab

02 · Slope slide — diagonal rules produce a natural angle of repose — Measured area 33.878% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame

Down first, then a random diagonal, decides the angle of repose

function update() {
  for (var y = rows - 2; y >= 0; y--) {
    for (var x = 0; x < cols; x++) {
      var i = idx(x, y);
      if (grid[i] !== 1) continue;
      // 1) down
      var bi = idx(x, y + 1);
      if (grid[bi] === 0) {
        grid[bi] = 1; shade[bi] = shade[i]; grid[i] = 0;
        continue;
      }
      // 2) diagonal — down-left then down-right order, SLIDE_P chance
      if (rand() < SLIDE_P) {
        var dir = rand() < 0.5 ? -1 : 1;
        var try1 = (x + dir >= 0 && x + dir < cols && grid[idx(x + dir, y + 1)] === 0 && grid[idx(x + dir, y)] === 0);
        var try2 = false;
        if (!try1) {
          var d2 = -dir;
          try2 = (x + d2 >= 0 && x + d2 < cols && grid[idx(x + d2, y + 1)] === 0 && grid[idx(x + d2, y)] === 0);
        }
        if (try1 || try2) {
          var dx = try1 ? dir : -dir;
          var di = idx(x + dx, y + 1);
          grid[di] = 1; shade[di] = shade[i]; grid[i] = 0;
        }

03Water flow — a liquid rule that spreads sideways when blocked below

Water tries down, then a diagonal, then sideways with its direction reversing every tick, while sand keeps its own fall but now also sinks straight through any water cell in its way. The same grid ends up holding two materials that behave nothing alike, from one extra density comparison.

Water flow — a liquid rule that spreads sideways when blocked below
Open in a new tab

03 · Water flow — a liquid rule that spreads sideways when blocked below — Measured area 33.589% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

A density check lets sand sink and water float past it

function update() {
  flowDir = -flowDir;   // water's left-right direction alternates every tick
  var x, y, i, j, nx;
  for (y = rows - 2; y >= 0; y--) {
    for (x = 0; x < cols; x++) {
      i = idx(x, y);
      var c = grid[i];
      if (c !== SAND && c !== WATER) continue;
      // down — water can't rise above sand or stone; sand sinks through water (density)
      var bi = idx(x, y + 1);
      if (grid[bi] === EMPTY || (c === SAND && grid[bi] === WATER)) {
        swap(i, bi);
        continue;
      }

04Fire spread — a consumption rule that burns neighboring fuel and shrinks

A burning cell rolls the dice against each of its four neighbors, converting wood or oil to fire by chance, then counts its own life down to ash once that budget runs out. Flames chew through fuel and go dark behind themselves rather than lighting the whole scene at once — the mechanic behind a destruction effect or an ember-lit hero background.

Fire spread — a consumption rule that burns neighboring fuel and shrinks
Open in a new tab

04 · Fire spread — a consumption rule that burns neighboring fuel and shrinks — Measured area 49.402% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Ignite, count down, go dark — fire never stops moving

function update() {
  tick++;
  // bottom row to top row. Skip cells already updated this tick (stops a fire chain from racing to completion in one tick)
  var moved = new Uint8Array(cols * rows);
  var x, y, i, d;
  for (y = rows - 1; y >= 0; y--) {
    var ltr = (tick & 1) === 0; // alternate scan direction by parity — prevents asymmetric spread
    for (var s = 0; s < cols; s++) {
      x = ltr ? s : cols - 1 - s;
      i = idx(x, y);
      if (moved[i]) continue;
      var m = grid[i];
      if (m === FIRE) {
        // ignite neighbors — turn adjacent wood or oil to fire by chance
        var nb = [[1,0],[-1,0],[0,1],[0,-1]];
        for (d = 0; d < 4; d++) {
          var nx = x + nb[d][0], ny = y + nb[d][1];
          if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
          var j = idx(nx, ny);
          if ((grid[j] === WOOD || grid[j] === OIL) && rand() < IGNITE_P) {
            grid[j] = FIRE; life[j] = LIFE; moved[j] = 1;
          }
        }

05Plant growth — a generative rule that grows upward on contact with water

A seed does nothing until water brushes past it from below or the side, at which point it has a chance to turn the empty cell above into new stem and consume the water that triggered it. Growth follows the water's own path rather than a fixed schedule, so a garden scene or a meditative background grows differently every run.

Plant growth — a generative rule that grows upward on contact with water
Open in a new tab

05 · Plant growth — a generative rule that grows upward on contact with water — Measured area 30.37% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame

Growth waits for water to touch it, then spends it

} else if (m === SEED || m === PLANT) {
  // growth — if water touches from below or the side, probabilistically turn the empty cell above into stem, consuming the water
  var touch = [
    [0, 1, y < rows - 1 ? grid[idx(x, y + 1)] : STONE],
    [1, 0, x < cols - 1 ? grid[idx(x + 1, y)] : STONE],
    [-1, 0, x > 0 ? grid[idx(x - 1, y)] : STONE]
  ];
  for (var t2 = 0; t2 < 3; t2++) {
    if (touch[t2][2] !== WATER) continue;
    var wx = x + touch[t2][0], wy = y + touch[t2][1];
    if (y > 0 && grid[idx(x, y - 1)] === EMPTY && rand() < GROW_P) {
      grid[idx(x, y - 1)] = PLANT;      // new stem — plant/leaf alternating color is decided by row number at render time
      grid[idx(wx, wy)] = EMPTY;         // consume the water
      moved[idx(x, y - 1)] = 1;
    }
    break; // a single drop of water is used only once
  }
}

06Pointer brush — a spawner pouring material from the cursor like a brush

Every frame, empty cells within a radius of the pointer's grid position fill with the selected material at a set probability — driven by pointermove, not a click — so dragging the cursor across the canvas pours sand, water or seeds like a loaded brush. It's the one item in this set built to be steered by hand rather than left running on its own.

Pointer brush — a spawner pouring material from the cursor like a brush
Open in a new tab

06 · Pointer brush — a spawner pouring material from the cursor like a brush — Measured area 35.529% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovefillRectrequestAnimationFrame

pointermove drives a radius of probabilistic fill, not a click

function pour(gx, gy, mat) {
  var r2 = BRUSH_R * BRUSH_R;
  for (var dy = -BRUSH_R; dy <= BRUSH_R; dy++) {
    for (var dx = -BRUSH_R; dx <= BRUSH_R; dx++) {
      if (dx * dx + dy * dy > r2) continue;
      var x = gx + dx, y = gy + dy;
      if (x < 0 || x >= cols || y < 0 || y >= rows) continue;
      var i = idx(x, y);
      if (grid[i] === EMPTY && rand() < POUR_P) grid[i] = mat;
    }
  }
}

07Acid dissolve — a reaction rule that erases the material it meets

An acid cell checks whether its neighbor below or beside is dirt or stone, clears that neighbor to empty on contact, and has its own chance to be consumed the moment it dissolves something. The result eats downward and sideways through solid ground rather than simply falling through it, carving tunnels a sand pile alone never could.

Acid dissolve — a reaction rule that erases the material it meets
Open in a new tab

07 · Acid dissolve — a reaction rule that erases the material it meets — Measured area 30.269% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

Dissolving a neighbor and dissolving itself are two separate rolls

function updateAcid(x, y, dir) {
  var m = get(x, y);
  if (m !== ACID) return;
  var spots = [[x, y + 1], [x - 1, y], [x + 1, y]];
  var melted = false;
  for (var i = 0; i < spots.length; i++) {
    var nx = spots[i][0], ny = spots[i][1];
    if (isErodible(get(nx, ny))) { set(nx, ny, EMPTY); melted = true; }
  }
  if (melted && rand() < EAT_P) { set(x, y, EMPTY); return; }
  if (moveInto(x, y, x, y + 1, ACID)) return;
  var first = dir > 0 ? -1 : 1;
  if (moveInto(x, y, x + first, y + 1, ACID)) return;
  if (moveInto(x, y, x - first, y + 1, ACID)) return;
  if (moveInto(x, y, x + first, y, ACID)) return;
  moveInto(x, y, x - first, y, ACID);
}

08Hourglass timer — walls channeling the fall into a readable time display

Two cone-shaped walls with a narrow neck are drawn as fixed cells before the chamber above is filled solid with sand; from there, the same fall-and-slide rule used in item 02 is the entire mechanism, with no clock or countdown code anywhere in it. The neck width alone decides how fast the top chamber drains into the bottom one.

Hourglass timer — walls channeling the fall into a readable time display
Open in a new tab

08 · Hourglass timer — walls channeling the fall into a readable time display — Measured area 50.213% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame

The walls do the timing — the fall rule never changes

function moveInto(x, y, nx, ny) {
  if (get(nx, ny) !== EMPTY) return false;
  set(x, y, EMPTY);
  set(nx, ny, SAND);
  return true;
}

function updateSand(x, y, dir) {
  if (get(x, y) !== SAND) return false;
  if (moveInto(x, y, x, y + 1)) return true;
  if (rand() > SLIDE_P) return false;
  var first = dir > 0 ? -1 : 1;
  if (moveInto(x, y, x + first, y + 1)) return true;
  return moveInto(x, y, x - first, y + 1);
}

09Ecosystem scene — sand, water, fire and plants running together in one grid

Terrain, a lake, a sparse rain spawner and a lightning igniter that lights one random flammable cell every STRIKE_T seconds all share a single grid, with no click anywhere in the loop — the timer starts every fire alone. Fire eats grass, rain puts fire out, and growth follows the water back in, so the scene keeps re-balancing itself instead of running down.

Ecosystem scene — sand, water, fire and plants running together in one grid
Open in a new tab

09 · Ecosystem scene — sand, water, fire and plants running together in one grid — Measured area 32.884% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ImageDataputImageDatarequestAnimationFrame

A STRIKE_T timer ignites the forest — no click involved

function update() {
  tick++;
  resolveSolid(tick);
  // rain drizzle — sparse, from a few cells at the top
  for (var rx = 0; rx < cols; rx++) {
    if (rand() < RAIN_P && get(rx, 0) === EMPTY) set(rx, 0, WATER);
  }
  // lightning igniter — every interval, ignite one random flammable cell
  if (tick >= nextStrike) {
    nextStrike = tick + Math.round(STRIKE_T * 60);
    for (var tries = 0; tries < 60; tries++) {
      var sx = Math.floor(rand() * cols);
      var sy = Math.floor(rand() * rows);
      var m = get(sx, sy);
      if (isFlammable(m)) { set(sx, sy, FIRE); break; }
    }
  }

Where a falling sand demo actually earns its place

Drop 09's ecosystem scene into a loading screen or a 404 page — a spot visitors only sit on for a moment — and every click refills the grid differently, because 06's pointer brush is pouring material wherever the cursor lands. A hero section for a game or a dev-tool landing page suits 08's hourglass timer better, since walls channel the fall into a fixed rhythm that reads as a countdown rather than noise.

Items that run on a single rule, like 02's slope slide or 03's water flow, work in a sidebar ornament or a card background where the motion shouldn't fight for attention — a multi-material scene like 09 belongs only where it can own the whole frame.

Where the grid breaks once probability crosses its edge

A slide probability of 1.0 never lets a pile hold a flat top

02's SLIDE_P at 1.0 means every blocked cell tries a diagonal before giving up, so the pile keeps spreading instead of settling into a mound with any flat ground on top — drop it toward 0.4 and the same grid clumps into a steeper, damper-looking heap. 08 reuses this exact constant for its hourglass walls, so the same knob also controls how briskly each chamber drains.

An ignite probability with no life cap burns through everything at once

04's IGNITE_P sets how eagerly a flame jumps to a neighbor, but it's LIFE that stops one cell from igniting forever. Strip out the countdown to ash and every fire cell keeps re-igniting its neighborhood every single frame, so a scene meant to crawl burns down in one pass instead of spreading believably.

An eat probability of 1.0 dissolves the acid before it finishes eating

07's EAT_P decides whether a successful dissolve also consumes the acid cell itself. Set it to 1.0 and every acid cell vanishes the instant it erases one neighbor, so it can never bore more than a single cell deep — jetqny5v the channels this item is built to carve only appear once EAT_P drops low enough that an acid cell survives more than one dissolve in a row.

A strike interval with no rain balance turns a forest to ash and leaves it that way

09's STRIKE_T controls how often lightning tries to ignite a random flammable cell — push it from 1.2 seconds down toward 0.3 and the ecosystem can't regrow between strikes, so the forest burns to ash and stays there. RAIN_P is what prevents that by default: raise it and fire gets put out faster than STRIKE_T can restart it.

Three cell-size variants and how reduced-motion changes them

Three constants, three different falling-sand scenes

Variant Changed value What it feels like
Fall speed 01 basic fall 1 cell/frame → 3 cells/frame Sand rushes down like a waterfall instead of drifting
Angle of repose 02 diagonal-try probability 1.0 → 0.4 Sand clumps as if damp, piling into a steeper mound
Water spread width 03 sideways reach 1 cell → 3 cells per tick Water floods wide and thin instead of pooling

Nothing here is hand-drawn, so nudging one number reshapes the whole scene instead of asking someone to redraw a falling sand simulator frame by frame.

What reduced-motion keeps

A single matchMedia('(prefers-reduced-motion: reduce)') check decides whether requestAnimationFrame ever runs at all — when it returns true, each of the nine items instead calls its own update function a fixed number of times up front, draws once, and adds an is-still class that stops there. A visitor who asked for less motion still sees a settled scene — a pile, a filled hourglass, a grown garden — never a blank canvas.

Only one item reads pointer input, and it isn't a click

06 is the single item in this set driven by pointer input, and it listens for pointermove rather than pointerdown — the brush pours while the cursor moves across the field, with no press or release required, and field carries tabindex="0" so a keyboard visitor tabbing onto the demo isn't stuck behind a mouse-only interaction. 09's fire looks the most interactive of the nine but isn't: its igniter fires on a STRIKE_T timer alone, with no click, tap or keypress anywhere in its rules. The other seven items read no pointer or keyboard input at all, since their motion comes entirely from the clock.

Questions people ask before wiring up the pointer brush

What makes a falling sand simulator different from a physics engine

A physics engine tracks continuous position, velocity and collision shapes for every object with floating-point math solved every frame. A falling sand simulator skips all of that — every cell holds one integer material code, and the entire behavior comes from a handful of rules like "swap with the empty cell below," repeated across a grid. That's also most of what making a falling sand simulator from scratch actually involves: no engine, no rigid bodies, just a grid and a scan order.

Can I use this falling sand simulator code as-is

Yes — every code block above is copied from the running demo's own source, not retyped, and the complete file for each of the nine items sits inside the zip behind the password on this page. None of the nine reach out to a library or CDN, so a free falling sand simulator built from any one of these files runs the moment its script tag lands on a page.

Why does item 08 measure motion in 21 of 23 sampled transitions instead of 23 of 23 like the rest

All nine items sample 24 frames and compare 23 consecutive pairs for a change. Every other item moves in all 23 because sand is always sliding somewhere in the grid. Item 08's hourglass pauses once its rules pack the lower chamber and the flow through the narrow neck runs dry — for a short stretch near that point, consecutive sampled frames land with nothing left to move, and only once the rebuild refills the top chamber does the fall resume, which is exactly the pause a real hourglass reads as "time's up."

Sources: MDN — CanvasRenderingContext2D · MDN — CanvasRenderingContext2D.fillRect() · MDN — Window.requestAnimationFrame() · MDN — ImageData · MDN — prefers-reduced-motion · Wikipedia — Falling sand game · Wikipedia — Cellular automaton

Enter the archive password

The password is inside this article. You will find it as you read.