Falling Sand

Created April 9, 2025 (Today)Updated April 9, 2025 (Today)

A falling sand simulation models individual grains of sand that move downward due to gravity, bump into and interact with other sand particles, and can slide or tumble sideways when stacked unstably.

Basic Rules

  • Rule 1: If the cell below is empty, swap the cell with the cell below
  • Rule 2: If the cell left is empty, swap the cell with the cell left
  • Rule 3: If the cell right is empty, swap the cell with the cell right

In Code:

function basicRules(idx, belowIdx, leftIdx, rightIdx, grid) {
    // Rule 1: If the cell below is empty, swap the cell with the cell below
    if (grid.isEmpty(belowIdx)) {
        grid.swap(idx, belowIdx)
    } else {
        // randomize left and right falling direction
        if (Math.random() < 0.5) {
            let swap = leftIdx
            leftIdx = rightIdx
            rightIdx = swap
        }

        if (leftIdx > -1 && grid.isEmpty(leftIdx)) {
            // Rule 2: If the cell left is empty, swap the cell with the cell left
            grid.swap(idx, leftIdx)
        } else if (rightIdx > -1 && grid.isEmpty(rightIdx)) {
            // Rule 3: If the cell right is empty, swap the cell with the cell right
            grid.swap(idx, rightIdx)
        }
    }
}

With these simple rules, sand pouring forms neat triangular piles.

click to restart

Cellular Automata

We can also think of this as a sort of CA. Encoding the 3 cells below as a 3 bit number, we can enumerate all 7 possible states:

  • [0,0,0] => 0 => [0,X,0]
  • [0,0,1] => 1 => [0,X,1]
  • [0,1,0] => 2 => [X,1,X] (special case)
  • [0,1,1] => 3 => [X,1,1]
  • [1,0,0] => 4 => [1,X,0]
  • [1,0,1] => 5 => [1,X,1]
  • [1,1,0] => 6 => [1,1,X]
  • [1,1,1] => 7 => [1,1,1]

Which simplifies to 4 rules:

  • 0, 1, 4, 5 -> swap with center cell
  • 3 -> swap with left cell
  • 6 -> swap with right cell
  • 2 -> apply special case
  • 7 -> no action (full)

special case: for the case where both left and right are available, swap with the left cell with 50% probability.

In code: I'm not sure this is more clear, but it's an interesting exercise.

function caRules(idx, belowIdx, leftIdx, rightIdx, grid) {
    // Encode the 3 cells below as a 3-bit number: left (4), below (2), right (1)
    const encoded =
        (grid.isEmpty(leftIdx) ? 0 : 4) + //
        (grid.isEmpty(belowIdx) ? 0 : 2) + //
        (grid.isEmpty(rightIdx) ? 0 : 1)

    if ([0, 1, 4, 5].includes(encoded)) {
        grid.swap(idx, belowIdx)
    } else if (encoded === 3) {
        grid.swap(idx, leftIdx)
    } else if (encoded === 6) {
        grid.swap(idx, rightIdx)
    } else if (encoded === 2) {
        // Randomly choose between left and right if both are available
        const target = Math.random() < 0.5 ? leftIdx : rightIdx
        grid.swap(idx, target)
    } // 7 is a no-op (full)
}

Same as before, but with the CA rules.

click to restart

Varying Placement & Colors

Let's see if we can make this more interesting by varying the placement and colors of the sand. Two "cursors" move independently and drop sand with different colors.

click to restart

Adding Weight

For the case where a particle collides with another particle below it, we can add another dimension to the rules. If the particle below has less "weight" assigned to it, we can attempt to push it asside (left or right).

  1. Give sand particles a random weight on generation.
  2. When it collides with a particle of less weight, it will attempt to push that particle asside if there is open space.
  3. Shade the heavier particles to be darker.

In code:

function attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid) {
    // make sure its actually heavier
    if (grid.getWeight(belowIdx) > grid.getWeight(idx)) {
        return false
    }

    if (encoded === 3) {
        // we can push the particle to the left open space
        grid.swap(belowIdx, leftIdx)
        grid.swap(idx, belowIdx)
        return true
    } else if (encoded === 6) {
        // we can push the particle to the right open space
        grid.swap(belowIdx, rightIdx)
        grid.swap(idx, belowIdx)
        return true
    } else if (encoded === 2) {
        // both left and right are open, so we get to choose
        const target = Math.random() < 0.5 ? leftIdx : rightIdx
        grid.swap(belowIdx, target)
        grid.swap(idx, belowIdx)
        return true
    }
    return false
}

function weightedRules(idx, belowIdx, leftIdx, rightIdx, grid) {
    // Encode the 3 cells below as a 3-bit number: left (4), below (2), right (1)
    const encoded =
        (grid.isEmpty(leftIdx) ? 0 : 4) + //
        (grid.isEmpty(belowIdx) ? 0 : 2) + //
        (grid.isEmpty(rightIdx) ? 0 : 1)

    if ([0, 1, 4, 5].includes(encoded)) {
        grid.swap(idx, belowIdx)
    } else if (encoded === 3) {
        if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) grid.swap(idx, leftIdx)
    } else if (encoded === 6) {
        if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) grid.swap(idx, rightIdx)
    } else if (encoded === 2) {
        if (!attemptDisplace(idx, leftIdx, belowIdx, rightIdx, encoded, grid)) {
            // Randomly choose between left and right if both are available
            const target = Math.random() < 0.5 ? leftIdx : rightIdx
            grid.swap(idx, target)
        }
    } // 7 is a no-op (full)
}

Weighted Sand:

click to restart

Resources

Further Research

Types of rules to explore

  • Rock/Wood
  • Fire
  • Erosion (global)
  • Water
  • Plants / Grass
  • Sky (global)
  • Birds

Other Ideas

  • Textures to init sand state?
  • Add Sky and background
  • Simple erosion simulation