The Promise: Intelligence From Nothing
Before I understood Boids, I assumed realistic flocking must involve complex path-planning: birds communicating, building consensus, taking turns leading. The reality is almost offensive in its simplicity. Every bird knows exactly three things:
That's it. No communication, no leader, no global state. Each agent samples a local neighbourhood radius and applies weighted forces. The emergent result looks uncannily alive.
The Naive Algorithm — O(n²) Neighbour Search
My first implementation was simple and correct: for every boid, loop over every other boid and check the distance. For N boids that's N × (N−1) / 2 comparisons per frame.
function updateBoid(i, boids) {
let sep = new THREE.Vector3();
let ali = new THREE.Vector3();
let coh = new THREE.Vector3();
let count = 0;
for (let j = 0; j < boids.length; j++) {
if (i === j) continue;
const d = boids[i].pos.distanceTo(boids[j].pos);
if (d < PERCEPTION_RADIUS) {
// Separation: push away from close neighbours
const diff = boids[i].pos.clone().sub(boids[j].pos).divideScalar(d * d);
sep.add(diff);
// Alignment: average velocity
ali.add(boids[j].vel);
// Cohesion: track centre of mass
coh.add(boids[j].pos);
count++;
}
}
if (count > 0) {
ali.divideScalar(count).normalize().multiplyScalar(MAX_SPEED);
coh.divideScalar(count).sub(boids[i].pos).normalize().multiplyScalar(MAX_SPEED);
}
// Weighted sum → acceleration
boids[i].acc
.add(sep.multiplyScalar(W_SEP))
.add(ali.multiplyScalar(W_ALI))
.add(coh.multiplyScalar(W_COH));
}
Works great at 200 boids. At 2,000 it struggles. At 10,000 the browser tab freezes — 100 million distance checks per frame.
The Fix: Spatial Hash Grid
The key insight: a boid only cares about neighbours within
PERCEPTION_RADIUS. We can bucket boids into a 3D grid of
cells sized
PERCEPTION_RADIUS × PERCEPTION_RADIUS × PERCEPTION_RADIUS. Then for each boid, we only check the 3×3×3 = 27 surrounding cells
instead of all N boids.
const CELL = PERCEPTION_RADIUS; // cell size = perception radius
function cellKey(x, y, z) {
// Discretise world position to cell index
const cx = Math.floor(x / CELL);
const cy = Math.floor(y / CELL);
const cz = Math.floor(z / CELL);
return `${cx},${cy},${cz}`;
}
// Build hash each frame
const grid = new Map();
for (const b of boids) {
const key = cellKey(b.pos.x, b.pos.y, b.pos.z);
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(b);
}
// Then in updateBoid: only check 27 neighbours cells
function neighbourCells(pos) {
const cx = Math.floor(pos.x / CELL);
const cy = Math.floor(pos.y / CELL);
const cz = Math.floor(pos.z / CELL);
const cells = [];
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
for (let dz = -1; dz <= 1; dz++)
cells.push(`${cx+dx},${cy+dy},${cz+dz}`);
return cells;
}
| Boid count | Naïve O(n²) | Spatial hash |
|---|---|---|
| 500 | 60 FPS | 60 FPS |
| 2,000 | 22 FPS | 60 FPS |
| 10,000 | <1 FPS | 58 FPS |
Rendering: Cones, Not Spheres
Each boid is a ConeGeometry — pointed in the direction of
flight — rendered via InstancedMesh. Every frame I update
the instance matrix to face the velocity vector using
quaternion.setFromUnitVectors.
const UP = new THREE.Vector3(0, 1, 0); // cone default orientation
const dir = new THREE.Vector3();
const q = new THREE.Quaternion();
for (let i = 0; i < boids.length; i++) {
dir.copy(boids[i].vel).normalize();
q.setFromUnitVectors(UP, dir);
dummy.position.copy(boids[i].pos);
dummy.quaternion.copy(q);
dummy.scale.setScalar(1);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
The speed trick: allocate the InstancedMesh at the
maximum count once and never resize it. Updating matrices is fast (a
Float32Array copy). Creating and destroying GPU buffers each frame is
slow.
Tuning the Weights — Where the Magic Is
The three rule weights control the character of the flock completely. A few examples from my experiments:
- High separation, low cohesion → birds scatter into loose ribbons, like starlings dispersing after a hawk
- High alignment, low separation → birds lock into arrow formations, almost military in precision
- High cohesion, low alignment → birds cluster into tight swirling balls — more like schooling fish than birds
- Balanced (1 : 1.2 : 0.9) → the "default murmuration" feel — rippling waves of direction change through a dense cloud
The UI sliders in the simulation let you explore this parameter space live. It's surprisingly addictive — you can spend an hour just adjusting weights and watching the flock personality change.
Adding a Predator
The most dramatic extension is a 4th rule: flee from predator. A single hawk mesh circles the flock; any boid within a larger "danger radius" adds a strong repulsion vector. The whole murmuration splits, reforms, and churns — exactly like real starling murmurations reacting to a peregrine falcon.
// Rule 4: flee predator
const threat = boid.pos.distanceTo(hawk.pos);
if (threat < FLEE_RADIUS) {
const flee = boid.pos.clone().sub(hawk.pos)
.normalize()
.multiplyScalar(MAX_SPEED * 2.0); // panic — double speed
boid.acc.add(flee.multiplyScalar(W_FLEE));
}
The Boids flocking simulation is live at /birds-flock/ (scenic mode with a landscape) and /boids/ (parameter explorer with adjustable weights and predator toggle). The deep-dive algorithm article is at Boids Algorithm — How Flocking Emerges From Three Rules.
What I Learnt
Boids was the project that made me fall in love with emergent systems. Before it, I thought simulations needed complex logic to look complex. After it, I started looking at every natural phenomenon — ant trails, traffic flow, neuron firing — as a potential set of three simple local rules waiting to be discovered.
The spatial hash pattern also became a template I re-use everywhere: SPH fluid, molecular dynamics, the collision broadphase for the billiard simulation. Anytime you have many agents interacting only with local neighbours, the spatial hash is the first tool to reach for.