Build notes · Generative Assets

Sardine Run

A live bait-ball of two thousand sardines, simulated in canvas 2D. Each fish runs three rules and flees your cursor; when the shoal banks, the light runs across its silver flanks as a single moving wave. This page is how it was built.

01Concept

SARDINE RUN is a fictional Pelagic Cam — a drifting camera rig on the annual sardine run off South Africa's Wild Coast. Its one job: make you feel the shoal as a single animal that folds, splits, and flashes away from a predator. You are the predator. The brief was to distinguish it sharply from the collection's other ocean piece, lumen — that one is a dark vertical descent through an institute; this is bright open water, horizontal, and driven entirely by your hand.

02Palette

Argued from the water itself: sunlit blue up top, cold dark below, and the one non-negotiable colour of the run — the silver flash of a banking flank.

Blue water
#0B3A5C
The lit surface column; the top of the depth gradient and the calm the ball rests in.
Deep
#062033
The pressure below. Grounds the content bands and sinks the fringes of the shoal.
Silver flash
#DCE8EE
The flank catching sun. Reserved for the wordmark, headings and the brightest fish — the signature colour.
Sun shaft
#8FD0E0
Cold light from above. Used for lines, marks and accents only — never small body text, to hold contrast.

Per the collection's contrast rule, body and label text use a dedicated pale ink (#DCE8EE / #9FBBCB) that clears 4.5:1 on the deep field; the bright #8FD0E0 is kept for rules and marks, not small type.

03Type

Archivo
Display · 900 / 800 / 600

A grotesque with real muscle at heavy weights. Set enormous and tight for the wordmark, with "RUN" hollowed to an outline so the shoal can swim through it — the one moment of typographic bravery.

Inter
Body · 400 / 500 / 600

Quiet, humanist, and legible at small sizes over the animated water. Carries all reading copy, field-log entries and the live HUD readouts.

04Technique

No libraries, no images — a single <canvas> 2D context. The shoal is a real boids simulation: every one of the 2,000 fish holds position and velocity in flat Float32Arrays, and each frame reads only the neighbours inside a small radius via a spatial hash grid (rebuilt each frame) so the cost stays roughly linear instead of the 4,000,000 comparisons a naive pass would need.

The signature — bait ball + flash-on-turn. Three classic rules give the body its mind: separation shoves fish apart below sixteen pixels, alignment steers each toward the mean heading of its neighbours, cohesion pulls it toward their centroid. A distance-proportional spring to a slowly wandering point contracts the whole flock into a defined ball that breathes rather than filling the frame. Your cursor is a predator: every fish inside a 150px radius accelerates directly away, force rising with the square of closeness, so the ball opens a hole and folds shut behind. The flash is the payoff — each fish measures the angle between its old and new heading; a hard turn spikes a per-fish flash value that then decays. Because neighbours turn together, the spike propagates outward as a wave. Fish are drawn as short round-capped line segments, sorted into seven brightness buckets from blue-grey to silver and stroked one beginPath per bucket, with the brightest two overdrawn additively for a shimmer. The flash wave you see folding around the cursor is emergent, not scripted.

Robustness: devicePixelRatio capped at 2; the rAF loop pauses on document.hidden and clamps its delta on resume; resize rebuilds the gradient and grid; a failed 2D context drops to a CSS gradient scene. Reduced-motion settles one frame and rim-lights the ball, then stops. On touch or after a few idle seconds, an autonomous predator resumes slow passes so the shoal is never still.

05Iteration log

PASS 1

Craft

  • Found: the live cohesion readout sat at 0% — the flock spread to fill the whole frame instead of forming a ball, because the pull to centre was normalised (constant magnitude regardless of distance), so it could never contract the shoal.
  • Found: a leftover dead alignment expression (terms multiplied by 0.0) and a non-ASCII homoglyph in a variable name (coх) that risked a silent reference bug.
  • Changed: replaced the centre pull with a distance-proportional spring (capped), so the shoal now condenses into a ~340px ball that reads as the hero's subject. Cleaned the alignment steer and renamed the identifier to ASCII. Re-tuned spacing and the type scale so the outlined "RUN" locks under "SARDINE".
PASS 2

Depth

  • Enriched the signature: widened separation (13→16px) and perception (26→28px) and softened the spring so the ball is larger and more legible, with its fringe fish clearly flaring silver as they wheel away. Added the additive flash-bloom on the brightest two buckets so the wave shimmers.
  • Second-read detail: leave the cursor still and after ~4 seconds a hunter resumes circling on its own; the shoal keeps swimming behind the copy as you scroll (the content bands are translucent); sun shafts and marine snow drift the whole time.
  • Micro-interaction: the live HUD — shoal, cohesion %, flashes/second — responds in real time to how hard you drive the predator into the ball.
PASS 3

Hardening

  • 375px: on portrait the ball's wander centre is biased up (0.44→0.32 of height) with less vertical travel so it clears the bottom-anchored hero copy, and a portrait bottom scrim keeps text contrast independent of where the fish are. Verified no horizontal overflow and that the wordmark, nav mark and links all sit inside the viewport box — not just that scrollWidth stayed 375.
  • Reduced-motion: renders one settled frame and rim-lights the outer fish so it reads as a silver ball rather than a dark blob — no animation loop.
  • Removed one ornament: dropped the thin ring drawn around the cursor; the dark predator lens communicates position on its own and reads cleaner (Chanel rule).