Falling Sand - part 3
Get Cellular Automata to next level, Mastering State Management & Visual Alchemy in our particle playground
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 rangespeed
: Animation tempoxf/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:
Store template patterns (
x0x xSx xxx
→x_xx_x_xxx
)Match current state against masked templates
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:
Finds next material variant in
symbols
Cycles through versions (M₁→M₂→M₃→Air)
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:
Conversion from Water: Gas is created when Water interacts with certain conditions, converting part of the Water into Gas.
Rising Effect: Gas bubbles upward, creating a mesmerizing flow.
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:
Falling Sparks: Brush particles fall like leaves, moving randomly sideways and downward.
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