Falling Sand - part 3

pery mimonpery mimon
12 min read

After establishing our core simulation in Parts 1-2, we're ready for game-changing enhancements. Today we'll add grid persistence, hypnotic animations, and state optimizations while introducing the mysterious Matrix material. Strap in - we're turning our particle simulator into a persistent digital ecosystem! or almost, I just give the seed and you will plant them and showcase your impressive playground.


Save/Load the grid: Freezing Time

Problem: In the next step, I will introduce a new way to play with color and cell animation. However, since it involves playing, you'll want to start with your beautiful garden, because losing beautiful particle arrangements on page refresh hurts.
Solution: Local grid file preservation!

start with add the button to our previous material-selector from part 2

<!-- Add to our control panel -->
index.html
  <div id="material-selector">
    <button id="saved-btn" class="button-base">Freeze Reality</button>

    <!-- matrials selector...--->
</div>

Now, let's create a new file to handle our binary-savvy operations (files.js) and infuse it with superpowers!

// files.js
export async function fetchArrayBuffer(filename){
    return await fetch(`./${filename}`)
        .then(response=> {
            if (!response.ok) throw response.statusText
            return response
        })
        .then(response => response.arrayBuffer())
}

export function saveBlob(blob, filename){
    const link = document.createElement('a')
    link.href = URL.createObjectURL(blob)
    link.download = filename
    link.click()
    // clean up
    URL.revokeObjectURL(link.href)
}
export function savedArrayBuffer(buffer, filename = 'file.bin') {
    const blob = new Blob([buffer], {type: 'application/octet-stream'})
    return saveBlob(blob, filename)
}
//falling-sand.js
import {fetchArrayBuffer, savedArrayBuffer} from './files.js'

// Snapshot current grid
var $button = document.getElementById('saved-btn')
$button?.addEventListener('click', e => {
    savedArrayBuffer(grid.cells, `saved-grid${cols}X${rows}.hex`)
})

// Restore frozen grid
try {
    var blob = await fetchArrayBuffer(`saved-grid${cols}X${rows}.hex`)
    grid.cells.set(new Uint8Array(blob), 0)
} catch (err) { console.log(err) }

Now, click "Freeze Reality" to capture the moment. The browser will download your file for that particular resolution, and your job is to place it in the project folder so the garden automatically resumes where you left off. It's perfect for comparing cells configurations! (I'm assuming you use a tool to refresh your browser on changes.)


🌈 Chromatic Sorcery

Static colors are so 2023. Let's make particles dance using HSL witchcraft.

Until now, in the matrial object we describe the color of each matrial by array of two number startHue and endHue. However, by tweaking the array a bit and assigning different meanings to the numbers and event extending a bit, we can achieve animated colors with different directions for each material.

So Instead of using an array like [startHue, endHue], we can use array of [hue, range, speed, xFactor, yFactor].

  • hue: Base color angle (0-360)

  • range: Luminosity oscillation range

  • speed: Animation tempo

  • xf/yf: Spatial gradient factors

To make that sorcery work, let's update grid.draw.

// Grid.js - Pixel art update
class Grid{
...
draw(ctx, frames, cellWidth, cellHeight, materials) {
        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                const cell = this.cells[this.index(x, y)]
                if (cell === 0) continue; // Skip air
                const symbol = materials.symbols[cell]
                const [hue, range, speed, xf, yf] = materials[symbol].color
                ctx.fillStyle = `hsl(
                  ${hue}, 
                  70%, 
                  ${50 + (x * xf + y * yf + frames * speed/100) % range}%
                )`
                ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
            }
        }
    }
}
arameters become our magic incantations:

For example, these are the numbers I chose. and you can these alchemical combinations:

//falling-sand.js
var materials = {
    symbols,
    // color: [hue, range, speed, xf, yf]
    S: {name: 'Sand', color: [60, 40, 40, 11, 1]}, // Desert waves
    W: {name: 'Water', color: [200, 20, 40, -1, 11]}, // Rippling azure
}

🎭 The Masked Performer

Problem: Adding materials exploded our state rules exponentially.
Solution: Pattern masking - our new backstage crew!

The problem arises when I want to introduce a new material, as it will add numerous numbers to the symbols string, potentially overwhelming the JavaScript map. Although it can handle millions of entries quite efficiently, this could still be an issue.

Instead of tracking every permutation, we:

  1. Store template patterns (x0x xSx xxxx_xx_x_xxx)

  2. Match current state against masked templates

  3. Apply first matching rule

Result: 90% fewer states tracked, same behavior!

// Convert patterns to wildcard templates
const getMask = (pattern) => 
  pattern.replaceAll(/[^x]/g, (_, i) => i)

// Apply masks during rule checks  
export const maskedPattern = (string, mask) =>
    mask.replaceAll(/[^x]/g, (_, i) => string[i] ?? _)

Before show full implementation one more things:

+ Operator: Life-Span Magic

What if we want to give material life-span and make a material disappear after a few iterations or change to other matrial? I have a preliminary idea for this that could evolve in the future. Well for the first part at least.

The rule is simple: we use multiple appearances of the material letter in the symbol string, like var symbols = 'ASWMMMM', and then increment through them with our new increment operator.

var symbols = 'ASWMMMM'
// Matrix material rules
defineStates(..., 'xMx', '+0+ 000', symbols)

The + operator:

  1. Finds next material variant in symbols

  2. Cycles through versions (M₁→M₂→M₃→Air)

  3. Enables time-limited materials!


🔄 Real Swapping: No More Lazy Assumptions

Fixing the "s" operator and embracing true material interactions

In the previous article, I took a shortcut with the s operator, silently assuming it only swapped materials with Air. While this worked for basic interactions, it limited our ability to create materials that truly swap with others. No more excuses—it's time to implement proper swapping!

The old s operator was essentially a "copy and replace with Air" operation. While this worked for simple materials like Sand and Water, it fell short when we wanted materials to interact more dynamically—like swapping places with each other. This limitation became especially apparent when experimenting with more complex materials and rules.

We’ve reworked the defineStates function to handle real swapping. Now, when s is used, it explicitly swaps the positions of two materials, regardless of their type. This opens up a world of possibilities for material interactions.

Here’s the core updated in defineStates logic, full code below

function getStates(statePattern, currentIndex, nextIndex, pattern, symbols) {
    let normPattern = statePattern
        .replaceAll(' ', '') // Clear spaces
        .replaceAll(/./g, explicit(symbols)) // Replace explicit materials with their indices
        .replaceAll('+', nextIndex) // Handle the + operator for state progression

    return pivotPattern(normPattern, 's', 0) // Split pattern around the 'swap' operator
        .map(state => swapWiring(state, 's', pattern, 4).replace('s', currentIndex)) // Wire up the swap
        .toArray()
}

Limitations and Edge Cases:
While this update significantly improves swapping, there’s still one limitation: when the state pattern uses x (a wildcard) and the s operator targets that x, we can’t know at defineState time which material will be swapped. This is a trade-off for the flexibility of wildcards, but it’s something to keep in mind when designing new materials and rules.


🎲 Multiple Options for Next States

Because sometimes, even particles need choices

The stateMachine has always supported multiple next states by providing an array of options. While this was implicitly used with the s operator, we can now leverage it explicitly to create more nuanced behaviors. Sometimes, a material’s next state isn’t a single deterministic outcome. For example, a particle might:

  • Swap places with a neighbor.

  • Do nothing and stay in place.

  • Transform into a different material.

  • Change the odds by introducing the appearance of some states more than once.

We’ve updated defineStates to explicitly handle arrays of next states.

export default function defineStates(stateMachine, masks, pattern, nextState, symbols) {
    if (!Array.isArray(nextState)) nextState = [nextState] // Explicitly handle multiple options
    // ... rest of the function
}
...
defineStates(stateMachine, masks, 'x0x xXx xxx', ['0s0', '0X0', '000'], symbols);

Limitations

To really play with odds, I probably should introduce some updates to the state format.


🖌️ Marking Touched Cells

Fixing the "double update" bug

While experimenting with a bubble material that flows upward, I discovered a critical flaw in the update logic: cells were being recalculated multiple times in a single update cycle. This led to visual glitches and unexpected behaviors.

When a cell updates the cell above it, the updated cell gets recalculated in the same cycle before the user even sees the change. This creates a cascade of recalculations, leading to bugs of not understanding why the logic not work

The Solution: Introduce a touched grid to track which cells have already been processed in the current cycle. This ensures each cell is only updated once per frame.

const cols = 100, rows = 100
var grid = new Grid(cols, rows)
var touched = new Grid(cols, rows) // < here

her is the update version of what changed or added to define-state

//define-states.js
const explicit = (symbols) =>
    (c) => symbols.includes(c) ? symbols.indexOf(c) : c

const getMask = (pattern) =>
    pattern.replaceAll(/[^x]/g, (_, i) => i) //"ff0xSxxxx" -> "012x4xxxx"

export const maskedPattern = (string, mask) =>
    mask.replaceAll(/[^x]/g, (_, i) => string[i] ?? _)

export default function defineStates (stateMachine, masks, pattern, nextState, symbols) {
    if (!Array.isArray(nextState)) nextState = [nextState] // < explicit multiple options for next
    pattern = pattern.replaceAll(' ', '')
    var target = pattern[4]
    var symIndexes = symbols.matchAll(target).map((m, i) => [i, m.index])

    masks.add(getMask(pattern)) // < Convert all x to mask
    for (let [i, symIndex] of symIndexes) {
        var patternBase = pattern
            .replaceAll(target, symIndex)
            .replaceAll(/./g, explicit(symbols))

        for (let pattern2 of replicaPatterns(patternBase, 'f', 1, symbols.length)) {
            let states = nextState.map(state =>
                getStates(state, symIndex, symIndexes[i + 1] ?? 0,pattern2, symbols),
            ).flat(Infinity)

            if (states.length === 1) states = states[0]
            stateMachine.set(pattern2, states)
        }
    }
}
function getStates (statePattern, currentIndex, nextIndex, pattern, symbols) {
    let normPattern = statePattern
        .replaceAll(' ', '') // clear spaces
        .replaceAll(/./g, explicit(symbols)) // replace explicit Material with first Material index
        .replaceAll('+', nextIndex) // replace + with the next index

    return pivotPattern(normPattern, 's', 0) //split pattern around the 'swap' operator but save the 's' because we use it and index for swap
        .map(state => swapWiring(state, 's', pattern, 4).replace('s', currentIndex))
        .toArray()

}

function swapWiring (pattern, fromMask, source, toIndex) {
    if (!pattern.includes(fromMask)) return pattern
    let i = pattern.indexOf(fromMask)
    let norm = pattern.padEnd(toIndex, '0')
    return norm.slice(0, toIndex) + source[i] + norm.slice(toIndex + 1)
}
//....
function update () {

    for (let i = grid.cells.length - 1; i > 0; i--) {
        const {x, y} = grid.xy(i)

        const cell = grid.getCell(x, y)
        if (!cell) continue
        const touch = touched.getCell(x, y)
        if (touch) continue

        let sym = symbols.at(cell)

        const state = grid.getChunk(x, y)
        for (let mask of masks) {
            let masked = maskedPattern(state, mask)
            var newState = stateMachine.get(masked)
            if (newState) break
        }

        if (Array.isArray(newState)) newState = randomItem(newState)
        if (!newState) continue
        grid.setCell(x, y, 0)
        grid.setChunk(x, y, newState)
        touched.setCell(x, y, 0)
        touched.setChunk(x, y, newState)
    }

    touched.cells.fill(0)
}
var symbols = 'ASWMMMM' // Air, Sand, Water, Matrix (new!) see below
var materials = {
    symbols,
    // color: [hue, range, speed, xf, yf]
    S: { name: 'Sand', color: [60, 42] }, // Yellow hues
    W: { name: 'Water', color: [200, 210] }, // Blue hues
}

const stateMachine = new Map()
const masks = new Set()
/*  Sand Rules */
defineStates(stateMachine, masks, 'x0x xSx xxx', '0s0', symbols) // Flow down
defineStates(stateMachine, masks, '0f0 xSx xxx', 's0s', symbols) // Flow diagonal random
defineStates(stateMachine, masks, 'ff0 xSx xxx', '00s', symbols) // Flow right diagonal
defineStates(stateMachine, masks, '0ff xSx xxx', 's00', symbols) // Flow left diagoal
defineStates(stateMachine, masks, 'fff xSx xxx', '000 0s0', symbols) // Settle

/* Water Rules */
defineStates(stateMachine, masks, 'x0x xWx xxx', '0s0', symbols) // Flow down
defineStates(stateMachine, masks, '0f0 xWx xxx', 's0s', symbols) // Flow diagonal random
defineStates(stateMachine, masks, 'ff0 xWx xxx', '00s', symbols) // Flow right diagonal
defineStates(stateMachine, masks, '0ff xWx xxx', 's00', symbols) // Flow left diagoal
defineStates(stateMachine, masks, 'fff xW0 xxx', '000 00s', symbols) // Flows right when empty
defineStates(stateMachine, masks, 'fff 0Wf xxx', '000 s00', symbols) // Flows left when blocked right and left empty
defineStates(stateMachine, masks, 'xWx xSx xxx', '0S0 0W0', symbols) // Swap with sand
...

🧪 Experimental Materials:: Gas, Brush, and Matrix

Expanding our particle playground with mesmerizing behaviors

With the core mechanics of our simulation now robust and flexible, it’s time to introduce some exciting new materials: Gas, Brush, and the enigmatic Matrix. Each of these materials brings unique behaviors and visual flair, pushing the boundaries of what our particle simulator can do.

💨 Gas: Bubbling Up and Disappearing Gracefully

Playing with upward-flowing materials

Gas is a lightweight material that interacts primarily with Water, creating beautiful bubbling effects. Here’s how it works:

Behavior Rules:

  1. Conversion from Water: Gas is created when Water interacts with certain conditions, converting part of the Water into Gas.

  2. Rising Effect: Gas bubbles upward, creating a mesmerizing flow.

  3. Disappearing Act: When Gas reaches Air, it gracefully disappears, converting back into Water to maintain conservation.

defineStates(stateMachine, masks, 'SSS WWW WWW', ['000 sss sss', '000 0G0 000'], symbols); // sand + water
defineStates(stateMachine, masks, 'xSx xGx xWx', ['000 0W0 0G0'], symbols); // Bubbles up
defineStates(stateMachine, masks, 'xWx xGx xWx', ['0W0 0W0 0G0'], symbols); // Bubbles up
defineStates(stateMachine, masks, 'xxx xGx xAx', ['000 0W0 000'], symbols); // Disapear on air

🎨 Brush: Magical Sparks and Falling Leaves

Adding whimsy and charm to the simulation

The Brush material is all about creating enchanting, ephemeral effects. It behaves like falling leaves or magical sparks, adding a touch of whimsy to the simulation.

Behavior Rules:

  1. Falling Sparks: Brush particles fall like leaves, moving randomly sideways and downward.

  2. Graceful Disappearance: When Brush touches another material, it disappears without altering the material it touches.

defineStates(stateMachine, masks, 'xxx xBx xxx', '+s+ sss', symbols); // Fall like spray
defineStates(stateMachine, masks, 'xfx xBx xxx', '000 000', symbols); // Disappear without damage

🧊 Matrix: The Shape-Shifting Enigma


🎨 Visualizing the New Materials

Each material comes with its own unique color palette, adding that to you object for visual distinction and beauty

var materials = {
    symbols,
    // color: [hue, range, speed, xf, yf]
    S: {name: 'Sand', color: [60, 40, 40, 11, 1]}, // Yellow hues
    W: {name: 'Water', color: [200, 20, 40, -1, 11]}, // Blue hues
    M: {name: 'Matrix', color: [120, 50, 80, -11, 1]}, // Green hues
    B: {name: 'Brush', color: [290, 50, 50, 0, 0]}, // Pink hues
    G: {name: 'Gas', color: [180, 30, 50, 0, 7]}, // Azure hues
}

🎨 Experimental Particle Art

Now that we have the new operator and visualization, I encourage you to experiment with multiple patterns. With our new tools, let's create particle poetry. Here are some cool examples I found:

Snow Globe Effect

defineStates(..., 'xMx', '+s+ sss'); // Frozen crystallization

Lava Lamp Dreams

defineStates(..., 'xMx', '0+0 0s0 0s0'); // Rising bubbles

Autumn Wind Simulation

defineStates(..., 'xMx', 'sss sss'); // Fluttering descent

Epilogue: The Living Canvas

We've transformed our simulator into an persistent, evolving artwork. But the true magic lies in your hands - what strange materials will you conjure? What hypnotic patterns will emerge from these digital primordial ooze?

In our next (final?) installment, we might explore... cellular automata rules? User interaction patterns? The canvas awaits your command.

Experiment with the live demo here and share your most mesmerizing creations!

Full Code

complete code you can fine here

More on that Subject

0
Subscribe to my newsletter

Read articles from pery mimon directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

pery mimon
pery mimon