Skip to main content

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:

graph TD A["Flocking System"] --> B["Core Rules"] A --> C["Steering Behaviors"] A --> D["Advanced Features"] B --> E["Separation"] B --> F["Alignment"] B --> G["Cohesion"] C --> H["Seek/Flee"] C --> I["Arrive"] C --> J["Wander"] D --> K["Obstacle Avoidance"] D --> L["Leader Following"] D --> M["Predator/Prey"]

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

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ 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:

  1. 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).
  2. 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).
  3. 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 pattern steer = (averaged.normalize() * MAX_SPEED) โˆ’ current_velocity, limited to MAX_FORCE.
  4. Implement alignment(neighbors) as the average neighbor velocity, then the same Reynolds steering pattern.
  5. Implement cohesion(neighbors) as steer toward the neighbor centroid, then the same Reynolds steering pattern.
  6. 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.
  7. 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.
  8. 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!