My first enemy was one Update method, four hundred lines long, steered by a handful of booleans: isChasing, isSearching, hasLostPlayer. Every new behaviour broke an old one, because any flag could be true at the same time as any other.
A finite state machine removes that whole class of bug. The enemy is in exactly one state at a time, each state is a small class that does one job, and the rules for moving between states sit together where you can read them.
One interface, one class per state
public interface IState
{
void Enter();
void Tick();
void Exit();
}
public class StateMachine
{
public IState Current { get; private set; }
public void Set(IState next)
{
if (next == Current) return;
Current?.Exit();
Current = next;
Current.Enter();
}
public void Tick() => Current?.Tick();
}Enter and Exit are where the setup and clean-up live: start an animation, change the agent’s speed, stop a sound. They run exactly once per change, which is precisely what the boolean version kept getting wrong.
A state does one job
public class Patrol : IState
{
readonly Enemy enemy;
int index;
public Patrol(Enemy enemy) => this.enemy = enemy;
public void Enter() => GoTo(index);
public void Tick()
{
var agent = enemy.Agent;
if (agent.pathPending || agent.remainingDistance > agent.stoppingDistance) return;
index = (index + 1) % enemy.Waypoints.Length;
GoTo(index);
}
public void Exit() { }
void GoTo(int i) => enemy.Agent.SetDestination(enemy.Waypoints[i].position);
}The pathPending check matters: straight after SetDestination, remainingDistance can still read zero while the path is being calculated, and the enemy skips every other waypoint.
Transitions live in one place
public class Enemy : MonoBehaviour
{
public NavMeshAgent Agent;
public Transform[] Waypoints;
public Transform Player;
[SerializeField] float viewDistance = 12f;
[SerializeField] float viewAngle = 90f;
[SerializeField] LayerMask obstacles;
readonly StateMachine brain = new();
Patrol patrol;
Chase chase;
Search search;
void Awake()
{
patrol = new Patrol(this);
chase = new Chase(this);
search = new Search(this);
brain.Set(patrol);
}
void Update()
{
if (CanSeePlayer()) brain.Set(chase);
else if (brain.Current == chase) brain.Set(search);
else if (brain.Current == search && search.GaveUp) brain.Set(patrol);
brain.Tick();
}
}That Update is the enemy’s whole personality in five lines. When a designer asks “what happens if it loses you?”, the answer is right there.
Seeing the player
bool CanSeePlayer()
{
Vector3 toPlayer = Player.position - transform.position;
if (toPlayer.sqrMagnitude > viewDistance * viewDistance) return false;
if (Vector3.Angle(transform.forward, toPlayer) > viewAngle * 0.5f) return false;
// Eye to eye, so a knee-high crate doesn't hide the player.
Vector3 eye = Vector3.up * 1.6f;
return !Physics.Linecast(transform.position + eye, Player.position + eye, obstacles);
}Cheapest checks first: the distance check costs a few multiplications, the angle check a little more, and only an enemy that passes both pays for the raycast.
Make the state visible
Draw the current state’s name above each enemy while you work on them:
void OnDrawGizmos()
{
if (brain.Current != null)
UnityEditor.Handles.Label(transform.position + Vector3.up * 2.2f, brain.Current.GetType().Name);
}