When the enemy’s Chase→Patrol transition started misfiring because two branches shared a variable, I knew the update loop had to go.
The Problem
The first version of the Hide & Seek enemy was a single Update() method with a chain of if/else blocks. Patrol logic, detection checks, chase behaviour, and navigation calls were all in one place. Adding a search phase or alert scan meant the method grew — and changing any branch required reading the entire function to understand what else it might affect.
When I added the Search state, the Chase→Patrol transition started firing incorrectly because both branches read from the same _lastKnownPosition variable. Fixing one broke the other. The root cause wasn’t the length of the function — it was shared mutable state with no clear ownership.
The IState Interface
Extracting each behaviour into its own class removes the ownership problem: each state holds its own variables, and no other state can touch them. The interface is three methods.
// Infrastructure/Interfaces/IState.cs
public interface IState
{
void Enter();
void Tick();
void Exit();
}
Enter() runs once on activation, Tick() runs every frame from the owner’s Update(), Exit() cleans up on transition. BaseState implements all three as empty virtuals — concrete states override only what they need.
A concrete state looks like this:
// AI/Enemy/States/EnemyPatrolState.cs
public class EnemyPatrolState : BaseState
{
readonly EnemyController _enemy;
int _waypointIndex;
public EnemyPatrolState(EnemyController enemy)
{
_enemy = enemy;
}
public override void Enter()
{
_enemy.Navigation.SetSpeed(_enemy.Data.patrolSpeed);
MoveToNextWaypoint();
}
public override void Tick()
{
if (_enemy.Navigation.IsAtDestination)
MoveToNextWaypoint();
if (_enemy.SuspicionMeter.State == SeekState.Alert)
_enemy.ChangeState(new EnemyAlertState(_enemy));
}
void MoveToNextWaypoint()
{
var wp = _enemy.Waypoints[_waypointIndex % _enemy.Waypoints.Length];
_enemy.Navigation.SetDestination(wp.position);
_waypointIndex++;
}
}
SuspicionMeter tracks how aware the enemy is of the player — SeekState.Alert means it has confirmed the player’s position and is ready to chase. Waypoints are serialized Transform references on EnemyController, set in the Unity Inspector per-enemy.
Adding a new state means writing one new class. Nothing else changes.
State Transitions
EnemyController is a passive hub: it holds references to subsystems and passes itself to each state. States read from it — they never write to shared fields on it. That discipline is what keeps the controller from becoming a god object; the compiler won’t enforce it, so it has to be a convention. StateMachine manages the active state and drives transitions:
// Infrastructure/StateMachine/StateMachine.cs
public class StateMachine
{
IState _currentState;
public void ChangeState(IState newState)
{
_currentState?.Exit();
_currentState = newState;
_currentState?.Enter();
}
public void Tick() => _currentState?.Tick();
}
EnemyController owns one StateMachine, calls Tick() from Update(), and exposes a ChangeState(IState) wrapper so states can trigger transitions. The machine doesn’t know what the states are — it only knows the IState interface.
What I Learned
The biggest practical benefit isn’t cleanliness — it’s testability. Each state is a plain C# class with no MonoBehaviour dependency. I can instantiate EnemyPatrolState in a unit test, feed it a mock EnemyController, and verify that it transitions when suspicion crosses the threshold. With a monolithic method there’s no clean seam to inject a mock — the logic and the dependencies are fused.
Pre-allocate states at Awake() — don’t new them on every transition. Each new EnemyChaseState(this) call allocates a heap object. With six states and frequent transitions that’s measurable GC pressure. Creating all six states once at startup and passing references eliminates it entirely. The object pooling devlog covers the same class of problem from a different angle.
State as the unit of change. When requirements changed — adding a Search state between Chase and Patrol — the existing states were untouched. The cost of change stayed local to the new file. That’s the property worth naming: not cleanliness, but isolation.
EnemyController as a contract, not a god object. Passing the full controller into each state felt wrong at first — that concern is addressed up in the State Transitions section. The short version: states only read from it. The compiler won’t enforce that; it’s a convention, and it has to stay one.
Every new behaviour is a new file, not a new risk to existing code.