Falling sand
below empty ? fall : diagonal ? slide : spread
How it works
One rule per grain
There is no physics engine here and no notion of a body. The grid holds a material id per cell, and each grain asks three questions in order: is the cell below me empty, is either cell diagonally below me empty, and — if I am water — is either cell beside me empty. The first yes wins and the two cells swap.
That is enough for piles to form at a believable angle of repose, for streams to split around obstacles, and for water to find its level. All of it falls out of local rules; nothing tracks a heap or a surface.
Order is the whole problem
The grid is updated in place, which makes traversal order part of the algorithm. Scanning top-down would move a grain into a cell that has not been visited yet, and the same grain would fall again on the same tick — sand would drop at several cells per frame. So the pass runs from the bottom row up.
Scanning each row consistently left to right is just as wrong, in a subtler way: whichever diagonal is tested first wins every tie, and every pile visibly leans. Here the direction flips per row and per frame, and the two diagonals are tried in a coin-flipped order, which averages the bias out.
Working in place also means one array instead of two and no copy per frame, at the cost of never being able to look at the previous state. Every rule has to be expressible as a swap.
Density and drainage
Sand treats water as passable: if the cell below holds water they swap, so grains sink through and displace it upward. That single extra case is the entire density model, and it is why sand poured into a pool settles at the bottom rather than resting on the surface.
The bottom row drains with probability 0.25 per cell per tick, so the box never silts up and stops being interesting. Two taps in the ceiling keep it running when nobody is touching it, and the shelves are laid out from a seeded PRNG so the room is the same on every visit.
Each cell also carries a fixed shade bit that travels with it on every swap. Without that, grains would repaint themselves as they moved and the piles would shimmer.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, mulberry32 } from '../useCraft';
const PIXEL = 4; // one cell → a PIXEL×PIXEL block on screen
const EMPTY = 0;const SAND = 1;const WATER = 2;const STONE = 3;
/** Two shades per material, picked per grain, so piles have visible texture. */const COLOURS: Record<number, [string, string]> = { [SAND]: ['#ffb545', '#e8912b'], [WATER]: ['#2f8fd8', '#1f6fb8'], [STONE]: ['#4a4f63', '#3b3f50'],};
interface Grid { w: number; h: number; cell: Uint8Array; /** 0 or 1, chooses which of the material's two shades a cell draws with. */ shade: Uint8Array; buffer: HTMLCanvasElement; img: ImageData; tick: number; drip: number;}
export default function FallingSand() { const grid = useRef<Grid | null>(null); const rand = useRef(mulberry32(0x5a4d));
const seed = (w: number, h: number) => { const gw = Math.max(16, Math.ceil(w / PIXEL)); const gh = Math.max(16, Math.ceil(h / PIXEL));
const buffer = document.createElement('canvas'); buffer.width = gw; buffer.height = gh; const bctx = buffer.getContext('2d'); if (!bctx) return;
const cell = new Uint8Array(gw * gh); const shade = new Uint8Array(gw * gh); const r = mulberry32(0x5a4d); for (let i = 0; i < shade.length; i++) shade[i] = r() < 0.5 ? 0 : 1;
// A few stone shelves to catch and split the streams on the way down. const shelves = 4; for (let s = 0; s < shelves; s++) { const y = Math.round(gh * (0.26 + s * 0.17)); const span = Math.round(gw * (0.22 + r() * 0.26)); const x0 = Math.round(r() * (gw - span)); for (let x = x0; x < x0 + span; x++) { for (let k = 0; k < 2; k++) { const yy = y + k; if (yy < gh - 1) cell[yy * gw + x] = STONE; } } }
grid.current = { w: gw, h: gh, cell, shade, buffer, img: bctx.createImageData(gw, gh), tick: 0, drip: 0 }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, dt, pointer }) => { const g = grid.current; if (!g) return;
emit(g, dt, rand.current);
if (pointer.inside) { pour(g, Math.floor(pointer.x / PIXEL), Math.floor(pointer.y / PIXEL), SAND, rand.current); }
settle(g, rand.current); draw(g);
const bctx = g.buffer.getContext('2d'); if (!bctx) return; bctx.putImageData(g.img, 0, 0);
ctx.clearRect(0, 0, w, h); ctx.imageSmoothingEnabled = false; ctx.drawImage(g.buffer, 0, 0, w, h); }, seed);
return ( <canvas ref={canvasRef} /* pan-y keeps vertical scrolling with the browser, so a finger landing here never traps the page; sideways drags still pour. */ style={{ width: '100%', height: '100%', display: 'block', cursor: 'crosshair' }} /> );}
/* ── The automaton ───────────────────────────────────────────────────────── */
/** Taps along the ceiling keep the box busy when nobody is touching it. */function emit(g: Grid, dt: number, rand: () => number) { g.drip += dt; if (g.drip < 0.03) return; g.drip = 0;
const sandX = Math.round(g.w * 0.3); const waterX = Math.round(g.w * 0.68); pour(g, sandX, 1, SAND, rand, 1); if (rand() < 0.7) pour(g, waterX, 1, WATER, rand, 1);}
function pour(g: Grid, cx: number, cy: number, type: number, rand: () => number, radius = 2) { for (let y = cy - radius; y <= cy + radius; y++) { for (let x = cx - radius; x <= cx + radius; x++) { if (x < 0 || y < 0 || x >= g.w || y >= g.h) continue; if (g.cell[y * g.w + x] !== EMPTY) continue; // Ragged edges rather than a solid square of new material. if (rand() < 0.45) g.cell[y * g.w + x] = type; } }}
/** * One pass, bottom row first, so a grain that moves down cannot be visited * again on the same tick and fall twice. Scan direction alternates each row and * each frame — a fixed left-to-right order makes every pile lean left. */function settle(g: Grid, rand: () => number) { const { w, h, cell } = g; g.tick++;
for (let y = h - 1; y >= 0; y--) { const flip = ((y + g.tick) & 1) === 1;
for (let k = 0; k < w; k++) { const x = flip ? w - 1 - k : k; const i = y * w + x; const type = cell[i]; if (type === EMPTY || type === STONE) continue;
// The floor drains, so the box never silts up and stops being interesting. if (y === h - 1) { if (rand() < 0.25) cell[i] = EMPTY; continue; }
const below = i + w; const left = x > 0; const right = x < w - 1;
// Try one diagonal before the other, coin-flipped, so piles stay symmetric. const bias = rand() < 0.5 ? -1 : 1; const canGo = (d: number) => (d < 0 ? left : right);
if (type === SAND) { if (cell[below] === EMPTY) { swap(cell, i, below); continue; } // Sand is denser than water, so it trades places and sinks through. if (cell[below] === WATER) { swap(cell, i, below); swapShade(g, i, below); continue; }
for (let n = 0; n < 2; n++) { const d = n === 0 ? bias : -bias; if (!canGo(d)) continue; const diag = below + d; if (cell[diag] === EMPTY || cell[diag] === WATER) { swap(cell, i, diag); swapShade(g, i, diag); break; } } continue; }
// Water: fall, then slide, then spread sideways to find its level. if (cell[below] === EMPTY) { swap(cell, i, below); continue; }
let moved = false; for (let n = 0; n < 2 && !moved; n++) { const d = n === 0 ? bias : -bias; if (!canGo(d)) continue; const diag = below + d; if (cell[diag] === EMPTY) { swap(cell, i, diag); moved = true; } } if (moved) continue;
for (let n = 0; n < 2; n++) { const d = n === 0 ? bias : -bias; if (!canGo(d)) continue; if (cell[i + d] === EMPTY) { swap(cell, i, i + d); break; } } } }}
function swap(cell: Uint8Array, a: number, b: number) { const t = cell[a]; cell[a] = cell[b]; cell[b] = t;}
/** Shades travel with the grain, otherwise piles shimmer as things move. */function swapShade(g: Grid, a: number, b: number) { const t = g.shade[a]; g.shade[a] = g.shade[b]; g.shade[b] = t;}
function draw(g: Grid) { const data = g.img.data; const rgb = RGB_CACHE;
for (let i = 0; i < g.cell.length; i++) { const type = g.cell[i]; const o = i * 4; if (type === EMPTY) { data[o + 3] = 0; continue; } const c = rgb[type][g.shade[i]]; data[o] = c[0]; data[o + 1] = c[1]; data[o + 2] = c[2]; data[o + 3] = type === WATER ? 216 : 255; }}
/** Hex is parsed once at module load rather than per pixel per frame. */const RGB_CACHE: Record<number, [number[], number[]]> = Object.fromEntries( Object.entries(COLOURS).map(([type, pair]) => [ Number(type), pair.map(hex => [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]), ]),) as Record<number, [number[], number[]]>;The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}