DEV Community

SEN LLC
SEN LLC

Posted on

Building a Boids Flocking Simulation in TypeScript — a Murmuration from Three Local Rules, with a Pure, Testable Engine

A boids flocking simulation in TypeScript on an HTML canvas. With no leader and no global plan, each agent looks only at its nearby neighbours and follows three local rules (separation, alignment, cohesion) — and a coordinated flock emerges. Two hinges: (1) each rule is a Reynolds steering force (desired velocity minus current), summed with weights; (2) the vector and steering math is a pure, DOM-free module, so it's unit-tested without a canvas. A turn from the recent CLIs to a clickable, live-demo web app.

🌐 Live demo: https://sen.ltd/portfolio/boids-flocking/
📦 GitHub: https://github.com/sen-ltd/boids-flocking

Boids flocking

Emergence from three local rules

Craig Reynolds' 1986 "boids" showed that flocking needs no leader and no global
plan. Each agent looks only at its nearby neighbours and applies three steering
rules; the coordinated flock is an emergent result, not a programmed one:

  • Separation — steer away from neighbours that are too close.
  • Alignment — steer toward the average heading of nearby boids.
  • Cohesion — steer toward the average position of nearby boids.

Each rule produces a Reynolds steering force — the desired velocity (at top
speed) minus the current one, clamped to a maximum:

function steer(desired: Vec, vel: Vec, p: Params): Vec {
  if (mag(desired) === 0) return { x: 0, y: 0 };
  return limit(sub(setMag(desired, p.maxSpeed), vel), p.maxForce);
}
Enter fullscreen mode Exit fullscreen mode

The three forces are computed against neighbours found by distance and summed
with the weights from the sliders — with separation weighted by inverse distance
so the closest neighbours push hardest.

The engine is pure and tested

All the vector and steering math lives in src/boids.ts with no reference to the
DOM, so it's unit-tested directly (12 vitest cases). The tests pin the direction
of each rule (two close boids push apart; a boid turns toward its neighbours'
heading; a diffuse ring contracts under cohesion), that step keeps every boid
inside the toroidal world and under the speed cap, and that it never mutates its
input:

it('a diffuse cloud contracts under cohesion', () => {
  // ...60 ticks of cohesion-only steering...
  expect(spread(world)).toBeLessThan(spread(boids));
});
Enter fullscreen mode Exit fullscreen mode

That "pure core, thin shell" split means the flocking behaviour can be verified
in CI without launching a browser.

The canvas shell is thin

The front-end in src/main.ts owns only the animation loop, rendering (each boid
a triangle coloured by heading, with motion trails), the control panel, and
drag-to-scatter interaction; the simulation itself is the imported pure function.

One bug worth noting: a devicePixelRatio canvas-scaling mistake made drawing
fill only the top-left quarter on Retina. Setting both canvas.width/height
(the backing resolution) and canvas.style.width/height (the logical size) fixed
it — a classic hi-DPI trap, caught by screenshotting at deviceScaleFactor 1 and 2.

src/boids.ts      — pure model: vectors, the three rules, toroidal step()
src/boids.test.ts — 12 vitest cases for the steering math
src/main.ts       — canvas render loop, controls, pointer interaction
Enter fullscreen mode Exit fullscreen mode

Takeaway

Boids is the classic demonstration that global order can emerge from simple
local rules. The keys are expressing each rule as a steering force and
factoring the model into a pure, testable engine. After a run of CLIs, this
one is a clickable live-demo web app in TypeScript + Canvas — go drag the
demo and scatter the flock.

🌐 Live demo: https://sen.ltd/portfolio/boids-flocking/
📦 GitHub: https://github.com/sen-ltd/boids-flocking

Top comments (0)