Testing Game AI: NPC Behavior, Pathfinding, and Behavior Trees

Testing Game AI: NPC Behavior, Pathfinding, and Behavior Trees

Game AI bugs are among the most entertaining to watch and the most embarrassing to ship. NPCs that walk into walls, enemies that attack through solid geometry, companions that stand idle during combat — these are all AI bugs that escaped testing.

Testing game AI is hard because AI behavior is inherently non-deterministic, context-dependent, and emergent. This guide covers practical approaches that work.

What Makes Game AI Testing Hard

  1. Non-determinism — pathfinding uses floating-point math; results vary slightly between runs
  2. State space explosion — an AI with 10 booleans has 1024 possible states
  3. Emergent behavior — multiple AI systems interacting produce unexpected outcomes
  4. Time dependence — behaviors play out over many frames, not in a single tick
  5. World state dependencies — AI decisions depend on the entire game world

The solution: test AI systems at multiple levels, from pure unit tests of decision logic to simulation-based integration tests that run the game in fast-forward.

Testing State Machines

Most game AI uses finite state machines (FSM). Test the transition logic, not the rendering:

// EnemyStateMachine.cs
public enum EnemyState {
    Idle,
    Patrol,
    Alert,
    Chase,
    Attack,
    Retreat,
    Dead
}

public class EnemyStateMachine {
    public EnemyState CurrentState { get; private set; } = EnemyState.Idle;
    
    private float health;
    private float distanceToPlayer;
    private bool playerVisible;
    
    public EnemyStateMachine(float health) {
        this.health = health;
    }

    public void Update(float playerDistance, bool canSeePlayer, float deltaTime) {
        distanceToPlayer = playerDistance;
        playerVisible = canSeePlayer;
        
        var nextState = CalculateNextState();
        if (nextState != CurrentState) {
            OnStateExit(CurrentState);
            CurrentState = nextState;
            OnStateEnter(CurrentState);
        }
    }

    public void TakeDamage(float damage) {
        health -= damage;
        if (health <= 0) CurrentState = EnemyState.Dead;
    }

    private EnemyState CalculateNextState() {
        if (health <= 0) return EnemyState.Dead;
        if (health < 20f && CurrentState == EnemyState.Chase) return EnemyState.Retreat;
        if (playerVisible && distanceToPlayer <= 2f) return EnemyState.Attack;
        if (playerVisible && distanceToPlayer <= 10f) return EnemyState.Chase;
        if (distanceToPlayer <= 15f && CurrentState != EnemyState.Patrol) return EnemyState.Alert;
        if (CurrentState == EnemyState.Idle) return EnemyState.Patrol;
        return CurrentState;
    }

    private void OnStateEnter(EnemyState state) { /* Setup for new state */ }
    private void OnStateExit(EnemyState state) { /* Cleanup old state */ }
}
// Tests/EditMode/EnemyStateMachineTests.cs
using NUnit.Framework;

public class EnemyStateMachineTests {
    private EnemyStateMachine ai;

    [SetUp]
    public void SetUp() {
        ai = new EnemyStateMachine(health: 100f);
    }

    [Test]
    public void InitialState_IsIdle() {
        Assert.AreEqual(EnemyState.Idle, ai.CurrentState);
    }

    [Test]
    public void Idle_TransitionsTo_Patrol() {
        ai.Update(playerDistance: 100f, canSeePlayer: false, deltaTime: 0.1f);
        Assert.AreEqual(EnemyState.Patrol, ai.CurrentState);
    }

    [Test]
    public void PlayerInRange_ButNotVisible_AlertsEnemy() {
        ai.Update(100f, false, 0.1f); // Start patrol first
        ai.Update(12f, false, 0.1f);  // Player within 15f but not visible
        Assert.AreEqual(EnemyState.Alert, ai.CurrentState);
    }

    [Test]
    public void PlayerVisible_AndInRange_StartsChase() {
        ai.Update(8f, true, 0.1f);
        Assert.AreEqual(EnemyState.Chase, ai.CurrentState);
    }

    [Test]
    public void PlayerVeryClose_AndVisible_StartsAttack() {
        ai.Update(1.5f, true, 0.1f);
        Assert.AreEqual(EnemyState.Attack, ai.CurrentState);
    }

    [Test]
    public void LowHealth_DuringChase_TriggersRetreat() {
        ai.Update(8f, true, 0.1f); // Start chasing
        Assert.AreEqual(EnemyState.Chase, ai.CurrentState);
        
        ai.TakeDamage(85f); // Bring health below 20
        ai.Update(8f, true, 0.1f); // Update should trigger retreat
        
        Assert.AreEqual(EnemyState.Retreat, ai.CurrentState);
    }

    [Test]
    public void Death_OverridesAllStates() {
        ai.Update(1.5f, true, 0.1f); // Attacking
        ai.TakeDamage(100f); // Kill
        ai.Update(0f, true, 0.1f);
        
        Assert.AreEqual(EnemyState.Dead, ai.CurrentState);
    }

    [Test]
    public void Dead_CannotTransitionToOtherStates() {
        ai.TakeDamage(100f);
        ai.Update(1.5f, true, 0.1f); // Player right next to dead enemy
        
        Assert.AreEqual(EnemyState.Dead, ai.CurrentState);
    }

    // Parameterized transition table test
    [TestCase(100f, false, 100f, EnemyState.Patrol)]
    [TestCase(8f,   true,  100f, EnemyState.Chase)]
    [TestCase(1f,   true,  100f, EnemyState.Attack)]
    [TestCase(8f,   true,  15f,  EnemyState.Retreat)]  // Low health
    public void StateTransitions_FollowRules(
        float distance, bool visible, float health, EnemyState expected)
    {
        var testAI = new EnemyStateMachine(health);
        // Set health if needed
        if (health < 20f) testAI.TakeDamage(100f - health);
        
        testAI.Update(distance, visible, 0.1f);
        
        Assert.AreEqual(expected, testAI.CurrentState);
    }
}

Testing Behavior Trees

Behavior trees are more composable than FSMs. Test individual nodes, then test tree composition:

// BehaviorTree nodes
public abstract class BTNode {
    public enum Status { Success, Failure, Running }
    public abstract Status Tick(BlackBoard board);
}

public class CanSeePlayerCondition : BTNode {
    public override Status Tick(BlackBoard board) =>
        board.playerVisible ? Status.Success : Status.Failure;
}

public class IsHealthLowCondition : BTNode {
    private readonly float threshold;
    public IsHealthLowCondition(float threshold) { this.threshold = threshold; }
    
    public override Status Tick(BlackBoard board) =>
        board.health <= threshold ? Status.Success : Status.Failure;
}

public class ChasePlayerAction : BTNode {
    public override Status Tick(BlackBoard board) {
        board.moveTarget = board.playerPosition;
        board.actionTaken = "chase";
        return Status.Running; // Chase is ongoing
    }
}
// Tests/EditMode/BehaviorTreeTests.cs
public class BehaviorTreeTests {
    private BlackBoard board;

    [SetUp]
    public void SetUp() {
        board = new BlackBoard {
            health = 100f,
            playerVisible = false,
            playerPosition = Vector3.zero,
        };
    }

    [Test]
    public void CanSeePlayer_PlayerVisible_ReturnsSuccess() {
        board.playerVisible = true;
        var node = new CanSeePlayerCondition();
        Assert.AreEqual(BTNode.Status.Success, node.Tick(board));
    }

    [Test]
    public void CanSeePlayer_PlayerHidden_ReturnsFailure() {
        board.playerVisible = false;
        var node = new CanSeePlayerCondition();
        Assert.AreEqual(BTNode.Status.Failure, node.Tick(board));
    }

    [Test]
    public void IsHealthLow_BelowThreshold_ReturnsSuccess() {
        board.health = 15f;
        var node = new IsHealthLowCondition(threshold: 20f);
        Assert.AreEqual(BTNode.Status.Success, node.Tick(board));
    }

    [Test]
    public void SelectorNode_ReturnsFirstSuccess() {
        // Selector tries each child until one succeeds
        var selector = new SelectorNode(new BTNode[] {
            new AlwaysFailNode(),
            new AlwaysSucceedNode(),
            new AlwaysFailNode(),
        });
        
        Assert.AreEqual(BTNode.Status.Success, selector.Tick(board));
    }

    [Test]
    public void SequenceNode_FailsOnFirstFailure() {
        // Sequence requires all children to succeed
        var sequence = new SequenceNode(new BTNode[] {
            new AlwaysSucceedNode(),
            new AlwaysFailNode(),      // This fails
            new AlwaysSucceedNode(),   // This never runs
        });
        
        Assert.AreEqual(BTNode.Status.Failure, sequence.Tick(board));
    }

    [Test]
    public void CombatTree_ChasesWhenPlayerVisible() {
        board.playerVisible = true;
        board.health = 100f;
        board.playerPosition = new Vector3(5, 0, 5);

        var combatTree = BuildCombatTree();
        combatTree.Tick(board);

        Assert.AreEqual("chase", board.actionTaken);
        Assert.AreEqual(board.playerPosition, board.moveTarget);
    }

    [Test]
    public void CombatTree_RetreatsWhenHealthLow() {
        board.playerVisible = true;
        board.health = 10f; // Low health

        var combatTree = BuildCombatTree();
        combatTree.Tick(board);

        Assert.AreEqual("retreat", board.actionTaken);
    }

    private BTNode BuildCombatTree() {
        return new SelectorNode(new BTNode[] {
            new SequenceNode(new BTNode[] {
                new IsHealthLowCondition(20f),
                new RetreatAction(),
            }),
            new SequenceNode(new BTNode[] {
                new CanSeePlayerCondition(),
                new ChasePlayerAction(),
            }),
            new PatrolAction(),
        });
    }
}

Testing Pathfinding

Pathfinding tests should verify logical properties, not exact paths:

// Tests/PlayMode/PathfindingTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.TestTools;

public class PathfindingTests {
    [UnityTest]
    public IEnumerator Agent_ReachesDestination_WithinTimeLimit() {
        var agentGO = new GameObject("TestAgent");
        var agent = agentGO.AddComponent<NavMeshAgent>();
        
        // Place on nav mesh
        agentGO.transform.position = NavMesh.SamplePosition(
            Vector3.zero, out var hit, 5f, NavMesh.AllAreas
        ) ? hit.position : Vector3.zero;

        var destination = new Vector3(10f, 0f, 0f);
        agent.SetDestination(destination);

        float timeout = 10f;
        float elapsed = 0f;

        while (elapsed < timeout) {
            yield return new WaitForSeconds(0.1f);
            elapsed += 0.1f;

            if (!agent.pathPending &&
                agent.remainingDistance <= agent.stoppingDistance) {
                break; // Reached destination
            }
        }

        Assert.Less(elapsed, timeout, "Agent should reach destination within 10 seconds");
        
        var finalDistance = Vector3.Distance(agentGO.transform.position, destination);
        Assert.Less(finalDistance, 1.5f, "Agent should be within 1.5 units of destination");
        
        Object.Destroy(agentGO);
    }

    [UnityTest]
    public IEnumerator Agent_FindsPathAround_Obstacle() {
        // This test requires a specific test scene with known geometry
        // Set up: obstacle between start and end, path should go around it
        
        var agentGO = GameObject.Find("TestPathfindingAgent");
        if (agentGO == null) {
            Assert.Ignore("TestPathfindingAgent not in scene — skipping");
            yield break;
        }

        var agent = agentGO.GetComponent<NavMeshAgent>();
        var startPos = agentGO.transform.position;
        var endPos = new Vector3(startPos.x + 20f, startPos.y, startPos.z);

        agent.SetDestination(endPos);
        yield return new WaitUntil(() => !agent.pathPending);

        // Path should exist
        Assert.AreNotEqual(NavMeshPathStatus.PathInvalid, agent.path.status,
            "A valid path should be found around the obstacle");

        // Path should be longer than direct distance (went around obstacle)
        var directDistance = Vector3.Distance(startPos, endPos);
        var pathLength = CalculatePathLength(agent.path);
        Assert.Greater(pathLength, directDistance * 1.1f,
            "Path should be longer than direct distance, indicating it went around an obstacle");
    }

    private float CalculatePathLength(NavMeshPath path) {
        float length = 0f;
        for (int i = 1; i < path.corners.Length; i++) {
            length += Vector3.Distance(path.corners[i - 1], path.corners[i]);
        }
        return length;
    }
}

Simulation-Based Testing

For complex AI behaviors that play out over time, run the game in fast-forward:

// Tests/PlayMode/AISimulationTests.cs
public class AISimulationTests {
    [UnityTest]
    public IEnumerator EnemyPatrol_CoversAllWaypoints_Within60Seconds() {
        var enemy = GameObject.Find("PatrolEnemy");
        var waypoints = GameObject.FindGameObjectsWithTag("Waypoint");
        
        Assert.IsNotNull(enemy, "PatrolEnemy must be in test scene");
        Assert.Greater(waypoints.Length, 0, "Waypoints must be in test scene");

        var visitedWaypoints = new HashSet<int>();
        var aiController = enemy.GetComponent<EnemyAIController>();
        
        float elapsed = 0f;
        const float timeout = 60f;

        while (elapsed < timeout && visitedWaypoints.Count < waypoints.Length) {
            yield return new WaitForSeconds(0.5f);
            elapsed += 0.5f;

            // Check which waypoints the enemy has visited
            for (int i = 0; i < waypoints.Length; i++) {
                if (!visitedWaypoints.Contains(i)) {
                    float dist = Vector3.Distance(
                        enemy.transform.position,
                        waypoints[i].transform.position
                    );
                    if (dist < 1.5f) visitedWaypoints.Add(i);
                }
            }
        }

        Assert.AreEqual(waypoints.Length, visitedWaypoints.Count,
            $"Enemy should visit all {waypoints.Length} waypoints within {timeout}s. " +
            $"Visited: {visitedWaypoints.Count}");
    }

    [UnityTest]
    public IEnumerator MultipleEnemies_DoNotStackInSamePosition() {
        var enemies = GameObject.FindGameObjectsWithTag("Enemy");
        Assert.Greater(enemies.Length, 1, "Need multiple enemies for this test");

        yield return new WaitForSeconds(5f); // Let AI run for 5 seconds

        // Check for AI clustering (common bug with shared pathfinding goals)
        for (int i = 0; i < enemies.Length; i++) {
            for (int j = i + 1; j < enemies.Length; j++) {
                float dist = Vector3.Distance(
                    enemies[i].transform.position,
                    enemies[j].transform.position
                );
                Assert.Greater(dist, 0.5f,
                    $"Enemies {i} and {j} are too close — possible AI stacking bug");
            }
        }
    }
}

Testing AI Under Edge Cases

public class AIEdgeCaseTests {
    [Test]
    public void StateMachine_HandlesNullTargetGracefully() {
        var ai = new EnemyStateMachine(100f);
        
        // Should not throw when player position is unreachable
        Assert.DoesNotThrow(() => {
            ai.UpdateWithNullTarget(deltaTime: 0.1f);
        });
        
        Assert.AreNotEqual(EnemyState.Dead, ai.CurrentState, 
            "Null target should not kill the AI");
    }

    [Test]
    public void StateMachine_HandlesTeleportedPlayer() {
        var ai = new EnemyStateMachine(100f);
        
        // Player suddenly teleports from close to far
        ai.Update(1f, true, 0.1f);   // Close, attacking
        Assert.AreEqual(EnemyState.Attack, ai.CurrentState);
        
        ai.Update(1000f, false, 0.1f); // Teleported away
        // Should return to patrol, not stay in attack forever
        Assert.AreNotEqual(EnemyState.Attack, ai.CurrentState);
    }

    [Test]
    public void DecisionTree_DoesNotInfiniteLoop() {
        var ai = new EnemyStateMachine(100f);
        const int MAX_UPDATES = 1000;
        int updates = 0;
        
        Assert.DoesNotThrow(() => {
            while (updates < MAX_UPDATES) {
                ai.Update(5f, true, 0.016f);
                updates++;
            }
        });
        
        Assert.AreEqual(MAX_UPDATES, updates);
    }
}

AI Testing Checklist

State machine:

  • All states have entry and exit handlers tested
  • Every valid transition is tested
  • Invalid transitions are blocked
  • Death state is terminal (no exits)
  • Low health retreat triggers correctly

Behavior tree:

  • Each leaf node (condition/action) has isolated tests
  • Selector returns first success
  • Sequence fails on first failure
  • Tree produces expected action for each game state combination

Pathfinding:

  • Agent reaches destination within time budget
  • Agent navigates around obstacles
  • Agent handles destination inside obstacle (invalid path)
  • Multiple agents don't stack or block each other
  • Agent handles dynamic obstacles

Simulation:

  • Patrol covers all waypoints within time limit
  • Enemy responds to player within reaction time budget
  • AI doesn't get stuck in corners
  • No infinite state oscillation (flip-flopping between states)

Game AI bugs are uniquely visible to players. A methodical testing approach at each layer — unit, integration, simulation — catches the obvious logical bugs early while simulation tests catch the emergent behaviors that only appear when multiple systems interact.

Read more