Every bird follows three rules and looks only at whoever is nearby. Nobody is leading, nobody knows the shape of the flock, and there is no flock stored anywhere. It exists only as something you see.
EXP-022
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Move the cursor into them: birds flee from it and you can split the flock in half and watch it heal. Then take alignment to zero and the whole thing dies instantly into a milling crowd, which tells you which of the three rules is actually carrying the collective behaviour.
The maths
separación = −Σ (rⱼ − rᵢ)/|rⱼ − rᵢ|
Push away from anyone too close, weighted by 1/distance so the nearest bird dominates. This is the rule that keeps the flock from collapsing into a point.
alineación = ⟨vⱼ⟩ − vᵢ
Steer toward the average heading of the neighbours. This is what turns a crowd into a current, and it is the only rule that transmits direction across the group.
cohesión = ⟨rⱼ⟩ − rᵢ
Steer toward the centre of mass of the neighbours. Alone it would make one dense clump; against separation it produces a flock with a size.
r < 46 px
The only parameter that is not a weight: how far a bird can see. Nothing in the model has access to anything beyond this radius, and yet the flock behaves as a single object hundreds of pixels across.
How it is built
220 birds, each one checking all the others every frame. That is naive and quadratic, and at this size it is also the right call: 48.000 distance checks per frame is nothing, and a spatial hash would add code without buying anything visible. The three steering vectors are normalised before being weighted, so the sliders control balance and not magnitude, and speed is clamped to a constant so the flock never accelerates away.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
Look only at the neighbours
const R = 46, RS = 20;
for (const b of B) {
let cx = 0, cy = 0, ax = 0, ay = 0, sx = 0, sy = 0, n = 0;
for (const o of B) {
if (o === b) continue;
const dx = o.x - b.x, dy = o.y - b.y, d2 = dx * dx + dy * dy;
if (d2 > R * R) continue;
n++; cx += o.x; cy += o.y; ax += o.vx; ay += o.vy;
if (d2 < RS * RS) { const d = Math.sqrt(d2) + 0.01; sx -= dx / d; sy -= dy / d; }
Two radii: 46px to be considered a neighbour at all, 20px to be considered too close. Distances stay squared through the comparison so no square root is needed except in the one branch that actually normalises. Everything the model knows lives inside this loop.
Cohesion is the neighbours' average position minus mine, alignment is their average velocity minus mine, separation is the accumulated push. Each gets normalised before being weighted, so the sliders control the balance between the rules rather than the overall speed — otherwise turning one up would just make the flock faster.
Velocity is renormalised to a fixed speed every frame, so the rules only ever change direction. Without that the accelerations accumulate and the flock leaves the screen. The modulo makes the canvas a torus: fly off the right and come back on the left, with no walls to distort the flocking.
Why it matters
Craig Reynolds wrote this in 1986 and it went straight into film: the bats and penguins in Batman Returns were boids. It matters beyond animation because it is the cleanest demonstration that collective behaviour does not require a collective plan, and the same argument gets used for traffic jams, crowd disasters and markets.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · boids
{ id: 'boids',
name: L('Flocking', 'Bandada'),
note: L('Three local rules and no leader. The flock is not in any of the birds.',
'Tres reglas locales y ningún líder. La bandada no está en ninguno de los pájaros.'),
params: [
{ key: 'sep', label: L('Separation', 'Separación'), min: 0, max: 3, step: 0.05, def: 1.4 },
{ key: 'ali', label: L('Alignment', 'Alineación'), min: 0, max: 3, step: 0.05, def: 1 },
{ key: 'coh', label: L('Cohesion', 'Cohesión'), min: 0, max: 3, step: 0.05, def: 0.9 }
],
make(w, h) {
let B = [];
const seed = (w, h) => {
B = [];
for (let i = 0; i < 220; i++) {
const a = Math.random() * TAU;
B.push({ x: Math.random() * w, y: Math.random() * h,
vx: Math.cos(a) * 2, vy: Math.sin(a) * 2 });
}
};
seed(w, h);
return {
resize(w, h) { seed(w, h); },
reset() { seed(w, h); },
step(ctx, w, h, t, acc, P, M) {
const R = 46, RS = 20;
for (const b of B) {
let cx = 0, cy = 0, ax = 0, ay = 0, sx = 0, sy = 0, n = 0;
for (const o of B) {
if (o === b) continue;
const dx = o.x - b.x, dy = o.y - b.y, d2 = dx * dx + dy * dy;
if (d2 > R * R) continue;
n++; cx += o.x; cy += o.y; ax += o.vx; ay += o.vy;
if (d2 < RS * RS) { const d = Math.sqrt(d2) + 0.01; sx -= dx / d; sy -= dy / d; }
}
if (n) {
cx = cx / n - b.x; cy = cy / n - b.y; // cohesión
ax = ax / n - b.vx; ay = ay / n - b.vy; // alineación
const norm = (x, y) => { const d = Math.hypot(x, y) || 1; return [x / d, y / d]; };
const [c1, c2] = norm(cx, cy), [a1, a2] = norm(ax, ay), [s1, s2] = norm(sx, sy);
b.vx += c1 * P.coh * 0.05 + a1 * P.ali * 0.09 + s1 * P.sep * 0.13;
b.vy += c2 * P.coh * 0.05 + a2 * P.ali * 0.09 + s2 * P.sep * 0.13;
}
if (M.in) { // el cursor asusta
const dx = b.x - M.x, dy = b.y - M.y, d = Math.hypot(dx, dy) + 1;
if (d < 130) { const g = (1 - d / 130) * 0.8; b.vx += (dx / d) * g; b.vy += (dy / d) * g; }
}
const sp = Math.hypot(b.vx, b.vy) || 1;
const cap = 2.6;
b.vx = (b.vx / sp) * cap; b.vy = (b.vy / sp) * cap;
b.x = (b.x + b.vx + w) % w; b.y = (b.y + b.vy + h) % h;
}
for (const b of B) {
const a = Math.atan2(b.vy, b.vx);
ctx.fillStyle = `rgba(${acc},0.9)`;
ctx.beginPath();
ctx.moveTo(b.x + Math.cos(a) * 5, b.y + Math.sin(a) * 5);
ctx.lineTo(b.x + Math.cos(a + 2.5) * 3.4, b.y + Math.sin(a + 2.5) * 3.4);
ctx.lineTo(b.x + Math.cos(a - 2.5) * 3.4, b.y + Math.sin(a - 2.5) * 3.4);
ctx.closePath(); ctx.fill();
}
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('separation · alignment · cohesion — each one looks only at its neighbours', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText(B.length + ' birds sep ' + P.sep.toFixed(2) +
' ali ' + P.ali.toFixed(2) + ' coh ' + P.coh.toFixed(2), 12, h - 12);
}
};
}
},