Summary

Context steering is an alternative to Reynolds’ steering-behaviours for games where individual agents are closely observed and movement consistency is critical. Traditional steering behaviours each return a desired velocity vector; the framework combines these vectors by weighting or prioritisation. This combination step is lossy — an avoid behaviour can cancel out a chase behaviour, leaving the agent paralysed, and there is no principled way to ensure collision avoidance holds in all cases.

Context steering solves this by having each behaviour write into a context map (a 1D circular array of scalar values representing headings) rather than returning a single vector. A separate framework step merges all context maps holistically to produce the final movement direction. The result is emergent, consistent collision avoidance with stateless, decoupled, and easily tuned behaviours.

Developed by Andrew Fray and used in Codemasters’ F1 2011, where it replaced traditional steering and reduced the AI codebase by approximately 4,000 lines.

(Fray, Game AI Pro 360, Ch. 14; see source-game-ai-pro-360-movement-pathfinding)


Key ideas

Why traditional steering fails

A traditional steering system averages or prioritises behaviour vectors. Consider an avoid behaviour returning “go north” and a chase behaviour returning “go south.” Their average is near zero — the agent stalls. Solutions like weighting, prioritisation, or adding obstacle-awareness to chase all introduce coupling and maintenance problems. The root cause: a behaviour vector communicates only what the behaviour wants, not the context in which it made that choice.

Context maps

Each context map is an array of scalar values, one per discretised heading direction:

// 16-slot context map (22.5° per slot)
float[] dangerMap  = new float[16];
float[] interestMap = new float[16];

Each slot represents a heading; its value represents how strongly the behaviour feels about that heading. Two map types exist:

  • Danger map — headings the behaviour wants to avoid; high values = stay away.
  • Interest map — headings the behaviour finds desirable; high values = go here.

Behaviours write into maps independently using a for-each loop over relevant world objects. They are stateless — they need no persistent data between frames.

Chase behaviour (writes to interest map)

For each target, write interest proportional to proximity into the interest map slot corresponding to the direction toward the target. A falloff over adjacent slots captures “passing near the target is also acceptable.”

Avoid behaviour (writes to danger map)

For each obstacle within range, write danger into the map slot(s) corresponding to the obstacle’s direction. The width of the written region represents the heading clearance required to pass the obstacle safely.

Combining maps

Multiple behaviours write to the same maps. Slots are combined by taking the maximum across all behaviours (not summing) — two obstacles in the same direction are not more dangerous than one; the maximum captures the binding constraint.

Parsing to a final direction

  1. Find the minimum danger value in the danger map.
  2. Mask out all slots with danger above the minimum (anything riskier than the safest available heading is forbidden).
  3. Apply the mask to the interest map (zero out any masked slot).
  4. Pick the highest remaining interest slot — this is the heading to move toward.
  5. Speed is proportional to the interest value in that slot.

This guarantees the agent never moves into higher danger if a safer heading exists.

public Vector2 ResolveContextMaps(float[] danger, float[] interest, int slots)
{
    // 1. Find minimum danger
    float minDanger = float.MaxValue;
    for (int i = 0; i < slots; i++)
        if (danger[i] < minDanger) minDanger = danger[i];
 
    // 2 & 3. Mask interest by danger
    float bestInterest = -1f;
    int bestSlot = 0;
    for (int i = 0; i < slots; i++)
    {
        if (danger[i] <= minDanger && interest[i] > bestInterest)
        {
            bestInterest = interest[i];
            bestSlot = i;
        }
    }
 
    // 4. Convert slot index to heading
    float angle = bestSlot * (2f * Mathf.PI / slots);
    return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * bestInterest;
}

Subslot interpolation

Context maps can use a small number of slots (8–16) without sacrificing movement continuity. After selecting the best slot, evaluate the interest gradient across adjacent slots and back-project the virtual “peak” into world space — this yields movement direction at any continuous angle, not just the discrete slot directions.


Implementation

// Unity C# — minimal context steering
public class ContextSteeringAgent : MonoBehaviour
{
    const int Slots = 16;
    float[] dangerMap  = new float[Slots];
    float[] interestMap = new float[Slots];
 
    void FixedUpdate()
    {
        Array.Clear(dangerMap,   0, Slots);
        Array.Clear(interestMap, 0, Slots);
 
        foreach (var target in FindTargets())
            WriteInterest(target.position, target.priority);
 
        foreach (var obstacle in FindObstacles())
            WriteDanger(obstacle.position, obstacle.radius);
 
        Vector2 move = ResolveContextMaps(dangerMap, interestMap, Slots);
        GetComponent<Rigidbody2D>().velocity = move * maxSpeed;
    }
 
    void WriteInterest(Vector2 targetPos, float weight)
    {
        float angle = Mathf.Atan2(targetPos.y - transform.position.y,
                                  targetPos.x - transform.position.x);
        float slotF = (angle + Mathf.PI) / (2f * Mathf.PI) * Slots;
        int slot = Mathf.RoundToInt(slotF) % Slots;
 
        float dist = Vector2.Distance(transform.position, targetPos);
        float intensity = weight / (1f + dist * 0.1f);   // fall off with distance
 
        // Write with angular falloff across neighbours
        for (int i = -2; i <= 2; i++)
            interestMap[(slot + i + Slots) % Slots] =
                Mathf.Max(interestMap[(slot + i + Slots) % Slots],
                          intensity * (1f - Mathf.Abs(i) / 3f));
    }
 
    void WriteDanger(Vector2 obstaclePos, float radius)
    {
        float dist = Vector2.Distance(transform.position, obstaclePos);
        if (dist > avoidanceRange + radius) return;
 
        float angle = Mathf.Atan2(obstaclePos.y - transform.position.y,
                                  obstaclePos.x - transform.position.x);
        float slotF = (angle + Mathf.PI) / (2f * Mathf.PI) * Slots;
        int slot = Mathf.RoundToInt(slotF) % Slots;
 
        float intensity = 1f - (dist / (avoidanceRange + radius));
        int spread = Mathf.CeilToInt(radius / slotAngleWidth);
 
        for (int i = -spread; i <= spread; i++)
            dangerMap[(slot + i + Slots) % Slots] =
                Mathf.Max(dangerMap[(slot + i + Slots) % Slots], intensity);
    }
 
    float maxSpeed = 5f, avoidanceRange = 4f;
    float slotAngleWidth = 2f * Mathf.PI / Slots;
}

Racing line variant (1D context maps)

For F1 2011, the context map was reduced to a 1D array representing lateral offset across the track width. Each slot represents a position on the track, not a heading direction. This maps naturally to the actual degree of freedom a racing car has:

  • Racing line behaviour writes broad low interest across all slots, peaking at the ideal racing line.
  • Avoid behaviour writes danger centred on other cars’ lateral positions, with a “skirt” of decreasing danger at the edges (controls lateral separation). Skirt width is a per-driver personality parameter.
  • Drafting behaviour writes interest at the lateral position of leading cars, proportional to speed and proximity.

To find the final position: walk left and right from the current slot, expanding as long as danger does not increase. Mask interest by the reachable slots. Pick the highest remaining interest.


Advanced techniques

Blurring

Apply a Gaussian or box blur across context map slots after all behaviours have written. This removes sharp spikes that can cause jitter in the final direction, and smooths danger zones so agents do not oscillate at the edge of avoidance ranges.

Temporal hysteresis

Blend the current frame’s context map with the previous frame’s result. This is a stateless form of hysteresis — it prevents flip-flopping between two nearly equal options without requiring any per-behaviour state.

Level-of-detail

Context map resolution scales linearly with cost. Agents far from the player can use 4–8 slots; agents near the player can use 16–32. This provides continuous performance control without compromising the integrity of collision avoidance.

Parallelism

Because behaviours are stateless and maps merge trivially, computing multiple agents’ behaviours in parallel is straightforward. Context maps can also be processed on a compute shader.


Trade-offs

Context steeringTraditional steering behaviours
Collision avoidanceGuaranteed (by design)Best-effort (depends on tuning)
Behaviour couplingNone — behaviours only see map slotsHigh — behaviours may need obstacle awareness
Stateful behavioursNot supported directlySupported
TuningMap resolution, slot falloffPer-behaviour weights + coupling heuristics
Dimensionality1D or 2D decision spaceAny (full 3D vector)
Best forRacing, single observable agents, open-plane movementFlocks, crowds, emergent group behaviour

Context steering is not a universal replacement for traditional steering. For flocks of hundreds of agents seen from above, traditional steering emergent behaviour is a strength, not a weakness. Context steering is the better choice when individual agents are closely observed and movement consistency is non-negotiable.


Examples

  • F1 2011 (Codemasters) — first commercial application; danger/interest in 1D track-offset space; codebase reduced by 4,000 lines, collision avoidance improved.
  • Any game with individual, closely-watched NPC movement in semi-constrained spaces (corridor shooters, sports AI, vehicle AI) is a good candidate.