Summary

The Combat Coordinator pattern solves a fundamental group AI problem: how do multiple independent NPCs produce coordinated-looking behaviour without central scripting? The solution is a global coordinator object that manages exclusive roles. NPCs request roles from the coordinator; only the NPC that holds a role is permitted to execute the corresponding behaviour. This produces natural turn-taking, prevents swarming, and enables tactics like flanking to emerge from individual decisions.

The pattern is described by McIntosh (The Last of Us Human Enemy AI, Game AI Pro 360, Ch. 2, see source-game-ai-pro-360-character-behavior).


Key ideas

  • Roles: Named behavioural slots with exclusive occupancy. Examples from The Last of Us: Flanker, OpportunisticShooter, Approacher, Investigator, StayUpAndAimer.
  • RequestRole / AcknowledgeRole: An NPC requests a role; the coordinator grants it if available. Once acknowledged, no other NPC can take that role until the holder releases it.
  • Dynamic assignment strategies: Some roles (OpportunisticShooter) are first-come-first-served. Others (Flanker) are assigned by the coordinator to the ideal NPC based on a computed criterion.
  • Continuous reevaluation: The coordinator recalculates ideal role assignment every frame, so the best candidate always gets the role when it is released.

Implementation

Core interface

public class CombatCoordinator : MonoBehaviour
{
    private Dictionary<RoleType, AIAgent> roleHolders = new();
 
    // Returns true if role was granted
    public bool RequestRole(AIAgent agent, RoleType role)
    {
        if (!roleHolders.ContainsKey(role) || roleHolders[role] == null)
        {
            roleHolders[role] = agent;
            return true;
        }
 
        // For roles that prefer the "ideal" agent, override if this agent is better
        if (IsIdealForRole(agent, role) && roleHolders[role] != agent)
        {
            roleHolders[role]?.OnRoleRevoked(role);
            roleHolders[role] = agent;
            return true;
        }
        return false;
    }
 
    public void ReleaseRole(AIAgent agent, RoleType role)
    {
        if (roleHolders.TryGetValue(role, out var holder) && holder == agent)
            roleHolders[role] = null;
    }
 
    private bool IsIdealForRole(AIAgent agent, RoleType role)
    {
        // Example: for Flanker, pick agent with lowest flank path cost
        if (role == RoleType.Flanker)
            return agent.FlankPathCost < (roleHolders[role]?.FlankPathCost ?? float.MaxValue);
        return false;
    }
}

OpportunisticShooter role

Ensures at least one NPC is always shooting the player when possible. Any NPC that can see and shoot the player requests this role. The first to get it immediately starts shooting — even mid-animation:

// In NPC update
void UpdateCombat()
{
    if (CanShootPlayer())
    {
        if (coordinator.RequestRole(this, RoleType.OpportunisticShooter))
        {
            // Interrupt current animation, blend to shoot
            animator.SetTrigger("ImmediateShoot");
            StartShooting();
        }
    }
    else
    {
        coordinator.ReleaseRole(this, RoleType.OpportunisticShooter);
    }
}

The original implementation without this role caused NPCs to be “noticeably slow in transitioning to shooting” — players could rush without being hit.

Flanker role

Flanking is the most complex role. Every NPC calculates a flank path every frame. The coordinator assigns the Flanker role to the NPC with the best (lowest cost) flank path:

Combat vector: Rather than using the raw exposure map (which changed wildly frame-to-frame), flanking cost uses the combat vector — the direction of current combat from the player’s perspective, computed as the average NPC position weighted by recent shot contributions.

Vector3 ComputeCombatVector(Vector3 playerPos, List<AIAgent> agents)
{
    Vector3 weightedSum = Vector3.zero;
    float totalWeight = 0f;
    foreach (var agent in agents)
    {
        float weight = 1f + agent.RecentShotWeight; // higher if agent fired recently
        weightedSum += (agent.transform.position - playerPos) * weight;
        totalWeight += weight;
    }
    return totalWeight > 0 ? (weightedSum / totalWeight).normalized : Vector3.zero;
}

Flank cost function: A cost shape is applied in the direction of the combat vector. The closer a path travels to the combat vector’s centre line, the higher the cost. This forces flanking paths to arc wide around the side and approach from behind.

float GetFlankCost(Vector3 pathPoint, Vector3 playerPos, Vector3 combatVector)
{
    Vector3 toPoint = (pathPoint - playerPos).normalized;
    float dotOnCombatVector = Vector3.Dot(toPoint, combatVector);
    // High cost when path goes directly along combat vector (straight into combat)
    // Low cost when path goes perpendicular or opposite
    return Mathf.Max(0f, dotOnCombatVector) * flankCostScale;
}

Cover and post selectors

Before the coordinator, NPCs need good cover options. In The Last of Us this was solved with post selectors — scriptable criteria that rate cover points per NPC per behaviour state. Each criterion returns a 0–1 float; all criteria multiply together to produce a final post score.

Key criteria example (scripted in LISP at Naughty Dog; equivalent C# sketch):

float EvaluateCoverPost(CoverPost post, AIAgent npc, Vector3 playerLastKnown)
{
    float score = 1f;
    
    // Must have a valid path
    if (!post.HasValidPath) return 0f;
    
    // Must be available (not occupied)
    if (!post.IsAvailable) return 0f;
    
    // Penalise posts that require running toward the player to reach
    if (RequiresRunningTowardPlayer(post, npc, playerLastKnown))
        score *= 0f; // reject
    
    // Prefer posts at moderate distance
    float dist = Vector3.Distance(npc.transform.position, post.position);
    score *= DistanceCurve.Evaluate(dist); // peaks at 3–5 m
    
    return score;
}

All criteria for all posts are evaluated continuously — when an NPC switches states, the best post for the new state is already known, with no delay.


Token systems for damage control

A complementary but distinct use of tokens: controlling combat difficulty by gating which NPC can actually hit the player (Barriales, Ch. 9, source-game-ai-pro-360-character-behavior).

Concept

All NPCs can enter shooting animations freely — but shots only land damage if the shooter holds the hit token. Shots without the token produce visual misses near the player instead.

Dynamic delay calculation

The token holder changes on a timer. The delay between hits is not fixed — it is calculated as:

delay = delayBase × ∏ rule_multipliers

Where each rule_multiplier responds to a contextual factor. Example rules:

RuleEffect
Distance < 5 m×0.5 (hit sooner — more pressure up close)
Player crouching×2.0 (give more time — safer feeling)
Player behind cover×2.0
Player facing away×2.0
Player running toward AI×0.5
float CalculateHitDelay(AIAgent shooter, PlayerState player)
{
    float delay = baseHitDelay;
    
    float dist = Vector3.Distance(shooter.transform.position, player.position);
    delay *= distanceCurve.Evaluate(dist);          // 0.5–1.0
    
    if (player.isCrouching)   delay *= 2.0f;
    if (player.isInCover)     delay *= 2.0f;
    if (player.isFacingAway)  delay *= 2.0f;
    
    float velocityAngle = GetAngleBetweenVelocityAndPlayerToAI(player, shooter);
    delay *= velocityCurve.Evaluate(velocityAngle);  // 0.5–2.0
    
    return delay;
}

Believable misses

When shots miss deliberately, aim at contextually interesting targets:

  • Full cover: Ground near the peek-out side; or hit the cover itself when player is peeking.
  • Half cover: Top edge of cover — closest to player’s eyes; most likely to be seen.
  • Open: Trace past the player’s face at eye level; aim at in-view destructibles.

Action tokens (group coordination)

A related pattern (Shroff, Ch. 4, source-game-ai-pro-360-character-behavior) uses action tokens to limit how many NPCs can perform a given action simultaneously:

public class ActionTokenPool
{
    private int availableTokens;
    private float timeBetweenAcquisitions; // min wait after releasing
 
    public bool TryAcquire(AIAgent agent)
    {
        if (availableTokens <= 0) return false;
        availableTokens--;
        agent.LastTokenTime = Time.time;
        return true;
    }
 
    public void Release(AIAgent agent)
    {
        // Optionally enforce a cooldown before re-acquisition
        availableTokens++;
    }
}

Token pools can scale with the number of NPCs in the scene (1 grenade token per 3 NPCs, etc.) and enforce minimum wait times after release.


Belgian AI — Kung-Fu Circle system

An alternative to the role-based coordinator for melee-heavy games: the Belgian AI system, also called the Kung-Fu Circle (Straatman & Verweij, Chapter 3, see source-game-ai-pro-360-tactics-strategy), as used in Kingdoms of Amalur: Reckoning.

Core concept

Place a grid of slots at fixed positions around the player. Enemies do not navigate freely to the player — they navigate to slots. A centralised stage manager assigns slots to enemies, controlling the pattern of approach without any per-NPC coordination logic.

     [ ]  [ ]  [ ]
  [ ]   [ ][ ]   [ ]
    [ ]     O   [ ]       O = player
  [ ]   [ ][ ]   [ ]
     [ ]  [ ]  [ ]

Each slot has a fixed position relative to the player (updated as the player moves) and an occupancy flag. The stage manager assigns the closest unoccupied slot to each approaching enemy.

Grid capacity and attack capacity budgets

Two independent budget values control difficulty:

BudgetControls
Grid capacityHow many enemies may occupy slots (approach within melee range) simultaneously
Attack capacityHow many of those occupying enemies may attack simultaneously

Setting grid capacity = 6, attack capacity = 2 allows six enemies to close in and surround the player, but only two attack at once. Enemies waiting to attack circle, dodge, and perform idle threat animations — producing natural-looking combat choreography without any scripted sequences.

public class BelgianStageManager : MonoBehaviour
{
    public int gridCapacity = 6;
    public int attackCapacity = 2;
 
    private List<CombatSlot> slots;
    private int occupiedCount = 0;
    private int attackingCount = 0;
 
    public bool RequestSlot(Enemy enemy, out CombatSlot assignedSlot)
    {
        assignedSlot = null;
        if (occupiedCount >= gridCapacity) return false;
 
        // Find nearest unoccupied slot
        var free = slots
            .Where(s => !s.IsOccupied)
            .OrderBy(s => Vector3.Distance(enemy.transform.position, s.WorldPosition))
            .FirstOrDefault();
 
        if (free == null) return false;
        free.Assign(enemy);
        occupiedCount++;
        assignedSlot = free;
        return true;
    }
 
    public bool RequestAttackToken(Enemy enemy)
    {
        if (attackingCount >= attackCapacity) return false;
        attackingCount++;
        return true;
    }
 
    public void ReleaseSlot(Enemy enemy, CombatSlot slot)
    {
        slot.Release();
        occupiedCount--;
    }
 
    public void ReleaseAttackToken()
    {
        attackingCount = Mathf.Max(0, attackingCount - 1);
    }
}

Inner and outer attack circles

The slot grid divides into two concentric zones:

  • Inner circle: Slots immediately adjacent to the player — melee attack range.
  • Outer circle: Slots further back — ranged attack range or waiting positions.

When an inner slot is occupied and an enemy has the attack token, it attacks. When the token is released (attack complete, enemy staggered, or enemy retreating), another waiting enemy — either inner or outer depending on weapon type — receives the token.

Flanking via slot geometry

Because slots form a ring around the player, enemies approaching from multiple directions naturally flank without coordination code. An enemy assigned to a slot on the player’s left and one assigned to a slot on the right will arrive from opposite directions — the geometry does the work.

Difficulty scaling

Increasing attackCapacity creates more pressure; decreasing it gives the player more breathing room. The system supports difficulty modes via these two integers alone, without redesigning encounter scripting. Designers can tune per-encounter by overriding the stage manager’s capacity values.

Comparison with role-based coordinator

SystemBest forCoupling
Belgian AI / Kung-Fu CircleMelee-heavy action; surrounding the playerVery low — only stage manager assigns slots
Role coordinator (The Last of Us)Cover shooter; ranged/mixed combatModerate — roles have semantic meaning (flanker, shooter)

The Belgian AI produces elegant results with minimal code when the combat pattern is fundamentally “surround and strike”. The role coordinator is more powerful but requires semantic role definitions per game.


Trade-offs

ApproachBest forLimitations
Global Combat CoordinatorCover-shooter, action-adventureSingle point of failure; complex multi-room scenarios need multiple coordinators
Belgian AI / Kung-Fu CircleMelee-heavy action; surrounding behaviourLess flexible for cover-based or ranged combat
Token poolsAny repeated group action (grenades, flanks, shouts)No role-specific targeting logic
Dynamic accuracyDifficulty balancing; perceived fairnessRequires careful tuning per encounter; players may notice at extremes

When to use

  • Any game where multiple NPCs fight the player simultaneously and uncoordinated behaviour would feel chaotic or unfair.
  • Cover shooters, action games, stealth games with patrols.
  • When difficulty needs fine-grained, designer-controllable tuning.

Evidence

  • McIntosh (Game AI Pro 360, Ch. 2, see source-game-ai-pro-360-character-behavior) describes the full Combat Coordinator implementation in The Last of Us, including the role list, RequestRole/AcknowledgeRole API, and the combat vector flanking approach.
  • Barriales (Ch. 9, same source) describes the dynamic accuracy system and token-based hit gating in detail with worked examples.
  • Shroff (Ch. 4, same source) describes action token pools in the context of NPC behaviour distribution.
  • Straatman & Verweij (Ch. 3, see source-game-ai-pro-360-tactics-strategy) describe the Belgian AI / Kung-Fu Circle system as used in Kingdoms of Amalur: Reckoning, including the grid capacity / attack capacity budget model and difficulty scaling via capacity values.

Implications

  • Coordination does not require NPCs to communicate directly. A shared coordinator object with an exclusive role registry produces the appearance of team tactics from individual greedy requests.
  • The combat vector is a more stable flanking reference than real-time exposure maps. Stability matters: an unstable flanking direction produces incoherent movement.
  • Deliberately missing shots is a powerful difficulty tool but must be made visually engaging — near-misses, surface impacts, and destructibles help.

Open questions

  • How does the Combat Coordinator scale to scenarios with multiple simultaneous player-character groups (co-op)?
  • Is there a clean way to express the post selector system (LISP-scripted criteria) in Unity as ScriptableObject evaluators without a custom compiler?
  • How should the coordinator handle NPCs dying mid-role? Immediate release vs. brief delay before another NPC can take the role?

game-ai-agent-design · utility-ai · npc-perception-systems · buddy-ai · influence-maps · ai-state-machine-pattern · squad-ai-patterns · tactical-position-selection · source-game-ai-pro-360-character-behavior · source-game-ai-pro-360-tactics-strategy