Overview
Boids is Craig Reynolds' flocking model from 1987. Each agent looks at its neighbors and follows three rules: don't crowd them, match their heading, drift toward the middle of the group. That's it. There's no leader, no path, no coordinator — and yet you get flocks that split around obstacles, re-merge, and generally move like they have somewhere to be.
I keep reaching for it in my Godot projects, because it's the cheapest "this world is alive" effect I know. Any time I need a crowd to move like it has an opinion — bugs swarming, ambient critters, groups of enemies that shouldn't stack into a single pixel — it's some flavor of these three rules with the weights re-tuned. The version below is the same three rules, live.
Demo
[ your cursor is a predator. the edges wrap. ]
Some things to try: zero out separation and watch them collapse into a conga line of overlapping triangles. Zero out cohesion and alignment and you get a gas — 220 loners politely avoiding each other. Max cohesion with low separation gets you a writhing blob. The good flocking lives in a surprisingly narrow band in the middle, which is also the honest lesson of using boids in a real project: the algorithm takes an afternoon, the tuning takes the week.
The Three Rules
Every frame, each boid scans its neighborhood (everything within some view radius) and accumulates three steering forces:
- Separation — push away from anyone inside your personal space, harder the closer they are. This is the load-bearing rule; without it everything else produces a dot.
- Alignment — nudge your velocity toward the local average heading. This is what makes it read as a flock instead of a mosh pit.
- Cohesion — drift toward the local center of mass, so the group doesn't evaporate.
Weighted sum, add to velocity, clamp the speed, done. The GDScript version I keep re-typing looks about like this:
# one script owns the whole flock — boids are rows in arrays, not nodes
func _flock(i: int) -> Vector2:
var sep := Vector2.ZERO
var ali := Vector2.ZERO
var coh := Vector2.ZERO
var n := 0
for j in _neighbors(i): # spatial grid lookup, not all-pairs
var to_j: Vector2 = pos[j] - pos[i]
var d := to_j.length()
if d > VIEW_RADIUS:
continue
n += 1
ali += vel[j]
coh += to_j
if d < SEP_RADIUS and d > 0.0:
sep -= to_j / (d * d) # closer = pushier
if n == 0:
return Vector2.ZERO
return sep * w_sep + (ali / n - vel[i]) * w_ali + (coh / n) * w_coh
The 1/d² falloff on separation matters more than it looks — linear falloff gives you spongy flocks that interpenetrate, quadratic gives you flocks that hold their spacing like they mean it.
Godot Notes
Things I learned by doing this wrong first:
- Don't make each boid a Node. A hundred Node2Ds each running their own
_processis how you discover the profiler. One script, flatPackedVector2Arrays for position and velocity, and either aMultiMeshInstance2Dor a single_draw()call to render. The demo on this page does the same thing — one buffer, one draw call. - The naive version is O(n²) — every boid checks every other boid. Fine at 200, a slideshow at 2000. A spatial hash grid with cells the size of your view radius fixes it, and it's maybe thirty lines.
- Clamp speed to a band, not just a max. Boids that stall look dead, boids that spike look wrong. Keeping speed between a floor and a ceiling is most of what makes the motion read as "creature" instead of "particle."
- Steering forces stack for free. Predator avoidance (the cursor in the demo), obstacle avoidance, a goal to migrate toward — they're all just more weighted vectors in the same sum. The fourth rule costs you five lines.
- Expose the weights as sliders while tuning. The difference between "swarm of insects" and "school of fish" is entirely in three floats, and you will not find the right ones by editing constants and re-running.