Boids: 3 Rules, Emergent Behaviour & 10,000-Bird Skies

Craig Reynolds published the Boids model in 1987. Three steering forces — separation, alignment, cohesion — are all it takes to fill the sky with a convincing murmuration. Here's how I translated those three lines of pseudo-code into a real-time 10,000-agent simulation running at 60 FPS in the browser.

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:

🚫 Separation Steer away from neighbours that are too close — don't collide.
🧭 Alignment Steer towards the average heading of nearby flock-mates.
🎯 Cohesion Steer towards the centre of mass of nearby flock-mates.

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:

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.