Introduction
What is a boid?
In 1986, Craig Reynolds asked a simple question: can complex flocking behavior — the kind you see in starling murmurations or schools of fish — emerge from individuals following a few local rules, with no leader and no global plan?
He created simulated bird-like objects — "bird-oids", shortened to boids — and gave each one the same small program. No boid knows the shape of the flock. No boid can see all the others. Each one perceives only its nearby neighbors through a limited field of view, and reacts with three simple steering behaviors:
That's it. Three rules, applied locally through each boid's limited perception. Reynolds showed that this is sufficient to produce realistic flocking, schooling, and herding — no choreography required.
In the steps that follow, we'll build up from scratch: first giving a single boid awareness (Step 1), then avoidance (Step 2), then putting many boids together with all three rules (Step 3). After that, we'll explore how to sample directions for 3D (Steps 4–5) and build a full 3D boid tank (Steps 6–7).
Step 1 · Awareness
Awareness zone
The first thing we need to consider in swarm dynamics is the individual. Before a group can behave intelligently, each member needs to perceive its local environment. We start from one boid's point of view: what can it see, and how strongly does it register what's nearby?
Every boid carries a forward-facing cone of awareness — a limited field of view that extends ahead along its heading. Anything outside this cone is invisible to the boid, no matter how close. This is deliberate: real organisms don't have eyes in the back of their head, and restricting perception to a forward cone produces much more natural avoidance behavior than a full 360° awareness would.
When another particle drifts into the cone, the boid registers it with an intensity that depends purely on distance — touching the boid produces maximum signal, while a particle at the edge of the cone barely registers. This creates a smooth gradient of attention rather than a binary on/off.
Hover a variable to see what it means.
Describes whether a particle is visible — it must be close enough and inside the forward cone.
Hover a variable to see what it means.
Describes how urgent the detection is — closer produces stronger signal. Linear falloff.
Detection kernel
The core of awareness is a two-part test. First, is the other particle within range? Second, is it inside the forward cone? If both are true, we compute an intensity value that fades linearly from 1 (point-blank) to 0 (at the radius boundary). That intensity drives the red awareness line you see in the canvas — brighter means closer.
const dx = particle.x - boid.x; const dy = particle.y - boid.y; const dist = Math.sqrt(dx * dx + dy * dy); // Angle relative to boid's heading const angle = Math.atan2(dy, dx) - boid.heading; // Inside ±55° awareness cone? const FOV = 55 * Math.PI / 180; const inCone = dist < radius && Math.abs(angle) < FOV; // Closer → more opaque (stronger signal) const opacity = inCone ? 1 - dist / radius : 0;
Step 2 · Avoidance
Collision dodge
Now that our boid has awareness, we need to understand how it makes decisions. Seeing a nearby particle isn't useful unless it leads to action. This step gives the boid a simple rule: if something is in your cone, steer away from it.
The strength of the avoidance is proportional to proximity — a particle barely inside the cone produces a gentle nudge, while one headed straight at the boid at close range produces a sharp turn away. When multiple particles are detected simultaneously, their individual repulsive contributions are summed into a single steering vector in the plane.
Like the full tank (Step 3), this boid moves forward only at a fixed cruise speed: steering forces may change its heading (the direction of its velocity) but not its speed. To keep the scene readable, we confine it to the lower half of the tank (between mid-height and the bottom). Particles rain down from above within ±45° of head-on. When the threat clears, a gentle pull toward the bottom-center mimics cruising home — the same idea as the cohesion-style centering in the flock, simplified here to one point.
Hover a variable to see what drives avoidance.
Describes how the boid steers away from danger — each detected particle contributes a repulsive direction, scaled by proximity; the combined steering then updates heading at fixed cruise speed.
Avoidance kernel
For each detected particle, we compute a unit direction vector away from the threat,
scale it by how close the particle is, and accumulate into a 2D steering vector
(avoidX, avoidY). We add a small vector toward a “home” point at the
bottom center when we want the boid to drift back after threats pass. That steering defines a
desired heading; the boid does not snap to it. Instead we integrate
angular acceleration → angular velocity → heading each frame, then move
forward at fixed cruise speed. This is why turns are smooth and physically plausible even near
walls and dense crowds.
// Accumulate avoidance (full 2D) let avoidX = 0, avoidY = 0; for (const p of detected) { const strength = 1 - p.dist / radius; avoidX += -p.dx / p.dist * strength; avoidY += -p.dy / p.dist * strength; } // Steering + gentle home toward bottom center const desiredVx = boid.vx + avoidX * avoidanceForce + (homeX - boid.x) * pull; const desiredVy = boid.vy + avoidY * avoidanceForce + (homeY - boid.y) * pull; // Rotational dynamics (no instant heading snap) const desiredHeading = Math.atan2(desiredVy, desiredVx); const err = wrapAngle(desiredHeading - boid.heading); boid.turnVel = (boid.turnVel + err * turnAccel) * turnDamping; boid.turnVel = clamp(boid.turnVel, -maxTurnRate, maxTurnRate); boid.heading += boid.turnVel; // Forward-only propulsion at fixed speed boid.vx = Math.cos(boid.heading) * cruise; boid.vy = Math.sin(boid.heading) * cruise;
Step 3 · Mutual awareness
Boid tank
Steps 1 and 2 gave a single boid perception and reaction. Now we put that logic into every boid in the tank and let them loose. There is no central controller, no predefined paths, no collision grid — each individual applies the exact same awareness cone and steering forces from the previous steps (then keeps a fixed cruise speed, as in Step 2), considering only the neighbors it can see.
The red boids are "hero" agents — they show their awareness cones and detection lines so you can see the local perception at work. The white boids are running the exact same logic but without the visual debug overlay, giving you a sense of the population density without the visual clutter. Together they demonstrate a key insight from Reynolds' 1987 paper: complex, coordinated group behavior emerges from simple, local rules.
Try cranking up the boid count and shrinking the awareness radius — you'll see them pack tighter and react later, producing more chaotic, last-second dodges. A larger radius produces smoother, earlier avoidance.
Move the pointer over the tank — boids treat your cursor as a moving obstacle. Outer walls are obstacles too, so boids turn away when a wall enters their cone.
Step 4 · Sampling
Fibonacci disc
Before a boid can check for collisions in 3D, it needs to know which directions to look. We need a way to distribute sample points evenly — and the tool for that is a deceptively simple loop: place each successive point at a fixed angular offset from the last, spiraling outward from the center.
The key is the turn fraction — how much of a full circle each point rotates relative to the previous one. At 0, all points pile on a single line. At 0.5, they alternate between two arms. But at 1/φ (where φ is the golden ratio), something remarkable happens: no two points ever land near each other, producing the most uniform distribution possible from a sequential process.
This works because the golden ratio is the "most irrational" number — its continued-fraction representation converges more slowly than any other irrational, so the angular gaps never repeat or cluster. Try dragging the turn fraction slider toward 0.618 and watch the pattern emerge.
Hover a variable to see what it means.
Describes where each sample point lands — the distance spirals outward while the angle advances by a fixed fraction of a full turn.
Hover a variable to see what it means.
Describes the ideal turn fraction — the reciprocal of the golden ratio produces the most uniform angular spread possible.
Golden ratio
The golden ratio φ ≈ 1.618 appears everywhere in nature — sunflower heads, pinecone scales, nautilus shells. When you use its reciprocal (1/φ ≈ 0.618) as the turn fraction, successive points never align into a small number of arms. Enable "Highlight Fibonacci arms" to see every 34th point (amber) and every 55th point (pink) — these are consecutive Fibonacci numbers, and they trace the visible spiral arms.
// Golden ratio turn fraction const PHI = (1 + Math.sqrt(5)) / 2; const GOLDEN_FRAC = 1 / PHI; for (let i = 0; i < N; i++) { const r = i / (N - 1); const angle = i * GOLDEN_FRAC * TWO_PI; points.push({ x: r * Math.cos(angle), y: r * Math.sin(angle), }); }
Step 5 · Projection
Disc → sphere
The Fibonacci disc gives us a beautiful flat distribution, but boids live in 3D — they need sample directions spread across a sphere. The problem: if we naively map our disc points onto a sphere, they bunch up at the poles.
The fix is a power correction on the radial distance. At the default exponent of 1.0, points cluster. At 0.5 (square root), the mapping becomes equal-area and points spread uniformly across the sphere surface. Drag the exponent slider to see the transition — and drag on the sphere itself to orbit around it.
These sphere points will become the boid's collision ray directions. One subtlety: perfectly uniform distribution isn't necessarily optimal. A boid looking straight ahead doesn't need many tightly-packed samples in a narrow forward cone — it's too wide to fit through tiny gaps. In practice, sparser forward sampling with denser peripheral coverage works better.
Hover a variable to see what it means.
Describes how radial distance is remapped — the exponent controls bunching. At p = 0.5, the mapping produces uniform sphere coverage.
Hover a variable to see what it means.
Describes how a disc radius becomes a polar angle on the sphere — this arccos mapping turns flat distance into latitude.
Drag to orbit
Sphere mapping kernel
The arccos mapping converts flat radial distance into a polar angle on the sphere.
The power exponent controls distribution — at 0.5, coverage is uniform.
// Power-corrected radial distance const t = i / (N - 1); const r = Math.pow(t, power); const angle = i * turnFrac * TWO_PI; // Disc radius → polar angle const theta = Math.acos(1 - 2 * r); const phi = angle;
Convert spherical coordinates into a unit direction vector — this becomes one of the boid's collision sample rays.
// Spherical → Cartesian direction const dir = { x: Math.sin(theta) * Math.cos(phi), y: Math.sin(theta) * Math.sin(phi), z: Math.cos(theta), };
Step 6 · 3D avoidance
3D collision avoidance
Now the Fibonacci sphere directions become collision rays. The boid cycles through its precomputed sample directions and steers toward the first one that isn't blocked by an obstacle.
The critical difference from 2D: dodging is not a lateral strafe. The boid rotates its entire body toward the clear direction, which also rotates its visual field. This means the set of visible obstacles changes as the boid turns — it's navigating with a moving camera, not a fixed one.
Each sample direction is tested with a simple ray-sphere intersection. The boid picks the first unobstructed ray (biased away from dead-ahead to prefer evasive maneuvers over braking), then smoothly slerps its heading toward that direction.
Hover a variable to see what it means.
Describes the 3D awareness test — the dot product checks if a neighbor is within the forward cone, analogous to the 2D angle test.
Ray sampling kernel
Each Fibonacci sample direction is transformed from the boid's local frame into world space using its current orientation (right/up/forward basis).
// Local → world transform const worldDir = v3Norm(v3Add( v3Add(v3Scale(right, s.x), v3Scale(up, s.y)), v3Scale(forward, s.z) ));
The ray is tested against obstacles and boundary walls. First clear direction wins — the boid turns toward that direction through angular velocity dynamics (acceleration + damping), then keeps moving forward.
// Ray-sphere intersection test for (const obs of obstacles) { const proj = v3Dot(toObs, worldDir); const perp = v3Sub(toObs, v3Scale(worldDir, proj)); if (v3Len(perp) < obs.size) blocked = true; } // Steering target from first clear ray desiredDir = bestDir; // Angular acceleration -> angular velocity -> forward rotation axis = normalize(cross(boid.forward, desiredDir)); angleErr = atan2(length(cross(boid.forward, desiredDir)), dot(boid.forward, desiredDir)); boid.angVel = damp(boid.angVel + axis * angleErr * turnAccel); boid.forward = rotate(boid.forward, boid.angVel);
Step 7 · 3D tank
3D boid tank
Everything comes together. Each boid carries the same Fibonacci-sampled collision rays from Step 5, the same rotation-based steering from Step 6, and is released into a shared 3D space with no central controller.
The red boids show their sample rays and awareness interactions. The white boids run identical logic but without the debug overlay. A soft spherical boundary keeps everyone from drifting off into the void — when a boid approaches the edge, it receives a gentle nudge back toward center.
This is Reynolds' 1987 insight, fully realized in 3D: complex, coordinated group behavior from nothing more than local perception and a handful of simple rules. No pathfinding graph, no collision grid, no central planner.
N-body avoidance kernel
Each boid treats every other boid as an obstacle, running the same ray-sampling avoidance from Step 7. The first unblocked sample direction becomes the separation target; cohesion/alignment add to that target direction; then one rotational-dynamics step updates heading smoothly.
// N-body ray check for (const s of boid.sampleDirs) { const wd = localToWorld(s, boid); let clear = true; for (const other of allBoids) { if (other === boid) continue; if (rayHits(boid.pos, wd, other)) { clear = false; break; } } if (clear) { bestDir = wd; break; } } let desiredDir = boid.forward + bestDir * avoidW + cohDir * cohesionW + avgDir * alignW + wallPush; steerToward3D(boid, desiredDir);
Walls are obstacles too — each ray checks if it exits the rectangular boundary within sensing range. The boid sees the tank and turns away on its own.
// Ray-wall intersection per axis for (let a = 0; a < 3; a++) { const tPlus = (wall[a] - pos[a]) / dir[a]; const tMinus = (-wall[a] - pos[a]) / dir[a]; const tWall = Math.min( tPlus > 0 ? tPlus : Infinity, tMinus > 0 ? tMinus : Infinity); if (tWall < range) blocked = true; }