A falling-sand cellular automaton in one HTML file, inspired by Max Bittker's sandspiel — but pared down to a spatial food web. There are just 11 elements. Draw with the mouse, then watch them interact. Pick any element on the right and its tuning sliders appear; press api rules under them and that element's behavior is plain JavaScript you can rewrite live.
The cast. Rock makes permanent walls, firebreaks and animal refuges. Ground is the one loose mineral substrate: it falls, piles up and supports plants. Water is the resource the food web runs on, and Springs renew it. Exposed water evaporates into Vapor, which drifts upward and condenses as rain. Plants are the producers: they need ground or existing growth beneath them and enough stored moisture. Grazers (teal) eat plants; Predators (pink) eat grazers. Whatever dies becomes short-lived Detritus. Fire is the disturbance that resets patches of the world.
The trophic chain. spring → water → plant → grazer → predator. The chart under the canvas tracks those three living populations. Resource surplus creates offspring; shortage causes starvation, so population waves and trophic cascades come from the food chain itself rather than from hidden meal counters.
Fire is ecological, not just pretty. How well a plant catches depends on how dry it is, so parched inland scrub goes up while lush riverbank growth barely burns — and water is a firebreak (a quenched flame flashes to steam). Grazers mostly flee the flames; animals caught in them become detritus. Burned patches can regrow from surviving wet edges.
Compact life controls. A plant exposes its mean ticks per sprout attempt. Grazers and predators each expose movement speed and the energy needed for birth. Animals begin with 10 energy, spend it slowly, gain a species-specific amount from food, and split their energy with an offspring once they have a surplus and enough local space. Dragging a slider affects the next tick. The code editor remains available for rules outside this deliberately small core.
Things to try: shorten the plant sprout-attempt interval and watch the shoreline fill · lower grazer birth energy and watch a boom overshoot its food · slow predators so refuges matter more, then speed them up and look for a delayed trophic cascade · chase a herd toward a rock crevice · wall off scrub with rock and burn outside it · raise evaporation and watch the lake move into the sky.
Engine notes: cells update bottom-up with an alternating scan direction and a clock bit so nothing moves twice per tick — the same trick sandspiel uses. This is a single static file with zero dependencies: save the page and it keeps working offline.
The world is a grid. Every cell holds a species id plus two bytes:
ra (random by default — used for color texture, or anything you like) and
rb (a free register, zero by default — commonly a life counter).
Each tick, every non-empty cell runs its element's update(api) once.
x grows right, y grows down. All offsets (dx,dy) are relative to the current cell.
Reads off the grid edge return ROCK.
api.get(dx,dy) → species id of a neighbor ·
api.getRa(dx,dy), api.getRb(dx,dy) → its registers ·
api.me → my species ·
api.ra, api.rb → my registers (assignable: api.rb = 40) ·
api.x, api.y → my position.
api.set(dx,dy,id,{ra,rb}?) → overwrite a cell (new random ra, rb 0 unless given) ·
api.move(dx,dy) → move me there (overwrites target, leaves empty behind) ·
api.swap(dx,dy) → exchange me with target.
move/swap return true and keep following your cell — usually you want to return right after.
api.randDir() → -1 or 1 · api.rand(n) → integer 0..n-1 ·
api.chance(p) → true with probability p.
api.isLiquid(id), api.isGas(id), api.isPowder(id) ·
api.densityOf(id) · api.flammabilityOf(id) ·
api.nameOf(id) · api.prop(id,'anyKey') reads any field off another element's
definition — add your own fields and rules can react to them. (Fire does exactly this: it
reads Plant's custom burnDry field, then scales it by how dry that plant is.)
Any numeric field can become a slider. Declare it, then list it in a
tune block, and read it back inside update as
this.<name>:
speed: 0.4,
tune: { speed: { min: 0, max: 1, step: 0.05, label: "fall speed" } },
update(api) { if (api.chance(this.speed)) api.move(0, 1); }
The panel above builds sliders from that block, and moving one takes effect on the next
tick — no recompiling. Use a method (update(api) {…}), not an arrow
function, or this will not be your element. Slider positions survive
edits elsewhere.
name (also becomes the ALL_CAPS constant other rules use) ·
color "#rrggbb" · vary 0..1 color grain ·
liquid/gas/powder flags · density number ·
flammable 0..1 (chance per tick a neighboring flame ignites it) ·
paintRa/paintRb starting register values for brush-painted cells
(random ra and zero rb by default) ·
group "water" or "life": which palette row the chip joins (anything else is matter) ·
tune slider definitions (see above) ·
update(api) behavior, optional ·
shade(ra,rb,frame,out) optional dynamic color: fill and return out=[r,g,b] ·
plus any custom keys you invent.
• return after a move/swap.
• rb === 0 is a handy "just born" signal (see Fire) — or use rb as a
resource tank: Plant's stored moisture, or an animal's energy (eat to fill, burn to live,
split to breed, die at zero). Use paintRb so brush-placed cells start stocked.
• keep updates small: they run for every cell of that element, every tick.
• a runtime error pauses that element's rule (chip turns red) until you apply a fix.