Learn Creative Coding (#50) - Boids: Flocking Simulation

in StemSocial7 days ago

Learn Creative Coding (#50) - Boids: Flocking Simulation

cc-banner

Four episodes into the emergent systems arc and we've gone from 1D rows of bits (episode 47) to 2D Game of Life grids (episode 48) to smooth floating-point continuous automata (episode 49). All of those systems work on fixed grids. Every cell sits at a specific position and never moves. The rules update the cell's state, but the cell itself stays put.

Today we leave the grid behind entirely. Instead of cells updating their state in place, we have autonomous agents that move freely through space. Each agent makes its own decisions about where to go, based only on what it can see around it. No central controller. No master plan. Just local rules, applied independently by every agent, every frame.

The result is flocking. Schools of fish. Flocks of birds. Swarms of insects. That coordinated group movement you see in nature, where hundreds of animals move as one without any leader calling the shots. It looks like it requires some sophisticated collective intelligence, but it doesn't. It requires three rules and a for loop.

Craig Reynolds figured this out in 1986. He called his agents "boids" (bird-oid objects, which is a wonderfully goofy name) and showed that three simple steering behaviors are enough to produce realistic flocking. The paper is called "Flocks, Herds, and Schools: A Distributed Behavioral Model" and it's one of those rare academic papers that changed both computer science AND Hollywood. Every film with CG birds, fish, or crowds since the late 80s uses some version of Reynolds' boids.

The three rules

Each boid is an agent with a position and a velocity. Every frame, it looks at the other boids nearby (within some perception radius) and applies three steering forces:

Separation: steer away from nearby flockmates to avoid crowding. If a boid is too close to its neighbors, it steers in the opposite direction. Without this rule, boids pile on top of each other.

Alignment: steer toward the average heading of nearby flockmates. Match the direction the group is moving. Without this rule, boids scatter in random directions even when they're close together.

Cohesion: steer toward the average position of nearby flockmates. Move toward the center of the local group. Without this rule, the flock drifts apart over time.

That's it. Three vectors, summed together, applied to each boid's velocity. The weights you give each rule control the "personality" of the flock. High separation with low cohesion gives you a nervous, scattered group. Low separation with high cohesion gives you a tight school of fish. High alignment gives you organized migration in formation. The three forces fight each other and the balance between them determines the collective behavior.

The boid data structure

Each boid is dead simple. Position, velocity, and acceleration:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

const NUM_BOIDS = 300;
const MAX_SPEED = 3.0;
const MAX_FORCE = 0.05;
const PERCEPTION = 50;

class Boid {
  constructor() {
    this.x = Math.random() * W;
    this.y = Math.random() * H;
    const angle = Math.random() * Math.PI * 2;
    this.vx = Math.cos(angle) * (1 + Math.random() * 2);
    this.vy = Math.sin(angle) * (1 + Math.random() * 2);
    this.ax = 0;
    this.ay = 0;
  }
}

const boids = [];
for (let i = 0; i < NUM_BOIDS; i++) {
  boids.push(new Boid());
}

Random starting positions, random initial velocities pointing in random directions. The acceleration gets recalculated every frame based on the three flocking rules, then applied to velocity, then reset to zero for the next frame. Same pattern as the particle physics from episode 18 - forces accumulate into acceleration, acceleration changes velocity, velocity changes position.

MAX_SPEED caps how fast a boid can go. Without it, forces accumulate and boids accelerate to infinity. MAX_FORCE caps how sharply a boid can turn. Without it, boids snap instantly to their desired direction, which looks mechanical instead of smooth. The force limit is what gives the movement that flowing, organic feel - boids want to go one way but can only turn gradually.

Separation: personal space

The first rule. For each boid, look at every other boid within the perception radius. For each one that's close, compute a vector pointing away from it. The closer the neighbor, the stronger the repulsion. Sum all those "get away" vectors and that's the separation force.

function separation(boid, others) {
  let steerX = 0, steerY = 0;
  let count = 0;

  for (const other of others) {
    const dx = boid.x - other.x;
    const dy = boid.y - other.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist > 0 && dist < PERCEPTION * 0.5) {
      // weight by inverse distance - closer = stronger push
      steerX += dx / (dist * dist);
      steerY += dy / (dist * dist);
      count++;
    }
  }

  if (count > 0) {
    steerX /= count;
    steerY /= count;

    // normalize and scale to max speed
    const mag = Math.sqrt(steerX * steerX + steerY * steerY);
    if (mag > 0) {
      steerX = (steerX / mag) * MAX_SPEED;
      steerY = (steerY / mag) * MAX_SPEED;
    }

    // steering = desired - current
    steerX -= boid.vx;
    steerY -= boid.vy;

    // limit force
    const fmag = Math.sqrt(steerX * steerX + steerY * steerY);
    if (fmag > MAX_FORCE) {
      steerX = (steerX / fmag) * MAX_FORCE;
      steerY = (steerY / fmag) * MAX_FORCE;
    }
  }

  return { x: steerX, y: steerY };
}

Notice the dist * dist in the denominator. The repulsion force is inversely proportional to the SQUARE of the distance. A boid 10 pixels away pushes 4x harder than one 20 pixels away. This creates a sharp "don't touch me" zone around each boid while barely affecting ones further out. If you used just 1/dist instead of 1/(dist*dist), the separation would be smoother but less aggressive - boids would tolerate closer neighbors.

Also notice the separation radius is PERCEPTION * 0.5 - half the perception radius. Separation cares about personal space, not the whole visible area. The boid can see far for alignment and cohesion, but only worries about crowding from boids that are really close.

The "steering = desired - current" pattern is key to all Reynolds-style steering. The desired velocity points toward where the boid wants to go. The steering force is the difference between desired and current velocity. It's not "go this direction." It's "turn toward this direction at a limited rate." The MAX_FORCE limit means the boid adjusts gradually, which is what produces the smooth, organic curves.

Alignment: go with the flow

The second rule. Average the velocity vectors of all nearby boids. That average is the "desired" direction - the boid wants to match the group's heading.

function alignment(boid, others) {
  let avgVx = 0, avgVy = 0;
  let count = 0;

  for (const other of others) {
    const dx = boid.x - other.x;
    const dy = boid.y - other.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist > 0 && dist < PERCEPTION) {
      avgVx += other.vx;
      avgVy += other.vy;
      count++;
    }
  }

  if (count > 0) {
    avgVx /= count;
    avgVy /= count;

    // normalize to max speed
    const mag = Math.sqrt(avgVx * avgVx + avgVy * avgVy);
    if (mag > 0) {
      avgVx = (avgVx / mag) * MAX_SPEED;
      avgVy = (avgVy / mag) * MAX_SPEED;
    }

    // steering force
    let steerX = avgVx - boid.vx;
    let steerY = avgVy - boid.vy;

    const fmag = Math.sqrt(steerX * steerX + steerY * steerY);
    if (fmag > MAX_FORCE) {
      steerX = (steerX / fmag) * MAX_FORCE;
      steerY = (steerY / fmag) * MAX_FORCE;
    }

    return { x: steerX, y: steerY };
  }

  return { x: 0, y: 0 };
}

Same pattern: compute a desired velocity (average of neighbors), subtract current velocity, limit the force. The boid doesn't snap to the group heading - it turns toward it gradually. This is why flocks have smooth curves and arcs instead of jerky direction changes.

Alignment is the rule that makes the flock feel cohesive. Without it, you have a clump of boids (from cohesion) that are all moving in random directions relative to each other. Add alignment and suddenly they're all flowing the same way. It's the differenece between a crowd waiting at a bus stop and a flock of starlings.

Cohesion: stay together

The third rule. Average the positions of all nearby boids. Steer toward that center point.

function cohesion(boid, others) {
  let avgX = 0, avgY = 0;
  let count = 0;

  for (const other of others) {
    const dx = boid.x - other.x;
    const dy = boid.y - other.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist > 0 && dist < PERCEPTION) {
      avgX += other.x;
      avgY += other.y;
      count++;
    }
  }

  if (count > 0) {
    avgX /= count;
    avgY /= count;

    // desired direction: toward the center of neighbors
    let desX = avgX - boid.x;
    let desY = avgY - boid.y;

    const mag = Math.sqrt(desX * desX + desY * desY);
    if (mag > 0) {
      desX = (desX / mag) * MAX_SPEED;
      desY = (desY / mag) * MAX_SPEED;
    }

    let steerX = desX - boid.vx;
    let steerY = desY - boid.vy;

    const fmag = Math.sqrt(steerX * steerX + steerY * steerY);
    if (fmag > MAX_FORCE) {
      steerX = (steerX / fmag) * MAX_FORCE;
      steerY = (steerY / fmag) * MAX_FORCE;
    }

    return { x: steerX, y: steerY };
  }

  return { x: 0, y: 0 };
}

Same steering pattern a third time. The desired direction points from the boid toward the average position of its neighbors. The steering force is the difference between that desired velocity and the boid's current velocity, limited by MAX_FORCE.

Cohesion is what prevents the flock from drifting apart. Without it, even if boids are aligned (moving the same direction), they gradually spread out because their paths aren't identical. Cohesion pulls them back toward the group center. It's like an invisible rubber band connecting each boid to the average position of the group around it.

Putting it all together

Now we combine the three forces. Each rule produces a steering vector. We weight them and add them to the boid's acceleration:

const SEP_WEIGHT = 1.5;
const ALI_WEIGHT = 1.0;
const COH_WEIGHT = 1.0;

function update() {
  for (const boid of boids) {
    const sep = separation(boid, boids);
    const ali = alignment(boid, boids);
    const coh = cohesion(boid, boids);

    boid.ax = sep.x * SEP_WEIGHT + ali.x * ALI_WEIGHT + coh.x * COH_WEIGHT;
    boid.ay = sep.y * SEP_WEIGHT + ali.y * ALI_WEIGHT + coh.y * COH_WEIGHT;

    // apply acceleration to velocity
    boid.vx += boid.ax;
    boid.vy += boid.ay;

    // limit speed
    const speed = Math.sqrt(boid.vx * boid.vx + boid.vy * boid.vy);
    if (speed > MAX_SPEED) {
      boid.vx = (boid.vx / speed) * MAX_SPEED;
      boid.vy = (boid.vy / speed) * MAX_SPEED;
    }

    // minimum speed so boids don't stop
    if (speed < 1.0) {
      boid.vx = (boid.vx / speed) * 1.0;
      boid.vy = (boid.vy / speed) * 1.0;
    }

    // update position
    boid.x += boid.vx;
    boid.y += boid.vy;

    // wrap at edges
    if (boid.x < 0) boid.x += W;
    if (boid.x > W) boid.x -= W;
    if (boid.y < 0) boid.y += H;
    if (boid.y > H) boid.y -= H;

    // reset acceleration
    boid.ax = 0;
    boid.ay = 0;
  }
}

The weights matter a lot. Separation at 1.5 is slightly stronger than alignment and cohesion at 1.0 each. This means personal space is prioritized over group cohesion - boids won't pile up even if the flock pulls them close. If you set separation too low, boids overlap and the flock becomes a dense clump. If you set it too high, boids can't get close enough for alignment and cohesion to kick in, so the flock falls apart.

The minimum speed is a nice touch. Without it, boids in a tight cluster can have their forces cancel out - separation pushes them away while cohesion pulls them back, resulting in near-zero velocity. They just sit there vibrating. The minimum speed forces them to always keep moving, which produces much more natural behavior.

Edge wrapping (toroidal space) is the simplest boundary strategy. Boids that exit one side appear on the other. This works fine but creates an artifact: boids near the right edge can't "see" boids near the left edge even though they're spatially close (after wrapping). For more correct behavior, you'd also check wrapped distances. But for a creative coding piece, simple wrapping looks fine.

Rendering: triangles, not dots

Here's where the boids become visually alive. Don't draw them as circles. Draw them as triangles pointing in the direction they're moving. Suddenly they look like birds or fish instead of particles.

function draw() {
  // semi-transparent background for motion trails
  ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
  ctx.fillRect(0, 0, W, H);

  for (const boid of boids) {
    const angle = Math.atan2(boid.vy, boid.vx);
    const size = 6;

    ctx.save();
    ctx.translate(boid.x, boid.y);
    ctx.rotate(angle);

    // triangle pointing right (in local coordinates)
    ctx.beginPath();
    ctx.moveTo(size, 0);           // tip
    ctx.lineTo(-size * 0.6, -size * 0.4);  // left wing
    ctx.lineTo(-size * 0.6, size * 0.4);   // right wing
    ctx.closePath();

    // color by velocity direction
    const hue = ((angle + Math.PI) / (Math.PI * 2)) * 360;
    ctx.fillStyle = 'hsl(' + hue + ', 70%, 60%)';
    ctx.fill();

    ctx.restore();
  }
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}

loop();

The atan2(vy, vx) gives the angle of the velocity vector. We rotate the canvas to that angle and draw a simple triangle. The triangle points in the direction of movement, so you can see each boid's heading at a glance.

The semi-transparent background clear (rgba(10, 10, 15, 0.15) instead of solid fill) creates motion trails. Previous frames fade out gradually instead of being erased. This makes the flock's movement visible as flowing streams and curves. It's the same technique we used for particle trails back in episode 11, and it works beautifully here. The trails show you the flock's history - where they've been, how the stream split and merged.

Coloring by velocity direction (using hue = angle) means boids moving the same direction have the same color. You can see alignment at work - clusters of same-colored boids are moving together. When the flock turns, you see the color shift ripple through the group. It's a beautiful way to visualize the emergent coordination.

The O(n^2) problem

There's a performance elephant in the room. Each boid checks every other boid. For 300 boids, that's 300 * 300 = 90,000 distance calculations per frame. For 1000 boids, it's a million. For 5000 boids, 25 million. The naive algorithm is O(n^2) and it hits a wall fast.

For creative coding, 300-500 boids with the naive approach runs fine in JavaScript at 60fps. But if you want thousands, you need spatial partitioning.

// simple grid-based spatial hashing
const CELL_SIZE = PERCEPTION;
const GRID_W = Math.ceil(W / CELL_SIZE);
const GRID_H = Math.ceil(H / CELL_SIZE);

function buildGrid(boids) {
  const grid = new Array(GRID_W * GRID_H);
  for (let i = 0; i < grid.length; i++) grid[i] = [];

  for (const boid of boids) {
    const gx = Math.floor(boid.x / CELL_SIZE) % GRID_W;
    const gy = Math.floor(boid.y / CELL_SIZE) % GRID_H;
    grid[gy * GRID_W + gx].push(boid);
  }

  return grid;
}

function getNeighbors(boid, grid) {
  const gx = Math.floor(boid.x / CELL_SIZE);
  const gy = Math.floor(boid.y / CELL_SIZE);
  const neighbors = [];

  // check 3x3 grid neighborhood
  for (let dy = -1; dy <= 1; dy++) {
    for (let dx = -1; dx <= 1; dx++) {
      const nx = ((gx + dx) % GRID_W + GRID_W) % GRID_W;
      const ny = ((gy + dy) % GRID_H + GRID_H) % GRID_H;
      const cell = grid[ny * GRID_W + nx];
      for (const other of cell) {
        if (other === boid) continue;
        const ddx = boid.x - other.x;
        const ddy = boid.y - other.y;
        const dist = Math.sqrt(ddx * ddx + ddy * ddy);
        if (dist < PERCEPTION) {
          neighbors.push(other);
        }
      }
    }
  }

  return neighbors;
}

The grid divides the space into cells the size of the perception radius. Each boid is placed in the cell that contains its position. When looking for neighbors, instead of checking all n boids, you only check the boids in the same cell and the 8 surrounding cells. Since each cell covers PERCEPTION-sized area, any boid within perception range must be in one of these 9 cells.

This brings the average case from O(n^2) down to roughly O(n * k) where k is the average number of boids per cell neighborhood - usually much smaller than n. With 1000 boids on a 800x600 canvas and PERCEPTION=50, that's about 16x12 = 192 grid cells, so roughly 5 boids per cell. Each boid checks ~45 others (9 cells * 5 boids) instead of 999. Massive improvement.

Use the grid version and you can comfortably run 2000-3000 boids at 60fps in JavaScript. More than enough for a visually impressive flock.

Weight tuning: different flocks

The creative juice is in the weights. Different weight ratios produce completely different group behaviors. Here's an interactive version with keyboard controls:

let sepW = 1.5;
let aliW = 1.0;
let cohW = 1.0;
let perceptionR = 50;

document.addEventListener('keydown', function(e) {
  if (e.key === 'q') sepW += 0.1;
  if (e.key === 'a') sepW = Math.max(0, sepW - 0.1);
  if (e.key === 'w') aliW += 0.1;
  if (e.key === 's') aliW = Math.max(0, aliW - 0.1);
  if (e.key === 'e') cohW += 0.1;
  if (e.key === 'd') cohW = Math.max(0, cohW - 0.1);
  if (e.key === 'r') perceptionR += 5;
  if (e.key === 'f') perceptionR = Math.max(10, perceptionR - 5);

  console.log('sep:', sepW.toFixed(1),
              'ali:', aliW.toFixed(1),
              'coh:', cohW.toFixed(1),
              'perception:', perceptionR);
});

Some presets to try:

  • Tight school of fish: sep=1.0, ali=1.5, coh=2.0, perception=40. Strong cohesion pulls them together. High alignment keeps them organized. Moderate separation prevents overlap but doesn't push hard. Smaller perception means tight local groups
  • Nervous birds: sep=2.5, ali=0.8, coh=0.5, perception=60. Strong separation, weak cohesion. Boids scatter away from each other, loosely drifting back together. Lots of splitting and reforming
  • Migration formation: sep=1.0, ali=2.5, coh=0.8, perception=80. Very high alignment with large perception. Boids spread out but all move the same direction. V-formation like behavior emerges sometimes
  • Dense swarm: sep=0.5, ali=0.5, coh=3.0, perception=30. Very strong cohesion with small perception. Boids crowd together in tight clusters that bounce around like a single organism

The perception radius is maybe the most powerful single parameter. Small perception (20-30) means boids only react to very close neighbors - you get many small flocks that split and merge. Large perception (80-100) means boids coordinate across larger distances - you get one big flock with sweeping coordinated movements. The visual character changes dramatically.

Emergent behaviors you didn't program

Run the simulation and watch. You'll see things happening that are not in the code anywhere:

Lane formation: when two groups of boids approach head-on, they don't collide and scatter. They flow past each other, naturally forming lanes. The separation force pushes passing boids to the side, and the alignment force organizes them into parallel streams. Nobody programmed lanes. They emerge.

Vortex formation: near boundaries (or around obstacles if you add them), boids sometimes form circular vortex patterns. The curving motion of the boundary boids causes their neighbors to curve too, and the curvature propgates through the flock until you get a spinning ring. Nobody programmed spinning.

Split and merge: a flock will split when something (a boundary, an obstacle, a parameter change) pushes boids apart faster than cohesion can hold them. Two sub-flocks form, each with its own internal alignment. Later, if they come close enough, cohesion pulls them back together and they merge into one flock again. The flock acts like a liquid - it can divide and recombine.

Density waves: in large flocks, you can see density variations ripple through the group. One area gets slightly denser (boids happen to converge), separation pushes them outward, that outward push crowds their neighbors, separation pushes THOSE outward, and the wave propagates. Like a pressure wave through a fluid. The physics emerged from three steering rules.

This is the same principle we saw with cellular automata. Simple local rules, no central coordination, complex global behavior. The difference is that boids move freely through continuous space instead of sitting on a grid. The emergent patterns are spatial and dynamic instead of static grid patterns.

Adding attractors and repellers

Click to add a point that attracts or repels boids. This turns the simulation into a playable toy:

const attractors = [];
const repellers = [];

canvas.addEventListener('click', function(e) {
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;

  if (e.shiftKey) {
    repellers.push({ x: mx, y: my, radius: 80 });
  } else {
    attractors.push({ x: mx, y: my, radius: 100 });
  }
});

function externalForces(boid) {
  let fx = 0, fy = 0;

  for (const a of attractors) {
    const dx = a.x - boid.x;
    const dy = a.y - boid.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < a.radius && dist > 5) {
      fx += (dx / dist) * MAX_FORCE * 0.5;
      fy += (dy / dist) * MAX_FORCE * 0.5;
    }
  }

  for (const r of repellers) {
    const dx = boid.x - r.x;
    const dy = boid.y - r.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < r.radius && dist > 5) {
      fx += (dx / dist) * MAX_FORCE * 2.0;
      fy += (dy / dist) * MAX_FORCE * 2.0;
    }
  }

  return { x: fx, y: fy };
}

Click places an attractor. Shift+click places a repeller. Attractors gently pull boids toward them - the flock orbits around attractors, creating swirling patterns. Repellers push boids away hard (2x force) - the flock splits around them and reforms on the other side, like water flowing around a rock.

Place two repellers close together and boids funnel through the gap between them. Place attractors in a ring and boids orbit in a circle. The interplay between the flocking rules and the external forces creates patterns you can sculpt in real time. It's genuinely meditative to play with.

Creative exercise

Build a boids simulation with all the pieces from this episode combined. Here's a minimal complete version you can paste into an HTML file:

// complete boids simulation - paste into a script tag
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 600;

const boids = [];
for (let i = 0; i < 400; i++) {
  const a = Math.random() * Math.PI * 2;
  boids.push({
    x: Math.random() * 800, y: Math.random() * 600,
    vx: Math.cos(a) * 2, vy: Math.sin(a) * 2
  });
}

function flock(boid) {
  let sx = 0, sy = 0, ax = 0, ay = 0, cx = 0, cy = 0;
  let sc = 0, ac = 0, cc = 0;
  for (const o of boids) {
    const dx = boid.x - o.x, dy = boid.y - o.y;
    const d = Math.sqrt(dx * dx + dy * dy);
    if (d > 0 && d < 50) {
      ax += o.vx; ay += o.vy; ac++;
      cx += o.x; cy += o.y; cc++;
      if (d < 25) { sx += dx / (d * d); sy += dy / (d * d); sc++; }
    }
  }
  let fx = 0, fy = 0;
  if (sc > 0) { fx += (sx / sc) * 1.5; fy += (sy / sc) * 1.5; }
  if (ac > 0) { fx += ((ax / ac) - boid.vx) * 0.05; fy += ((ay / ac) - boid.vy) * 0.05; }
  if (cc > 0) { fx += ((cx / cc - boid.x)) * 0.003; fy += ((cy / cc - boid.y)) * 0.003; }
  return { x: fx, y: fy };
}

(function loop() {
  ctx.fillStyle = 'rgba(10,10,15,0.12)';
  ctx.fillRect(0, 0, 800, 600);
  for (const b of boids) {
    const f = flock(b);
    b.vx += f.x; b.vy += f.y;
    const s = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
    if (s > 3) { b.vx = b.vx / s * 3; b.vy = b.vy / s * 3; }
    b.x = (b.x + b.vx + 800) % 800;
    b.y = (b.y + b.vy + 600) % 600;
    const a = Math.atan2(b.vy, b.vx);
    ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(a);
    ctx.fillStyle = 'hsl(' + ((a + 3.14) / 6.28 * 360) + ',70%,60%)';
    ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(-3, -2.5); ctx.lineTo(-3, 2.5);
    ctx.closePath(); ctx.fill(); ctx.restore();
  }
  requestAnimationFrame(loop);
})();

That's the whole thing in a compact form. Expand it with the spatial grid, keyboard weight controls, and click-to-place attractors from the sections above for the full interactive version.

The connection to everything else

Boids are the first agent-based system we've built. Every previous emergent system (cellular automata, continuous CA) operated on fixed grids. Boids operate in continuous space with mobile agents. This is a fundamentally different paradigm and it opens up a whole world of possibilities.

The same steering architecture works for way more than flocking. Want predators chasing prey? Add a "flee" behavior that activates when a predator boid is nearby. Want obstacles? Add a "avoid obstacle" steering force. Want path following? Steer toward the nearest point on a path. Craig Reynolds described a whole vocabulary of steering behaviors that can be mixed and matched - seek, flee, arrive, wander, pursue, evade, follow path, follow leader, avoid obstacles.

And the mathematical similarity to continuous CA from episode 49 is not a coincidence. Both systems have agents (boids or cells) that react to their local neighborhood. Both produce emergent global patterns from local rules. Both have perception ranges that determine the scale of interaction. The main difference is that CA cells are fixed to a grid and boids move freely. Same principle, different geometry.

The emergent systems arc continues. We've still got reaction-diffusion systems coming up, where chemicals interact and diffuse to produce Turing patterns - leopard spots, zebra stripes, coral growth. And L-systems for growing fractal plants with grammar rules. And swarm intelligence with ant colonies and slime molds. Each one is a variation on the same theme: simple local rules, no central control, complex beautiful output.

Four episodes into the arc now. We went from rows of bits (episode 47) to 2D grids (48) to continuous floating-point states (49) to free-moving agents in continuous space (this one). Each step removed a constraint - discrete states, fixed grids, integer positions - and the visual output got more fluid and organic each time. The rules got simpler but the behavior got wilder. That's the pattern :-)

't Komt erop neer...

  • Boids are autonomous agents with position and velocity that follow three rules: separation (avoid crowding), alignment (match neighbor heading), and cohesion (move toward neighbor center). All three use the "desired minus current velocity, limited by max force" steering pattern
  • Craig Reynolds published this in 1986. Three rules are enough to produce realistic flocking behavior. No central controller, no leader, no master plan - just local rules applied independently by every boid every frame
  • Separation uses inverse-square distance weighting for a sharp "personal space" zone. Alignment averages neighbor velocities. Cohesion steers toward the average neighbor position. Each produces a force vector, weighted and summed into acceleration
  • Weight tuning controls flock personality: high separation + low cohesion = scattered and nervous, high cohesion + low separation = tight school of fish, high alignment = organized migration. The perception radius determines how far each boid can "see" and dramatically affects group scale
  • Rendering boids as direction-aligned triangles (using atan2 for angle) makes them look like birds or fish. Semi-transparent background clears create motion trails that reveal the flock's flow patterns. Color by velocity direction shows alignment visually
  • The naive algorithm is O(n^2) - each boid checks every other boid. Spatial hashing with a grid of PERCEPTION-sized cells reduces this to roughly O(n * k) where k is the average neighbors per cell region. Enables thousands of boids at 60fps
  • Emergent behaviors nobody programmed: lane formation, vortex patterns, flock splitting and merging, density waves. Same principle as cellular automata - local rules producing global structure without central coordination
  • Attractors and repellers add external forces that the flock flows around. Click-to-place interaction turns the simulation into a sculpting tool where you shape the flock's flow in real time

Sallukes! Thanks for reading.

X

@femdev

Sort:  

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You have been a buzzy bee and published a post every day of the month.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

Check out our last posts:

Be ready for the May edition of the Hive Power Up Month!
Hive Power Up Day - May 1st 2026