DEV Community

sheng li
sheng li

Posted on • Edited on

Build a Tiny Falling-Sand Simulator with JavaScript and Canvas

Falling-sand simulations look fluid, but the smallest useful version does not need a physics engine. A two-dimensional grid, a few local movement rules, and an HTML canvas are enough to produce sand that piles up and water that spreads.

This tutorial builds a deliberately small version. It is not a replacement for a mature particle sandbox. The goal is to expose the decisions that become hard to see inside a larger engine: how cells are stored, why update order matters, how materials compete for empty space, and where browser performance starts to fail.

The model: one cell, one material

Represent the world as a flat array. Each entry stores a material ID: 0 for empty, 1 for sand, 2 for water, and 3 for wall. A flat typed array is compact and avoids creating thousands of JavaScript objects every frame.

The example below is a complete HTML file. Save it as index.html and open it in a browser.

<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<title>Tiny Falling Sand</title>
<style>
  body { margin: 0; background: #111; color: #eee; font: 14px system-ui; }
  header { display: flex; gap: 8px; padding: 10px; align-items: center; }
  canvas { display: block; image-rendering: pixelated; margin: auto; }
</style>
<header>
  <button data-material="1">Sand</button>
  <button data-material="2">Water</button>
  <button data-material="3">Wall</button>
  <button id="clear">Clear</button>
</header>
<canvas id="world" width="640" height="400"></canvas>
<script>
const canvas = document.querySelector("#world");
const ctx = canvas.getContext("2d");
const scale = 4;
const width = canvas.width / scale;
const height = canvas.height / scale;
const cells = new Uint8Array(width * height);
let selected = 1;

const colors = ["#111", "#d9b66f", "#4da3ff", "#777"];
const index = (x, y) => y * width + x;
const inside = (x, y) => x >= 0 && x < width && y >= 0 && y < height;

function swap(a, b) {
  const value = cells[a];
  cells[a] = cells[b];
  cells[b] = value;
}

function tryMove(x, y, nx, ny) {
  if (!inside(nx, ny)) return false;
  const from = index(x, y);
  const to = index(nx, ny);
  if (cells[to] !== 0) return false;
  swap(from, to);
  return true;
}

function updateSand(x, y) {
  if (tryMove(x, y, x, y + 1)) return;
  const direction = Math.random() < 0.5 ? -1 : 1;
  if (tryMove(x, y, x + direction, y + 1)) return;
  tryMove(x, y, x - direction, y + 1);
}

function updateWater(x, y) {
  if (tryMove(x, y, x, y + 1)) return;
  const direction = Math.random() < 0.5 ? -1 : 1;
  if (tryMove(x, y, x + direction, y)) return;
  tryMove(x, y, x - direction, y);
}

function update() {
  for (let y = height - 2; y >= 0; y--) {
    const leftToRight = Math.random() < 0.5;
    for (let step = 0; step < width; step++) {
      const x = leftToRight ? step : width - 1 - step;
      const material = cells[index(x, y)];
      if (material === 1) updateSand(x, y);
      if (material === 2) updateWater(x, y);
    }
  }
}

function render() {
  ctx.fillStyle = colors[0];
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const material = cells[index(x, y)];
      if (material === 0) continue;
      ctx.fillStyle = colors[material];
      ctx.fillRect(x * scale, y * scale, scale, scale);
    }
  }
}

function paint(event) {
  const rect = canvas.getBoundingClientRect();
  const cx = Math.floor((event.clientX - rect.left) / scale);
  const cy = Math.floor((event.clientY - rect.top) / scale);
  for (let dy = -2; dy <= 2; dy++) {
    for (let dx = -2; dx <= 2; dx++) {
      if (inside(cx + dx, cy + dy)) cells[index(cx + dx, cy + dy)] = selected;
    }
  }
}

canvas.addEventListener("pointerdown", paint);
canvas.addEventListener("pointermove", event => {
  if (event.buttons) paint(event);
});

document.querySelectorAll("[data-material]").forEach(button => {
  button.addEventListener("click", () => selected = Number(button.dataset.material));
});
document.querySelector("#clear").addEventListener("click", () => cells.fill(0));

function frame() {
  update();
  render();
  requestAnimationFrame(frame);
}
frame();
</script>
</html>
Enter fullscreen mode Exit fullscreen mode

Why the grid updates from bottom to top

If the loop starts at the top, a sand cell can move down and then be visited again later in the same frame. It may fall several rows instantly. Updating from the bottom reduces that repeated movement because destinations have already been processed.

The loop also randomizes its horizontal direction. Always scanning left to right produces a visible bias: piles lean, and water prefers one side. Random scan direction is not perfect physics, but it removes one obvious artifact at almost no cost.

In-place updates versus double buffering

The sample changes the same array it is reading. That makes the program short and fast, but the result depends on scan order. A double-buffered engine reads from one grid and writes the next state into another grid. At the end of the step, it swaps the buffers.

Double buffering makes each frame easier to reason about because every particle reads the same previous state. It also introduces conflict resolution. Two sand cells may choose the same empty destination, so the engine needs a deterministic or randomized winner. The second grid must also be cleared or fully overwritten, which adds work.

There is no universal winner. In-place updates are useful for compact interactive toys. Double buffering is useful when reproducible steps, automated tests, or parallel processing matter more than minimal memory use.

Empty destinations are the first useful rule

tryMove only allows a material to enter an empty cell. That keeps the example understandable, but it also exposes a limitation: sand cannot sink through water because occupied cells never trade places.

A more realistic density rule assigns each material a weight. Sand may swap with water when sand is denser, while wall never moves. Add that only after the empty-cell version is stable; otherwise it becomes difficult to tell whether a bug comes from movement or density exchange.

Common mistakes

A particle moves twice in one frame. Update order is usually responsible. A separate updated bitmap or double buffer can guarantee one update per cell, at the cost of memory and copying.

Water forms a one-cell line. The simple rule checks only the immediate left and right cells. Realistic liquids often search a short horizontal range, track pressure, or choose from several candidate moves.

Large worlds become slow. This example redraws and scans every cell on every frame. Production engines use active regions, dirty rectangles, chunking, or WebGL to avoid work in stable empty areas.

Particles pass through the edge. Every neighbor lookup must validate coordinates before calculating or using an array index.

Rendering can cost more than movement

The sample draws one rectangle per occupied cell. That is easy to inspect, but thousands of fillRect calls can become more expensive than the movement rules. A common next step is to create one ImageData buffer, write RGBA values directly, and send the entire image to the canvas with one putImageData call.

Do not optimize by instinct. Measure the update step and render step separately. If a mostly empty world is still slow, full-grid scanning is probably the problem. If a static but full world is slow, rendering is the better target. Active chunks help the first case; pixel buffers or WebGL help the second.

Also separate simulation resolution from display resolution. This example simulates a 160 by 100 grid and scales every cell to four screen pixels. A larger canvas does not automatically require a larger simulation. Keeping the logical grid small is often the simplest mobile optimization.

A small debugging test matrix

Visual simulations need repeatable tests even when the final output is playful. Pause the loop and run one update at a time for these cases:

  1. One sand cell above empty space should move down exactly one row.
  2. One sand cell above wall should remain in place or move diagonally if a side is open.
  3. One water cell in a sealed one-cell container should not move.
  4. A symmetric pile should not lean consistently left or right across many runs.
  5. Painting at every canvas edge should never read or write outside the typed array.

For deterministic tests, replace Math.random() with an injected function that returns known values. That lets a test choose left or right without relying on probability. Store tiny scenes as arrays or JSON fixtures so a bug can be reproduced after the engine changes.

What to add next

Add one feature at a time and keep a tiny test scene for it:

  • Density swapping for sand and water.
  • Heat values stored in a second typed array.
  • Fire with a limited lifetime and upward movement.
  • Pausing and single-step updates for debugging.
  • JSON export so a failing scene can be reproduced.
  • Chunk activation so only moving regions update.

The useful lesson is not the number of materials. It is that each visible behavior comes from a small local rule plus an update policy. When a scene breaks, reduce it to one material, one boundary, and one expected movement.

This model also has clear limits. It does not simulate continuous velocity, pressure, temperature, conservation of mass, or real chemistry. Each cell moves at most according to a local grid rule, so diagonal shapes and one-cell gaps strongly affect the result. Those constraints are acceptable for a learning project as long as the interface and description do not present them as physically exact.

For a broader material list to use as test cases, this independent browser sandbox documents powders, liquids, gases, life, and machine materials at https://sandboxels.cc/materials/. It is not affiliated with the original Sandboxels project.

Top comments (0)