Field guide
How MURMUR works
One page, twelve hundred birds, three rules, and a scrollbar that turns the rules on.
The concept
MURMUR is a field study of collective motion: a one-page scroll narrative built around a live boids simulation. The thesis is emergence itself. A murmuration has no choreographer; every starling follows three local rules, and the cloud you see at dusk is what those rules add up to. So instead of describing that, the page performs it. As you scroll, the simulation is granted its rules one at a time: separation alone is a nervous scatter, adding alignment makes rivers, adding cohesion makes the full breathing flock. Your pointer plays the falcon: the flock bends around you, and never obeys you.
The medium is deliberately pure. No footage, no generated imagery, no 3D library, no particle plugin. Every frame is computed in your browser on a 2D canvas. The simulation is the artwork.
The signature: scroll-driven behavior
Most scroll effects drive pixels: they move, fade or scrub things. MURMUR's scroll drives behavior parameters. Each narrative beat is a plain<section> carrying its parameter set as data attributes, so the choreography lives in the markup, not in the engine:
<section data-beat="2"
data-sep="1.4" data-align="0.9" data-coh="0"
data-speed="1.5" data-density="0.9">
// every frame, in the render loop:
probe = scrollY + viewportHeight / 2
target = interpolate(beatCenters, probe) // smootherstep between beats
for (k of params) current[k] = lerp(current[k], target[k], 0.06)The engine measures each section's center, maps the scroll position onto the two nearest beats with a smootherstep curve, and then eases the live parameters toward that target a little every frame. Two layers of smoothing mean a rule never pops on: you watch alignment arrive, currents forming out of chaos over a second or two. This is the piece's whole trick, and it costs nothing: the same thousand birds, reinterpreted.
The simulation, at code level
The flock is a classic boids model (Reynolds, 1987) tuned for murmuration texture. State lives in preallocated typed arrays: positions, velocities, a per-bird size and wander phase, and a depth layer. There are no bird objects and no allocation in the frame loop.
// one bird, one frame: read up to 7 neighbours from the hash grid
for (neighbours within 56px, capped at 7) {
sep += (me - them) / d * (1 - d/R)^2 // push away, harder when close
avgV += their velocity // for alignment
avgP += their position // for cohesion
}
a = sep * sepWeight // rule one
a += (avgV/n - myV) * alignWeight // rule two
a += (avgP/n - myP) * cohWeight // rule three
a += wander + falconAvoidance + softContainment
v = clampSpeed(v + a * dt); p += v * dtFinding neighbours is the expensive part, so birds are binned into a spatial hash grid rebuilt each frame from two flat integer arrays:
// spatial hash: neighbour lookup drops from O(n²) to O(n·k)
cell = floor(x / R) + floor(y / R) * cols // R = neighbour radius
head[cell] -> next[i] -> next[j] -> -1 // linked lists in two Int32Arrays
// a bird only inspects its own cell + the 8 around itEach bird reads at most seven neighbours, which is also roughly what real starlings track. That cap is the stat on the main page, and the reason the alarm around your pointer ripples outward: a bird flees the falcon, its neighbours copy the flight through the alignment rule, and their neighbours copy them.
Rendering and performance
- Birds draw as 2 to 3px oriented wing chevrons: two strokes from a head point, angled back along the velocity. All segments of a depth layer batch into a single
stroke()call. - Two depth layers fake parallax: a third of the birds render smaller and dusk-tinted, the rest darker and larger.
- Canvas resolution is capped at 1.5x device pixel ratio; agent count adapts to viewport and
deviceMemory(about 1,200 desktop, 350 mobile), and the loop sheds birds if the frame budget is blown. - The loop pauses when the tab is hidden. The dusk sky itself is layered CSS gradients that deepen a few percent per beat, so the canvas only ever paints birds.
- With reduced motion, the engine seeds a deterministic flock, runs 240 warm-up steps headless with all three rules on, renders one still mid-murmuration frame, and stops: a full sky, not an empty one.
The workflow
Built with Astro and Tailwind CSS, fonts self-hosted (Fraunces for display, Hanken Grotesk for text), deployed on Cloudflare Pages. The page is fully static; the two small scripts (engine and page behavior) are plain external modules configured through data attributes, which keeps the strict Content Security Policy clean: no inline styles, no inline handlers.
The design was locked before the build: a DESIGN.md fixing the concept thesis, the dusk token palette with measured contrast ratios, the type scale, the motion vocabulary and a list of banned patterns (no plexus lines, no glow, no acid accents). After the first complete build, three mandatory iteration passes ran before anyone else saw it:
- Defects. Spacing breaks, contrast edges, load flashes, mobile overlap, console errors, the reduced-motion state.
- Complexify. More depth and detail wherever the accessibility and performance gates allow: layering, micro-interactions, richer beat transitions, the recurring wing-glyph.
- Cohesion and reduction. One voice; whatever pass two made too busy goes back out, checked against the locked design.
Hard gates before deploy: Lighthouse accessibility 100 and performance 90 or better on the live URL, zero axe violations, AA contrast on all running text, and a screenshot harness that verifies every beat at two viewports.
The complete creative process is reproducible: the design brief and the technical brief live as prompts in the repository'sprompts/ folder (design.md anddevelopment.md).