Learn Creative Coding (#51) - Advanced Flocking: Predators, Obstacles, and Formations

Last episode we built a flocking simulation from scratch. Three rules -- separation, alignment, cohesion -- and 300 little triangles suddenly looked like birds. We tuned the weights, added spatial hashing for performance, threw in attractors and repellers, and watched emergent behaviors pop up that nobody coded. Lane formation. Vortex patterns. Flock splitting and merging. All from local steering rules applied independently by every boid.
But the basic flock is peaceful. Nothing hunts it. Nothing blocks its path. Every boid follows the same rules with the same parameters. Real flocking in nature is not like that at all. Starlings flock specifically BECAUSE there are hawks. Fish school specifically BECAUSE there are sharks. The flock is a survival strategy, and it only makes sense in the context of danger.
So today we break the peace. We add predators that chase the flock. Obstacles that block the path. And formations where boids organize into specific geometric patterns. Each one is just another steering force layered on top of the three core rules from episode 50. The boid architecture handles it beautifully -- you keep adding forces to the acceleration vector and the emergent behavior gets richer.
Quick setup: the boid foundation
We need the base simulation from last time. Here's a condensed version with the spatial grid already built in. If you followed episode 50, this should look familiar:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 900;
const H = canvas.height = 700;
const MAX_SPEED = 3.0;
const MAX_FORCE = 0.05;
const PERCEPTION = 50;
const CELL_SIZE = PERCEPTION;
const GRID_W = Math.ceil(W / CELL_SIZE);
const GRID_H = Math.ceil(H / CELL_SIZE);
class Boid {
constructor(x, y) {
this.x = x || Math.random() * W;
this.y = y || Math.random() * H;
const a = Math.random() * Math.PI * 2;
this.vx = Math.cos(a) * 2;
this.vy = Math.sin(a) * 2;
this.ax = 0;
this.ay = 0;
this.maxSpeed = MAX_SPEED;
this.maxForce = MAX_FORCE;
}
applyForce(fx, fy) {
this.ax += fx;
this.ay += fy;
}
update() {
this.vx += this.ax;
this.vy += this.ay;
const spd = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
if (spd > this.maxSpeed) {
this.vx = (this.vx / spd) * this.maxSpeed;
this.vy = (this.vy / spd) * this.maxSpeed;
}
if (spd < 0.5) {
this.vx = (this.vx / spd) * 0.5;
this.vy = (this.vy / spd) * 0.5;
}
this.x = (this.x + this.vx + W) % W;
this.y = (this.y + this.vy + H) % H;
this.ax = 0;
this.ay = 0;
}
}
Same structure: position, velocity, acceleration. Forces accumulate into acceleration, acceleration changes velocity, velocity changes position, acceleration resets. The applyForce method is new -- it just adds to ax/ay, but having it as a method makes the code cleaner when we're stacking five or six different forces per frame.
Predators: introducing fear
A predator is just a special boid with different rules. Instead of separation-alignment-cohesion, the predator has one drive: chase the nearest prey. The prey boids get an additional force: flee from any predator within detection range.
The predator itself is simple. Find the closest prey boid, steer toward it:
class Predator extends Boid {
constructor(x, y) {
super(x, y);
this.maxSpeed = 3.5; // slightly faster than prey
this.maxForce = 0.08; // more agile
this.huntRadius = 150;
}
hunt(prey) {
let closest = null;
let closestDist = Infinity;
for (const p of prey) {
const dx = p.x - this.x;
const dy = p.y - this.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < this.huntRadius && d < closestDist) {
closestDist = d;
closest = p;
}
}
if (closest) {
// steer toward nearest prey
let dx = closest.x - this.x;
let dy = closest.y - this.y;
const mag = Math.sqrt(dx * dx + dy * dy);
dx = (dx / mag) * this.maxSpeed;
dy = (dy / mag) * this.maxSpeed;
let steerX = dx - this.vx;
let steerY = dy - this.vy;
const fmag = Math.sqrt(steerX * steerX + steerY * steerY);
if (fmag > this.maxForce) {
steerX = (steerX / fmag) * this.maxForce;
steerY = (steerY / fmag) * this.maxForce;
}
this.applyForce(steerX, steerY);
}
}
}
The predator has slightly higher maxSpeed and maxForce than regular boids. This makes it faster and more agile -- it can turn tighter and accelerate harder. Without this speed advantage, the predator can never catch anything because boids at the same speed can always turn away in time. The speed difference doesn't need to be huge. Just 15-20% faster is enough to make the predator a genuine threat.
The hunting behavior uses the same Reynolds steering pattern from last episode: compute a desired velocity (toward the prey), subtract the current velocity, limit the force. The predator doesn't teleport to the prey -- it turns toward it gradually, creating those curved persuit paths you see in nature documentaries.
Flee: the prey response
Now the prey needs to know about predators. Each prey boid checks if any predator is within its flee radius. If so, it steers AWAY from the predator -- hard.
function flee(boid, predators) {
let steerX = 0, steerY = 0;
let count = 0;
const fleeRadius = 100;
for (const pred of predators) {
const dx = boid.x - pred.x;
const dy = boid.y - pred.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < fleeRadius && dist > 0) {
// flee force inversely proportional to distance
steerX += (dx / dist) / dist;
steerY += (dy / dist) / dist;
count++;
}
}
if (count > 0) {
const mag = Math.sqrt(steerX * steerX + steerY * steerY);
if (mag > 0) {
steerX = (steerX / mag) * MAX_SPEED;
steerY = (steerY / mag) * MAX_SPEED;
}
steerX -= boid.vx;
steerY -= boid.vy;
const fmag = Math.sqrt(steerX * steerX + steerY * steerY);
if (fmag > MAX_FORCE * 3) {
steerX = (steerX / fmag) * MAX_FORCE * 3;
steerY = (steerY / fmag) * MAX_FORCE * 3;
}
}
return { x: steerX, y: steerY };
}
Notice the force limit is MAX_FORCE * 3. Flee is stronger than any flocking force. When a predator is close, survival overrides social behavior. The boid doesn't care about staying with the group anymore -- it runs. This priority is crucial. If the flee force was the same magnitude as cohesion, boids would sometimes swim toward the predator because cohesion pulled them there. In nature, the fear response dominates everything else, and our simulation should reflect that.
The inverse distance weighting means the flee force gets much stronger as the predator gets closer. A predator at 90 pixels produces a mild nudge. A predator at 20 pixels produces a violent swerve. This creates a realistic "awareness zone" -- boids far from the predator barely react, boids close to it panic.
Predator-prey dynamics: what emerges
Wire it up. 300 prey boids, 2 predators. The predators hunt, the prey flees, and the three flocking rules still apply to the prey:
const prey = [];
for (let i = 0; i < 300; i++) prey.push(new Boid());
const predators = [];
predators.push(new Predator(100, 100));
predators.push(new Predator(700, 500));
function updateAll() {
const grid = buildGrid(prey);
for (const boid of prey) {
const neighbors = getNeighbors(boid, grid);
const sep = separation(boid, neighbors);
const ali = alignment(boid, neighbors);
const coh = cohesion(boid, neighbors);
const fl = flee(boid, predators);
boid.applyForce(sep.x * 1.5, sep.y * 1.5);
boid.applyForce(ali.x * 1.0, ali.y * 1.0);
boid.applyForce(coh.x * 1.0, coh.y * 1.0);
boid.applyForce(fl.x * 2.0, fl.y * 2.0);
boid.update();
}
for (const pred of predators) {
pred.hunt(prey);
pred.update();
}
}
Run this and watch. The flock's behavior changes completely. Without predators (episode 50), the flock drifts lazily, splitting and merging with no urgency. With predators, the flock contracts. Boids clump together because the predator creates a moving repulsion zone that pushes boids inward from the side the predator approaches. The flock doesn't just drift -- it moves with purpose, flowing away from danger.
When a predator approaches head-on, the flock splits. Boids to the left flee left, boids to the right flee right. Two sub-flocks form around the predator and reconnect behind it. This is exactly what starling murmurations do when a hawk dives through. Nobody programmed the split-and-rejoin behavior. It emerges from flee plus cohesion working together.
When a predator chases from behind, the flock stretches out. Boids at the back (closest to danger) flee hardest, pushing into the boids ahead of them, which push into the boids ahead of THEM. A pressure wave travels through the flock and the whole group accelerates away. The shape goes from round to elongated, with the predator at the narrow tail end. Again -- not programmed. Emergent.
Two predators create even more interesting dynamics. If they approach from opposite sides, the flock compresses into a tight ball. If they approach from the same direction, one of them often chases the flock toward the other. The flock has to make a hard turn to escape both, and sometimes it fragments into three or four sub-groups.
Obstacle avoidance: walls and circles
Predators are mobile threats. Obstacles are static threats. The boid needs to detect obstacles ahead of it and steer around them before colliding.
The simplest approach: look ahead in the direction of travel. If there's an obstacle there, steer perpendicular to avoid it.
const obstacles = [
{ x: 450, y: 350, radius: 60 },
{ x: 250, y: 200, radius: 40 },
{ x: 650, y: 500, radius: 50 }
];
function avoidObstacles(boid) {
let steerX = 0, steerY = 0;
const lookAhead = 40;
for (const obs of obstacles) {
const dx = boid.x - obs.x;
const dy = boid.y - obs.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const buffer = obs.radius + lookAhead;
if (dist < buffer) {
// how deeply are we in the avoidance zone?
const urgency = 1.0 - (dist - obs.radius) / lookAhead;
if (dist <= obs.radius) {
// inside the obstacle -- push out hard
steerX += (dx / dist) * MAX_FORCE * 5;
steerY += (dy / dist) * MAX_FORCE * 5;
} else {
// approaching -- steer away proportional to urgency
steerX += (dx / dist) * MAX_FORCE * 2 * Math.max(0, urgency);
steerY += (dy / dist) * MAX_FORCE * 2 * Math.max(0, urgency);
}
}
}
return { x: steerX, y: steerY };
}
The avoidance has two zones. The outer zone (between obstacle.radius and obstacle.radius + lookAhead) produces a gentle steering force that increases as the boid gets closer. The inner zone (inside the obstacle itself) produces a strong push-out force. The inner zone shouldn't trigger during normal operation -- the outer zone should redirect boids before they reach the surface. But boids moving fast or from awkward angles might penetrate, so the emergency push-out is a safety net.
The urgency factor is neat. At the edge of the avoidance zone (distance = buffer), urgency is 0 -- no steering. At the obstacle surface (distance = radius), urgency is 1 -- full steering. This creates a smooth ramp-up instead of a sudden jerk when a boid enters the zone.
Rendering obstacles
Draw the obstacles as circles with a soft glow to show the avoidance zone:
function drawObstacles() {
for (const obs of obstacles) {
// avoidance zone glow
const grad = ctx.createRadialGradient(
obs.x, obs.y, obs.radius,
obs.x, obs.y, obs.radius + 40
);
grad.addColorStop(0, 'rgba(255, 80, 60, 0.15)');
grad.addColorStop(1, 'rgba(255, 80, 60, 0.0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(obs.x, obs.y, obs.radius + 40, 0, Math.PI * 2);
ctx.fill();
// solid obstacle
ctx.fillStyle = 'rgba(255, 80, 60, 0.4)';
ctx.beginPath();
ctx.arc(obs.x, obs.y, obs.radius, 0, Math.PI * 2);
ctx.fill();
}
}
The radial gradient makes the avoidance zone visible. You can see exactly where boids start reacting. This is useful for debugging -- if boids are hitting obstacles, the avoidance zone is too small or the force is too weak.
Place two obstacles close together (gap just wide enough for a few boids) and the flock funnels through. The boids form a narrow stream between the obstacles, spreading back out on the other side. Place three obstacles in a triangle and the flock navigates around the outside, choosing whichever side has the largest opening. Add a predator and the flock is forced to weave between obstacles while fleeing -- the interaction between avoidance and flee creates surprisingly natural escape routes.
Leader-follower: V-formations
Basic flocking produces a disordered mass. Nice, organic, but shapeless. What if we want structure? What if one boid leads and the others arrange themselves behind it?
The leader is a boid that follows the mouse (or a predefined path). The other boids get an additional steering force: move toward the leader, but prefer positions behind and slightly to the side. This preference for offset positions is what produces V-formation.
let leader = new Boid(W / 2, H / 2);
leader.maxSpeed = 2.5;
canvas.addEventListener('mousemove', function(e) {
const rect = canvas.getBoundingClientRect();
leader.targetX = e.clientX - rect.left;
leader.targetY = e.clientY - rect.top;
});
function leaderSeek() {
if (leader.targetX === undefined) return;
let dx = leader.targetX - leader.x;
let dy = leader.targetY - leader.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) return;
dx = (dx / dist) * leader.maxSpeed;
dy = (dy / dist) * leader.maxSpeed;
let sx = dx - leader.vx;
let sy = dy - leader.vy;
const fm = Math.sqrt(sx * sx + sy * sy);
if (fm > leader.maxForce) {
sx = (sx / fm) * leader.maxForce;
sy = (sy / fm) * leader.maxForce;
}
leader.applyForce(sx, sy);
}
The leader steers toward the mouse position. It's not instant -- it uses the same steering-force pattern, so it turns toward the mouse gradually. This smooth turning is essential because the followers need to track the leader's trajectory, and jerky leader movement makes the formation unstable.
Now the follower behavior. Each boid wants to be behind the leader, offset to one side:
function followLeader(boid, leader, side) {
// "behind the leader" = opposite of leader's velocity
const leaderAngle = Math.atan2(leader.vy, leader.vx);
const offset = 35; // distance behind
const spread = 20; // lateral offset
// position behind and to one side
const targetX = leader.x - Math.cos(leaderAngle) * offset
+ Math.cos(leaderAngle + Math.PI / 2) * spread * side;
const targetY = leader.y - Math.sin(leaderAngle) * offset
+ Math.sin(leaderAngle + Math.PI / 2) * spread * side;
let dx = targetX - boid.x;
let dy = targetY - boid.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 3) return { x: 0, y: 0 };
dx = (dx / dist) * boid.maxSpeed;
dy = (dy / dist) * boid.maxSpeed;
let sx = dx - boid.vx;
let sy = dy - boid.vy;
const fm = Math.sqrt(sx * sx + sy * sy);
if (fm > boid.maxForce) {
sx = (sx / fm) * boid.maxForce;
sy = (sy / fm) * boid.maxForce;
}
return { x: sx, y: sy };
}
The side parameter is +1 or -1, putting the boid to the left or right of the leader's trajectory. Each boid picks a side based on its index (even = left, odd = right). The offset controls how far behind the leader the target point is. The spread controls how far to the side.
But a single row on each side is not a V. For a real V, each boid needs to follow the boid AHEAD of it in the formation, not the leader directly. The boid in row 1 follows the leader. The boid in row 2 follows the boid in row 1. Row 3 follows row 2. And so on:
function buildFormation(boids, leader) {
// sort boids by distance to leader
const sorted = boids.slice().sort(function(a, b) {
const da = Math.sqrt((a.x - leader.x) ** 2 + (a.y - leader.y) ** 2);
const db = Math.sqrt((b.x - leader.x) ** 2 + (b.y - leader.y) ** 2);
return da - db;
});
for (let i = 0; i < sorted.length; i++) {
const rank = Math.floor(i / 2) + 1;
const side = (i % 2 === 0) ? 1 : -1;
// follow the boid one rank ahead, or the leader
const ahead = (i < 2) ? leader : sorted[i - 2];
const leaderAngle = Math.atan2(ahead.vy, ahead.vx);
const targetX = ahead.x - Math.cos(leaderAngle) * 25
+ Math.cos(leaderAngle + Math.PI / 2) * 15 * side;
const targetY = ahead.y - Math.sin(leaderAngle) * 25
+ Math.sin(leaderAngle + Math.PI / 2) * 15 * side;
let dx = targetX - sorted[i].x;
let dy = targetY - sorted[i].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 3) continue;
dx = (dx / dist) * MAX_SPEED * 0.8;
dy = (dy / dist) * MAX_SPEED * 0.8;
sorted[i].applyForce(
(dx - sorted[i].vx) * 0.03,
(dy - sorted[i].vy) * 0.03
);
}
}
Each pair of boids (one left, one right) follows the pair ahead. This cascading offset produces a V that deepens as more boids join. The sorting by distance to leader means the closest boids take the front positions and the farthest boids fill in at the back. The formation is dynamic -- if the leader turns sharply, the V deforms as each boid adjusts to its new target position, then gradually re-forms.
The force multiplier here (0.03) is deliberately weak compared to the regular flocking forces. The formation is a suggestion, not a command. Boids still run their separation-alignment-cohesion rules, and the formation force blends in as another influence. If a predator shows up, the flee force overpowers the formation force and the V breaks apart -- exactly like geese scattering when a hawk appears. When the threat passes, the formation gradually reconstitutes.
Combining everything
The full simulation layers all these forces. Each prey boid runs five behaviors per frame:
function updatePrey(prey, predators, obstacles, leader) {
const grid = buildGrid(prey);
for (const boid of prey) {
const neighbors = getNeighbors(boid, grid);
// core flocking (episode 50)
const sep = separation(boid, neighbors);
const ali = alignment(boid, neighbors);
const coh = cohesion(boid, neighbors);
// advanced forces (this episode)
const fl = flee(boid, predators);
const obs = avoidObstacles(boid);
// weighted sum
boid.applyForce(sep.x * 1.5, sep.y * 1.5);
boid.applyForce(ali.x * 1.0, ali.y * 1.0);
boid.applyForce(coh.x * 1.0, coh.y * 1.0);
boid.applyForce(fl.x * 2.5, fl.y * 2.5);
boid.applyForce(obs.x * 2.0, obs.y * 2.0);
boid.update();
}
}
The weight hierarchy matters: flee (2.5) > obstacle avoidance (2.0) > separation (1.5) > alignment (1.0) = cohesion (1.0). Survival trumps navigation, which trumps social behavior. This ordering produces naturalistic priorities -- a boid will break formation to avoid a wall, and will break away from the flock entirely to escape a predator. But when nothing threatens it, the social forces dominate and the flock holds together.
You can add formation force too, but keep it weak (0.5 or less) so it doesn't fight the avoidance behaviors. The formation is a peacetime luxury.
Rendering the predator-prey simulation
Draw predators as larger, red triangles. Prey as small, color-coded triangles. Obstacles as glowing circles:
function drawBoid(boid, size, color) {
const angle = Math.atan2(boid.vy, boid.vx);
ctx.save();
ctx.translate(boid.x, boid.y);
ctx.rotate(angle);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(-size * 0.6, -size * 0.4);
ctx.lineTo(-size * 0.6, size * 0.4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function draw() {
ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
ctx.fillRect(0, 0, W, H);
drawObstacles();
for (const boid of prey) {
const angle = Math.atan2(boid.vy, boid.vx);
const hue = ((angle + Math.PI) / (Math.PI * 2)) * 360;
drawBoid(boid, 5, 'hsl(' + hue + ', 70%, 60%)');
}
for (const pred of predators) {
drawBoid(pred, 10, '#ff3333');
// predator vision cone
const angle = Math.atan2(pred.vy, pred.vx);
ctx.save();
ctx.translate(pred.x, pred.y);
ctx.rotate(angle);
ctx.fillStyle = 'rgba(255, 50, 50, 0.05)';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, pred.huntRadius, -0.5, 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
The predator's vision cone is a subtle red wedge rendered ahead of it. It shows the hunt radius and direction, letting you see what the predator is targeting. The prey are colored by velocity direction, same as episode 50 -- you can visually track alignment as the flock responds to threats.
The semi-transparent background clear creates trails for both prey and predators. The predator's red trail shows its pursuit path -- curved arcs as it chases, sharp turns when the flock splits. The prey trails show the flock's evasion patterns. Over time, the trails paint a picture of the spatial dynamics: where is safe, where is dangerous, where the flock tends to go.
Intercept vs chase: smarter predators
The basic predator steers toward the prey's current position. That's "pursuit by chase" -- always aiming where the target IS. This works but it's exploitable. A prey boid moving perpendicular to the predator's approach can evade indefinitely because the predator is always turning toward a position the prey has already left.
A smarter predator predicts where the prey WILL BE and steers there instead. This is "pursuit by intercept":
function intercept(predator, target) {
const dx = target.x - predator.x;
const dy = target.y - predator.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// estimate time to intercept based on distance and closing speed
const closingSpeed = predator.maxSpeed + Math.sqrt(
target.vx * target.vx + target.vy * target.vy
);
const lookAheadTime = dist / closingSpeed;
// predict future position
const futureX = target.x + target.vx * lookAheadTime;
const futureY = target.y + target.vy * lookAheadTime;
// steer toward predicted position
let sx = futureX - predator.x;
let sy = futureY - predator.y;
const mag = Math.sqrt(sx * sx + sy * sy);
sx = (sx / mag) * predator.maxSpeed;
sy = (sy / mag) * predator.maxSpeed;
sx -= predator.vx;
sy -= predator.vy;
const fm = Math.sqrt(sx * sx + sy * sy);
if (fm > predator.maxForce) {
sx = (sx / fm) * predator.maxForce;
sy = (sy / fm) * predator.maxForce;
}
predator.applyForce(sx, sy);
}
The look-ahead time is estimated by dividing the distance by the combined speeds of predator and prey. The further away the prey is, the further ahead the predator aims. Close prey gets targeted almost at its current position (short look-ahead). Distant prey gets targeted well ahead of its current position (long look-ahead).
The intercept predator is terrifyingly effective. Instead of curving endlessly behind a fleeing boid, it cuts diagonals. It aims at where the flock is going, not where it is. The flock can't just run in a straight line anymore -- the predator will be waiting ahead. The flock has to change direction unpredictably, which creates those chaotic swirling patterns you see in real starling murmurations.
Try both types side by side. One chase predator (red) and one intercept predator (orange). The chase predator follows behind the flock, creating a "push" that makes the flock move together but in a predictable direction. The intercept predator appears ahead of the flock, creating sudden panicked turns. The flock's response to both simultaneously is genuinely beautiful -- it develops a swirling, unpredictable trajectory that changes character depending on where the two predators are relative to each other.
Multiple species: territory
Two flocks with different parameters that avoid each other. No predator-prey relationship -- they just don't want to mix. Territory emerges.
const flockA = [];
const flockB = [];
for (let i = 0; i < 200; i++) {
flockA.push(new Boid(Math.random() * W * 0.4, Math.random() * H));
}
for (let i = 0; i < 200; i++) {
flockB.push(new Boid(W * 0.6 + Math.random() * W * 0.4, Math.random() * H));
}
function avoidOtherFlock(boid, otherFlock) {
let steerX = 0, steerY = 0;
let count = 0;
const avoidRadius = 60;
for (const other of otherFlock) {
const dx = boid.x - other.x;
const dy = boid.y - other.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < avoidRadius && dist > 0) {
steerX += dx / (dist * dist);
steerY += dy / (dist * dist);
count++;
}
}
if (count > 0) {
const mag = Math.sqrt(steerX * steerX + steerY * steerY);
if (mag > 0) {
steerX = (steerX / mag) * MAX_SPEED;
steerY = (steerY / mag) * MAX_SPEED;
}
steerX -= boid.vx;
steerY -= boid.vy;
const fm = Math.sqrt(steerX * steerX + steerY * steerY);
if (fm > MAX_FORCE * 1.5) {
steerX = (steerX / fm) * MAX_FORCE * 1.5;
steerY = (steerY / fm) * MAX_FORCE * 1.5;
}
}
return { x: steerX, y: steerY };
}
Start flock A on the left side (blue) and flock B on the right (orange). Each flock runs its own separation-alignment-cohesion internally. Additionally, every boid avoids members of the other flock using an inverse-square repulsion similar to the separation rule.
What emerges is territorial behavior. The two flocks establish a boundary between them -- a no-man's-land that neither flock enters because approaching the boundary means encountering dense "other flock" repulsion. The boundary is dynamic. If one flock has more momentum moving rightward, it pushes the boundary to the right. The other flock compresses, and its internal cohesion resists further compression. They negotiate.
The boundary is not a line. It's a wavy, shifting front that looks like the interface between two immiscible liquids. Sometimes a few boids from one flock get separated and end up on the wrong side -- they rush back toward their own group, briefly penetrating the other flock's space and causing a local disturbance. It's remarkably like watching two rival schools of fish at a reef boundary.
Creative exercise: stress painting
Here's the final creative payoff. Combine predators, obstacles, and trails. Don't clear the background at all. Color each prey boid based on its "stress level" -- how close the nearest predator is. Terrified boids draw in hot colors (red, orange), calm boids draw in cool colors (blue, green). The trail each boid leaves behind is its stress history.
function getStress(boid, predators) {
let minDist = Infinity;
for (const pred of predators) {
const dx = boid.x - pred.x;
const dy = boid.y - pred.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDist) minDist = dist;
}
// normalize: 0 = very close (max stress), 1 = far away (no stress)
return Math.min(1.0, minDist / 200);
}
function drawStressPainting() {
// NO background clear -- trails accumulate forever
for (const boid of prey) {
const stress = getStress(boid, predators);
// stressed = warm (red/orange), calm = cool (blue/cyan)
let hue;
if (stress < 0.5) {
hue = stress * 60; // 0=red, 30=orange
} else {
hue = 180 + (stress - 0.5) * 120; // 180=cyan, 240=blue
}
ctx.fillStyle = 'hsla(' + hue + ', 80%, 55%, 0.3)';
ctx.fillRect(boid.x - 1, boid.y - 1, 2, 2);
}
}
Let it run. Over a few minutes, the canvas fills with a painting that reveals the spatial dynamics of fear and safety. Areas where predators patrol frequently are painted in reds and oranges -- danger zones. Areas far from predator paths are blue and cyan -- safe havens. The obstacle avoidance zones show up as gaps in the painting where boids never travel. The trails reveal the flock's preferred escape corridors, the predator's hunting grounds, the boundaries between danger and safety.
The painting is a record of every boid's emotional state at every position it visited. It's emergent art built on emergent behavior built on three simple rules plus a predator. I ran this for about ten minutes once and the result looked like a topographic map of anxiety. Which is a weird thing to say about 300 triangles and a for loop, but there it is :-)
The connection forward
Boids are agents. They have position, velocity, internal state (stress, which flock they belong to, their rank in a formation). They make decisions based on local information. They interact with each other and with their environment. They produce emergent collective behavior.
This is the agent-based modeling paradigm, and it shows up everywhere in the emergent systems arc. Reaction-diffusion systems (coming up next) are different mathematically -- they use continuous fields instead of discrete agents -- but they produce the same kind of emergent patterns. Turing spots. Zebra stripes. Coral growth. Local rules, no central controller, complex output.
Later in the arc we'll look at ant colonies and slime molds, which are agent-based systems very similar to boids but with the added ability to modify their environment. Ants leave pheromone trails that influence other ants. Slime mold cells deposit chemicals that attract other cells. The agents don't just respond to the world -- they change it, and those changes influence future behavior. It's boids plus memory, and the patterns that emerge are even richer.
Five episodes into the emergent systems arc. We went from 1D bits (episode 47) to 2D grids (48) to continuous fields (49) to free-moving agents (50) to agents with predators, obstacles, and social structure (this one). Each step added complexity to the agents, not the rules. The rules are still simple. The agents are still local. And the output keeps getting more alive :-)
't Komt erop neer...
- Predators are special boids that chase the nearest prey using Reynolds steering. Prey boids add a "flee" force when a predator is within detection range. Flee force is stronger than flocking forces because survival overrides social behavior
- Intercept pursuit predicts the prey's future position and steers there instead of chasing the current position. It's much more effective than simple chase -- the flock has to change direction unpredictably to survive, producing chaotic swirling patterns
- Obstacle avoidance uses distance-based steering: boids approaching an obstacle steer away proportional to urgency (closer = harder steer). Two zones -- outer avoidance ramp and inner emergency push-out
- V-formation works by each boid targeting a position behind and to the side of the boid ahead of it in rank. The formation force is weaker than flee and avoidance, so it breaks apart under threat and reforms in safety
- Multiple species with mutual avoidance produce territorial dynamics. Two flocks establish a shifting boundary between them that looks like immiscible liquids. Nobody programmed territory -- it emerges from inter-flock repulsion
- Stress painting: color boids by distance to nearest predator (warm = scared, cool = calm) and never clear the background. The accumulated trails reveal spatial patterns of danger and safety -- emergent art from emergent behavior
- All advanced behaviors are just additional steering forces added to the same accumulator. The weight hierarchy determines priorities: flee > obstacles > separation > alignment = cohesion > formation
Sallukes! Thanks for reading.
X