Summary

Buddy AI (companion AI) is among the hardest problems in character AI because the agent must be helpful, never annoying, and convincingly alive — all simultaneously, in all game states. The classical failures are well-known: companions that block the player, shoot at the wrong time, get noticed in stealth, or turn into mindless drones with no presence.

The principles and systems below are drawn from Dyckhoff’s account of developing Ellie’s AI in The Last of Us in five months with a small team (Game AI Pro 360, Ch. 3, see source-game-ai-pro-360-character-behavior).


Key ideas

Core principles

  1. Keep the buddy close to the player. If she is in the same space behaving similarly, her actions cannot be more obviously wrong than the player’s own. Distance is the enemy of trust.
  2. Avoid cheating — but make principled exceptions. Cheats erode the character’s authenticity even when the player never notices. Reserve them for cases where realism would definitively harm the player experience.
  3. Think about her as a real person. The development team genuinely cared about Ellie as a character. This mindset produced better design decisions than asking “what is the technically correct AI behaviour.”
  4. Never break stealth. “If [a buddy] ruin[s] what the player is trying to do just once, it’s all in vain.” (Dyckhoff)
  5. Use long timers. Memorable combat moments come from rarity. Gifting, saving the player from a grapple, throwing a brick — these are powerful precisely because they don’t happen every encounter.

Follow system

Follow region

A torus around the leader described by configurable parameters. Candidate follow positions are generated inside this region using three sets of navmesh rays:

  1. Fan rays from leader — ensure there is a clear navigation path from buddy to leader. One candidate per ray that reaches the follow region.
  2. Forward rays from each candidate — reject positions facing a wall (a buddy next to a wall looks unnatural).
  3. Rays from player forward position to each candidate — ensure the buddy won’t be on the other side of a doorway or fence from the player.

Position rating

Positions are rated every frame on:

  • Distance to leader (penalise if too far or too close)
  • Staying on the same side of the leader (avoid running past)
  • Visibility to potential threats (hide or expose as appropriate)
  • Not being directly in front of the player
  • Distance to other buddies (spread out)

Movement speed scaling

Each movement mode (walk/run/sprint) has a fixed reference speed. To avoid constant oscillation between modes, the buddy scales her animation playback speed by up to ±25% to stay within a mode longer, then smoothly transitions at range boundaries.

// Illustrative: select movement mode and scale speed
MovementMode GetMovementMode(float desiredSpeed, out float animSpeedScale)
{
    if (desiredSpeed < walkMaxSpeed * 1.25f)
    {
        animSpeedScale = desiredSpeed / walkReferenceSpeed;
        animSpeedScale = Mathf.Clamp(animSpeedScale, 0.75f, 1.25f);
        return MovementMode.Walk;
    }
    else if (desiredSpeed < runMaxSpeed * 1.25f)
    {
        animSpeedScale = desiredSpeed / runReferenceSpeed;
        animSpeedScale = Mathf.Clamp(animSpeedScale, 0.75f, 1.25f);
        return MovementMode.Run;
    }
    // ...Sprint case
}

Dodging

Ellie does not pre-emptively dodge the player. If the player runs straight at her, she plays a brief canned dodge animation at the last second — making it feel like a character choice (“don’t crowd me”) rather than broken pathfinding. This transforms an annoying AI behaviour into a character moment.

No teleportation

Ellie never teleports to stay near the player. The team treated the no-teleport constraint as a design quality enforcer: every situation where they would have teleported forced them to find a real solution instead. The only exception was teleporting to the player’s side during a melee grapple rescue, which is visually hidden by the player’s locked camera during the grapple animation.


Combat utility

Unarmed combat

  • Brick throw: Triggered when enemy perception system predicts the enemy will spot the player imminently. The throw stuns the target briefly. Protected by a long cooldown so it remains memorable.
  • Enemy grapples Ellie: She has escape animations. Disallowed from being re-targeted for 15–30 seconds after escaping.
  • Player is grappled: Ellie can save the player — a reliable source of gratitude. She teleports into position (camera is locked during grapples).
  • Gifting: Ellie picks up ammo and health packs and passes them to the player. Hooked directly into the existing loot drop system so it does not affect balance. Long timer prevents devaluation.

Armed combat — shooting permission model

Invert the default: Ellie wants not to shoot. Shooting is granted under explicit conditions:

bool HasPermissionToShoot(EllieCombatContext ctx)
{
    // Player is already making noise — she can join
    if (ctx.playerIsFiringWeapon) return true;
    if (ctx.playerIsInNoisyMeleeComba) return true;
    
    // Enemy is an immediate threat to the player
    if (ctx.enemyIntendingToAttackPlayer) return true;
    
    // Player is NOT trying to stealth (compare player position
    // to enemy's last known player position)
    if (!ctx.playerAppearsToBeInStealth) return true;
    
    return false;
}

Balancing damage output

Ellie was still too effective after enabling guns. The solution: if the player won’t see the hit, don’t apply damage. She fires normally but shots that aren’t witnessed are cosmetic. Real damage kicks in only during specific moments (player in danger, player hasn’t seen her kill anyone recently).

A “furtive idle” animation fills the gaps between shots — making her appear nervous rather than malfunctioning during the mandatory pauses.

Stealth invisibility

The final, principled cheat: enemies cannot see Ellie when the player is in stealth. The team preferred this over the alternative (occasional stealth breaks) because one stealth failure destroys the trust built over hours of play.


Cover generation

Ellie uses a hybrid cover edge system:

  • Static cover edges — precomputed by tools from wall/floor collision geometry.
  • Procedural cover edges — computed at runtime from collision raycasts fanned around the player’s current position. Hit normals cluster similar points into edge features.

Both sets are cached and merged. The cache is evicted only when capacity is exhausted. Procedural generation is expensive (restricted to buddy NPCs only — enemy NPCs use sparse precomputed cover).

Cover rating criteria:

  • Visibility to enemies (dot-product check against known enemy positions)
  • Proximity to the player (stay close)
  • Predicted future visibility (project enemy movement forward)

Cover share: A special animation set allows Joel to enter cover directly next to Ellie (hand on wall, shielding her). A bug that produced this outcome by accident turned out to look great and was intentionally kept.


Finishing touches

Vocalisation integration

Combat AI and dialogue are closely integrated. Ellie comments on specific kill types, near-misses, and whether she has saved the player. An unplanned emergent effect: Ellie’s vocalisation often mirrored the player’s own reaction, creating an emotional connection.

Callout system

Ellie calls out unseen threats — but only after confirming the enemy is still visible. Over-enthusiastic callouts that pointed to already-hidden enemies made her seem less intelligent than she was. Rule: only call out an enemy that the player can currently see.

Ambient explore

Designers instrument the world with “points of interest.” Ellie wanders to them during non-combat exploration (looking in cabinets, examining objects). A one-day implementation that significantly improved narrative immersion.


Unity implementation sketch

public class BuddyFollowSystem : MonoBehaviour
{
    [SerializeField] private Transform leader;
    [SerializeField] private float followRadiusMin = 1.5f;
    [SerializeField] private float followRadiusMax = 3.5f;
    
    private NavMeshAgent agent;
    private Vector3 currentFollowTarget;
    
    void Awake() => agent = GetComponent<NavMeshAgent>();
 
    void Update()
    {
        Vector3 best = FindBestFollowPosition();
        float distToTarget = Vector3.Distance(transform.position, best);
        
        // Only move if meaningfully far from target
        if (distToTarget > 0.4f)
        {
            agent.speed = SelectMovementSpeed(distToTarget);
            agent.SetDestination(best);
        }
    }
 
    Vector3 FindBestFollowPosition()
    {
        float bestScore = float.MinValue;
        Vector3 best = transform.position;
        
        // Generate candidates via navmesh sampling
        for (int i = 0; i < 12; i++)
        {
            float angle = i * 30f * Mathf.Deg2Rad;
            float radius = Random.Range(followRadiusMin, followRadiusMax);
            Vector3 candidate = leader.position + new Vector3(
                Mathf.Cos(angle) * radius, 0, Mathf.Sin(angle) * radius);
            
            if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, 1f, NavMesh.AllAreas))
            {
                float score = EvaluateFollowPosition(hit.position);
                if (score > bestScore) { bestScore = score; best = hit.position; }
            }
        }
        return best;
    }
 
    float EvaluateFollowPosition(Vector3 pos)
    {
        float score = 0f;
        float dist = Vector3.Distance(pos, leader.position);
        
        // Prefer positions in the follow region
        if (dist >= followRadiusMin && dist <= followRadiusMax) score += 1f;
        
        // Avoid being in front of the leader
        Vector3 toPos = (pos - leader.position).normalized;
        float dotForward = Vector3.Dot(leader.forward, toPos);
        if (dotForward < 0.3f) score += 0.5f;
        
        return score;
    }
 
    float SelectMovementSpeed(float distance)
    {
        if (distance < 1f) return 1.5f;   // walk
        if (distance < 4f) return 3.5f;   // run
        return 6f;                          // sprint
    }
}

Trade-offs

Design choiceWhen to useWhen to avoid
Keep buddy closeAlmost alwaysOpen-world games where independence is a feature
Stealth invisibilityWhen buddy stealth is imperfectWhen the game’s core loop is about NPC realism
Long ability timersCompanion in long narrative gameCompanion in short action game (too rare to notice)
No teleportationIf you can solve all follow edge casesVery complex environments where solving them is infeasible
Inverted shooting permissionStealth-heavy gamesPure action games where the buddy should always be aggressive

Evidence

  • All of this section is drawn from Dyckhoff (Game AI Pro 360, Ch. 3, see source-game-ai-pro-360-character-behavior). The Ellie AI was built in approximately 5 months by one core engineer.
  • Botta (Ch. 1) describes the follow skill used by Infected — a lightweight version where non-alerted NPCs just follow an alerted one without knowing why. This shows that even “dumb” following produces emergent group behaviour.

Implications

  • The most impactful decisions in buddy AI are often philosophical (no teleportation, no stealth breaks) rather than technical. Establish these principles at the start of development.
  • Rarity amplifies impact. Timer-gated special actions feel like moments of connection rather than mechanical repetition.
  • Integrate with the dialogue/vocalisations system from day one. Audio commentary is a massive force multiplier for perceived intelligence.

Open questions

  • How does the follow system scale to open-world games where the player has large amounts of open terrain? The navmesh ray fan approach works well in corridors but may degrade in open space.
  • Is there a principled way to implement the callout system (only call out visible enemies) in Unity given the variable update frequency of perception queries?
  • What Unity tools or packages support the runtime procedural cover edge generation described here?

game-ai-agent-design · npc-perception-systems · combat-coordinator-pattern · ai-state-machine-pattern · source-game-ai-pro-360-character-behavior