Flocking/Swarm Behavior
Emergent Group Behaviors
Create mesmerizing swarm behaviors with simple rules! Learn Craig Reynolds' boids algorithm, steering behaviors, and how separation, alignment, and cohesion create lifelike flocking patterns! ๐ฆ๐๐ฆ
Understanding Flocking
๐ฆ The Bird Flock Analogy
Think of flocking like a murmuration of starlings:
- Separation: Don't crash into neighbors
- Alignment: Fly in the same direction as neighbors
- Cohesion: Stay close to the group
- Avoidance: Dodge obstacles
- Goal Seeking: Head towards objectives
- Emergent Patterns: Complex behaviors from simple rules
Interactive Flocking Demo
Click to attract boids, right-click to repel! Watch emergent behaviors form!
Spawn Swarms:
Boids: 0 | Avg Speed: 0 | Obstacles: 0 | Predators: 0
FPS: 60 | Collisions Avoided: 0 | Groups Formed: 0
Flocking Implementation
import pygame
import math
import random
from typing import List, Tuple
class Vector2D:
"""2D Vector helper class"""
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x: float = x
self.y: float = y
def add(self, other: 'Vector2D') -> 'Vector2D':
return Vector2D(self.x + other.x, self.y + other.y)
def subtract(self, other: 'Vector2D') -> 'Vector2D':
return Vector2D(self.x - other.x, self.y - other.y)
def multiply(self, scalar: float) -> 'Vector2D':
return Vector2D(self.x * scalar, self.y * scalar)
def divide(self, scalar: float) -> 'Vector2D':
if scalar != 0:
return Vector2D(self.x / scalar, self.y / scalar)
return Vector2D(0, 0)
def magnitude(self) -> float:
return math.sqrt(self.x ** 2 + self.y ** 2)
def normalize(self) -> 'Vector2D':
mag = self.magnitude()
if mag > 0:
return self.divide(mag)
return Vector2D(0, 0)
def limit(self, max_val: float) -> 'Vector2D':
if self.magnitude() > max_val:
return self.normalize().multiply(max_val)
return self
def distance(self, other: 'Vector2D') -> float:
return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
def angle(self) -> float:
return math.atan2(self.y, self.x)
class Boid:
"""Individual boid in the flock"""
def __init__(self, x: float, y: float, max_speed: float = 150) -> None:
self.position: Vector2D = Vector2D(x, y)
self.velocity: Vector2D = Vector2D(random.uniform(-1, 1), random.uniform(-1, 1))
self.acceleration: Vector2D = Vector2D(0, 0)
# Movement constraints
self.max_speed: float = max_speed
self.max_force: float = 0.2
# Perception
self.vision_range: int = 50
self.vision_angle: float = math.radians(270)
# Behavior weights
self.separation_weight: float = 1.5
self.alignment_weight: float = 1.0
self.cohesion_weight: float = 1.0
# Visual
self.size: int = 5
self.color: tuple[int, int, int] = (76, 175, 80)
def flock(self, boids: List['Boid']) -> None:
"""Apply flocking rules"""
neighbors = self.get_neighbors(boids)
# Calculate forces
sep = self.separation(neighbors)
align = self.alignment(neighbors)
coh = self.cohesion(neighbors)
# Weight forces
sep = sep.multiply(self.separation_weight)
align = align.multiply(self.alignment_weight)
coh = coh.multiply(self.cohesion_weight)
# Apply forces
self.acceleration = self.acceleration.add(sep)
self.acceleration = self.acceleration.add(align)
self.acceleration = self.acceleration.add(coh)
def get_neighbors(self, boids: List['Boid']) -> List['Boid']:
"""Find nearby boids within vision"""
neighbors = []
for other in boids:
if other == self:
continue
distance = self.position.distance(other.position)
if distance < self.vision_range:
# Check if within vision angle
if self.can_see(other):
neighbors.append(other)
return neighbors
def can_see(self, other: 'Boid') -> bool:
"""Check if another boid is within vision cone"""
to_other = other.position.subtract(self.position)
angle_to_other = to_other.angle()
my_angle = self.velocity.angle()
angle_diff = abs(angle_to_other - my_angle)
if angle_diff > math.pi:
angle_diff = 2 * math.pi - angle_diff
return angle_diff < self.vision_angle / 2
def separation(self, neighbors: List['Boid']) -> Vector2D:
"""Avoid crowding neighbors (separation)"""
desired_separation = 25
steer = Vector2D(0, 0)
count = 0
for other in neighbors:
distance = self.position.distance(other.position)
if 0 < distance < desired_separation:
# Calculate repulsion force
diff = self.position.subtract(other.position)
diff = diff.normalize()
diff = diff.divide(distance) # Weight by distance
steer = steer.add(diff)
count += 1
if count > 0:
steer = steer.divide(count)
# Implement Reynolds: Steering = Desired - Velocity
if steer.magnitude() > 0:
steer = steer.normalize()
steer = steer.multiply(self.max_speed)
steer = steer.subtract(self.velocity)
steer = steer.limit(self.max_force)
return steer
def alignment(self, neighbors: List['Boid']) -> Vector2D:
"""Align with average heading of neighbors"""
steer = Vector2D(0, 0)
count = 0
for other in neighbors:
steer = steer.add(other.velocity)
count += 1
if count > 0:
steer = steer.divide(count)
steer = steer.normalize()
steer = steer.multiply(self.max_speed)
steer = steer.subtract(self.velocity)
steer = steer.limit(self.max_force)
return steer
def cohesion(self, neighbors: List['Boid']) -> Vector2D:
"""Steer towards average position of neighbors"""
steer = Vector2D(0, 0)
count = 0
for other in neighbors:
steer = steer.add(other.position)
count += 1
if count > 0:
steer = steer.divide(count)
return self.seek(steer)
return steer
def seek(self, target: Vector2D) -> Vector2D:
"""Seek a target position"""
desired = target.subtract(self.position)
desired = desired.normalize()
desired = desired.multiply(self.max_speed)
steer = desired.subtract(self.velocity)
steer = steer.limit(self.max_force)
return steer
def flee(self, target: Vector2D) -> Vector2D:
"""Flee from a target position"""
desired = self.position.subtract(target)
desired = desired.normalize()
desired = desired.multiply(self.max_speed)
steer = desired.subtract(self.velocity)
steer = steer.limit(self.max_force)
return steer
def update(self, dt: float) -> None:
"""Update boid position"""
# Update velocity
self.velocity = self.velocity.add(self.acceleration)
self.velocity = self.velocity.limit(self.max_speed)
# Update position
self.position.x += self.velocity.x * dt
self.position.y += self.velocity.y * dt
# Reset acceleration
self.acceleration = Vector2D(0, 0)
def edges(self, width: int, height: int) -> None:
"""Wrap around screen edges"""
if self.position.x < 0:
self.position.x = width
elif self.position.x > width:
self.position.x = 0
if self.position.y < 0:
self.position.y = height
elif self.position.y > height:
self.position.y = 0
def draw(self, screen: pygame.Surface) -> None:
"""Draw the boid"""
# Calculate angle for rotation
angle = math.degrees(self.velocity.angle())
# Create triangle points
points = [
(self.size, 0),
(-self.size, -self.size // 2),
(-self.size, self.size // 2)
]
# Rotate and translate points
rotated_points = []
for px, py in points:
# Rotate
rx = px * math.cos(math.radians(angle)) - py * math.sin(math.radians(angle))
ry = px * math.sin(math.radians(angle)) + py * math.cos(math.radians(angle))
# Translate
rotated_points.append((self.position.x + rx, self.position.y + ry))
# Draw boid
pygame.draw.polygon(screen, self.color, rotated_points)
Best Practices
โก Flocking Tips
- Balance Forces: Adjust weights for different behaviors
- Spatial Partitioning: Use grids/quadtrees for optimization
- Vision Limits: Realistic perception constraints
- Smooth Movement: Limit acceleration for natural motion
- Obstacle Avoidance: Add steering to avoid collisions
- Leader Following: Designate leaders for directed movement
- Performance: Limit neighbor checks for large flocks
- Visual Variety: Add slight variations to individuals
Key Takeaways
- ๐ฆ Simple rules create complex behaviors
- ๐ Separation prevents collisions
- ๐งญ Alignment creates coordinated movement
- ๐ฏ Cohesion keeps groups together
- ๐๏ธ Vision constraints add realism
- โ๏ธ Balance weights for different effects
- ๐ Emergent patterns arise naturally
- ๐ฎ Great for crowds, particles, and swarms
๐๏ธโโ๏ธ Practice Exercise
๐๏ธโโ๏ธ Exercise 1: Reynolds Boids โ Three Forces, Local Rules, Emergent Flock in One Pygame Window
Objective: Build a runnable pygame window in roughly 90 lines that shows three orthogonal Reynolds boids disciplines visible per frame on a 768ร480 play area plus a 320px force-vector sidebar (1088ร480 total). 30 boids spawn at random positions with random initial velocities and apply three additive steering forces every tick โ separation (avoid crowding neighbors via inverse-distance-weighted repulsion), alignment (match neighbor average velocity), and cohesion (steer toward neighbor centroid) โ within a vision_range of 50px and vision_angle of 270 degrees, with all forces summed into acceleration then Euler-integrated. (a) Local rules โ emergent global behavior: each boid does ZERO global computation and only sees neighbors within its vision cone; the three additive steering forces each compute a small per-tick adjustment; the global flock pattern emerges from the bottom up with no central FlockManager โ multi-agent emergent behavior, the orthogonal axis to chat-62's single-agent utility-AI ranking. (b) Reynolds steering formula steer = desired โ current_velocity capped by max_force: every rule ends with the same shape โ compute desired velocity from neighbors, normalize to max_speed, subtract current velocity, cap magnitude at max_force; the cap is what makes turns gradual over many frames rather than snap-pivots, the same incremental-adjustment-toward-target shape as the chat-46 platformer_camera smooth-follow exponential decay. (c) Distance-weighted separation via inverse-distance scaling: the separation rule's diff.normalize().divide(distance) makes the steering force grow hyperbolically as boids get closer, so a near-collision pushes much harder than a comfortable-spacing nudge โ natural collision-avoidance shape rather than uniform avoidance. Keys 1/2/3 toggle each force ON/OFF independently so each rule's absence is visible (turning off separation makes boids clump into a single point; turning off alignment makes the flock lose direction; turning off cohesion makes the flock disperse). R resets all three to ON. Sidebar shows a representative boid's three force vectors as colored arrows (red=separation, green=alignment, blue=cohesion) plus the resulting velocity in white, scaled for visibility. HUD shows separation/alignment/cohesion ON/OFF state, average flock speed, and the legend โ three orthogonal Reynolds boids disciplines visible per frame as toggleable forces and concrete force-vector lengths.
Instructions:
- Create a Vector2D helper class with add / subtract / multiply / divide / magnitude / normalize / limit methods (the lesson's Vector2D shape; needed for all three rules' vector arithmetic).
- Create a Boid class with position / velocity / acceleration vectors plus a stored last_forces tuple of (separation, alignment, cohesion) for sidebar visualization. Implement a vision-cone neighbors check (within VISION_RANGE=50px and VISION_ANGLE=270ยฐ of the boid's current heading via dot-product comparison).
- Implement separation(neighbors) using
steer += diff.normalize().divide(distance)for every neighbor inside DESIRED_SEP=25px โ the inverse-distance weighting is what makes urgent collisions push harder than comfortable spacings. Average across neighbors, then end with the Reynolds steering patternsteer = (averaged.normalize() * MAX_SPEED) โ current_velocity, limited to MAX_FORCE. - Implement alignment(neighbors) as the average neighbor velocity, then the same Reynolds steering pattern.
- Implement cohesion(neighbors) as steer toward the neighbor centroid, then the same Reynolds steering pattern.
- Per-tick update: compute neighbors once, compute the three forces, multiply each by its weight (0 if toggled off, 1.5 / 1.0 / 1.0 default for sep / ali / coh), sum into acceleration, integrate
velocity += acceleration; position += velocity * dt, wrap position around play area edges via modulo. - Render: blue boid dots in the play area; sidebar with a representative boid plus its three force-vector arrows (red / green / blue) and resulting velocity arrow (white). HUD shows toggle state and average speed.
- Wire keys 1 / 2 / 3 to toggle separation / alignment / cohesion individually; key R to reset all three to ON. Verify by toggling: sep-off boids clump to one point, ali-off boids point random ways, coh-off boids disperse to grid edges.
๐ก Hint
The Reynolds steering pattern repeats three times โ extract a helper reynolds(desired, vel) that does desired.normalize().multiply(MAX_SPEED).subtract(vel).limit(MAX_FORCE). The vision-cone check is a dot-product comparison: angle between (other โ self) and self.velocity must be less than VISION_ANGLE / 2. Keep MAX_FORCE small (~0.2) โ that's the per-tick steering cap that produces smooth gradual turns; a larger value snap-pivots the boid each tick. Distance-weighted separation requires you to call .divide(distance) AFTER .normalize() so the weighting is by 1/distance not by 1/distanceยฒ. Position-wrap (position.x %= PLAY_W) keeps boids on screen forever without edge-collision logic.
โ Example Solution
import pygame, random, math
W, H, PLAY_W = 1088, 480, 768
N, VISION_RANGE, VISION_ANGLE = 30, 50, math.radians(270)
MAX_SPEED, MAX_FORCE, DESIRED_SEP = 150, 0.2, 25
class V:
def __init__(s, x: float = 0, y: float = 0) -> None:
s.x: float = x
s.y: float = y
def add(s, o: 'V') -> 'V': return V(s.x + o.x, s.y + o.y)
def sub(s, o: 'V') -> 'V': return V(s.x - o.x, s.y - o.y)
def mul(s, k: float) -> 'V': return V(s.x * k, s.y * k)
def div(s, k: float) -> 'V': return V(s.x / k, s.y / k) if k else V()
def mag(s) -> float: return math.hypot(s.x, s.y)
def norm(s) -> 'V':
m = s.mag()
return s.div(m) if m else V()
def limit(s, m: float) -> 'V':
return s.norm().mul(m) if s.mag() > m else s
def reynolds(desired: 'V', vel: 'V') -> 'V':
return desired.norm().mul(MAX_SPEED).sub(vel).limit(MAX_FORCE)
class Boid:
def __init__(s, x: float, y: float) -> None:
s.p: V = V(x, y)
s.a: V = V()
ang = random.uniform(0, 2 * math.pi)
s.v: V = V(math.cos(ang) * 80, math.sin(ang) * 80)
s.last: tuple[V, V, V] = (V(), V(), V())
def can_see(s, o: 'Boid') -> bool:
d = o.p.sub(s.p); m = d.mag()
if m == 0 or m >= VISION_RANGE: return False
if s.v.mag() == 0: return True
cos_a = (d.x * s.v.x + d.y * s.v.y) / (m * s.v.mag())
return cos_a > math.cos(VISION_ANGLE / 2)
def step(s, boids: list['Boid'], ws: list[float], dt: float) -> None:
ns = [o for o in boids if o is not s and s.can_see(o)]
sep = V(); n = 0
for o in ns:
d = s.p.sub(o.p); m = d.mag()
if 0 < m < DESIRED_SEP:
sep = sep.add(d.norm().div(m)); n += 1 # inverse-distance
sep = reynolds(sep.div(n), s.v) if n else V()
if ns:
avg_v = V()
for o in ns: avg_v = avg_v.add(o.v)
ali = reynolds(avg_v.div(len(ns)), s.v)
cen = V()
for o in ns: cen = cen.add(o.p)
coh = reynolds(cen.div(len(ns)).sub(s.p), s.v)
else:
ali = coh = V()
sep, ali, coh = sep.mul(ws[0]), ali.mul(ws[1]), coh.mul(ws[2])
s.last = (sep, ali, coh)
s.a = sep.add(ali).add(coh)
s.v = s.v.add(s.a).limit(MAX_SPEED)
s.p = s.p.add(s.v.mul(dt))
s.p.x %= PLAY_W; s.p.y %= H
pygame.init()
screen = pygame.display.set_mode((W, H))
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier", 14)
boids = [Boid(random.uniform(0, PLAY_W), random.uniform(0, H)) for _ in range(N)]
on = [True, True, True]
weights = [1.5, 1.0, 1.0]
while True:
dt = clock.tick(60) / 1000.0
for e in pygame.event.get():
if e.type == pygame.QUIT: pygame.quit(); raise SystemExit
if e.type == pygame.KEYDOWN:
if e.key == pygame.K_1: on[0] = not on[0]
if e.key == pygame.K_2: on[1] = not on[1]
if e.key == pygame.K_3: on[2] = not on[2]
if e.key == pygame.K_r: on = [True, True, True]
ws = [weights[i] if on[i] else 0 for i in range(3)]
for b in boids: b.step(boids, ws, dt)
screen.fill((20, 30, 50))
for b in boids:
pygame.draw.circle(screen, (180, 220, 255), (int(b.p.x), int(b.p.y)), 4)
pygame.draw.line(screen, (80, 80, 80), (PLAY_W, 0), (PLAY_W, H))
bx, by = PLAY_W + 160, H // 2
pygame.draw.circle(screen, (255, 200, 100), (bx, by), 8)
sep, ali, coh = boids[0].last
for vec, col in [(sep, (255, 80, 80)), (ali, (80, 255, 80)), (coh, (80, 80, 255))]:
ex, ey = bx + int(vec.x * 250), by + int(vec.y * 250)
pygame.draw.line(screen, col, (bx, by), (ex, ey), 3)
vx, vy = bx + int(boids[0].v.x * 0.4), by + int(boids[0].v.y * 0.4)
pygame.draw.line(screen, (255, 255, 255), (bx, by), (vx, vy), 3)
avg = sum(b.v.mag() for b in boids) / N
lines = [f"sep={'ON' if on[0] else 'OFF'} ali={'ON' if on[1] else 'OFF'} coh={'ON' if on[2] else 'OFF'}",
"1/2/3 toggle R reset",
f"avg speed={avg:.0f}",
"red=sep grn=ali blu=coh wht=v"]
for i, t in enumerate(lines):
screen.blit(font.render(t, True, (240, 240, 240)), (PLAY_W + 10, 10 + i * 18))
pygame.display.flip()
๐ฏ Quick Quiz
Question 1: In Reynolds boids (separation + alignment + cohesion), how does the global flock pattern arise from the per-boid code?
Question 2: All three boid rules end with the same shape: steer = desired.normalize().multiply(MAX_SPEED).subtract(velocity).limit(MAX_FORCE). What does the limit(MAX_FORCE) step contribute that just returning desired directly would not?
Question 3: The separation rule weights closer neighbors more strongly via diff.normalize().divide(distance). Why this inverse-distance scaling instead of treating every in-range neighbor equally?
What's Next?
Now that you've mastered flocking behaviors, next we'll explore simple decision-making systems for game AI!