The previous chapter (11.1, pathfinding) answered "what route should I take across the level." This chapter answers a different question: once an agent knows roughly where it wants to go, or is just chasing, fleeing, or hanging out near other agents, how does it actually move there frame by frame so it looks alive instead of robotic? An enemy that snaps its velocity straight at the player, a companion that teleports in tiny jumps toward you, a flock of birds that all fly in a perfectly rigid line — none of that looks right. Steering behaviors (a set of small, reusable rules for computing a movement force, invented by Craig Reynolds in 1987) are the standard answer, and they are used everywhere: every RTS unit, every open-world NPC, every school of fish or flock of birds in a game, and the crowd systems in almost any big-budget title.
This chapter builds steering up from nothing: first the shared model every behavior uses (position, velocity, and a force that nudges velocity — directly reusing the calculus chapter's force-to-position pipeline and the linear algebra chapter's vector toolbox), then one behavior at a time (seek, flee, arrive, pursue, evade, wander, obstacle avoidance), then how to combine several behaviors running at once, then the famous case where dozens or hundreds of agents follow the same three rules and a flock emerges on its own. It closes with how steering and pathfinding fit together, and a section on the single most common complaint about steering code in practice: why it jitters, and how to stop it.
Every steering agent carries the same two pieces of state you already know from chapter 2.1: a position (a point — where it is) and a velocity (a vector — which way it is moving, and how fast). Each frame, the agent decides what it wants to do — chase a target, run from a threat, hold formation with its neighbors — and turns that decision into a single vector called the steering force. That force does not move the agent directly. It pushes on the agent's velocity, exactly the way a real force pushes on a real object.
That is precisely the chain chapter 2.5 built: force divided by mass gives acceleration, acceleration integrates into velocity, velocity integrates into position. Steering code almost always assumes mass = 1, so "steering force" and "acceleration" become the same number — one less thing to track. The integration step is semi-implicit Euler, the stable, cheap default from chapter 2.5, section 6: update velocity first, then use the new velocity to update position.
Two numbers control how an agent "feels": maxSpeed (how fast it can ever move) and maxForce (how sharply it can turn or accelerate, since force is capped acceleration). A low maxForce with a high maxSpeed gives you something heavy and hard to turn, like a cargo ship. A high maxForce gives you something twitchy and responsive, like a housefly. Here is the whole model as one small class, with no target or behavior baked in yet — just the physics pipeline:
using UnityEngine;
// The smallest possible steering agent: position + velocity, plus the
// two knobs every behavior in this chapter respects.
public class Steerable
{
public Vector2 position;
public Vector2 velocity;
public float maxSpeed = 5f;
public float maxForce = 10f; // mass is assumed to be 1, so force == acceleration
// Runs ANY steering force through the same pipeline from chapter 2.5:
// clamp the force, step velocity first, then step position.
public void ApplySteering(Vector2 steeringForce, float dt)
{
steeringForce = Vector2.ClampMagnitude(steeringForce, maxForce); // cap acceleration
velocity += steeringForce * dt; // acceleration -> velocity
velocity = Vector2.ClampMagnitude(velocity, maxSpeed); // cap top speed
position += velocity * dt; // velocity -> position
}
}
Notice this uses Vector2 (not Vector3) — the rest of this chapter works in a flat, top-down plane, the same way chapter 2.1 introduced Vec2 before moving to 3D. Every formula here extends to Vector3 unchanged (for example on the XZ ground plane, the way chapter 6.2's character movement did); the only thing that changes is one extra component per vector.
To see the pipeline do something before we attach any real behavior, push the agent with a constant, made-up force and watch velocity and position evolve. This is the exact same "step velocity, then position" arithmetic as the frictionless spring in chapter 2.5 — just with a different source for the force:
using UnityEngine;
Steerable agent = new Steerable { maxSpeed = 10f, maxForce = 10f };
Vector2 constantForce = new Vector2(2f, 0f); // a plain push, not aimed at any target yet
float dt = 0.5f;
for (int step = 0; step < 4; step++)
{
Debug.Log($"step {step}: velocity=({agent.velocity.x:F1}, {agent.velocity.y:F1}) " +
$"position=({agent.position.x:F1}, {agent.position.y:F1})");
agent.ApplySteering(constantForce, dt);
}
Output (Console window):
step 0: velocity=(0.0, 0.0) position=(0.0, 0.0)
step 1: velocity=(1.0, 0.0) position=(0.5, 0.0)
step 2: velocity=(2.0, 0.0) position=(1.5, 0.0)
step 3: velocity=(3.0, 0.0) position=(3.0, 0.0)
Every frame velocity grows by force * dt = 2 * 0.5 = 1, and position grows by whatever the new velocity is, times dt — exactly semi-implicit Euler. Every behavior in this chapter, from here to flocking, only changes one thing: how steeringForce gets computed. The pipeline that turns it into movement never changes again.
steering = desired velocity - current velocity. Read it as "the push needed to fix the gap between how I'm moving and how I want to be moving." Once you see this pattern you can predict most of the chapter before reading it.velocity = desired) instead of steering toward it (velocity += steering * dt). That snaps the agent's heading instantly every frame — no momentum, no smooth turning, the exact "instant, not floaty" robotic movement chapter 6.2 warned about for player controllers. The whole point of routing everything through a force is that velocity changes gradually.Seek is the simplest real behavior: move toward a target position as directly as possible. The "what do I want" step from section 1 becomes "I want to be moving at maxSpeed, straight at the target." Compute that desired velocity, subtract the current velocity, and you have the steering force.
using UnityEngine;
public static class Steering
{
public static Vector2 Seek(Vector2 position, Vector2 velocity, Vector2 target,
float maxSpeed, float maxForce)
{
Vector2 desired = (target - position).normalized * maxSpeed; // straight at the target, full speed
Vector2 steer = desired - velocity; // "how wrong is my heading?"
return Vector2.ClampMagnitude(steer, maxForce);
}
}
Trace it by hand for nine frames: an agent starting at rest at the origin, seeking a target ten units to the right, with maxSpeed = 5, maxForce = 10, dt = 0.2:
Vector2 pos = Vector2.zero, vel = Vector2.zero;
Vector2 target = new Vector2(10f, 0f);
float maxSpeed = 5f, maxForce = 10f, dt = 0.2f;
for (int step = 0; step < 9; step++)
{
float dist = Vector2.Distance(target, pos);
Debug.Log($"{step,2} pos=({pos.x,6:F3},{pos.y,6:F3}) vel=({vel.x,6:F3},{vel.y,6:F3}) dist={dist,6:F3}");
Vector2 steer = Steering.Seek(pos, vel, target, maxSpeed, maxForce);
vel = Vector2.ClampMagnitude(vel + steer * dt, maxSpeed);
pos += vel * dt;
}
Output (Console window):
step pos.x pos.y vel.x vel.y dist
0 0.000 0.000 0.000 0.000 10.000
1 0.200 0.000 1.000 0.000 9.800
2 0.560 0.000 1.800 0.000 9.440
3 1.048 0.000 2.440 0.000 8.952
4 1.638 0.000 2.952 0.000 8.362
5 2.311 0.000 3.362 0.000 7.689
6 3.049 0.000 3.689 0.000 6.951
7 3.839 0.000 3.951 0.000 6.161
8 4.671 0.000 4.161 0.000 5.329
Velocity climbs quickly at first (the gap between "not moving" and "desired" is largest at the very start), then climbs more slowly as it approaches maxSpeed = 5 — because as velocity gets closer to desired, the steering force desired - velocity itself gets smaller. That easing-in is exactly what makes seek look natural instead of snapping straight to top speed like the naive Rigidbody example in chapter 6.2. Notice one thing seek does not do: it never slows down as it approaches the target. It will fly right through it at maxSpeed and curve back around. Fixing that is section 4.
Flee is seek with the sign flipped: the desired velocity points away from the threat instead of toward a target. Every other line of code is identical — this is a good sign that the formula in section 1 is doing real work, not just describing seek specifically.
public static Vector2 Flee(Vector2 position, Vector2 velocity, Vector2 threat,
float maxSpeed, float maxForce)
{
Vector2 desired = (position - threat).normalized * maxSpeed; // away from the threat, full speed
Vector2 steer = desired - velocity;
return Vector2.ClampMagnitude(steer, maxForce);
}
An agent starting two units from a threat at the origin, maxSpeed = 4, maxForce = 8, dt = 0.2:
step pos.x vel.x dist
0 2.000 0.000 2.000
1 2.160 0.800 2.160
2 2.448 1.440 2.448
3 2.838 1.952 2.838
4 3.311 2.362 3.311
5 3.849 2.689 3.849
Same acceleration curve as seek, just running away instead of chasing. In practice, flee alone rarely ships as-is — a fleeing enemy that just runs in a straight line away from the player is easy to corner. Real flee code is almost always combined with obstacle avoidance (section 7) and sometimes a bit of wander (section 6) so it does not run in a perfectly predictable line.
Seek(position, velocity, threat, ...) and then negating the result instead of negating the direction inside the desired-velocity calculation. Negating the final steering force is not the same as fleeing — it flips the correction, not the destination, and produces movement that does not point cleanly away from the threat except by coincidence.Arrive fixes seek's "flies through the target" problem by shrinking the desired speed as the agent gets close. Outside a slowing radius, arrive behaves exactly like seek — full speed, straight at the target. Inside the slowing radius, desired speed ramps down linearly with distance, reaching zero exactly at the target.
public static Vector2 Arrive(Vector2 position, Vector2 velocity, Vector2 target,
float maxSpeed, float maxForce, float slowingRadius)
{
Vector2 toTarget = target - position;
float dist = toTarget.magnitude;
float desiredSpeed = dist < slowingRadius
? maxSpeed * (dist / slowingRadius) // ramp down, linear in distance
: maxSpeed; // full speed, same as seek
Vector2 desired = toTarget.normalized * desiredSpeed;
Vector2 steer = desired - velocity;
return Vector2.ClampMagnitude(steer, maxForce);
}
Same setup as the seek trace — start at rest, target ten units away, maxSpeed = 5, maxForce = 10, dt = 0.2 — but now with a slowingRadius of 5:
step pos.x vel.x dist desiredSpeed
0 0.000 0.000 10.000 5.000
1 0.200 1.000 9.800 5.000
2 0.560 1.800 9.440 5.000
3 1.048 2.440 8.952 5.000
4 1.638 2.952 8.362 5.000
... (identical to SEEK while dist >= slowingRadius)
9 5.537 4.329 4.463 4.463
10 6.408 4.356 3.592 3.592
11 7.249 4.203 2.751 2.751
12 8.031 3.913 1.969 1.969
13 8.736 3.524 1.264 1.264
14 9.350 3.072 0.650 0.650
15 9.868 2.587 0.132 0.132
Up through step 8 this is a byte-for-byte copy of the seek trace, because the agent is still outside the slowing radius. At step 9 the distance (4.463) drops below slowingRadius = 5, and from then on desiredSpeed tracks distance directly. Velocity peaks around step 10 (4.356) and then eases down smoothly, landing almost exactly on the target with almost no speed left — no overshoot, no circling back. That smooth landing is the entire reason arrive exists; it is what a companion character or a car parking itself should look like.
slowingRadius based on how fast the agent can actually decelerate, not an arbitrary number. A fast, low-maxForce agent needs a bigger slowing radius to avoid overshooting anyway (its steering force is too weak to cancel a high speed quickly); a slow or nimble agent can use a small one.Seek aims at where a target is. That works fine for a stationary point, but if the target is moving, by the time the agent arrives, the target has moved away — the agent ends up chasing its tail, always aiming slightly behind. Pursue fixes this by aiming at a predicted future position instead: take the target's current position, add its velocity times some lookahead time, and seek that point. Evade is the same idea applied to flee — predict, then run from the prediction.
The lookahead time needs to shrink as the pursuer gets closer (predicting far into the future when you are about to collide overshoots badly) and grow when it is far away. A simple, effective estimate: lookAheadTime = distanceToTarget / pursuerMaxSpeed — roughly "how long would it take me to close this gap at top speed."
public static Vector2 Pursue(Vector2 position, Vector2 velocity,
Vector2 targetPos, Vector2 targetVel,
float maxSpeed, float maxForce)
{
float dist = Vector2.Distance(targetPos, position);
float lookAheadTime = dist / maxSpeed;
Vector2 predicted = targetPos + targetVel * lookAheadTime;
return Steering.Seek(position, velocity, predicted, maxSpeed, maxForce);
}
// Evade: predict the same way, then flee the predicted point instead.
public static Vector2 Evade(Vector2 position, Vector2 velocity,
Vector2 targetPos, Vector2 targetVel,
float maxSpeed, float maxForce)
{
float dist = Vector2.Distance(targetPos, position);
float lookAheadTime = dist / maxSpeed;
Vector2 predicted = targetPos + targetVel * lookAheadTime;
return Steering.Flee(position, velocity, predicted, maxSpeed, maxForce);
}
A pursuer starting at the origin (maxSpeed = 6, maxForce = 15) chasing a target that starts twelve units away and drifts steadily upward at (0, 3) per second, dt = 0.2:
step tgt.x tgt.y pred.x pred.y pur.x pur.y dist
0 12.000 0.000 12.000 6.000 0.000 0.000 12.000
1 12.000 0.600 12.000 6.498 0.215 0.107 11.796
2 12.000 1.200 12.000 6.919 0.597 0.308 11.437
3 12.000 1.800 12.000 7.278 1.111 0.588 10.956
4 12.000 2.400 12.000 7.588 1.727 0.938 10.377
5 12.000 3.000 12.000 7.860 2.421 1.349 9.721
6 12.000 3.600 12.000 8.103 3.174 1.812 9.005
7 12.000 4.200 12.000 8.322 3.973 2.322 8.244
8 12.000 4.800 12.000 8.525 4.803 2.874 7.450
Watch the pred.y column: it is always well above tgt.y, because the prediction looks ahead by however long the pursuer still needs to close the gap. The pursuer's path curves upward from the very first frame, heading toward where the target is going instead of chasing its current position — and distance closes steadily (12.0 -> 7.45) instead of the pursuer trailing at a constant offset the way plain seek would.
1 second) regardless of distance. Far away, that underpredicts and the pursuer still trails behind. Very close, it overpredicts wildly and the pursuer can veer past a target that was about to be caught. Scaling lookahead by distance, as above, keeps the prediction sane at every range.Wander gives an agent something to do when it has no target at all — background NPCs milling around, a curious companion, ambient wildlife. The naive approach, picking a brand new random direction every frame, looks terrible: the agent visibly twitches, because its heading can flip almost 180 degrees between two consecutive frames. Wander instead keeps a single running angle and nudges it by a small random amount each frame, so the heading drifts smoothly instead of jumping.
To get a trace with real, reproducible numbers, this demo reuses the tiny LCG (linear congruential generator) from chapter 2.6 — state = state * 1103515245 + 12345 — instead of Unity's built-in Random, purely so the printed output below is exactly reproducible from the same seed.
using UnityEngine;
public class SimpleRng
{
uint state;
public SimpleRng(uint seed) { state = seed; }
public uint NextRaw()
{
state = state * 1103515245u + 12345u; // same LCG as chapter 2.6
return state;
}
public float NextRange(float lo, float hi)
{
float t = NextRaw() / 4294967295f; // 0..1
return lo + t * (hi - lo);
}
}
public class Wanderer
{
SimpleRng rng = new SimpleRng(7);
float wanderAngle = 0f;
public float wanderRadius = 1.2f;
public float wanderDistance = 2f;
public float wanderJitter = 0.5f; // max radians the angle can change in one frame
public Vector2 Wander(Vector2 velocity)
{
wanderAngle += rng.NextRange(-1f, 1f) * wanderJitter;
Vector2 forward = velocity.sqrMagnitude > 0.0001f ? velocity.normalized : Vector2.right;
Vector2 circleCenter = forward * wanderDistance;
Vector2 displacement = new Vector2(Mathf.Cos(wanderAngle), Mathf.Sin(wanderAngle)) * wanderRadius;
return circleCenter + displacement; // used directly as a steering force
}
}
Starting at rest-ish with velocity (2, 0), maxSpeed = 3, maxForce = 6, dt = 0.2, seed 7:
step wAngle force.x force.y vel.x vel.y pos.x pos.y
0 0.299 3.147 0.353 2.000 0.000 0.000 0.000
1 -0.052 3.198 -0.009 2.629 0.071 0.526 0.014
2 -0.386 3.111 -0.410 2.999 0.063 1.126 0.027
3 -0.540 3.029 -0.627 3.000 -0.016 1.726 0.024
4 -0.544 3.025 -0.699 2.998 -0.117 2.325 0.000
5 -0.492 3.052 -0.709 2.992 -0.213 2.924 -0.042
6 -0.503 3.041 -0.775 2.986 -0.294 3.521 -0.101
7 -0.518 3.027 -0.843 2.977 -0.372 4.116 -0.176
Look at the wAngle column: it changes by at most 0.5 radians per step (that is wanderJitter doing its job), and it drifts steadily downward across these eight frames rather than bouncing around randomly. The resulting velocity direction (vel.y slowly going more negative while vel.x stays near maxSpeed) turns into a gentle, curving path — organic-looking, not jittery, even though every number in this table came from a fixed, fully deterministic formula, exactly as chapter 2.6 explained about pseudo-random numbers.
UnityEngine.Random.Range or a seeded System.Random rather than hand-rolling an LCG — the LCG here exists only so this specific trace's numbers can be reproduced exactly on paper. The wander technique (small angle nudge, not a fresh random direction) is what matters, and it works identically with any random source.An agent that only seeks or wanders will walk straight through walls, crates, and other obstacles. Obstacle avoidance gives it a cheap way to notice trouble ahead: cast a short "feeler" — a straight line, or in this simplified version, a single point — out in front of the agent, and check whether that point lands inside any obstacle. If it does, steer away, hard.
public struct Obstacle
{
public Vector2 center;
public float radius;
}
public static Vector2 AvoidObstacle(Vector2 position, Vector2 velocity, Obstacle obstacle,
float agentRadius, float feelerLength, float maxForce)
{
Vector2 feelerDir = velocity.sqrMagnitude > 0.0001f ? velocity.normalized : Vector2.right;
Vector2 feelerTip = position + feelerDir * feelerLength;
float d = Vector2.Distance(feelerTip, obstacle.center);
if (d >= obstacle.radius + agentRadius)
return Vector2.zero; // feeler is clear -- nothing to avoid
return (feelerTip - obstacle.center).normalized * maxForce;
}
An agent moving along +x from the origin at velocity (3, 0), an obstacle sitting at (6, 1) with radius 1, agentRadius = 0.5, feelerLength = 3, seeking a far-off target at (20, 0) whenever the feeler is clear, maxSpeed = 4, maxForce = 10, dt = 0.15:
step pos.x pos.y feeler.x feeler.y d2obs mode
0 0.000 0.000 3.000 0.000 3.162 seek
1 0.472 0.000 3.473 0.000 2.718 seek
2 0.964 0.000 3.964 0.000 2.268 seek
3 1.472 0.000 4.472 0.000 1.826 seek
4 1.994 0.000 4.994 0.000 1.419 AVOID
5 2.356 -0.159 5.104 -1.362 2.526 seek
6 2.754 -0.293 5.597 -1.250 2.286 seek
7 3.182 -0.405 6.083 -1.167 2.168 seek
8 3.636 -0.498 6.574 -1.103 2.180 seek
9 4.111 -0.575 7.073 -1.052 2.316 seek
10 4.606 -0.637 7.582 -1.009 2.558 seek
11 5.116 -0.686 8.102 -0.972 2.882 seek
12 5.639 -0.723 8.632 -0.937 3.267 seek
13 6.174 -0.750 9.170 -0.903 3.697 seek
For steps 0 through 3 the feeler tip is still outside the obstacle's danger zone (d2obs > radius + agentRadius = 1.5), so the agent just seeks its target and moves in a straight line. At step 4 the feeler tip lands inside the obstacle's zone (1.419 < 1.5) and the avoidance force fires — a single, hard push away from the obstacle. That one push is enough to bend the whole path downward (pos.y goes negative and stays there), and from step 5 onward the feeler stays clear, so the agent goes back to plain seeking, now on a path that curves cleanly under the obstacle instead of through it.
Physics2D.Raycast or Physics.SphereCast along the velocity direction against a LayerMask of obstacles gives you far more accurate feelers than a single point check, including hitting arbitrary mesh shapes, not just circles. The steering math above is identical either way — only how you detect "is something in my way" changes.A real agent almost never runs just one behavior. A guard might need to seek a patrol point, avoid obstacles, and stay loosely aligned with nearby guards, all at the same time. There are two common ways to combine multiple steering forces into one.
Weighted blending gives every active behavior a weight, multiplies each force by its weight, adds them all together, and clamps the result to maxForce. Every behavior always contributes something.
public static Vector2 CombineWeighted(float maxForce, params (Vector2 force, float weight)[] behaviors)
{
Vector2 total = Vector2.zero;
foreach (var (force, weight) in behaviors)
total += force * weight;
return Vector2.ClampMagnitude(total, maxForce);
}
Worked example: a seek force of (4, 2) at weight 1.0, blended with an avoidance force of (-3, 6) at weight 2.0 (avoidance matters twice as much as seeking), maxForce = 10:
raw sum = 1.0*(4, 2) + 2.0*(-3, 6) = (-2, 14) length = 14.142
clamped = (-1.414, 9.899) length = 10.000
The raw sum overshoots maxForce by a lot (14.142 vs. a cap of 10), so it gets scaled down uniformly, keeping its direction but shrinking its length — the same ClampMagnitude operation every behavior in this chapter already uses.
Priority-based combination instead tries each behavior in order of importance, and the first one that has anything meaningful to say wins outright for that frame — lower-priority behaviors only run if every higher-priority one returned (close to) nothing.
public static Vector2 CombinePriority(float maxForce, params Vector2[] behaviorsInPriorityOrder)
{
foreach (Vector2 force in behaviorsInPriorityOrder)
{
if (force.sqrMagnitude > 0.0001f)
return Vector2.ClampMagnitude(force, maxForce); // first non-trivial behavior wins
}
return Vector2.zero;
}
With avoidance checked first: if the obstacle feeler from section 7 is clear, avoidForce is exactly Vector2.zero, so priority falls through to whatever comes next (seek, wander, or a blend of the two). The instant the feeler detects trouble, avoidance returns a non-zero force and takes over completely for that frame, ignoring seek and wander entirely until the path is clear again — which is exactly the behavior the obstacle-avoidance trace in section 7 showed happening at step 4.
Flocking is what happens when every agent in a group runs the exact same three simple steering rules, looking only at its nearby neighbors, with no leader and no global plan. Craig Reynolds called the simulated agents boids and published the technique in 1987; it is still the standard way to make believable flocks of birds, schools of fish, herds, or crowds without scripting a single specific path for anyone.
Each rule only looks at neighbors inside some radius (checking the whole flock every frame would not scale, and a boid should only react to what is actually near it). Separation and cohesion produce a target-like vector and reuse the same "desired minus velocity" idea from seek; alignment is slightly different — it seeks a velocity to match, not a position.
public class Boid
{
public Vector2 position;
public Vector2 velocity;
}
public static class Flocking
{
public static Vector2 Separation(Boid self, System.Collections.Generic.List<Boid> neighbors, float sepRadius)
{
Vector2 force = Vector2.zero;
int count = 0;
foreach (Boid other in neighbors)
{
float d = Vector2.Distance(self.position, other.position);
if (d > 0.00001f && d < sepRadius)
{
force += (self.position - other.position).normalized / d; // closer = stronger push
count++;
}
}
return count > 0 ? force / count : Vector2.zero;
}
public static Vector2 Alignment(Boid self, System.Collections.Generic.List<Boid> neighbors)
{
if (neighbors.Count == 0) return Vector2.zero;
Vector2 avgVel = Vector2.zero;
foreach (Boid other in neighbors) avgVel += other.velocity;
avgVel /= neighbors.Count;
return avgVel - self.velocity; // steer to match the group's heading
}
public static Vector2 Cohesion(Boid self, System.Collections.Generic.List<Boid> neighbors, float maxSpeed)
{
if (neighbors.Count == 0) return Vector2.zero;
Vector2 center = Vector2.zero;
foreach (Boid other in neighbors) center += other.position;
center /= neighbors.Count;
Vector2 desired = (center - self.position).normalized * maxSpeed;
return desired - self.velocity; // seek the flock's center
}
public static Vector2 Flock(Boid self, System.Collections.Generic.List<Boid> neighbors,
float sepRadius, float maxSpeed, float maxForce,
float wSep, float wAlign, float wCoh)
{
Vector2 sep = Separation(self, neighbors, sepRadius) * wSep;
Vector2 ali = Alignment(self, neighbors) * wAlign;
Vector2 coh = Cohesion(self, neighbors, maxSpeed) * wCoh;
return Vector2.ClampMagnitude(sep + ali + coh, maxForce);
}
}
Four boids start close together, each heading a slightly different direction — a loose, disorganized cluster. neighborRadius = 5 (every boid can see every other one here), sepRadius = 1.3, maxSpeed = 2.5, maxForce = 4, weights wSep = 1.5, wAlign = 1, wCoh = 1, dt = 0.2:
frame 0: headings(deg) = [0.0, 18.4, -31.0, 8.1]
boid0: pos=(0.000, 0.000) vel=(2.000, 0.000)
boid1: pos=(1.000, 0.500) vel=(1.800, 0.600)
boid2: pos=(0.600,-0.800) vel=(1.500,-0.900)
boid3: pos=(-0.800,0.300) vel=(2.100, 0.300)
frame 1: headings(deg) = [-0.0, 7.8, -12.3, 4.5]
boid0: pos=(0.406, 0.000) vel=(2.030,-0.001)
boid1: pos=(1.254, 0.535) vel=(1.269, 0.175)
boid2: pos=(0.850,-0.855) vel=(1.250,-0.273)
boid3: pos=(-0.447,0.328) vel=(1.763, 0.140)
frame 2: headings(deg) = [0.4, -2.2, 2.5, 1.3]
boid0: pos=(0.801, 0.002) vel=(1.973, 0.012)
boid1: pos=(1.443, 0.528) vel=(0.945,-0.037)
boid2: pos=(1.060,-0.845) vel=(1.049, 0.047)
boid3: pos=(-0.142,0.335) vel=(1.529, 0.035)
Look at the headings(deg) line on each frame: [0.0, 18.4, -31.0, 8.1] spreads across almost 50 degrees at the start, but by frame 2 it has collapsed to [0.4, -2.2, 2.5, 1.3] — under 5 degrees apart. Nobody told any boid what direction to face; alignment alone pulled all four headings together in two simulation steps just by each boid averaging its neighbors' velocities. Meanwhile the boids have not collapsed into a single point — pairwise distances at frame 2 are boid0-boid1: 0.830, boid0-boid2: 0.886, boid0-boid3: 1.000, boid1-boid2: 1.425, boid1-boid3: 1.597, boid2-boid3: 1.684 — separation is actively keeping the two closest boids (boid0 and boid1, under the 1.3 separation radius) from overlapping, while cohesion keeps the whole group from drifting apart. Three simple, local rules, and a flock falls out on its own.
maxForce, over-correcting every frame.Pathfinding (chapter 11.1 — A* over a grid or navmesh) answers "what sequence of points gets me from here to the goal without walking through walls." It hands back a list of waypoints: straight-line segments strung together, often with sharp, robotic-looking corners. Steering answers a completely different question: "given the next waypoint I need to reach, how do I move toward it smoothly, frame by frame." Neither one replaces the other — production movement code almost always layers steering on top of a pathfinder's output.
The pattern is simple: seek the current waypoint; once the agent is within some small arrival radius of it, advance to the next waypoint; on the very last waypoint, switch from seek to arrive so the agent actually stops instead of overshooting the goal and circling back.
public class PathFollower
{
public System.Collections.Generic.List<Vector2> waypoints;
public int currentIndex = 0;
public float arrivalRadius = 0.6f;
public float slowingRadius = 2f;
public float maxSpeed = 5f;
public float maxForce = 10f;
public Vector2 GetSteering(Vector2 position, Vector2 velocity)
{
Vector2 target = waypoints[currentIndex];
float dist = Vector2.Distance(position, target);
bool isLastWaypoint = currentIndex == waypoints.Count - 1;
if (dist < arrivalRadius && !isLastWaypoint)
{
currentIndex++;
target = waypoints[currentIndex];
isLastWaypoint = currentIndex == waypoints.Count - 1;
}
return isLastWaypoint
? Steering.Arrive(position, velocity, target, maxSpeed, maxForce, slowingRadius)
: Steering.Seek(position, velocity, target, maxSpeed, maxForce);
}
}
A two-waypoint path, (4, 0) then (4, 4), starting from the origin at rest, arrivalRadius = 0.6, slowingRadius = 2, maxSpeed = 5, maxForce = 10, dt = 0.2:
step pos.x pos.y idx distToWaypoint
0 0.000 0.000 0 4.000
1 0.200 0.000 0 3.800
2 0.560 0.000 0 3.440
3 1.048 0.000 0 2.952
4 1.638 0.000 0 2.362
5 2.311 0.000 0 1.689
6 3.049 0.000 0 0.951
7 3.839 0.000 0 0.161
8 4.479 0.200 1 3.830
9 4.966 0.558 1 3.575
10 5.302 1.037 1 3.236
11 5.490 1.604 1 2.822
12 5.535 2.227 1 2.345
13 5.440 2.876 1 1.826
14 5.220 3.508 1 1.315
15 4.922 4.063 1 0.924
16 4.591 4.501 1 0.775
By step 7 the agent is within 0.161 of waypoint 0 — inside the 0.6 arrival radius — so on the next frame currentIndex flips to 1 and the target jumps to the final waypoint. Since it is now the last waypoint, steering switches from seek to arrive, and you can see the same smooth deceleration from section 4 kick in: distance shrinks steadily (2.822 -> 2.345 -> 1.826 -> 1.315 -> 0.924 -> 0.775) without ever snapping or overshooting. This two-layer split — pathfinder decides where, steering decides how to move there — is exactly how most shipped games structure NPC movement.
"My steering agent jitters/twitches/vibrates" is close to the most common steering bug report there is. Almost every case traces back to one of a small handful of causes, and every one of them has already shown up somewhere earlier in this chapter.
maxForce, instantly" the moment the feeler crosses one exact distance. Sitting right at that boundary — which happens constantly for a slow-moving or hovering agent — makes the force flicker on and off every frame. Fix it with hysteresis: use a different, farther-out distance to turn avoidance off than the one that turns it on, so the agent has to clearly get away before avoidance releases.maxForce too high relative to maxSpeed. An agent that can change velocity almost instantly overshoots its desired heading, over-corrects back the other way, overshoots again — a fast, small oscillation. This is the same "energy leaking in" family of problem chapter 2.5 covered for physics integrators, just showing up as an angle wobbling instead of energy climbing.Beyond removing hard thresholds, the single most useful general-purpose fix is to smooth the steering force itself before applying it, using Lerp from chapter 2.4: instead of applying this frame's raw steering force directly, blend it a little bit toward the previous frame's smoothed force.
Vector2 smoothedSteering;
Vector2 GetSmoothedSteering(Vector2 rawSteering, float smoothing)
{
smoothedSteering = Vector2.Lerp(smoothedSteering, rawSteering, smoothing); // smoothing in (0, 1]
return smoothedSteering;
}
Six frames of a raw steering signal that snaps hard between (5, 0) and (-4, 3) twice in a row (imagine a behavior switch happening abruptly), smoothed with Lerp(prev, raw, 0.3):
frame raw.x raw.y smoothed.x smoothed.y
0 5.000 0.000 5.000 0.000
1 5.000 0.000 5.000 0.000
2 -4.000 3.000 2.300 0.900
3 -4.000 3.000 0.410 1.530
4 5.000 0.000 1.787 1.071
5 5.000 0.000 2.751 0.750
The raw signal jumps instantly every time it changes (frame 1 to frame 2, and again frame 3 to frame 4). The smoothed value never jumps — it always eases a fraction of the way toward wherever the raw value currently is, the same blending idea chapter 2.4 used for animation and camera motion, just applied to a force instead of a position. A smaller smoothing value (closer to 0) gives a calmer, laggier agent; a larger one (closer to 1) tracks the raw signal more closely and smooths less.
maxForce back up — which reintroduces the exact oscillation smoothing was meant to remove. If an agent needs both fast reactions and no jitter, the usual fix is not more smoothing or more force; it is removing the hard threshold that caused the flip-flopping in the first place.position = (0, 0) with velocity = (1, 0), seeking a target at (6, 8), with maxSpeed = 5, maxForce = 3, dt = 1. Compute, in order: the desired velocity, the raw steering force (desired - velocity) and its length, the clamped steering force, the new velocity after one step, and the new position after one step.Desired velocity: the direction to the target is (6, 8), which has length sqrt(6^2 + 8^2) = sqrt(100) = 10 (a 6-8-10 right triangle). Normalized, that is (0.6, 0.8); scaled to maxSpeed = 5, desired = (3, 4).
Raw steering: desired - velocity = (3 - 1, 4 - 0) = (2, 4), with length sqrt(4 + 16) = sqrt(20) ~= 4.472.
Clamped steering: 4.472 exceeds maxForce = 3, so scale by 3 / 4.472 ~= 0.6708: steer ~= (1.342, 2.683).
New velocity: velocity + steer * dt = (1 + 1.342, 0 + 2.683) = (2.342, 2.683). Its length is ~= 3.561, under maxSpeed = 5, so no speed clamp is needed.
New position: position + newVelocity * dt = (0 + 2.342, 0 + 2.683) = (2.342, 2.683).
Notice the new velocity is not yet pointed straight at the target, and the agent is nowhere near the target after one step — that is expected. Seek only ever nudges velocity a little closer to desired each frame; it takes several frames (as section 2's trace showed) for velocity to converge.
maxSpeed = 8 and slowingRadius = 4. Compute the desired speed at three different distances from the target: 10, 3, and 1.At distance = 10: this is outside slowingRadius = 4, so desiredSpeed = maxSpeed = 8 (full speed, exactly like seek).
At distance = 3: this is inside the slowing radius, so desiredSpeed = maxSpeed * (distance / slowingRadius) = 8 * (3 / 4) = 6.
At distance = 1: also inside the slowing radius, so desiredSpeed = 8 * (1 / 4) = 2.
Desired speed only starts dropping once the agent crosses into the slowing radius, and it drops in a straight line down to 0 exactly at the target — the same ramp shown in section 4's diagram.
separation = (-1, 3), alignment = (2, 1), cohesion = (4, -2), with weights wSep = 2, wAlign = 1, wCoh = 0.5, and maxForce = 6. Compute the weighted sum and, if needed, the clamped final steering force.Weighted sum: 2*(-1, 3) + 1*(2, 1) + 0.5*(4, -2) = (-2, 6) + (2, 1) + (2, -1) = (2, 6).
Length: sqrt(2^2 + 6^2) = sqrt(4 + 36) = sqrt(40) ~= 6.325, which is over maxForce = 6.
Clamped: scale by 6 / 6.325 ~= 0.9487, giving steer ~= (1.897, 5.692), with length exactly 6.000.
Separation was weighted highest (2) and cohesion lowest (0.5), matching the usual real-world priority: never letting boids overlap matters more than keeping the group tightly centered.
That is the steering toolbox a game programmer reaches for constantly: seek and flee for the basic push toward or away from something, arrive for a clean stop, pursue and evade for moving targets, wander for background motion that does not look robotic, obstacle avoidance to keep everyone out of walls, weighted blending and priority to run several of these at once, and flocking to get convincing group movement out of nothing more than separation, alignment, and cohesion running independently on every agent. Pair it with last chapter's pathfinding — the path decides where to go, steering decides how to get there smoothly — and watch for the handful of concrete causes behind jitter before reaching for smoothing, and you have everything needed to make agents in a game move like they are actually deciding something, frame after frame.