Frost Dendrites
Release a particle far away. Let it wander at random. Freeze it where it first touches. Repeat, and branches appear that nobody wrote.
About this piece
The whole rule is four lines long. One cell at the centre is frozen. A particle is released on a circle just outside the crystal and takes a random walk — one cell at a time, no preferred direction. The moment it lands next to something frozen, it freezes there too, and the next particle is released. This is diffusion-limited aggregation, published by Thomas Witten and Leonard Sander in 1981 as a model of how soot, electrodeposits and mineral dendrites actually grow.
Nothing in that rule mentions branches, tips or symmetry. You get all three anyway, and the reason is worth stating plainly: a wanderer has to survive the entire approach. Tips stick out into open space and meet arrivals first; the fjords between them are shielded, and a particle that would have to thread one almost always touches a tip on the way in. So tips capture nearly everything, gaps stay gaps, and arms thin as they lengthen instead of filling out.
How thin, exactly
Thin enough to measure. A DLA cluster's mass grows roughly as r1.71, not as the r² a solid disc would give. So doubling the radius costs about 3.3× the particles rather than 4×, and the gap widens without limit — at ten times the radius a disc would be 100× heavier and this is only about 51×. That exponent, 1.71 in two dimensions, has never been derived from the rule; it is a measured number, and reproducing it is the standard way people check a DLA implementation. The one on this page holds to it. Grown out to the frame of a 1280×900 window it settles at 4,615 frozen cells inside a radius of 141, and log 4615 ÷ log 141 = 1.70. Across the five frame sizes it was measured at — a phone, two gallery-tile sizes, the social card and that desktop window — the exponent lands between 1.67 and 1.75.
The stick test here uses four neighbours, not eight — a particle has to share an edge with the crystal, not just a corner. On a square lattice that biases growth very slightly along the two axes, which is precisely why this reads as frost on a window rather than as soot. That anisotropy is a known artefact of lattice DLA, and it is kept here because it is the more beautiful of the two failures.
The one optimisation, and why it is not a cheat
Naive DLA is brutally slow: a particle released far away takes on the order of the square of its distance to arrive anywhere. The trick is exact rather than approximate. A wanderer sitting a distance d beyond the crystal’s outer radius r cannot possibly touch anything before it first leaves the circle of radius d − r − 1 drawn around itself — and by symmetry, a two-dimensional random walk leaves such a circle at a point uniformly distributed around it. So the walk teleports straight there in one step. Only inside the last two cells does it fall back to honest single hops, where the geometry actually matters. Counted honestly, that is worth about 6×: the 1280×900 crystal above costs 7.9 million walker steps where the unit-step walk would have cost 45 million. Six, not six hundred — because most of the remaining cost is the last stretch inside the cluster’s own radius, where the walker has to take real steps through the gaps. That is not waste; that shielding is the thing making the branches, and there is no shortcut through it that does not change the picture.
What to look for
Freezing is permanent, so the canvas is never cleared while the crystal grows — each frame paints only the handful of cells that froze during it. That is what makes a piece this detailed cheap enough to leave running in a background tab. When the crystal reaches the frame it holds for a few seconds, then thaws with a destination-out wash (an alpha erase, so the paper colour behind the canvas stays itself instead of walking toward black) and a new seed nucleates. Click the canvas to skip straight to a fresh crystal.
Honest limits: this is lattice DLA at one particle at a time, not a physical simulation of ice. Real window frost also grows from vapour along existing scratches and follows the humidity gradient, which is why it has a preferred direction and this does not. Every run is genuinely different, and there is no seed you could quote to get a particular crystal back. And the lattice cell is a fixed three CSS pixels at every size, so a 390×844 phone grows a genuinely smaller crystal — about 980 cells at radius 62 against the desktop’s 4,615 at 141 — rather than the same crystal scaled down. Reduced-motion visitors get the growth run to completion in one burst and then held still.
Draw your own
The rule behind the plate is diffusion-limited aggregation, introduced by T. A. Witten and L. M. Sander in Physical Review Letters 47, 1400–1403 (1981). Two moves are all there is. A walker wanders: each step it goes one cell left, right, up or down, chosen at random, with no memory of where it came from. A walker sticks the first time it lands on a cell that shares an edge with the frozen crystal; it freezes there for good and the next walker sets off. Branching is never written down anywhere. It comes from the fact that a random walker almost never threads a narrow gap: whatever sticks out furthest meets walkers first, so tips keep growing and the spaces between them stay empty.
The snippet below is that rule in 25 lines of HTML and plain JavaScript, with no library. It works on a 120 × 120 grid of 4 px cells (a 480 px canvas) with one frozen seed in the middle. Each walker is released on a circle three cells outside the crystal’s current radius; one that steps off the edge of the grid is thrown away and a new one is released. It stops at 1,000 frozen cells, which is 16,000 of the canvas’s 230,400 pixels, about 6.9%. Run 300 times in a test harness, the finished crystal reached a median radius of 54 cells, and log 1000 ÷ log 54 ≈ 1.73: the same thinness as the r1.71 measured on the plate above, from a far smaller crystal. It paints with the same cobalt, coral and ground that this page’s animation reads from the site palette, light or dark to match your device. Save it as an .html file and open it.
<canvas id="frost" width="480" height="480"></canvas><script>
const [bg, cobalt, coral] = matchMedia('(prefers-color-scheme: dark)').matches // follow the OS theme
? ['#0B0D12', '150,180,255', '255,120,84'] : ['#E7E2D5', '40,72,205', '190,68,28'];
document.body.style.background = bg; const ctx = document.getElementById('frost').getContext('2d');
const S = 4, N = 120, C = 60, GOAL = 1000, ice = new Uint8Array(N * N); // 4 px cells, 120 x 120
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let r = 0, count = 0; freeze(C, C); // the seed: one frozen cell in the middle
function freeze(x, y) { // stick: mark the cell, track the radius, paint (3 in 10 coral)
ice[y * N + x] = 1; count++; r = Math.max(r, Math.hypot(x - C, y - C));
ctx.fillStyle = 'rgb(' + (Math.random() < 0.3 ? coral : cobalt) + ')'; ctx.fillRect(x * S, y * S, S, S);
}
function walk() { // release one walker on a circle just outside the crystal
const a = Math.random() * 2 * Math.PI, d = Math.min(r + 3, C - 3);
let x = Math.round(C + d * Math.cos(a)), y = Math.round(C + d * Math.sin(a));
while (x > 0 && y > 0 && x < N - 1 && y < N - 1) { // stepping off the grid = lost
const i = y * N + x; // touching ice on any of its 4 sides? then it freezes right here
if (ice[i - 1] || ice[i + 1] || ice[i - N] || ice[i + N]) return ice[i] || freeze(x, y);
const k = Math.random() * 4 | 0; if (k < 2) x += k ? 1 : -1; else y += k > 2 ? 1 : -1;
}
}
(function frame() { // 5 walkers a frame; under reduced motion, grow it all before the first paint
for (let i = 0; count < GOAL && (still || i < 5); i++) walk();
if (count < GOAL) requestAnimationFrame(frame);
})();
</script>
It releases 5 walkers a frame. In those 300 runs it took a median of about 2,000 walkers to freeze 1,000 cells, because roughly half wander off the grid and are lost, so the crystal takes around 400 frames to finish, about 7 seconds at 60 frames a second. Under prefers-reduced-motion it grows the whole crystal before the first paint and shows it still.
Where to take it: add the four diagonal cells to the touch test (ice[i − N − 1] and its three mirror images) and walkers freeze on a corner contact as well, so the same 1,000 cells stretch thinner and reached the edge of the grid in every one of 300 trial runs. Let a touch stick only a quarter of the time, by adding && Math.random() < 0.25 to that test, and walkers get a chance to slip past the tips into the gaps: the crystal comes out denser, with a median radius of 45 cells instead of 54. Raising GOAL past about 1,000 mostly thickens the rim, because in half the runs the crystal has already grown past 54 cells, where the release circle hits its 57-cell limit and new walkers start among the outer arms instead of outside them.
What the snippet does not do: every step is a single cell, with none of the plate’s exact long jump, so it needs about 1.3 million steps for its 1,000 cells. Throwing away walkers that leave the grid, and releasing replacements evenly round a circle only three cells out, is an approximation: a real walker would wander back in from wherever it went, so the result is a close cousin of textbook DLA, not an exact sample of it. Its speed is counted in frames, so a 120 Hz screen grows it twice as fast; it reads your light or dark setting once, when the page loads; and it draws at 480 CSS pixels without scaling for high-density screens.
Curious how the loop and canvas fit together? Read how it works →
More from the gallery
- Plate 15 Voronoi Drift Cell walls redrawn as their seeds drift and relax.
- Plate 16 Hilbert Weave One unbroken line that visits every cell of the plate.
- Plate 17 Turing Bloom Two spreading chemicals growing a coral reef out of nothing.
- Plate 18 Penrose Tiling Two rhombs tiling the plane in a pattern that never repeats.
- Plate 19 Sandpile Avalanche Grains piling up until sites topple and an avalanche runs.
- Plate 20 Moiré Interference Two line grids turning against each other into slow bands.