GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Canvas Boids Algorithm Flocking Birds Simulation

A boids algorithm demo steers every agent with three local vectors, separation, alignment, cohesion, and that is enough to raise hundreds of birds from three

Auto-plays · click a tile to jump to its section · all nine in one zip

Why three simple rules branch into nine flocks

Each step adds one more force on top of the same three-vector core, and reading them in order is the fastest way to see how little a flock actually needs. 01 is the bare minimum — separation, alignment, cohesion and nothing else — while 02 and 03 dress that same core in drawing (wingbeat phase, virtual depth) without touching the physics. 04 is the first scene to add a fourth force, flee, and 05 keeps every force but rebuilds how neighbors are found. 06 blows a wind field through the flock, 07 adds a weak attractor, 08 switches the paint step to glow compositing, and 09 spends its budget on target points sampled from text instead of free roaming. Trails accumulate starting at 06, so the last three scenes are also the first to leave a visible wake behind every agent.

Why build flocks from three rules? — separation, alignment, cohesion and the neighbor radius

The simplest way to make a crowd move together isn't to put one agent in charge — it's to give none of them any authority at all. Each bird only looks inside its own neighbor radius and folds that view into three numbers: push away if something is close (separation), match the surrounding heading (alignment), and drift toward the local center of mass (cohesion). Craig Reynolds proposed exactly this combination in 1987, and it's still the reference shape people mean when they say separation alignment cohesion or search for a flocking algorithm. Weight the three vectors, clamp the sum to a maximum force, and no single agent ever sees the whole flock — yet the flock reads as one coherent shape. What changes between these nine scenes is almost never the three rules themselves; it's the radius, the weights, and whatever fourth force gets bolted on (flee in 04 and 09, wind in 06, an attractor in 07). That's also what separates this boids algorithm simulation from 314's fire particles, where each particle burns down on its own with no notion of a neighbor, and from 306's spring-constrained physics mesh, where explicit joints — not a perception radius — hold points together.

Item code: nine boids algorithm simulation builds

01The three-rule flock — a minimal swarm on separation, alignment, cohesion

Adding three vectors — steer away, match the neighbors' heading, drift toward their center — makes a flock of 260 agents form itself with no leader at all, measured at 41.242% cumulative area across 23 of 24 moving frames.

The three-rule flock — a minimal swarm on separation, alignment, cohesion
Open in a new tab

01 · The three-rule flock — a minimal swarm on separation, alignment, cohesion — Measured area 41.242% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrameMath.hypot

Three vectors gathered inside the neighbor radius

var R_NEIGH = 42;    // neighbor radius (px)
var MAX_SPD = 128;   // max speed (px/s)
var MAX_FOR = 340;   // max steering force (px/s^2)
for (var j = 0; j < n; j++) {
  if (j === i) continue;
  var o = boids[j];
  var dx = o.x - b.x, dy = o.y - b.y;
  var d2 = dx * dx + dy * dy;
  if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
  var d = Math.sqrt(d2);
  cnt++;
  aliX += o.vx; aliY += o.vy;
  cx += o.x; cy += o.y;
  if (d < R_NEIGH * 0.5) {   // separation — only inside half the radius, weighted stronger the closer it gets
    var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
    sepX -= dx / d * w; sepY -= dy / d * w;
  }
}

Every boid scans its neighbor radius once per frame, summing neighbor velocity and position while weighting separation more heavily the closer another boid sits.

Steering blends the three weights before clamping

function steer(vx, vy, tvx, tvy, out) {
  var sx = tvx - vx, sy = tvy - vy;
  var m = Math.hypot(sx, sy);
  if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
  out[0] = sx; out[1] = sy;
}
if (cnt > 0) {
  steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);                          // alignment
  steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);  // cohesion
  fx += W_ALI * sA[0] + W_COH * sB[0];
  fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
  steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
  fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}

Screen edges stitched into a torus

b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// torus wrap — exit one edge, re-enter from the opposite edge
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;

The combined force is clamped to a maximum speed before the position updates, and any boid drifting past one edge simply re-enters from the opposite side.

02Bird silhouettes — feathers whose wingbeat phases drift apart

Offsetting each bird's wingbeat phase a little and rotating the body to its heading composes a black flock crossing the sky as a single silhouette scene, measured at 39.212% cumulative area over 23 of 24 moving frames.

Bird silhouettes — feathers whose wingbeat phases drift apart
Open in a new tab

02 · Bird silhouettes — feathers whose wingbeat phases drift apart — Measured area 39.212% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

saverotaterequestAnimationFrame

Wingbeat phase assigned at spawn, per bird

function spawnB(b, i) {
  b.x = rand() * W;
  b.y = rand() * H;
  var ang = rand() * TAU;
  var spd = MAX_SPD * (0.5 + rand() * 0.5);
  b.vx = Math.cos(ang) * spd;
  b.vy = Math.sin(ang) * spd;
  b.ph = rand() * TAU;              // wingbeat phase — offset per bird
  b.flap = 5.2 + rand() * 3.4;      // wingbeat speed (rad/s)
  b.sz = 0.8 + rand() * 0.55;       // wing scale
  b.grp = (i || 0) % 4;             // sub-flock — spreads screen coverage
}

Each bird draws its own random phase and flap speed at spawn, so the whole flock never beats its wings in lockstep.

Two wings fold on opposite phase

var flap = Math.sin(tSec * b.flap + b.ph);   // -1..1 wing flap
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
var s = b.sz;
ctx.beginPath();
ctx.moveTo(5 * s, 0);
ctx.lineTo(-3 * s, 1.6 * s);
ctx.lineTo(-3 * s, -1.6 * s);
ctx.closePath();
ctx.fill();
var wy = flap * 4.2 * s;     // front wing folds up when the phase is positive
var wy2 = -flap * 4.2 * s;   // back wing folds on the opposite phase

A dusk sky that breathes without leaving a trail

var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(' +
  (C_DUSK[0] + (C_GLOW[0] - C_DUSK[0]) * 0.35 | 0) + ',' +
  (C_DUSK[1] + (C_GLOW[1] - C_DUSK[1]) * 0.35 | 0) + ',' +
  (C_DUSK[2] + (C_GLOW[2] - C_DUSK[2]) * 0.35 | 0) + ')');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);

The sky is repainted as a full-opacity gradient every frame instead of a translucent overlay, so its stop position can drift with tSec and the whole backdrop stays alive behind the silhouettes.

03A school of fish — schooling with a sense of depth

Giving each fish a virtual depth and drawing far ones smaller and fainter layers a swimming school without any perspective projection, measured at 44.171% cumulative area over 23 of 24 moving frames.

A school of fish — schooling with a sense of depth
Open in a new tab

03 · A school of fish — schooling with a sense of depth — Measured area 44.171% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

globalAlphascalerequestAnimationFrame

Virtual depth sets both size and alpha

b.z = rand();   // virtual depth, 0 (near) .. 1 (far)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });   // far-to-near so near fish overlap far ones
for (var k = 0; k < order.length; k++) {
  var b = order[k];
  var depth = 1 - b.z;                     // 1 = near, 0 = far
  var sc = (0.45 + 0.75 * depth) * b.sz;   // farther fish draw smaller
  var alpha = 0.35 + 0.6 * depth;          // farther fish draw fainter
  var ang = Math.atan2(b.vy, b.vx);
}

Every fish carries a random depth value that scales both its size and opacity, so distant fish read as smaller and fainter without a camera or real projection matrix.

Streamlined body and tail lean on the heading axis

var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();

A slow-breathing light stays above the school

var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
var ly = -H * 0.1;
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
var la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, 'rgba(' + C_SKY[0] + ',' + C_SKY[1] + ',' + C_SKY[2] + ',' + la.toFixed(3) + ')');
lg.addColorStop(1, 'rgba(0,0,0,0)');

The overhead light's radius and alpha both ride the same slow sine, so the whole deep-sea stage brightens and dims without a single fish changing course.

04A predator arrives — a flee vector splitting the flock

Treating the pointer as a predator and adding a strong flee vector inside a radius makes the flock split open and re-seal in a living ripple, measured at 40.232% cumulative area over 23 of 24 moving frames.

A predator arrives — a flee vector splitting the flock
Open in a new tab

04 · A predator arrives — a flee vector splitting the flock — Measured area 40.232% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovefillRectrequestAnimationFrame

A fourth force that scales with distance squared

var FLEE_R    = 90;     // predator flee radius (px)
var FLEE_GAIN = 1.0;    // flee force multiplier
var pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
  var t = (FLEE_R - pd) / FLEE_R;              // 0..1, closer to 1 the nearer it is
  var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
  fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}

Squaring t before scaling the force means a bird an inch from the predator flees far harder than one at the radius edge, which is what tears the flock open instead of just nudging it.

Cohesion re-seals the gap the flee force tears open

if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx / cohN) * 0.04 * W_COH; fy += (cy / cohN) * 0.04 * W_COH; }

Torus shortest distance still finds neighbors across the seam

var dx = o.x - b.x, dy = o.y - b.y;
// torus shortest distance — wrap to whichever side is closer
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
  if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
}

05The arithmetic of a thousand — finding neighbors with a spatial hash

Dividing the screen into cells, registering each bird in one, and checking only the neighboring cells drops neighbor search from all-pairs to a grid lookup as the count grows, measured at 48.21% cumulative area over 23 of 24 moving frames with 1000 agents on screen.

The arithmetic of a thousand — finding neighbors with a spatial hash
Open in a new tab

05 · The arithmetic of a thousand — finding neighbors with a spatial hash — Measured area 48.21% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MapfillRectrequestAnimationFrame

A Map hash is rebuilt from scratch every frame

var CELL = 48;     // hash cell side (px)
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
  grid.clear();
  for (var i = 0; i < birds.length; i++) {
    var b = birds[i];
    var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
    var k = cellKey(cx, cy);
    var arr = grid.get(k);
    if (!arr) { arr = []; grid.set(k, arr); }
    arr.push(i);
  }
}

Because every bird moves each frame, the hash is thrown away and rebuilt each frame too, and a single "cx,cy" string key per cell keeps the data structure trivial.

Only the home cell and its 8 neighbors get checked

var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// home cell + 8 neighboring cells (3x3) only — this is the whole optimization
for (var oy = -1; oy <= 1; oy++) {
  for (var ox = -1; ox <= 1; ox++) {
    var arr = grid.get(cellKey(gcx + ox, gcy + oy));
    if (!arr) continue;
    for (var a = 0; a < arr.length; a++) {
      var j = arr[a];
      if (j === i) continue;
      checksThisFrame++;
      // distance test happens here — inside PERCEP (30) counts as a neighbor
    }
  }
}

Why a thousand birds stay smooth

An all-pairs comparison runs a million distance checks per frame at 1000 birds, but swapping in the 3x3 grid lookup drops that to a few tens of checks per bird, and the on-screen checksThisFrame counter turns that saving into a number you can watch move.

06Murmuration — a black cloud that leaves flow lines

Covering each frame translucently to leave trails and blowing curl-noise wind through the flock turns it into the black tide of starlings rising over a reed bed, measured at 51.635% cumulative area — the highest of all nine scenes — over 23 of 24 moving frames.

Murmuration — a black cloud that leaves flow lines
Open in a new tab

06 · Murmuration — a black cloud that leaves flow lines — Measured area 51.635% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

globalCompositeOperationcreateLinearGradientrequestAnimationFrame

One translucent overlay alpha leaves the trail

var TRAIL = 0.14;   // trail overlay alpha — lower leaves a longer trail
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' +
  (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);

Curl noise is a rotational field built from stacked sines

function psiAt(x, y, tSec) {
  var s = 0.004;
  return (
    Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
    Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
    Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45
  );
}
function windAt(x, y, tSec) {
  var e = 1.5;   // finite-difference step (px)
  var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
  var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
  // rotational field — zero divergence, air never piles up or drains
  var mag = 46 * WIND;
  return [dpsiDy * mag, -dpsiDx * mag];
}

Wind is the perpendicular gradient of a scalar potential rather than a hand-tuned direction, which is exactly what keeps the flow divergence-free and never bunching birds into a single point.

Wind joins the boid force before the speed clamp

var wv = windAt(b.x, b.y, tSec);
fx += wv[0]; fy += wv[1];
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }

07Around the food — orbits near an attractor

Giving the flock one weak attractor and lowering each bird's speed cap turns the scene into a slow whirl of birds circling a feeding spot, measured at 31.175% cumulative area over 23 of 24 moving frames.

Around the food — orbits near an attractor
Open in a new tab

07 · Around the food — orbits near an attractor — Measured area 31.175% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createRadialGradientfillRectrequestAnimationFrame

Gaussian-decayed pull plus a tangential swirl

var ATTRACT = 26;   // pull strength — kept low so orbits spread wide
var orbR = 270 * p.orbBias;                     // per-bird target orbit radius
var decay = Math.exp(-(d * d) / (orbR * orbR)); // Gaussian decay — strong near the center, free further out
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx;
var swirlR = 300 * p.orbBias;
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];

Each bird carries its own orbBias, so the Gaussian decay places some orbits tight against the feeding spot and others wide, spreading the whole flock across a range of rings instead of one ring.

The three-vector core rides along at reduced weight

function flockForce(idx, cx0, cy0) {
  var p = boids[idx], sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
  for (var j = 0; j < N; j++) {
    if (j === idx) continue;
    var q = boids[j];
    var dx = q.x - p.x, dy = q.y - p.y;
    if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
    if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
    var d2 = dx * dx + dy * dy;
    if (d2 > NEI_R2) continue;
    var d = Math.sqrt(d2) || 0.0001;
    if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
    aliX += q.vx; aliY += q.vy;
    cohX += dx; cohY += dy;
    cnt++;
  }
  var fx = sepX * W_SEP, fy = sepY * W_SEP;
  if (cnt > 0) {
    fx += (aliX / cnt - p.vx) * (W_ALI / 10);
    fy += (aliY / cnt - p.vy) * (W_ALI / 10);
    fx += (cohX / cnt) * (W_COH / 40);
    fy += (cohY / cnt) * (W_COH / 40);
  }
  return [fx, fy];
}

A 0.08 trail overlay draws the orbits as long arcs

var TRAIL = 0.08;   // trail overlay alpha — lower means longer visible arcs
var damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;

08Glowing plankton — a swarm shining in the night sea

Drawing each agent as a glow dot composited with lighter — so overlaps brighten — and letting them drift on a deep-blue sea gives the bioluminescent bloom that spreads from every oar-stroke, measured at 32.674% cumulative area over 23 of 24 moving frames.

Glowing plankton — a swarm shining in the night sea
Open in a new tab

08 · Glowing plankton — a swarm shining in the night sea — Measured area 32.674% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

globalCompositeOperationcreateRadialGradientrequestAnimationFrame

One glow sprite is baked once, then stamped repeatedly

var GLOW_R = 7;   // glow radius (px)
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
var sctx = sprite.getContext('2d');
(function buildSprite() {
  var r = spriteSize / 2;
  var g = sctx.createRadialGradient(r, r, 0, r, r, r);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  sctx.fillStyle = g;
  sctx.fillRect(0, 0, spriteSize, spriteSize);
})();

Baking one radial-gradient sprite ahead of time and stamping a tinted copy per plankton is far cheaper than redrawing a gradient for every one of them, every frame.

Lighter compositing turns overlaps into light

ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
  var glow = p.hue ? C_GLOWB : C_GLOWA;
  var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
  var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
  ctx.globalAlpha = 0.55 + 0.35 * pulse;
  ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
}

Drift and flee layer onto a loose school

var NEI_R = 30, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;   // a slight upward drift
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;

09A flock that spells — a sentence assembling while dodging the pointer

Assigning each bird a pixel of the text as its target and adding the flee vector only near the pointer gives a swarm that scatters when you barge in and re-forms the sentence when you step aside, measured at 31.423% cumulative area over 23 of 24 moving frames.

A flock that spells — a sentence assembling while dodging the pointer
Open in a new tab

09 · A flock that spells — a sentence assembling while dodging the pointer — Measured area 31.423% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillTextpointermoverequestAnimationFrame

Target points are sampled from an offscreen fillText

var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4;   // pixel sample spacing
octx.font = FONT_STACK;
octx.fillText('BOIDS', off.width / 2, off.height / 2);
var img = octx.getImageData(0, 0, off.width, off.height);
var d = img.data, ow = off.width, oh = off.height;
for (var yy = 0; yy < oh; yy += sampleGap) {
  for (var xx = 0; xx < ow; xx += sampleGap) {
    if (d[(yy * ow + xx) * 4] > 128) {
      targets.push({ x: xx / ow, y: yy / oh });
    }
  }
}

Reading getImageData back from an offscreen canvas that only drew the word turns every bright pixel of a letter into one candidate target point for a bird.

Target spring and flee vector pull against each other

var SPRING = 6.0;   // target spring strength — kept low so scatter takes time to recover
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;
var dxs = p.x - sX, dys = p.y - sY;
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
  var fm = (1 - ds / FLEE_R) * 460;
  ax += (dxs / ds) * fm;
  ay += (dys / ds) * fm;
}

A 0.07 trail overlay keeps the scattering paths visible

var TRAIL = 0.07;   // trail overlay alpha — leaves the scatter path readable
var jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
var jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;

Where a flocking demo actually earns its place

An intro where a logo or a brand line scatters and reassembles fits 09's flock that spells — it dodges the pointer and folds back into the sentence, which is exactly the kind of motion that holds a header area's attention. On a dark background, like a card hover or a loading overlay, 08's glowing plankton leaves only light behind, light enough not to compete with whatever sits on top.

For a scrolling background section, 06's murmuration — a black cloud trailing flow lines — or 02's bird silhouettes, drifting out of phase with each other, both work as a quiet repeat; either one belongs only in the margin that never overlaps text.

Where the flock breaks once the neighbor radius grows too wide

The first thing to break is the balance between separation and cohesion. Push cohesion too hard and the flock collapses toward a point, then separation shoves everyone back out, and the whole thing oscillates instead of settling. Building 03's fish school, a heavier cohesion weight made the school coil up and stall — dropping it to 0.6 is the exact value that keeps the nhgr9myq school moving like water instead of freezing into a knot. Second, 06's curl-noise finite-difference step e sits at 1.5px because the underlying potential is a smooth sum of sines that never turns sharply — push e much wider and the sampled gradient stops tracking the surface, so the wind snaps direction instead of curving. Third, shrinking 05's cell size (48px) below its own perception radius (30px) means a boid's true neighbors can sit outside the 3x3 search window, and the flock visibly splits down invisible seams. Last, 09 assigns targets by index modulo (i % targets.length), so tightening sampleGap past the point where target points outnumber birds leaves some letters permanently thin, since no boid ever gets assigned that leftover pixel.

Three neighbor-count variants and their reduced-motion fallback

One constant, three variants

Variant Changed value What it feels like
Agent count 01 three-rule flock 260 → 640 The flock reads denser, at the cost of a heavier frame
Cohesion weight 03 fish school cohesion 0.6 → 1.2 The school packs tighter but swims more sluggishly
Color $flock-ink → $flock-lilac The black bird silhouettes shift into a lilac-toned wave

All three variants move exactly one line in the constants block at the top of the source — agent count is N_B (260), cohesion is W_COH (0.6), and color is item 02's TONE switch flipping from 0 to 1.

A still frame for reduced-motion visitors

When prefers-reduced-motion: reduce is set, none of the nine scenes starts its requestAnimationFrame loop — each one instead pre-simulates a handful of steps and draws a single settled frame, using STILL_T at 1.6 seconds for 01–03, the moment the predator strikes for 04, and 3.0 seconds into the wind-drawn trail for 06. The simulation math still runs exactly as it would in motion, only the drawing happens once, so the shape of the flock still comes through.

Five scenes take pointer and keyboard input

All nine scenes carry role="application" and tabindex="0", so tab focus always reaches the demo; in 04, 05, 07, 08 and 09 a keydown handler goes further, letting both pointer and keyboard steer the extra force each one adds — 04 steers the predator with arrow keys, 07 maps horizontal position to swirl rotation and vertical position to pull strength, and 09 scatters the sentence with the arrow keys as well. A keyboard visitor who has reduced motion turned on still gets a single recomputed frame the instant a key is pressed.

Questions people ask before wiring up a flocking simulation

Can I paste this boids algorithm javascript code in and use it as-is?

Yes — each code block above comes straight from the running demo's own source rather than being retyped, and the file behind the password zip on this page has no external dependency to wire up. Copy one item's constants block, its step function, and its draw function into a page with a canvas element, and it runs.

Can a CSS flocking animation replace this?

A handful of birds following a fixed path can be done in CSS keyframes, but a flock that reads its own neighbors and splits open around a predator has to recompute the space between every pair of agents each frame, which keyframes simply can't express. Readers who land here searching flocking birds simulation are almost always after that reactive kind — the shape that only per-frame calculation can produce.

How many agents can this handle before it slows down?

An all-pairs comparison scales with the square of the agent count, so 300–400 is roughly the practical ceiling before frames start dropping. Item 05's spatial hash instead checks only a 3x3 window of grid cells per agent, which is why it runs 1000 birds at once — keep the cell size above the perception radius and the same trick scales past 2000.

Sources: MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · Craig Reynolds — Boids, the original page · Wikipedia — Boids · a swarm that walks toward sampled text pixels, without a flee force · a cell-to-cell diffusion field driving fluid instead of birds.

Enter the archive password

The password is inside this article. You will find it as you read.