# Enemy AI State Machine **Target:** Unity 6000.5 and above (Unity 6). Package: AI Navigation 2.x. Builds on [[NavMesh in Unity 6 (AI Navigation 2.x)]] and [[Architecture and Code Patterns]] (Step 4, State machines). Background reading: the *State* chapter in [[Game Programming Patterns (Review)]] (https://gameprogrammingpatterns.com/state.html). Sensing comes from [[Organic Enemy Sensing]]. ## Overview 1. Why class-based states 2. The core: `IState` and `StateMachine` 3. The enemy "brain" and shared context 4. The six states 5. Transitions (from-to and any-state) 6. Hierarchical option (Alert super-state) 7. Debugging: gizmos, label, and a history log 8. Wiring it up in the scene ## Step 1 - Why class-based states An `enum` plus a `switch` works for three states. Once you have six it falls apart: every state's enter and exit code ends up spread across one huge `Update`. Making each state a class gives you: - **Enter / Tick / Exit** in one place, so timers reset on enter and the agent's speed is restored on exit. - Transitions kept **separate** from behaviour, so a state doesn't need to know who comes after it. - Easy testing and reuse (Search can be reused by a guard dog). This is the *State* pattern from Nystrom's book. Unity's Animator is also a state machine, but keep gameplay logic out of it. Let the brain drive the Animator instead. ## Step 2 - Core ```csharp using System; using System.Collections.Generic; /// A single behaviour. Tick runs every frame while active. public interface IState { string Name { get; } void Enter(); void Tick(float dt); void Exit(); } /// Generic finite state machine with from-to transitions and any-state transitions. /// Transitions are checked in the order added; the first passing condition wins. public class StateMachine { class Transition { public readonly IState To; public readonly Func<bool> Condition; public Transition(IState to, Func<bool> condition) { To = to; Condition = condition; } } readonly Dictionary<IState, List<Transition>> transitions = new(); readonly List<Transition> anyTransitions = new(); List<Transition> current = Empty; static readonly List<Transition> Empty = new(0); public IState CurrentState { get; private set; } public float TimeInState { get; private set; } public event Action<IState, IState> StateChanged; // (from, to) public void AddTransition(IState from, IState to, Func<bool> condition) { if (!transitions.TryGetValue(from, out var list)) transitions[from] = list = new List<Transition>(); list.Add(new Transition(to, condition)); } public void AddAnyTransition(IState to, Func<bool> condition) => anyTransitions.Add(new Transition(to, condition)); public void SetState(IState state) { if (state == CurrentState) return; IState previous = CurrentState; previous?.Exit(); CurrentState = state; current = transitions.TryGetValue(state, out var list) ? list : Empty; TimeInState = 0f; state.Enter(); StateChanged?.Invoke(previous, state); } public void Tick(float dt) { Transition t = FindTransition(); if (t != null) SetState(t.To); TimeInState += dt; CurrentState?.Tick(dt); } Transition FindTransition() { foreach (var t in anyTransitions) if (t.To != CurrentState && t.Condition()) return t; foreach (var t in current) if (t.Condition()) return t; return null; } } ``` **Why "any" transitions are checked first:** Flee (low health) has to be able to interrupt any state. Without any-state transitions you'd have to add that same transition to all six states. ## Step 3 - The brain and shared context States need a shared, read-only view of the enemy: the agent, the target, health, and what the senses report. Put all of that on the brain and pass the brain into each state's constructor. ```csharp using UnityEngine; /// Contract the brain needs from a sensing component. EnemySenses in /// [[Organic Enemy Sensing]] implements it; SimpleSenses below is a stand-in. public interface IEnemySenses { bool CanSeeTarget { get; } // fully aware, line of sight right now bool IsSuspicious { get; } // heard or glimpsed something Vector3 LastKnownPosition { get; } // memory bool HasMemory { get; } Transform Target { get; } void ForgetTarget(); } /// Minimal senses for testing the FSM before building the full system: /// distance + angle + raycast, nothing organic. public class SimpleSenses : MonoBehaviour, IEnemySenses { [SerializeField] Transform target; [SerializeField] float range = 12f, fov = 110f; [SerializeField] LayerMask occluders = ~0; public bool CanSeeTarget { get; private set; } public bool IsSuspicious => false; public Vector3 LastKnownPosition { get; private set; } public bool HasMemory { get; private set; } public Transform Target => target; public void ForgetTarget() => HasMemory = false; void Update() { CanSeeTarget = false; if (!target) return; Vector3 eye = transform.position + Vector3.up * 1.6f; Vector3 to = target.position + Vector3.up - eye; if (to.magnitude > range || Vector3.Angle(transform.forward, to) > fov * 0.5f) return; if (Physics.Raycast(eye, to.normalized, out RaycastHit hit, range, occluders, QueryTriggerInteraction.Ignore) && hit.transform != target && !hit.transform.IsChildOf(target)) return; CanSeeTarget = true; LastKnownPosition = target.position; HasMemory = true; } } ``` ```csharp using UnityEngine; using UnityEngine.AI; /// Owns the state machine, builds states and transitions, exposes shared data. [RequireComponent(typeof(NavMeshAgent))] public class EnemyBrain : MonoBehaviour { [Header("Movement")] public float patrolSpeed = 2f; public float chaseSpeed = 4.5f; public Transform[] waypoints; [Header("Combat")] public float attackRange = 1.8f; public float attackCooldown = 1.2f; public int maxHealth = 100; [Range(0, 1)] public float fleeHealthFraction = 0.2f; [Header("Timers")] public Vector2 idleTime = new(1f, 3f); public float searchDuration = 8f; [Header("Debug")] public bool logTransitions = true; public NavMeshAgent Agent { get; private set; } public IEnemySenses Senses { get; private set; } public StateMachine Machine { get; private set; } public int Health { get; set; } public bool IsLowHealth => Health <= maxHealth * fleeHealthFraction; public float DistanceToTarget => Senses.Target ? Vector3.Distance(transform.position, Senses.Target.position) : float.MaxValue; void Awake() { Agent = GetComponent<NavMeshAgent>(); Senses = GetComponent<IEnemySenses>(); // GetComponent works with interfaces Health = maxHealth; BuildMachine(); } void BuildMachine() { Machine = new StateMachine(); var idle = new IdleState(this); var patrol = new PatrolState(this); var chase = new ChaseState(this); var attack = new AttackState(this); var search = new SearchState(this); var flee = new FleeState(this); // Any-state: survival overrides everything. Machine.AddAnyTransition(flee, () => IsLowHealth); Machine.AddTransition(idle, patrol, () => idle.Done); Machine.AddTransition(idle, chase, () => Senses.CanSeeTarget); Machine.AddTransition(idle, search, () => Senses.IsSuspicious); Machine.AddTransition(patrol, chase, () => Senses.CanSeeTarget); Machine.AddTransition(patrol, search, () => Senses.IsSuspicious); Machine.AddTransition(patrol, idle, () => patrol.ReachedWaypoint); Machine.AddTransition(chase, attack, () => Senses.CanSeeTarget && DistanceToTarget <= attackRange); Machine.AddTransition(chase, search, () => !Senses.CanSeeTarget); Machine.AddTransition(attack, chase, () => DistanceToTarget > attackRange * 1.2f); // hysteresis Machine.AddTransition(search, chase, () => Senses.CanSeeTarget); Machine.AddTransition(search, patrol, () => search.GaveUp); Machine.AddTransition(flee, idle, () => flee.Safe && !IsLowHealth); if (logTransitions) Machine.StateChanged += (from, to) => Debug.Log(quot;{name}: {from?.Name ?? "none"} -> {to.Name}", this); Machine.SetState(idle); } void Update() => Machine.Tick(Time.deltaTime); public void TakeDamage(int amount) => Health = Mathf.Max(0, Health - amount); } ``` > **Hysteresis:** you enter Attack at `attackRange` but only leave at 1.2x that. Without the gap, an enemy standing right on the boundary flickers between Chase and Attack every frame. ## Step 4 - The six states ```csharp using UnityEngine; /// Base class so states don't repeat the brain plumbing. public abstract class EnemyState : IState { protected readonly EnemyBrain brain; protected EnemyState(EnemyBrain brain) { this.brain = brain; } public virtual string Name => GetType().Name.Replace("State", ""); public virtual void Enter() { } public virtual void Tick(float dt) { } public virtual void Exit() { } } public class IdleState : EnemyState { float until; public bool Done => Time.time >= until; public IdleState(EnemyBrain b) : base(b) { } public override void Enter() { brain.Agent.isStopped = true; until = Time.time + Random.Range(brain.idleTime.x, brain.idleTime.y); } public override void Exit() => brain.Agent.isStopped = false; } public class PatrolState : EnemyState { int index = -1; public bool ReachedWaypoint { get; private set; } public PatrolState(EnemyBrain b) : base(b) { } public override void Enter() { ReachedWaypoint = false; brain.Agent.speed = brain.patrolSpeed; Vector3 dest = brain.waypoints != null && brain.waypoints.Length > 0 ? brain.waypoints[index = (index + 1) % brain.waypoints.Length].position : NavUtil.RandomPoint(brain.transform.position, 10f); brain.Agent.SetDestination(dest); } public override void Tick(float dt) => ReachedWaypoint = NavUtil.HasArrived(brain.Agent); } public class ChaseState : EnemyState { float repathTimer; public ChaseState(EnemyBrain b) : base(b) { } public override void Enter() { brain.Agent.speed = brain.chaseSpeed; repathTimer = 0f; } public override void Tick(float dt) { // Re-path a few times a second, not every frame. repathTimer -= dt; if (repathTimer > 0f) return; repathTimer = 0.2f; brain.Agent.SetDestination(brain.Senses.LastKnownPosition); } } public class AttackState : EnemyState { float nextAttack; public AttackState(EnemyBrain b) : base(b) { } public override void Enter() { brain.Agent.isStopped = true; nextAttack = Time.time + 0.3f; } // wind-up public override void Tick(float dt) { Transform t = brain.Senses.Target; if (!t) return; Vector3 look = t.position - brain.transform.position; look.y = 0; if (look.sqrMagnitude > 0.001f) brain.transform.rotation = Quaternion.RotateTowards(brain.transform.rotation, Quaternion.LookRotation(look), 360f * dt); if (Time.time >= nextAttack) { nextAttack = Time.time + brain.attackCooldown; Debug.Log(quot;{brain.name} attacks {t.name}"); // replace with damage/animation } } public override void Exit() => brain.Agent.isStopped = false; } /// Go to the last known position, then sweep random nearby points until time runs out. public class SearchState : EnemyState { float endTime; bool reachedLastKnown; public bool GaveUp => Time.time >= endTime; public SearchState(EnemyBrain b) : base(b) { } public override void Enter() { endTime = Time.time + brain.searchDuration; reachedLastKnown = false; brain.Agent.speed = (brain.patrolSpeed + brain.chaseSpeed) * 0.5f; brain.Agent.SetDestination(brain.Senses.LastKnownPosition); } public override void Tick(float dt) { if (!NavUtil.HasArrived(brain.Agent)) return; reachedLastKnown = true; brain.Agent.SetDestination(NavUtil.RandomPoint(brain.Senses.LastKnownPosition, 6f)); } public override void Exit() { if (GaveUp) brain.Senses.ForgetTarget(); } } /// Run to a reachable point away from the target. public class FleeState : EnemyState { public bool Safe => brain.DistanceToTarget > 20f && NavUtil.HasArrived(brain.Agent); public FleeState(EnemyBrain b) : base(b) { } public override void Enter() { brain.Agent.speed = brain.chaseSpeed * 1.1f; PickFleePoint(); } public override void Tick(float dt) { if (NavUtil.HasArrived(brain.Agent) && !Safe) PickFleePoint(); } void PickFleePoint() { Vector3 from = brain.Senses.Target ? brain.Senses.Target.position : brain.transform.position; Vector3 away = (brain.transform.position - from).normalized; // Try several candidates, keep the first reachable one. for (int i = 0; i < 8; i++) { Vector3 dir = Quaternion.Euler(0, Random.Range(-60f, 60f), 0) * away; Vector3 p = NavUtil.RandomPoint(brain.transform.position + dir * 15f, 4f); if (NavUtil.IsReachable(brain.transform.position, p, out _)) { brain.Agent.SetDestination(p); return; } } } } ``` `NavUtil` comes from [[NavMesh in Unity 6 (AI Navigation 2.x)]] (Step 8). ## Step 5 - Transition table | From | To | When | |---|---|---| | Any | Flee | health at or below 20% | | Idle | Patrol | idle timer is done | | Idle / Patrol | Chase | sees the target | | Idle / Patrol | Search | suspicious (a noise or a glimpse) | | Patrol | Idle | reached a waypoint | | Chase | Attack | sees the target and is in range | | Chase | Search | lost sight of the target | | Attack | Chase | out of range x 1.2 | | Search | Chase | sees the target again | | Search | Patrol | search timer ran out (forgets the target) | | Flee | Idle | safe and has healed | ## Step 6 - Hierarchical option A flat state machine repeats rules. For example, both Chase and Attack need "lost sight → Search". A **hierarchical** state machine nests states: an `AlertState` super-state contains its own sub-machine (Chase/Attack), and the parent only cares whether it's *Calm* or *Alert*. ```csharp /// A state that runs its own child StateMachine. Parent transitions are checked /// first (by the outer machine), so leaving Alert always wins over child logic. public class SuperState : IState { public string Name { get; } public StateMachine Child { get; } = new(); readonly IState initial; readonly bool resumeChild; bool started; public SuperState(string name, IState initialChild, bool resumeChild = false) { Name = name; initial = initialChild; this.resumeChild = resumeChild; } public void Enter() { // Either restart at the initial child or resume the last active one. if (!resumeChild || !started) Child.SetState(initial); else Child.CurrentState?.Enter(); started = true; } public void Tick(float dt) => Child.Tick(dt); public void Exit() => Child.CurrentState?.Exit(); } ``` To use it inside `BuildMachine()`, swap in this version: ```csharp // Calm = Idle <-> Patrol ; Alert = Chase <-> Attack ; Search and Flee stay top-level. var calm = new SuperState("Calm", idle); calm.Child.AddTransition(idle, patrol, () => idle.Done); calm.Child.AddTransition(patrol, idle, () => patrol.ReachedWaypoint); var alert = new SuperState("Alert", chase); alert.Child.AddTransition(chase, attack, () => DistanceToTarget <= attackRange); alert.Child.AddTransition(attack, chase, () => DistanceToTarget > attackRange * 1.2f); Machine.AddAnyTransition(flee, () => IsLowHealth); Machine.AddTransition(calm, alert, () => Senses.CanSeeTarget); Machine.AddTransition(calm, search, () => Senses.IsSuspicious); Machine.AddTransition(alert, search, () => !Senses.CanSeeTarget); // one rule for both children Machine.AddTransition(search, alert, () => Senses.CanSeeTarget); Machine.AddTransition(search, calm, () => search.GaveUp); Machine.AddTransition(flee, calm, () => flee.Safe && !IsLowHealth); Machine.SetState(calm); ``` Use a hierarchical machine when groups of states share their exits. If you find yourself wanting priorities, utility scores, or long sequences, look at behaviour trees (Unity's **Behavior** package, `com.unity.behavior`) or a utility AI. Those suit some designs better. *(I haven't checked whether the Behavior package supports 6000.5. Check Package Manager.)* ## Step 7 - Debugging ```csharp using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif /// Shows the current state over the enemy's head, colour-coded, plus path and attack range. /// Also keeps a short history visible in the inspector. [RequireComponent(typeof(EnemyBrain))] public class EnemyBrainDebug : MonoBehaviour { [SerializeField, TextArea(3, 10)] string history; EnemyBrain brain; void Awake() => brain = GetComponent<EnemyBrain>(); void Start() => brain.Machine.StateChanged += (f, t) => { history = quot;{Time.time:F1}s {f?.Name} -> {t.Name}\n" + history; if (history.Length > 600) history = history[..600]; }; static Color ColorFor(string state) => state switch { "Idle" => Color.gray, "Patrol" => Color.green, "Chase" => new Color(1f, .5f, 0f), "Attack" => Color.red, "Search" => Color.yellow, "Flee" => Color.magenta, _ => Color.white }; void OnDrawGizmos() { if (brain == null || brain.Machine == null) return; string n = brain.Machine.CurrentState?.Name ?? "-"; Gizmos.color = ColorFor(n); Gizmos.DrawWireSphere(transform.position, brain.attackRange); if (brain.Agent.hasPath) { var c = brain.Agent.path.corners; for (int i = 1; i < c.Length; i++) Gizmos.DrawLine(c[i - 1], c[i]); } #if UNITY_EDITOR Handles.Label(transform.position + Vector3.up * 2.3f, quot;{n} ({brain.Machine.TimeInState:F1}s)"); #endif } } ``` Tips: - Turn on **Gizmos** in the Game view too, so you can watch labels during play. - Keep `logTransitions` on while you tune. A state that flips back and forth spams the log, which points straight at a missing hysteresis gap. - For unit tests, `StateMachine` has no dependency on `MonoBehaviour`, so you can test it in Edit Mode with the Test Framework. ## Step 8 - Wire it up 1. Bake a NavMeshSurface ([[NavMesh in Unity 6 (AI Navigation 2.x)]]). 2. Create a capsule called `Enemy` and add **NavMeshAgent**, **SimpleSenses** (set Target = Player), **EnemyBrain** (assign waypoints), and **EnemyBrainDebug**. 3. Press Play. It should Idle, then Patrol, then Chase when it sees you, Attack when it's close, and Search after you break line of sight. 4. Call `TakeDamage(90)` from a test button to see Flee take over. 5. Swap `SimpleSenses` for `EnemySenses` from [[Organic Enemy Sensing]] to get believable detection. ## See also - [[Animation]] — drive Animator parameters from the AI brain; keep gameplay states in code - [[DOTS]] - thousands of simple swarm enemies with Entities; states become enableable components See also (added 2026-11-10): [[Boss Battles with Phases]] - hierarchical super-states, one per boss phase.