Boids
Flocking from three steering forces.
Intermediate / particles / steering / emergence
Each boid follows three local rules: separate from neighbors that are too close, align with their average heading, and cohere toward their center of mass. No bird sees the flock, yet the flock appears. Tuning the three weights moves the system between gas, swarm, and vortex.
Tagged: emergence, steering, flocking, spatial hashing
Live simulation
Hold the pointer in the field to scatter the flock. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Hold the pointer in the field to scatter the flock.
Mathematics
Each boid steers by three local rules weighted and summed into one acceleration: separation from crowding, alignment with local heading, and cohesion toward the local center of mass.
a_i = w_s·s_i + w_a·a_i + w_c·c_i
order: φ = | Σ v_i | / (N · v_max), 0 ≤ φ ≤ 1
neighbor query: all j with |r_j − r_i| ≤ r_vision
The order parameter φ is the standard flocking metric: 1 means every boid flies in the same direction, near 0 means a milling swarm. Raising separation above alignment and cohesion breaks the flock into gas; the transition between regimes is sharp, which is why boids are studied as a phase transition.
How it works
Positions and velocities live in Float32Arrays. A uniform spatial hash with cell size equal to the vision radius reduces the naive O(N²) neighbor query to roughly O(N): each boid inspects only the 9 cells around it. Previous positions are stored each tick so the renderer can interpolate between fixed simulation steps at any refresh rate.
The pointer acts as a predator: while held, a short-range repulsion is added to nearby boids' steering. Drag through a flock and watch it reform from the same three rules.
Implementation
src/engine/experiments/boids.ts · 256 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { clamp, lerp } from "../core/theme"; |
| 3 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 4 | |
| 5 | const CAPACITY = 400; |
| 6 | |
| 7 | /** |
| 8 | * Reynolds boids with a spatial hash for neighbor queries. State lives in |
| 9 | * typed arrays; previous positions are kept so render can interpolate |
| 10 | * between fixed ticks at any display refresh rate. |
| 11 | */ |
| 12 | export class BoidsExperiment extends BaseExperiment { |
| 13 | readonly id = "boids"; |
| 14 | |
| 15 | private count = 0; |
| 16 | private x = new Float32Array(CAPACITY); |
| 17 | private y = new Float32Array(CAPACITY); |
| 18 | private vx = new Float32Array(CAPACITY); |
| 19 | private vy = new Float32Array(CAPACITY); |
| 20 | private px = new Float32Array(CAPACITY); |
| 21 | private py = new Float32Array(CAPACITY); |
| 22 | private orderSum = 0; |
| 23 | private neighborSum = 0; |
| 24 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 25 | |
| 26 | private head = new Int32Array(0); |
| 27 | private next = new Int32Array(CAPACITY); |
| 28 | private gridW = 0; |
| 29 | private gridH = 0; |
| 30 | |
| 31 | protected params(): ParameterDef[] { |
| 32 | return [ |
| 33 | { key: "population", label: "Population", min: 40, max: 400, step: 20, defaultValue: 220 }, |
| 34 | { key: "vision", label: "Vision radius", min: 20, max: 80, step: 2, defaultValue: 44, unit: "px" }, |
| 35 | { key: "separation", label: "Separation weight", min: 0, max: 3, step: 0.1, defaultValue: 1.6 }, |
| 36 | { key: "alignment", label: "Alignment weight", min: 0, max: 3, step: 0.1, defaultValue: 1 }, |
| 37 | { key: "cohesion", label: "Cohesion weight", min: 0, max: 3, step: 0.1, defaultValue: 0.9 }, |
| 38 | { key: "speed", label: "Cruise speed", min: 40, max: 240, step: 10, defaultValue: 110, unit: "px/s" }, |
| 39 | ]; |
| 40 | } |
| 41 | |
| 42 | protected onReset(): void { |
| 43 | this.count = this.num("population"); |
| 44 | for (let i = 0; i < this.count; i++) { |
| 45 | this.x[i] = this.rng() * this.width; |
| 46 | this.y[i] = this.rng() * this.height; |
| 47 | const angle = this.rng() * Math.PI * 2; |
| 48 | this.vx[i] = Math.cos(angle) * this.num("speed"); |
| 49 | this.vy[i] = Math.sin(angle) * this.num("speed"); |
| 50 | this.px[i] = this.x[i]; |
| 51 | this.py[i] = this.y[i]; |
| 52 | } |
| 53 | this.orderSum = 0; |
| 54 | this.neighborSum = 0; |
| 55 | } |
| 56 | |
| 57 | protected onParameterChange(key: string): void { |
| 58 | if (key === "population") this.reset(); |
| 59 | } |
| 60 | |
| 61 | resize(width: number, height: number): void { |
| 62 | this.width = width; |
| 63 | this.height = height; |
| 64 | this.reset(); |
| 65 | } |
| 66 | |
| 67 | onPointer(state: PointerState): void { |
| 68 | this.pointer = state; |
| 69 | } |
| 70 | |
| 71 | private rebuildHash(): void { |
| 72 | const cell = Math.max(8, this.num("vision")); |
| 73 | this.gridW = Math.max(1, Math.ceil(this.width / cell)); |
| 74 | this.gridH = Math.max(1, Math.ceil(this.height / cell)); |
| 75 | const cells = this.gridW * this.gridH; |
| 76 | if (this.head.length !== cells) this.head = new Int32Array(cells); |
| 77 | this.head.fill(-1); |
| 78 | for (let i = 0; i < this.count; i++) { |
| 79 | const gx = Math.min(this.gridW - 1, Math.max(0, (this.x[i] / cell) | 0)); |
| 80 | const gy = Math.min(this.gridH - 1, Math.max(0, (this.y[i] / cell) | 0)); |
| 81 | const c = gy * this.gridW + gx; |
| 82 | this.next[i] = this.head[c]; |
| 83 | this.head[c] = i; |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | protected onUpdate(dt: number): void { |
| 88 | const vision = this.num("vision"); |
| 89 | const vision2 = vision * vision; |
| 90 | const sepRadius = vision * 0.45; |
| 91 | const sep2 = sepRadius * sepRadius; |
| 92 | const maxSpeed = this.num("speed"); |
| 93 | const maxForce = maxSpeed * 2.4; // px/s^2 |
| 94 | const cell = Math.max(8, vision); |
| 95 | this.rebuildHash(); |
| 96 | |
| 97 | const sepW = this.num("separation"); |
| 98 | const aliW = this.num("alignment"); |
| 99 | const cohW = this.num("cohesion"); |
| 100 | let orderX = 0; |
| 101 | let orderY = 0; |
| 102 | let neighborTotal = 0; |
| 103 | |
| 104 | for (let i = 0; i < this.count; i++) { |
| 105 | const gx = Math.min(this.gridW - 1, Math.max(0, (this.x[i] / cell) | 0)); |
| 106 | const gy = Math.min(this.gridH - 1, Math.max(0, (this.y[i] / cell) | 0)); |
| 107 | let sepX = 0; |
| 108 | let sepY = 0; |
| 109 | let aliX = 0; |
| 110 | let aliY = 0; |
| 111 | let cohX = 0; |
| 112 | let cohY = 0; |
| 113 | let neighbors = 0; |
| 114 | |
| 115 | for (let oy = -1; oy <= 1; oy++) { |
| 116 | const ny = gy + oy; |
| 117 | if (ny < 0 || ny >= this.gridH) continue; |
| 118 | for (let ox = -1; ox <= 1; ox++) { |
| 119 | const nx = gx + ox; |
| 120 | if (nx < 0 || nx >= this.gridW) continue; |
| 121 | for (let j = this.head[ny * this.gridW + nx]; j !== -1; j = this.next[j]) { |
| 122 | if (j === i) continue; |
| 123 | const dx = this.x[j] - this.x[i]; |
| 124 | const dy = this.y[j] - this.y[i]; |
| 125 | const d2 = dx * dx + dy * dy; |
| 126 | if (d2 > vision2) continue; |
| 127 | neighbors += 1; |
| 128 | aliX += this.vx[j]; |
| 129 | aliY += this.vy[j]; |
| 130 | cohX += dx; |
| 131 | cohY += dy; |
| 132 | if (d2 < sep2 && d2 > 1e-6) { |
| 133 | const inv = 1 / d2; |
| 134 | sepX -= dx * inv; |
| 135 | sepY -= dy * inv; |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | let ax = 0; |
| 142 | let ay = 0; |
| 143 | if (neighbors > 0) { |
| 144 | // Reynolds steering: desired direction minus current velocity. |
| 145 | const invN = 1 / neighbors; |
| 146 | if (aliW > 0) { |
| 147 | const scale = maxSpeed / (Math.hypot(aliX, aliY) || 1); |
| 148 | ax += aliW * (aliX * scale * invN - this.vx[i]); |
| 149 | ay += aliW * (aliY * scale * invN - this.vy[i]); |
| 150 | } |
| 151 | if (cohW > 0) { |
| 152 | const cx = this.x[i] + cohX * invN; |
| 153 | const cy = this.y[i] + cohY * invN; |
| 154 | const toX = cx - this.x[i]; |
| 155 | const toY = cy - this.y[i]; |
| 156 | const scale = maxSpeed / (Math.hypot(toX, toY) || 1); |
| 157 | ax += cohW * (toX * scale - this.vx[i]); |
| 158 | ay += cohW * (toY * scale - this.vy[i]); |
| 159 | } |
| 160 | if (sepW > 0) { |
| 161 | const scale = maxSpeed / (Math.hypot(sepX, sepY) || 1); |
| 162 | ax += sepW * (sepX * scale - this.vx[i]) * 1.5; |
| 163 | ay += sepW * (sepY * scale - this.vy[i]) * 1.5; |
| 164 | } |
| 165 | neighborTotal += neighbors; |
| 166 | } |
| 167 | |
| 168 | // Pointer acts as a predator: strong short-range repulsion. |
| 169 | if (this.pointer.inside) { |
| 170 | const dx = this.x[i] - this.pointer.x; |
| 171 | const dy = this.y[i] - this.pointer.y; |
| 172 | const d2 = dx * dx + dy * dy; |
| 173 | const radius = 120; |
| 174 | if (d2 < radius * radius && d2 > 1e-6) { |
| 175 | const falloff = 1 - d2 / (radius * radius); |
| 176 | const inv = (maxSpeed * 6 * falloff) / Math.sqrt(d2); |
| 177 | ax += dx * inv; |
| 178 | ay += dy * inv; |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | const mag = Math.hypot(ax, ay); |
| 183 | if (mag > maxForce) { |
| 184 | ax = (ax / mag) * maxForce; |
| 185 | ay = (ay / mag) * maxForce; |
| 186 | } |
| 187 | |
| 188 | this.px[i] = this.x[i]; |
| 189 | this.py[i] = this.y[i]; |
| 190 | let nvx = this.vx[i] + ax * dt; |
| 191 | let nvy = this.vy[i] + ay * dt; |
| 192 | const speed = Math.hypot(nvx, nvy); |
| 193 | if (speed > maxSpeed) { |
| 194 | nvx = (nvx / speed) * maxSpeed; |
| 195 | nvy = (nvy / speed) * maxSpeed; |
| 196 | } else if (speed < maxSpeed * 0.35 && speed > 1e-6) { |
| 197 | const boost = (maxSpeed * 0.35) / speed; |
| 198 | nvx *= boost; |
| 199 | nvy *= boost; |
| 200 | } |
| 201 | this.vx[i] = nvx; |
| 202 | this.vy[i] = nvy; |
| 203 | this.x[i] = (this.x[i] + nvx * dt + this.width) % this.width; |
| 204 | this.y[i] = (this.y[i] + nvy * dt + this.height) % this.height; |
| 205 | orderX += nvx; |
| 206 | orderY += nvy; |
| 207 | } |
| 208 | |
| 209 | this.orderSum = this.count > 0 ? Math.hypot(orderX, orderY) / (this.count * maxSpeed) : 0; |
| 210 | this.neighborSum = this.count > 0 ? neighborTotal / this.count : 0; |
| 211 | } |
| 212 | |
| 213 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors, alpha: number): void { |
| 214 | ctx.fillStyle = theme.bg; |
| 215 | ctx.fillRect(0, 0, this.width, this.height); |
| 216 | const trail = Math.max(2, this.num("speed") / 60); |
| 217 | for (let i = 0; i < this.count; i++) { |
| 218 | let ix = lerp(this.px[i], this.x[i], alpha); |
| 219 | let iy = lerp(this.py[i], this.y[i], alpha); |
| 220 | // Skip interpolation across a wrap seam. |
| 221 | if (Math.abs(ix - this.x[i]) > this.width / 2) ix = this.x[i]; |
| 222 | if (Math.abs(iy - this.y[i]) > this.height / 2) iy = this.y[i]; |
| 223 | const speed = Math.hypot(this.vx[i], this.vy[i]); |
| 224 | const dirX = speed > 0 ? this.vx[i] / speed : 1; |
| 225 | const dirY = speed > 0 ? this.vy[i] / speed : 0; |
| 226 | ctx.strokeStyle = i % 25 === 0 ? theme.accent : theme.fgSecondary; |
| 227 | ctx.lineWidth = i % 25 === 0 ? 1.5 : 1; |
| 228 | ctx.beginPath(); |
| 229 | ctx.moveTo(clamp(ix - dirX * trail, 0, this.width), clamp(iy - dirY * trail, 0, this.height)); |
| 230 | ctx.lineTo(clamp(ix + dirX * trail, 0, this.width), clamp(iy + dirY * trail, 0, this.height)); |
| 231 | ctx.stroke(); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | getMetrics() { |
| 236 | let speedSum = 0; |
| 237 | for (let i = 0; i < this.count; i++) speedSum += Math.hypot(this.vx[i], this.vy[i]); |
| 238 | return { |
| 239 | population: this.count, |
| 240 | meanSpeed: `${(speedSum / Math.max(1, this.count)).toFixed(0)} px/s`, |
| 241 | neighbors: this.neighborSum.toFixed(1), |
| 242 | order: this.orderSum.toFixed(3), |
| 243 | }; |
| 244 | } |
| 245 | |
| 246 | describe(): string { |
| 247 | const flock = |
| 248 | this.orderSum > 0.7 ? "a tightly aligned flock" : this.orderSum > 0.35 ? "a loose flock" : "a dispersed swarm"; |
| 249 | return `${this.count} boids form ${flock}; order parameter ${this.orderSum.toFixed(2)}.`; |
| 250 | } |
| 251 | |
| 252 | entities(): number { |
| 253 | return this.count; |
| 254 | } |
| 255 | } |
| 256 | |
Performance
- Time per tick
- ≈ O(N) per tick with the spatial hash (O(N²) naive)
- Space
- Typed arrays: 6 floats per boid plus hash links
- Notes
- At the default 220 boids the whole update runs in well under a millisecond; the performance overlay shows the real number.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.