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
- Non-determinism — pathfinding uses floating-point math; results vary slightly between runs
- State space explosion — an AI with 10 booleans has 1024 possible states
- Emergent behavior — multiple AI systems interacting produce unexpected outcomes
- Time dependence — behaviors play out over many frames, not in a single tick
- 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.