boids
Flocking from three local rules — with each rule switchable and an order parameter reporting what it was actually doing.
All three rules. Nobody is leading and nobody can see more than 40 pixels, yet the whole group settles into one direction and holds a roughly even spacing.
three rules, no leader
Every boid follows the same three rules, and each one looks only at the flockmates it can actually see — the ones inside its perception radius. Separation steers away from anything too close, alignment steers toward the average heading of its neighbours, and cohesion steers toward their average position. That is the whole model. There is no leader, no target, and nothing anywhere in the code that represents “the flock” as a thing.
Turn on show one boid to see it from the inside: the ring is how far that boid can see, the faint lines are the neighbours it is currently averaging over, and the three coloured arrows are the steering vectors it is computing this frame. The flock in the background is what those arrows add up to across a couple of hundred birds, none of which knows it is in a flock.
measuring it
“They flock” is easy to say and hard to argue with, so the demo reports a number instead: the order parameter, which is the length of the mean heading,
with each boid’s unit heading. It is 1 when everyone points the same way and near 0 when nobody agrees. Not exactly 0, though: random headings cancel imperfectly, leaving roughly , which for 160 boids is about 0.07 — measured at 0.062 over 300 random flocks. That is the floor the readout sits on before anything organises, and the trace in the corner of the canvas is that number over the last few seconds.
what each rule is for
The presets switch the rules off one at a time, which turns three plausible stories into three measurable ones. Below is 160 boids run for 900 steps, across 25 random starts, reported as the median with the 10–90% range after it — single runs vary far too much to quote one, for reasons the next section gets to.
All three rules: order 0.98 (0.87–0.99), neighbours 9.0px apart. Without alignment, order collapses to 0.14 (0.04–0.33) — barely above the 0.07 you get with no rules at all. They still gather and still avoid each other; they just never agree where to go. Without separation, spacing falls from 9.0px to 1.5px: a knot rather than a flock, and a beautifully aligned one, since piling up is excellent for agreeing on a heading. Without cohesion, spacing goes the other way to 15.3px — indistinguishable from the 15.6px of no rules at all, because nothing is holding them together any more.
Notice what that means for alignment specifically. It is the only rule that raises the order parameter, and it does so without any boid ever being told the flock’s heading — there is no such quantity anywhere. Each one copies its neighbours, and agreement propagates outward faster than any bird flies.
and it is chaotic
The simulation is deterministic: the same starting flock always produces the same result, exactly. It is also chaotic, and those are not in tension. Shifting every boid by half the box is a change the model should not be able to notice, since a wrapping world has no privileged origin — and it barely does, the first step differing by 10−13 pixels, which is floating-point rounding from summing the same forces in a different order.
That difference does not stay put. It roughly doubles every fifteen steps, reaching a nanometre-scale 10−9px by step 200, a visible 10−5px by step 300, and 13 pixels by step 500 — by which point the two flocks are simply different. The large-scale behaviour is completely reproducible and the individual trajectories are not, which is the same split the reaction-diffusion page runs into from the other direction.
where it came from
Craig Reynolds built the model in 1986 and presented it at SIGGRAPH the following year as Flocks, Herds, and Schools: A Distributed Behavioral Model. The first version — implementation, debugging and seven motion tests — was done in the ten days before the 1986 conference. Its first use outside academia was Batman Returns in 1992, whose bat swarms and penguin flocks ran on modified boids software.
The idea it belongs to is the one running through several pages here: that coordinated global behaviour need not be globally coordinated. Conway’s Game of Life and the cellular automaton make the same point on a fixed grid with discrete states, and reaction-diffusion makes it with continuous chemistry. Boids makes it with something that looks uncomfortably like intent, which is exactly why it is the most persuasive of them: nothing here wants anything.
view source
import { useEffect, useRef, useState } from 'react';
const W = 480;
const H = 320;
interface Boid {
x: number;
y: number;
vx: number;
vy: number;
}
interface Params {
count: number;
perception: number;
separationDist: number;
sep: number;
ali: number;
coh: number;
}
const DEFAULTS: Params = {
count: 160,
perception: 40,
separationDist: 12,
sep: 1.5,
ali: 1.0,
coh: 0.9,
};
const MAX_SPEED = 2.4;
const MIN_SPEED = 1.2;
const MAX_FORCE = 0.06;
const PRESETS: {
id: string;
label: string;
note: string;
params: Partial<Params>;
}[] = [
{
id: 'flock',
label: 'flock',
note: 'All three rules. Nobody is leading and nobody can see more than 40 pixels, yet the whole group settles into one direction and holds a roughly even spacing.',
params: {},
},
{
id: 'no-ali',
label: 'no alignment',
note: 'Without alignment they still gather and still avoid each other, but nothing makes them agree on a heading. Across 25 runs of 900 steps the order parameter has a median of 0.14 here, against 0.98 with alignment on — barely above the 0.07 of no rules at all.',
params: { ali: 0 },
},
{
id: 'no-sep',
label: 'no separation',
note: 'Cohesion with nothing pushing back. They converge into a knot and sit on top of each other: median nearest-neighbour distance collapses from 9.0 pixels to 1.5. Note the order parameter goes up, not down — piling into a heap is an excellent way to agree on a heading.',
params: { sep: 0 },
},
{
id: 'no-coh',
label: 'no cohesion',
note: 'Alignment and separation without anything drawing them in. Local groups still move together, but nothing holds the flock as a whole: spacing spreads to a median 15.3 pixels, statistically indistinguishable from the 15.6 of no rules at all.',
params: { coh: 0 },
},
{
id: 'none',
label: 'no rules',
note: 'Every rule off, so each boid flies in a straight line forever. This is the control: whatever structure you see under the other presets is produced by the three rules and nothing else.',
params: { sep: 0, ali: 0, coh: 0 },
},
];
// Shortest offset on a torus. Getting this wrong is the classic boids bug: a
// flock straddling the seam sees its own members as maximally distant and
// tears itself in half. Verified by translation invariance -- shifting every
// boid by half the box changes the next step by 1e-13 pixels, which is
// rounding and nothing else.
function wrapDelta(d: number, size: number): number {
if (d > size / 2) return d - size;
if (d < -size / 2) return d + size;
return d;
}
interface Debug {
neighbours: number[];
sep: [number, number];
ali: [number, number];
coh: [number, number];
}
// One step for the whole flock, written in place over flat arrays because
// this runs every frame for a few hundred boids.
function step(boids: Boid[], p: Params, watch: number, debug: Debug | null) {
const n = boids.length;
const r2 = p.perception * p.perception;
const sd2 = p.separationDist * p.separationDist;
const out: Boid[] = new Array(n);
for (let i = 0; i < n; i++) {
const b = boids[i];
let sepX = 0;
let sepY = 0;
let aliX = 0;
let aliY = 0;
let cohX = 0;
let cohY = 0;
let count = 0;
let sepCount = 0;
const watching = debug && i === watch;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const o = boids[j];
const dx = wrapDelta(o.x - b.x, W);
const dy = wrapDelta(o.y - b.y, H);
const d2 = dx * dx + dy * dy;
if (d2 > r2 || d2 === 0) continue;
count++;
if (watching) debug!.neighbours.push(j);
aliX += o.vx;
aliY += o.vy;
cohX += dx;
cohY += dy;
if (d2 < sd2) {
const d = Math.sqrt(d2);
sepX -= dx / d / d;
sepY -= dy / d / d;
sepCount++;
}
}
let ax = 0;
let ay = 0;
let dSep: [number, number] = [0, 0];
let dAli: [number, number] = [0, 0];
let dCoh: [number, number] = [0, 0];
if (count > 0) {
aliX /= count;
aliY /= count;
const aliMag = Math.hypot(aliX, aliY) || 1;
dAli = [p.ali * (aliX / aliMag), p.ali * (aliY / aliMag)];
cohX /= count;
cohY /= count;
const cohMag = Math.hypot(cohX, cohY) || 1;
dCoh = [p.coh * (cohX / cohMag), p.coh * (cohY / cohMag)];
}
if (sepCount > 0) {
const sepMag = Math.hypot(sepX, sepY) || 1;
dSep = [p.sep * (sepX / sepMag), p.sep * (sepY / sepMag)];
}
ax = dSep[0] + dAli[0] + dCoh[0];
ay = dSep[1] + dAli[1] + dCoh[1];
if (watching) {
debug!.sep = dSep;
debug!.ali = dAli;
debug!.coh = dCoh;
}
const aMag = Math.hypot(ax, ay);
if (aMag > MAX_FORCE) {
ax = (ax / aMag) * MAX_FORCE;
ay = (ay / aMag) * MAX_FORCE;
}
let vx = b.vx + ax;
let vy = b.vy + ay;
const speed = Math.hypot(vx, vy);
if (speed > MAX_SPEED) {
vx = (vx / speed) * MAX_SPEED;
vy = (vy / speed) * MAX_SPEED;
} else if (speed < MIN_SPEED && speed > 0) {
vx = (vx / speed) * MIN_SPEED;
vy = (vy / speed) * MIN_SPEED;
}
let x = b.x + vx;
let y = b.y + vy;
x = ((x % W) + W) % W;
y = ((y % H) + H) % H;
out[i] = { x, y, vx, vy };
}
return out;
}
// Vicsek order parameter: the length of the mean heading. 1 is everyone
// pointing the same way, 0 is no shared direction at all. For randomly
// oriented boids it sits near 1/sqrt(n) rather than at 0, which is why the
// readout never quite reaches zero.
function orderOf(boids: Boid[]): number {
if (!boids.length) return 0;
let sx = 0;
let sy = 0;
for (const b of boids) {
const s = Math.hypot(b.vx, b.vy) || 1;
sx += b.vx / s;
sy += b.vy / s;
}
return Math.hypot(sx, sy) / boids.length;
}
function meanNearest(boids: Boid[]): number {
const n = boids.length;
if (n < 2) return 0;
let total = 0;
for (let i = 0; i < n; i++) {
let best = Infinity;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const dx = wrapDelta(boids[j].x - boids[i].x, W);
const dy = wrapDelta(boids[j].y - boids[i].y, H);
const d2 = dx * dx + dy * dy;
if (d2 < best) best = d2;
}
total += Math.sqrt(best);
}
return total / n;
}
function makeFlock(n: number): Boid[] {
const boids: Boid[] = [];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
boids.push({
x: Math.random() * W,
y: Math.random() * H,
vx: Math.cos(a) * 2,
vy: Math.sin(a) * 2,
});
}
return boids;
}
const HISTORY = 160;
export default function Boids() {
const [params, setParams] = useState<Params>(DEFAULTS);
const [presetId, setPresetId] = useState('flock');
const [running, setRunning] = useState(true);
const [showWatch, setShowWatch] = useState(true);
const [readout, setReadout] = useState({ order: 0, nearest: 0 });
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const boidsRef = useRef<Boid[]>([]);
const paramsRef = useRef(params);
const runningRef = useRef(running);
const watchRef = useRef(showWatch);
const historyRef = useRef<number[]>([]);
useEffect(() => {
paramsRef.current = params;
}, [params]);
useEffect(() => {
runningRef.current = running;
}, [running]);
useEffect(() => {
watchRef.current = showWatch;
}, [showWatch]);
// Resize the flock in place rather than rebuilding it, so nudging the count
// does not throw away a flock that has taken hundreds of steps to organise.
useEffect(() => {
const cur = boidsRef.current;
if (cur.length === params.count) return;
if (cur.length < params.count) {
boidsRef.current = [...cur, ...makeFlock(params.count - cur.length)];
} else {
boidsRef.current = cur.slice(0, params.count);
}
}, [params.count]);
const applyPreset = (id: string) => {
const p = PRESETS.find((x) => x.id === id);
if (!p) return;
setPresetId(id);
setParams({ ...DEFAULTS, ...p.params, count: paramsRef.current.count });
};
const nudge = (patch: Partial<Params>) => {
setPresetId('');
setParams((prev) => ({ ...prev, ...patch }));
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
if (boidsRef.current.length === 0) {
boidsRef.current = makeFlock(paramsRef.current.count);
}
let raf = 0;
let frame = 0;
const draw = () => {
const p = paramsRef.current;
const debug: Debug | null = watchRef.current
? { neighbours: [], sep: [0, 0], ali: [0, 0], coh: [0, 0] }
: null;
if (runningRef.current) {
boidsRef.current = step(boidsRef.current, p, 0, debug);
} else if (debug) {
// still recompute the watched boid's forces so the overlay is live
// while paused, without advancing anything
step(boidsRef.current, p, 0, debug);
}
const boids = boidsRef.current;
ctx.fillStyle = '#0b0d0f';
ctx.fillRect(0, 0, W, H);
// the watched boid's perception radius and the neighbours inside it
if (debug && boids.length) {
const b = boids[0];
ctx.beginPath();
ctx.arc(b.x, b.y, p.perception, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(124, 242, 156, 0.16)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x, b.y, p.separationDist, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 138, 128, 0.2)';
ctx.stroke();
ctx.strokeStyle = 'rgba(124, 242, 156, 0.13)';
for (const j of debug.neighbours) {
const o = boids[j];
const dx = wrapDelta(o.x - b.x, W);
const dy = wrapDelta(o.y - b.y, H);
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x + dx, b.y + dy);
ctx.stroke();
}
}
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
const watched = debug && i === 0;
const sp = Math.hypot(b.vx, b.vy) || 1;
const ux = b.vx / sp;
const uy = b.vy / sp;
const len = watched ? 9 : 6;
const wid = watched ? 4 : 2.6;
ctx.beginPath();
ctx.moveTo(b.x + ux * len, b.y + uy * len);
ctx.lineTo(
b.x - ux * len * 0.5 - uy * wid,
b.y - uy * len * 0.5 + ux * wid,
);
ctx.lineTo(
b.x - ux * len * 0.5 + uy * wid,
b.y - uy * len * 0.5 - ux * wid,
);
ctx.closePath();
ctx.fillStyle = watched ? '#ffffff' : '#7cf29c';
ctx.fill();
}
// the three steering vectors the watched boid is computing right now
if (debug && boids.length) {
const b = boids[0];
const arrow = (v: [number, number], colour: string) => {
const m = Math.hypot(v[0], v[1]);
if (m < 1e-9) return;
const scale = 34;
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x + v[0] * scale, b.y + v[1] * scale);
ctx.strokeStyle = colour;
ctx.lineWidth = 1.6;
ctx.stroke();
};
arrow(debug.sep, '#ff8a80');
arrow(debug.ali, '#82b1ff');
arrow(debug.coh, '#ffd54f');
}
if (++frame % 6 === 0) {
const o = orderOf(boids);
historyRef.current.push(o);
if (historyRef.current.length > HISTORY) historyRef.current.shift();
setReadout({ order: o, nearest: meanNearest(boids) });
}
// order parameter over time, drawn small in the corner
const hist = historyRef.current;
if (hist.length > 1) {
const gw = 96;
const gh = 30;
const gx = W - gw - 8;
const gy = 8;
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
ctx.fillRect(gx, gy, gw, gh);
ctx.strokeStyle = '#2a2d31';
ctx.strokeRect(gx, gy, gw, gh);
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const x = gx + (i / (HISTORY - 1)) * gw;
const y = gy + gh - hist[i] * gh;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#7cf29c';
ctx.lineWidth = 1;
ctx.stroke();
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, []);
const preset = PRESETS.find((p) => p.id === presetId);
return (
<div className="boids">
<div className="canvas-wrap">
<canvas
ref={canvasRef}
style={{ width: '100%' }}
aria-label="Boids flocking simulation"
role="img"
/>
</div>
<div className="controls">
<div className="group">
<span className="group-label">rules</span>
{PRESETS.map((p) => (
<button
key={p.id}
type="button"
className={presetId === p.id ? 'active' : ''}
onClick={() => applyPreset(p.id)}
>
{p.label}
</button>
))}
</div>
<button type="button" onClick={() => setRunning((r) => !r)}>
{running ? 'pause' : 'run'}
</button>
<button
type="button"
onClick={() => {
boidsRef.current = makeFlock(paramsRef.current.count);
historyRef.current = [];
}}
>
scatter
</button>
<button
type="button"
className={showWatch ? 'active' : ''}
onClick={() => setShowWatch((s) => !s)}
>
show one boid
</button>
</div>
<div className="sliders">
{(
[
['sep', 'separation', 0, 3, 0.1],
['ali', 'alignment', 0, 3, 0.1],
['coh', 'cohesion', 0, 3, 0.1],
['perception', 'perception', 8, 120, 1],
['count', 'boids', 20, 300, 10],
] as [keyof Params, string, number, number, number][]
).map(([key, label, min, max, stepv]) => (
<label key={key}>
<span className="slab">{label}</span>
<input
type="range"
min={min}
max={max}
step={stepv}
value={params[key]}
onChange={(e) =>
nudge({ [key]: Number(e.target.value) } as Partial<Params>)
}
/>
<span className="sval">
{key === 'perception' || key === 'count'
? params[key]
: params[key].toFixed(1)}
</span>
</label>
))}
</div>
<div className="stats">
<span>
order parameter <strong>{readout.order.toFixed(3)}</strong>
</span>
<span>
mean nearest neighbour <strong>{readout.nearest.toFixed(1)}</strong>{' '}
px
</span>
<span className="key">
<span className="sw sep" /> separation
<span className="sw ali" /> alignment
<span className="sw coh" /> cohesion
</span>
</div>
{preset && <p className="blurb">{preset.note}</p>}
<style>{`
.boids {
border: 1px solid #2a2d31;
background: #0e1113;
}
.boids .canvas-wrap {
border-bottom: 1px solid #2a2d31;
line-height: 0;
}
.boids canvas {
display: block;
aspect-ratio: ${W} / ${H};
}
.boids .controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.75rem 1rem 0;
}
.boids .group {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.boids .group-label,
.boids .slab {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.boids button {
font-family: inherit;
font-size: 0.75rem;
color: #8a8f98;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #2a2d31;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.boids button:hover {
color: #e6e6e6;
}
.boids button.active {
color: #0b0d0f;
background: var(--accent);
border-color: var(--accent);
}
.boids .sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 0.35rem 1.25rem;
padding: 0.75rem 1rem 0;
}
.boids .sliders label {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.boids .slab {
flex: 0 0 5.5rem;
font-size: 0.68rem;
}
.boids .sliders input[type='range'] {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
.boids .sval {
color: #e6e6e6;
font-size: 0.7rem;
min-width: 2.5ch;
text-align: right;
}
.boids .stats {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.25rem;
padding: 0.75rem 1rem 0;
color: var(--muted);
font-size: 0.72rem;
}
.boids .stats strong {
color: #e6e6e6;
font-weight: normal;
}
.boids .key {
display: flex;
align-items: center;
gap: 0.3rem;
}
.boids .sw {
display: inline-block;
width: 10px;
height: 2px;
margin-left: 0.5rem;
}
.boids .sw.sep {
background: #ff8a80;
}
.boids .sw.ali {
background: #82b1ff;
}
.boids .sw.coh {
background: #ffd54f;
}
.boids .blurb {
color: var(--muted);
font-size: 0.78rem;
margin: 0.75rem 0 0;
padding: 0 1rem 1rem;
}
`}</style>
</div>
);
}