Draw My Canvas / studio

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.

Plate 14diffusion-limited

Waiting for the animation above to load…

What lands in the file, and what the width does

One click writes whatever the animation is drawing at that moment to a PNG, with the drawmycanvas.com mark drawn into the picture rather than laid over it. Leave the width box empty and you get the stage exactly as your browser rasterised it — your window’s width times its device pixel ratio, which is about 1,600 px across from a 1280‑px window on a HiDPI laptop and about 353 px from a 390‑px phone.

Type a width instead, or take a preset, and the frame is redrawn into a canvas that wide: the height follows the stage’s own shape and the mark scales with it. The stage is 16:9 on a wide window and 4:3 below 560 px, so a width of 1200 saves 1200×675 on a laptop and 1200×900 on a phone. 1200 px is the width Open Graph and X link cards are cut from — the canonical card is 1200×630, and a card crops the extra height rather than letterboxing it.

Honest limits. Asking for more pixels than the stage was drawn at resamples pixels that were never drawn: a 1920‑px file exported from a 353‑px phone stage is bigger, not sharper. For a big file that is sharp, use Save as wallpaper: it draws the plate again from scratch at exactly 1179×2556 or 1290×2796 (phones), 1920×1080, 2560×1440 or 3840×2160 (4K), so every line is rasterised at that size and the shape is the screen’s, never stretched. Because the plate restarts, a wallpaper is a fresh run of it rather than the exact frame on screen; it is given as long as the stage has been running, up to 20 seconds, to develop. A very large width is a real memory allocation and a browser is allowed to refuse it; when that happens the line above says so plainly and nothing else on the page changes. Stills are PNG only — no JPEG, no WebP. On browsers that can record video, Record a clip saves 5, 10 or 20 seconds of the running animation as MP4 or WebM (whichever this browser can encode) at the same width, with the mark in every frame. A clip is not a seamless loop, it has no audio, and a width bigger than the stage is resampled rather than sharper — only the wallpaper is redrawn at its size. Where a browser cannot record video the button never appears and a PNG is the only export. And nothing is uploaded: the frame or clip is assembled in your own browser, so no frame of this plate ever reaches us.

Live on an HTML canvas · vanilla JavaScript · no dependencies Open fullscreen

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>
Paste it into an empty .html file.

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

All 32 animations in the gallery →