Lesson 5: Steering & Flocking
A swirling flock of birds has no leader: every bird follows a few local rules, and the flock's shape emerges from them. In this lesson you build those rules as steering forces with real units, so your enemies, crowds and critters glide, swerve and gather instead of snapping from one direction to another.
šÆ Learning Objectives
By the end of this lesson, you will be able to:
- Build Reynolds-style steering (seek, flee and arrive) that returns an acceleration in px/s² and integrates with
dtin seconds. - Explain why a per-frame steering constant makes agents "barely turn", and fix it with a reaction time and a force limit.
- Build separation, alignment and cohesion from a neighbor list and blend them into one clamped steering force.
- Measure the effect of each flocking rule with two gauges: average spacing and heading order.
- Compare brute-force neighbor search with a spatial hash and estimate how the cost grows with flock size.
Project: Flock and Hawk, a 60-boid flock with rule toggles that scatters from your mouse pointer.
In This Lesson
š§ Steering: Desired Minus Current
Think about driving a car toward a parking space. You never teleport the car onto the right heading. You look at where you want to be going, compare it with where you are going, and turn the wheel a little toward the difference. A moment later you look again. That loop is the whole idea behind Craig Reynolds' steering behaviors (his 1987 boids paper and his 1999 "Steering Behaviors for Autonomous Characters"):
- Each behavior computes a desired velocity: the velocity the agent would like to have right now.
- The steering force is the desired velocity minus the current velocity.
- That force is limited, so the agent can only change its velocity so fast. That limit is what makes turns look smooth.
You already know the other half from the Velocity, Acceleration & Timesteps lesson: acceleration changes velocity, velocity changes position, and both are scaled by dt (semi-implicit Euler: velocity first, then position with the new velocity).
Getting the units right
Many steering tutorials were written for loops without dt: the force was added to the velocity once per frame, and a typical limit was max_force = 0.2. Plug that into a dt-based loop and the units no longer match. A boid with a top speed of 150 px/s and a force of 0.2 px/s² needs 300 ÷ 0.2 = 1,500 seconds to reverse direction. The boids "barely turn": a classic bug when per-frame code is moved into a dt-based loop.
The fix is to be honest about units. desired - velocity is a velocity error in px/s. Dividing it by a reaction time in seconds turns it into an acceleration in px/s², and clamp_magnitude(MAX_FORCE) caps it:
MAX_SPEED = 220.0 # px/s
MAX_FORCE = 440.0 # px/s^2: can reverse full speed (440 px/s of change) in about one second
REACTION_TIME = 0.2 # s: how quickly a velocity error becomes a full push
def steer_toward(desired, velocity):
"""Reynolds steering: the acceleration (px/s^2) that turns velocity toward desired."""
return ((desired - velocity) / REACTION_TIME).clamp_magnitude(MAX_FORCE)
def integrate(pos, velocity, accel, dt):
"""Semi-implicit Euler: velocity first, then position with the new velocity."""
velocity = (velocity + accel * dt).clamp_magnitude(MAX_SPEED)
return pos + velocity * dt, velocity
| Quantity | Unit | What it means for feel |
|---|---|---|
MAX_SPEED | px/s | How fast the agent can go |
MAX_FORCE | px/s² | How sharply it can turn or brake. Reversal time ā 2 Ć MAX_SPEED Ć· MAX_FORCE |
REACTION_TIME | s | How "twitchy" small corrections are; big errors hit the MAX_FORCE cap anyway |
pygame.Vector2.clamp_magnitude(max) shortens a vector that is too long and leaves shorter ones alone, and it is safe on a zero vector. Its two-argument form, clamp_magnitude(min, max), also stretches short vectors, so it raises ValueError on a zero vector: guard that case before calling it.
ā Growth Mindset: Units Are a Debugging Superpower
When an agent turns like a supertanker or spins like a top, the cause is almost never "AI is hard". It is usually a number in the wrong unit. Write the unit next to every constant (# px/s^2) and ask "how long would a full turn take?" on paper. If you can't answer that yet, that is the thing to work out before you touch the code again.
šÆ Seek and Flee
Seek wants to go straight at the target at full speed. Flee wants to go straight away from a threat at full speed, but only while the threat is close (the panic radius); outside it, it asks for zero velocity and brakes.
PANIC_RADIUS = 200.0 # px
def seek(pos, velocity, target):
offset = target - pos
if offset.length_squared() == 0:
return pygame.Vector2()
return steer_toward(offset.normalize() * MAX_SPEED, velocity)
def flee(pos, velocity, threat):
offset = pos - threat # points AWAY from the threat
dist_sq = offset.length_squared()
if dist_sq == 0 or dist_sq > PANIC_RADIUS ** 2:
return steer_toward(pygame.Vector2(), velocity) # safe: brake to a stop
return steer_toward(offset.normalize() * MAX_SPEED, velocity)
An older shortcut you may see is flee = -seek(threat). It looks symmetric, but it is wrong. Seek is desired - velocity, so its negative is velocity - desired: the "away" part is right, but the +velocity term keeps pushing the agent along whatever heading it already has, even toward the threat. Always compute flee's own desired velocity.
š® Predict, then run
A seeking agent heads for a target that doesn't move. Does it stop on the target? Predict, then try SEEK mode in the program in the next section. (It doesn't: seek always wants full speed, so it overshoots, turns and swings back through the target. That is why the next behavior exists.)
š¬ Arrive
Arrive is seek with brakes. Outside a slowing radius it behaves exactly like seek. Inside, the desired speed shrinks in proportion to the distance, so the agent eases in and stops on the spot, like a player character walking up to a door.
SLOW_RADIUS = 140.0 # px
def arrive(pos, velocity, target):
offset = target - pos
dist = offset.length()
if dist < 1:
desired = pygame.Vector2() # there: ask for zero velocity
else:
speed = MAX_SPEED * min(1.0, dist / SLOW_RADIUS)
desired = offset * (speed / dist) # same direction, scaled speed
return steer_toward(desired, velocity)
Pick the slowing radius with the braking distance in mind. At full speed v with a braking limit a, stopping takes v² ÷ (2a) pixels: 220² ÷ 880 = 55 px with this lesson's numbers. A slowing radius comfortably larger than that (140 px here) lets arrive stop without overshooting.
Here is the complete warm-up program (the lab file seek_arrive_solution.py). Click to move the yellow target and press SPACE to switch between SEEK, FLEE and ARRIVE. The green line is the velocity and the red line is the steering force.
"""Seek, Flee and Arrive: Advanced Lesson 5 warm-up (solution).
One vehicle steers toward (or away from) the target you click.
SPACE cycles the behavior: SEEK -> FLEE -> ARRIVE. Close the window to quit.
Speeds are px/s, steering forces are accelerations in px/s^2, dt is in seconds.
"""
import pygame
WIDTH, HEIGHT = 800, 450
MAX_SPEED = 220.0 # px/s
MAX_FORCE = 440.0 # px/s^2: can reverse full speed in about one second
REACTION_TIME = 0.2 # s: how quickly a velocity error becomes a full push
SLOW_RADIUS = 140.0 # px: arrive starts braking inside this circle
PANIC_RADIUS = 200.0 # px: flee ignores targets farther away than this
MODES = ["SEEK", "FLEE", "ARRIVE"]
def steer_toward(desired, velocity):
"""Reynolds steering: the acceleration (px/s^2) that turns velocity toward desired.
desired - velocity is a velocity error in px/s. Dividing by a reaction time
turns it into px/s^2, and the clamp keeps every turn gradual.
"""
return ((desired - velocity) / REACTION_TIME).clamp_magnitude(MAX_FORCE)
def seek(pos, velocity, target):
offset = target - pos
if offset.length_squared() == 0:
return pygame.Vector2()
return steer_toward(offset.normalize() * MAX_SPEED, velocity)
def flee(pos, velocity, threat):
offset = pos - threat # points AWAY from the threat
dist_sq = offset.length_squared()
if dist_sq == 0 or dist_sq > PANIC_RADIUS ** 2:
return steer_toward(pygame.Vector2(), velocity) # safe: brake to a stop
return steer_toward(offset.normalize() * MAX_SPEED, velocity)
def arrive(pos, velocity, target):
offset = target - pos
dist = offset.length()
if dist < 1:
desired = pygame.Vector2() # there: ask for zero velocity
else:
speed = MAX_SPEED * min(1.0, dist / SLOW_RADIUS)
desired = offset * (speed / dist) # same direction, scaled speed
return steer_toward(desired, velocity)
def integrate(pos, velocity, accel, dt):
"""Semi-implicit Euler: velocity first, then position with the new velocity."""
velocity = (velocity + accel * dt).clamp_magnitude(MAX_SPEED)
return pos + velocity * dt, velocity
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Seek, Flee and Arrive (click to move the target, SPACE to switch)")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
pos = pygame.Vector2(120, HEIGHT / 2)
velocity = pygame.Vector2(0, -120)
target = pygame.Vector2(470, HEIGHT / 2)
mode = 2 # start in ARRIVE
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.05)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
target = pygame.Vector2(event.pos)
elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
mode = (mode + 1) % len(MODES)
if MODES[mode] == "SEEK":
accel = seek(pos, velocity, target)
elif MODES[mode] == "FLEE":
accel = flee(pos, velocity, target)
else:
accel = arrive(pos, velocity, target)
pos, velocity = integrate(pos, velocity, accel, dt)
pos.x %= WIDTH # wrap around the edges
pos.y %= HEIGHT
screen.fill((18, 22, 34))
if MODES[mode] == "ARRIVE":
pygame.draw.circle(screen, (60, 70, 100), target, SLOW_RADIUS, 1)
if MODES[mode] == "FLEE":
pygame.draw.circle(screen, (100, 60, 60), target, PANIC_RADIUS, 1)
pygame.draw.circle(screen, (250, 204, 21), target, 8)
pygame.draw.circle(screen, (96, 165, 250), pos, 12)
pygame.draw.line(screen, (134, 239, 172), pos, pos + velocity * 0.3, 2)
pygame.draw.line(screen, (248, 113, 113), pos, pos + accel * 0.1, 2)
text = f"{MODES[mode]} speed {velocity.length():5.1f} px/s distance {pos.distance_to(target):5.1f} px"
screen.blit(font.render(text, True, (230, 230, 230)), (10, 10))
screen.blit(font.render("green = velocity red = steering force", True, (160, 170, 190)), (10, 36))
pygame.display.flip()
pygame.quit()
print(f"mode: {MODES[mode].lower()}")
print(f"distance to target: {pos.distance_to(target):.1f} px, speed {velocity.length():.1f} px/s")
if __name__ == "__main__":
main()
š” Why this matters
Seek, flee and arrive are the building blocks of most movement AI: a guard walking to a waypoint arrives, a homing missile seeks, a deer flees. Because each behavior returns a force in the same units, you can add them together, which is exactly what flocking does next.
š¦ Boids: Three Local Rules
Reynolds called his simulated birds boids. Each boid looks only at its neighbors (other boids within a view radius, and inside a vision cone so it ignores the ones straight behind it) and applies three rules:
Finding neighbors uses the dot product from the Vectors lesson: the angle between the boid's heading and the direction to another boid is inside a 270° cone when the cosine of that angle is above cos(135°).
VIEW_RADIUS = 60.0 # px
VIEW_COS = math.cos(math.radians(270 / 2)) # 270-degree vision cone
def neighbors(boid, boids):
found = []
heading = boid.vel.normalize() if boid.vel.length_squared() > 0 else None
for other in boids:
if other is boid:
continue
offset = other.pos - boid.pos
dist_sq = offset.length_squared()
if dist_sq == 0 or dist_sq > VIEW_RADIUS ** 2:
continue
if heading is not None and offset.normalize().dot(heading) < VIEW_COS:
continue # behind us, in the blind spot
found.append(other)
return found
Each rule turns the neighbor list into a desired velocity and reuses steer_toward():
def separation(boid, near):
"""Steer away from crowding neighbors; closer ones decide the direction more."""
push = pygame.Vector2()
for other in near:
offset = boid.pos - other.pos
dist_sq = offset.length_squared()
if 0 < dist_sq < SEP_RADIUS ** 2:
push += offset / dist_sq # unit vector / distance
if push.length_squared() == 0:
return pygame.Vector2()
return steer_toward(push.normalize() * MAX_SPEED, boid.vel)
def alignment(boid, near):
"""Steer toward the neighbors' average velocity."""
if not near:
return pygame.Vector2()
average = sum((other.vel for other in near), pygame.Vector2()) / len(near)
if average.length_squared() == 0:
return pygame.Vector2()
return steer_toward(average.normalize() * MAX_SPEED, boid.vel)
def cohesion(boid, near):
"""Seek the neighbors' center of mass."""
if not near:
return pygame.Vector2()
center = sum((other.pos for other in near), pygame.Vector2()) / len(near)
offset = center - boid.pos
if offset.length_squared() == 0:
return pygame.Vector2()
return steer_toward(offset.normalize() * MAX_SPEED, boid.vel)
What the separation weighting really does
offset / dist_sq is a unit vector divided by the distance, so a neighbor 10 px away contributes twice as much as one 20 px away. Be precise about what that buys you. The sum is then normalized, which throws its length away: the 1/distance weighting decides the direction of the push (the nearest neighbors dominate it), not how hard it pushes. The strength comes from the rule's weight and the MAX_FORCE clamp. If you want a push that also gets stronger up close, scale the desired speed by closeness instead of normalizing. Both are reasonable design choices; neither is "the physics".
Note the sum(..., pygame.Vector2()) start value: without it, sum() starts from the integer 0, and 0 + Vector2 raises TypeError.
āļø Blending the Forces
A boid's total steering is a weighted sum of its rules, clamped once more to MAX_FORCE. The weights are your tuning knobs: more separation gives a looser flock, more cohesion a tighter ball, more alignment a disciplined stream.
WEIGHTS = {"separation": 1.8, "alignment": 1.0, "cohesion": 0.8, "flee": 3.0}
def flock_force(boid, boids, enabled, hawk=None):
near = neighbors(boid, boids)
total = pygame.Vector2()
rules = {"separation": separation, "alignment": alignment, "cohesion": cohesion}
for name, rule in rules.items():
if enabled[name]:
total += rule(boid, near) * WEIGHTS[name]
total += flee(boid, hawk) * WEIGHTS["flee"]
return total.clamp_magnitude(MAX_FORCE)
def step(boids, enabled, dt, hawk=None):
forces = [flock_force(b, boids, enabled, hawk) for b in boids] # 1. everyone looks
for boid, accel in zip(boids, forces): # 2. then everyone moves
update_boid(boid, accel, dt)
The flock's flee(boid, hawk) is a slightly different version of the warm-up's flee(). It takes the boid, returns zero when there is no hawk (hawk is None), and returns zero instead of braking when the hawk is outside the panic radius, because a boid should keep flocking, not stop, once it is safe. The complete version is in the exercise solution below.
Two details make this robust:
- Look first, then move. If you moved each boid as soon as its force was computed, later boids would react to a mix of old and new positions, and the result would depend on list order.
- A speed band.
vel.clamp_magnitude(MIN_SPEED, MAX_SPEED)keeps boids from hovering in place, which real birds can't do. Guard the zero vector first, as the lab does.
Play with the rules below. Each button toggles one rule; watch the two gauges. Spacing is the average distance from each boid to its nearest flockmate, and order is the length of the average velocity divided by the average speed: 1.0 when every boid flies the same way, near 0 when headings are random.
What you should see in the lab version (these numbers come from running the lab with its default seed, 7: 60 boids after 10 simulated seconds at 1/60 s steps): with every rule on, spacing is about 23 px and order about 0.9. Separation off: spacing collapses to about 1 px, because boids pile on top of each other. Alignment off: order falls to about 0.2. Cohesion off: order stays high but spacing grows to about 35 px, a looser sheet instead of a tight group. The random start matters a lot: across seeds 0 to 11, the all-on order ranged from about 0.5 to 0.96, separation-off spacing from under 1 px to about 6 px, and alignment-off order from about 0.05 to 0.25. Compare gauges within one seed, not across seeds.
ā Growth Mindset: Tuning Is an Experiment, Not a Guess
Your first flock may look like a traffic jam or an explosion. That's normal: nobody gets the weights right the first time, including the people who ship games with flocks in them. Change one weight at a time, watch the gauges, and write down what happened. You aren't bad at tuning; you just haven't collected enough observations yet.
š Scaling Up the Flock
The neighbors() function compares every boid with every other boid. With n boids that is n(n ā 1) distance checks per frame: 3,540 for 60 boids, but 249,500 for 500. The work grows with the square of the flock size, so doubling the flock roughly quadruples it.
The fix is the spatial hash from the Spatial Hashing & Object Pools lesson. Choose a cell size equal to the view radius, drop every boid into the cell under it once per frame, and look only in the 3 Ć 3 block of cells around each boid. Anything farther away can't be within the view radius anyway.
def build_grid(boids, cell=VIEW_RADIUS):
grid = {}
for b in boids:
grid.setdefault((int(b.pos.x // cell), int(b.pos.y // cell)), []).append(b)
return grid
def nearby(boid, grid, cell=VIEW_RADIUS):
cx, cy = int(boid.pos.x // cell), int(boid.pos.y // cell)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
yield from grid.get((cx + dx, cy + dy), ())
How much this saves depends on how crowded the cells are, so measure it rather than trust a rule of thumb. Wrap step() in the time.perf_counter() pattern from the Profiling & Performance lesson and compare both versions at 100, 300 and 500 boids on your machine. (With screen wrapping, boids near one edge also have neighbors across the other edge; the simple grid above ignores that, which is usually fine.)
šļø Practice Exercise: Flock and Hawk
Objective: make sixty boids flock with separation, alignment and cohesion, and scatter from a hawk that follows your mouse pointer.
Time: about 40 minutes. Starter file: flock_starter.py (your instructor has it). The boids fly straight and ignore each other; the neighbor search, the hawk and the drawing are done. The numbered to-do comments (1 to 5) in the file follow steps 2 to 5 below, in order.
- Run the starter and move the mouse: the boids ignore everything. (ā 2 min)
- Write
steer_toward(): divide the velocity error byREACTION_TIME, then clamp toMAX_FORCE. Nothing changes on screen yet, because no force reaches the velocity. (ā 5 min) - In
update_boid(), addaccel * dtto the velocity and keep its length betweenMIN_SPEEDandMAX_SPEED(guard the zero vector). The hawk now scares the boids. (ā 5 min) - Write
separation()with theoffset / dist_sqweighting. Watchspacingin the HUD. (ā 8 min) - Write
alignment()andcohesion(). Watchorderclimb. (ā 10 min) - Press 1, 2 and 3 to switch each rule off and on (R turns them all back on) and note what each gauge does. (ā 10 min)
You are done when:
- with all rules on, the flock forms groups that fly together and
orderclimbs well above 0.5; - switching separation off makes
spacingdrop to a few pixels, and switching alignment off makesorderdrop; - the flock splits around the mouse pointer and closes up again behind it;
- closing the window prints the rule states and the final spacing.
š” Hint
Every rule has the same shape: build a desired velocity (a direction times MAX_SPEED), then return steer_toward(desired, boid.vel). Guard every normalize() with a length check, because two boids can land on the same pixel. If the boids barely turn, print one steering force: it should be in the hundreds of px/s², not below 1.
ā Example Solution
The lab file has a few extra lines marked lab runtime near the top and and frame_budget() in the loop, so the instructor's checker can run it automatically. They do nothing when you run it yourself, and they are left out here.
"""Flock and Hawk: Advanced Lesson 5 practice exercise (solution).
Sixty boids flock with separation, alignment and cohesion, and flee the hawk
(your mouse pointer). Keys 1/2/3 toggle each rule, R turns them all back on.
Close the window to quit. Speeds are px/s, forces px/s^2, dt in seconds.
"""
import math
import random
import pygame
WIDTH, HEIGHT = 900, 540
NUM_BOIDS = 60
MAX_SPEED = 160.0 # px/s
MIN_SPEED = 60.0 # px/s: boids never hover in place
MAX_FORCE = 320.0 # px/s^2
REACTION_TIME = 0.25 # s
VIEW_RADIUS = 60.0 # px: how far a boid can see its neighbors
VIEW_COS = math.cos(math.radians(270 / 2)) # 270-degree vision cone
SEP_RADIUS = 24.0 # px: neighbors closer than this push us away
PANIC_RADIUS = 110.0 # px: the hawk scares boids inside this circle
WEIGHTS = {"separation": 1.8, "alignment": 1.0, "cohesion": 0.8, "flee": 3.0}
RULE_KEYS = {pygame.K_1: "separation", pygame.K_2: "alignment", pygame.K_3: "cohesion"}
class Boid:
def __init__(self, pos, vel):
self.pos = pygame.Vector2(pos)
self.vel = pygame.Vector2(vel)
def steer_toward(desired, vel):
"""Acceleration (px/s^2) that turns vel toward desired, clamped to MAX_FORCE."""
return ((desired - vel) / REACTION_TIME).clamp_magnitude(MAX_FORCE)
def neighbors(boid, boids):
"""Boids within VIEW_RADIUS and inside the vision cone (never the boid itself)."""
found = []
heading = boid.vel.normalize() if boid.vel.length_squared() > 0 else None
for other in boids:
if other is boid:
continue
offset = other.pos - boid.pos
dist_sq = offset.length_squared()
if dist_sq == 0 or dist_sq > VIEW_RADIUS ** 2:
continue
if heading is not None and offset.normalize().dot(heading) < VIEW_COS:
continue # behind us, in the blind spot
found.append(other)
return found
def separation(boid, near):
"""Steer away from crowding neighbors; closer ones decide the direction more."""
push = pygame.Vector2()
for other in near:
offset = boid.pos - other.pos
dist_sq = offset.length_squared()
if 0 < dist_sq < SEP_RADIUS ** 2:
push += offset / dist_sq # unit vector / distance
if push.length_squared() == 0:
return pygame.Vector2()
return steer_toward(push.normalize() * MAX_SPEED, boid.vel)
def alignment(boid, near):
"""Steer toward the neighbors' average velocity."""
if not near:
return pygame.Vector2()
average = sum((other.vel for other in near), pygame.Vector2()) / len(near)
if average.length_squared() == 0:
return pygame.Vector2()
return steer_toward(average.normalize() * MAX_SPEED, boid.vel)
def cohesion(boid, near):
"""Seek the neighbors' center of mass."""
if not near:
return pygame.Vector2()
center = sum((other.pos for other in near), pygame.Vector2()) / len(near)
offset = center - boid.pos
if offset.length_squared() == 0:
return pygame.Vector2()
return steer_toward(offset.normalize() * MAX_SPEED, boid.vel)
def flee(boid, hawk):
"""Run straight away from the hawk while it is inside PANIC_RADIUS."""
if hawk is None:
return pygame.Vector2()
offset = boid.pos - hawk
dist_sq = offset.length_squared()
if dist_sq == 0 or dist_sq > PANIC_RADIUS ** 2:
return pygame.Vector2()
return steer_toward(offset.normalize() * MAX_SPEED, boid.vel)
def flock_force(boid, boids, enabled, hawk=None):
"""Weighted sum of every enabled rule, clamped to MAX_FORCE."""
near = neighbors(boid, boids)
total = pygame.Vector2()
rules = {"separation": separation, "alignment": alignment, "cohesion": cohesion}
for name, rule in rules.items():
if enabled[name]:
total += rule(boid, near) * WEIGHTS[name]
total += flee(boid, hawk) * WEIGHTS["flee"]
return total.clamp_magnitude(MAX_FORCE)
def update_boid(boid, accel, dt):
"""Semi-implicit Euler with a speed band, then wrap around the screen."""
boid.vel += accel * dt
if boid.vel.length_squared() == 0: # clamp_magnitude needs a direction
boid.vel = pygame.Vector2(MIN_SPEED, 0)
boid.vel = boid.vel.clamp_magnitude(MIN_SPEED, MAX_SPEED)
boid.pos += boid.vel * dt
boid.pos.x %= WIDTH
boid.pos.y %= HEIGHT
def step(boids, enabled, dt, hawk=None):
"""Advance the whole flock one frame. Forces use this frame's positions only."""
forces = [flock_force(b, boids, enabled, hawk) for b in boids]
for boid, accel in zip(boids, forces):
update_boid(boid, accel, dt)
def make_flock(seed=None):
rng = random.Random(seed)
boids = []
for _ in range(NUM_BOIDS):
angle = rng.uniform(0, math.tau)
speed = rng.uniform(MIN_SPEED, MAX_SPEED)
vel = pygame.Vector2(math.cos(angle), math.sin(angle)) * speed
boids.append(Boid((rng.uniform(0, WIDTH), rng.uniform(0, HEIGHT)), vel))
return boids
def mean_spacing(boids):
"""Average distance from each boid to its nearest flockmate (a crowding gauge)."""
total = 0.0
for boid in boids:
total += min(boid.pos.distance_to(o.pos) for o in boids if o is not boid)
return total / len(boids)
def heading_order(boids):
"""1.0 when every boid flies the same way, near 0 when headings are random."""
average = sum((b.vel for b in boids), pygame.Vector2()) / len(boids)
mean_speed = sum(b.vel.length() for b in boids) / len(boids)
return average.length() / mean_speed
def draw_boid(surface, boid, color):
forward = boid.vel.normalize() if boid.vel.length_squared() > 0 else pygame.Vector2(1, 0)
side = pygame.Vector2(-forward.y, forward.x)
tip = boid.pos + forward * 9
left = boid.pos - forward * 6 + side * 5
right = boid.pos - forward * 6 - side * 5
pygame.draw.polygon(surface, color, (tip, left, right))
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Flock and Hawk (1/2/3 toggle rules, R reset)")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24)
boids = make_flock(seed=7)
enabled = {"separation": True, "alignment": True, "cohesion": True}
hawk = None # set by mouse motion
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.05)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEMOTION:
hawk = pygame.Vector2(event.pos)
elif event.type == pygame.WINDOWLEAVE:
hawk = None
elif event.type == pygame.KEYDOWN:
if event.key in RULE_KEYS:
name = RULE_KEYS[event.key]
enabled[name] = not enabled[name]
elif event.key == pygame.K_r:
enabled = {name: True for name in enabled}
step(boids, enabled, dt, hawk)
screen.fill((15, 23, 42))
if hawk is not None:
pygame.draw.circle(screen, (90, 40, 40), hawk, PANIC_RADIUS, 1)
pygame.draw.circle(screen, (248, 113, 113), hawk, 9)
for boid in boids:
draw_boid(screen, boid, (125, 211, 252))
states = " ".join(f"{i}:{name[:3]} {'ON' if enabled[name] else 'off'}"
for i, name in enumerate(enabled, start=1))
hud = f"{states} spacing {mean_spacing(boids):5.1f} px order {heading_order(boids):.2f}"
screen.blit(font.render(hud, True, (226, 232, 240)), (10, 10))
pygame.display.flip()
pygame.quit()
rules = ", ".join(f"{name} {'on' if on else 'off'}" for name, on in enabled.items())
print(f"Boids: {len(boids)}; rules: {rules}")
print(f"mean spacing {mean_spacing(boids):.1f} px, heading order {heading_order(boids):.2f}")
if __name__ == "__main__":
main()
š Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
āļø This lesson's prompts:
- In your own words, why does
desired - velocityneed to be divided by a time before it can be used as an acceleration? - Which of the three flocking rules surprised you most when you turned it off? Describe what you saw and what the gauges said.
- Name a game you know where a group moves as one (fish, zombies, soldiers, traffic). Which steering behaviors do you think it uses?
š Summary
Steering replaces "set the velocity" with "push the velocity toward what you want". Each behavior computes a desired velocity; the difference from the current velocity, divided by a reaction time and clamped to a maximum force, is an acceleration in px/s² that plugs straight into your dt-based integrator. Seek, flee and arrive handle single targets. Boids add three local rules, separation, alignment and cohesion, and a weighted, clamped sum of them makes a flock with no leader. Two gauges, spacing and heading order, turn "does it look right?" into something you can measure.
š Key Takeaways
- Steering force = (desired velocity ā current velocity) Ć· reaction time, clamped to
MAX_FORCEin px/s². - Seek overshoots; arrive scales the desired speed down inside a slowing radius; flee has its own desired velocity (never just
-seek). - Separation, alignment and cohesion use only nearby neighbors; the flock's shape emerges from them.
- After normalizing, 1/distance weighting sets the push's direction, not its strength.
- Compute every force first, then move everyone; guard every
normalize()and theclamp_magnitude(min, max)zero case. - Brute-force neighbor search grows with n²; a spatial hash keeps big flocks affordable. Measure before and after.
š Looking Ahead
Steering is great at "go there smoothly", but it can't find a way around a maze of walls. In the next lesson, Advanced Pathfinding, you make A* faster and learn flow fields, which let a whole crowd share one search and follow it with the steering you built today.
ā Common Questions
Why not just set the velocity to the desired velocity?
You can, and the agent will snap to every new heading in a single frame. It looks robotic, and it ignores momentum, so physics and animation stop matching. The force limit is what gives agents a believable turning circle.
What value should REACTION_TIME have?
Small values (0.1ā0.25 s) make corrections crisp; large ones make agents feel sluggish. For large errors it hardly matters, because the force hits MAX_FORCE anyway. Tune MAX_FORCE first for the turning circle, then REACTION_TIME for how twitchy small corrections look.
My boids clump into a single dot. What's wrong?
Separation is off, too weak, or its radius is smaller than the boids themselves. Check that it pushes away (boid.pos - other.pos, not the reverse) and try a larger weight. The spacing gauge tells you immediately whether it's working.
Do I need the vision cone?
No. Without it, boids react to neighbors behind them too, and flocks tend to look a little stiffer. It is a cheap dot-product test, so it's worth trying both and keeping the one you like.
Can steering avoid walls?
Yes: a common trick casts a short "feeler" ahead and steers away from a wall it hits. It works for open spaces with a few obstacles, but steering alone can't solve mazes. That's what pathfinding is for, and the two are usually combined: pathfinding picks the route, steering follows it.
Should flocking run every frame?
For small flocks, yes. For large ones, games often update neighbor lists or even the rules less often (every few frames, in staggered groups) while still integrating movement every frame. Measure first; the spatial hash usually fixes the cost before you need tricks like that.
šÆ Quick Quiz
Question 1: A boid flies at 150 px/s and needs to reverse to ā150 px/s. With MAX_FORCE = 300 px/s², about how long does the full reversal take at maximum force?
Question 2: Why is flee = -seek(threat) wrong?
Question 3: Separation adds offset / dist_sq for each close neighbor, then normalizes the sum. What does the 1/distance weighting control?
Question 4: Why does step() compute every boid's force before moving any boid?
Question 5: In Flock and Hawk you switch alignment off and leave the other rules on. What do the gauges show?
š Going Further
- Wander: give an idle boid a target on a small circle projected ahead of it, and nudge that target's angle randomly each frame (with a
random.Randominstance). Seek it for a natural meander. - Pursuit: make the hawk seek where the nearest boid will be:
target = boid.pos + boid.vel * t, witht= distance Ć· hawk speed. - Leader following: pick one boid as the leader, let it arrive at mouse clicks, and have the others arrive at a point behind it.
- Measure the spatial hash: swap
neighbors()for the grid version and timestep()at 100, 300 and 500 boids withtime.perf_counter(). - Read the source: Craig Reynolds' Boids page and Steering Behaviors for Autonomous Characters, and the pygame-ce pygame.math reference for
clamp_magnitude. - Coming up in Game Dev III: Advanced: Real-time Strategy combines flow fields with steering to move whole armies.