11.2 Steering & Flocking

Phase 11 · Game AI · Study time: 15–30 h

Local movement forces — seek, avoid, separate, align — that combine into believable individual and crowd motion.

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.

1. The steering model: force, acceleration, velocity, position

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.

THE STEERING PIPELINE -- runs once per agent, every frame "what do I want right now?" --> desired velocity | v steering force = desired - current velocity | v clamp to maxForce (mass = 1, so force = acceleration) velocity += steeringForce * dt <-- update velocity FIRST (ch. 2.5) velocity = clamp(velocity, maxSpeed) position += velocity * dt <-- THEN position, using the NEW velocity

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.

Tip One formula shows up in almost every behavior below: 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.
Common mistake Setting velocity directly to the desired direction (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.

2. Seek: steering toward a target

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.

SEEK -- steering = desired - velocity target * ^ / / desired = normalize(target - pos) * maxSpeed / pos o---------> velocity (where the agent is ACTUALLY heading right now) steering = desired - velocity (an arrow from velocity's tip to desired's tip -- the same "point minus point = vector" idea from chapter 2.1, section 2)
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.

3. Flee: steering away from a threat

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.

FLEE -- same formula as SEEK, only "desired" is flipped desired = normalize(pos - threat) * maxSpeed (pos minus threat, not threat minus pos) threat * pos o ---------> velocity | | desired (points AWAY from threat) v steering = desired - velocity (identical formula to SEEK)
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.

Common mistake Calling 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.

4. Arrive: seek that knows how to stop

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.

ARRIVE -- desired speed ramps down inside the slowing radius desiredSpeed maxSpeed |----------------------_ | -_ | -_ (straight-line ramp down) | -_ 0 +----------------------------------> distance to target 0 slowingRadius outside slowingRadius: desiredSpeed = maxSpeed inside slowingRadius: desiredSpeed = maxSpeed * (distance / slowingRadius)
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.

Tip Pick 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.

5. Pursue and evade: aiming at a moving target

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.

PURSUE -- aim where the target WILL BE, not where it is now target now * --velocity-> * predicted position (target position + target velocity * lookAheadTime) pursuer o ---------------------------> (seek the PREDICTED point, not the current one)

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.

Common mistake Using a fixed lookahead time (say, always 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.

6. Wander: organic, undirected movement

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.

WANDER -- a circle projected ahead of the agent; the aim point creeps around its rim wanderRadius +-----------+ | x <--- aim point, at wanderAngle around the circle agent o --forward--> (circle center, wanderDistance ahead of the agent) | | +-----------+ wanderAngle += small random value each frame (NOT a fresh random angle each frame) steering = circleCenter + (cos(wanderAngle), sin(wanderAngle)) * wanderRadius

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.

Tip Real Unity code should use 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.

7. Obstacle and wall avoidance

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.

OBSTACLE AVOIDANCE -- a feeler out in front checks for trouble agent o ===feeler (length = feelerLength)==> x <- feeler tip ( ) obstacle (radius) if distance(feelerTip, obstacleCenter) < obstacleRadius + agentRadius: avoidForce = normalize(feelerTip - obstacleCenter) * maxForce (push straight away)
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.

Tip Unity ships real raycasting for this: 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.
Common mistake Using only one feeler pointed straight ahead. An obstacle that is mostly to one side can still clip the agent's edge without ever crossing a single forward feeler. Production steering code usually casts two or three feelers — one straight ahead and one or two angled slightly left and right — so obstacles at the agent's shoulder get caught too.

8. Combining behaviors: weighted blending and priority

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.

Tip Weighted blending is smoother but can produce a "confused" result when two strong forces fight (an obstacle directly between the agent and its seek target can produce a blended force that drives the agent straight at the obstacle, because seek and avoid partially cancel). Priority avoids that specific failure but can feel abrupt when it switches. Many shipped games use a mix: priority for safety-critical behaviors like obstacle avoidance, weighted blending for everything softer, like seek plus a little wander.

9. Flocking: separation, alignment, and cohesion

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.

THE THREE BOID RULES (Craig Reynolds, 1987) 1) SEPARATION -- steer away from neighbors that are too close o <-- ME --> o (nearby neighbors push me apart from both) 2) ALIGNMENT -- steer to match the average heading of nearby neighbors o --> o --> ME --> (turn to match the group's average velocity) o --> 3) COHESION -- steer toward the average position (center) of nearby neighbors o o * <- average position of all neighbors o ME --> (steer toward that center point)

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.

Tip If you run this for many more frames, the headings do not freeze perfectly in place — the flock keeps gently jostling, with separation and cohesion in constant, tiny disagreement. That is correct and matches real flocks; a boid simulation that goes perfectly rigid and static usually means the weights are too strong relative to maxForce, over-correcting every frame.
Common mistake Checking every boid against every other boid every single frame (an O(n^2) neighbor search — see chapter 1.4 on Big-O). That is fine for a handful of boids but falls over fast with hundreds. Shipped flocking systems bucket boids into a spatial grid (or reuse a structure from a physics/collision broad-phase) so each boid only has to check the handful of nearby cells, not the entire flock.

10. Steering vs. pathfinding: waypoints plus smooth local movement

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.

PATHFINDING (previous chapter, 11.1) STEERING (this chapter) hands back a list of WAYPOINTS moves smoothly BETWEEN them start *---*---*---*---* goal start o~~curve~~o~~curve~~o goal (straight, jagged segments, (Seek each waypoint in turn, Arrive correct but stiff-looking) on the last one, curves the corners)

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.

11. Taming jitter

"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.

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.

Common mistake Smoothing so heavily that the agent feels sluggish and unresponsive, then "fixing" that by cranking 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.
Tip Before reaching for smoothing, always ask "what changed by a large amount, in one frame, for no gradual reason?" Jitter almost always has a specific, findable cause (a threshold, a re-rolled random value, a weight fight) — smoothing hides the symptom, finding the cause fixes it for good.

12. Glossary

13. Exercises

Exercise 1 An agent is at 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.
Show answer

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.

Exercise 2 An arrive behavior has maxSpeed = 8 and slowingRadius = 4. Compute the desired speed at three different distances from the target: 10, 3, and 1.
Show answer

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.

Exercise 3 Three flocking contributions for one boid: 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.
Show answer

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.

← Back to all chapters