There’s something genuinely strange about watching a crowd of autonomous agents share a canvas.
That’s the question behind DRIFT: what happens when you put three radically different vehicle archetypes into the same space, give each one its own agenda, and just let physics run?
The starting point was Craig Reynolds’ foundational work on steering behaviors: seek, flee, separation, alignment, cohesion. Those behaviors are well-documented and well-taught. The challenge I set for myself was to use them as raw material for something that reads more like an ecosystem than a demo.
I ended up with three vehicle types:
Seekers chase a moving attractor that is basically a signal that traces a path across the canvas. They leave luminous trails and pulse as they move.
Drifters ignore the signal entirely. They flock through alignment, cohesion, and separation. and wander using noise.
Ghosts flee. They push away from the signal and from the combined mass of every other vehicle in the scene. They end up haunting the edges of the canvas.
The signal itself moves on a parametric Lissajous curve, so it sweeps the canvas continuously without any user input required.
The Ghost’s `applyBehaviors` method is the piece I find most satisfying. The rule sounds simple — flee everything — but the implementation has a specific texture to it.
javascript
applyBehaviors(signal, allVehicles) {
let fleeSignal = this.flee(signal, 220);
let fleeCrowd = createVector(0, 0);
for (let v of allVehicles) {
fleeCrowd.add(this.flee(v.pos, 90));
}
let w = this.wander(1.2);
fleeSignal.mult(2.0);
fleeCrowd.mult(0.8);
w.mult(0.9);
this.applyForce(fleeSignal);
this.applyForce(fleeCrowd);
this.applyForce(w);
}
What I like here is that `fleeCrowd` is an accumulated vector. For every seeker and drifter on the canvas, the ghost computes a flee force and adds them all together. The result is that the ghost reads the density of the crowd. A ghost near a tight cluster of drifters gets a much stronger push than one near a single seeker. It behaves like a pressure system.
The wander force on top of that means no two ghosts trace the same path even under identical starting conditions. The noise field shifts slowly over time, so the wandering feels natural.
The wander method from the base `Vehicle` class handles this:
wander(strength) {
let angle = noise(
this.pos.x * 0.003,
this.pos.y * 0.003,
driftT * 0.4
) * TWO_PI * 2;
return p5.Vector.fromAngle(angle).mult(strength * this.maxForce);
}
Getting the ghost behavior to feel ghostly rather than glitchy. The first version had ghosts with a flee radius too small, so they’d enter the crowd and then snap violently outward. Increasing the signal flee radius to 220 pixels and smoothing the crowd flee with accumulated vectors fixed the snapping.
The Lissajous signal path. My first instinct was to use `mouseX` and `mouseY` as the attractor, which is the standard approach for seek demos. The problem is that a static mouse produces boring convergence, everyone piles up on the target and sits there. A Lissajous curve gave the signal genuine sweep across the canvas, which keeps seekers in motion even after they’ve converged. The math is minimal:
function getSignalPos(t) {
let cx = width * 0.5;
let cy = height * 0.5;
let rx = width * 0.32;
let ry = height * 0.28;
return createVector(
cx + rx * sin(t * 0.41 + 0.6),
cy + ry * sin(t * 0.27)
);
The frequency ratio `0.41 / 0.27` is irrational enough that the path never perfectly repeats, so the sketch keeps shifting over long observation periods.
The three archetypes don’t interact across types in any interesting way. Seekers don’t react to drifters. Drifters don’t notice ghosts. The only cross-archetype behavior is the ghost’s crowd flee, which reads seeker and drifter positions as obstacles. A next version could introduce:
– Seekers that are temporarily distracted by passing drifter clusters, pulled off their trajectory before resuming the chase.
– The signal occasionally splitting into two attractors, creating competing factions among the seekers.
Visually, the grid underneath the simulation was meant to read as a city viewed from above, but it’s almost invisible after the first frame. Rendering it to a persistent background layer would strengthen that spatial metaphor.