๐ค Lesson 6.3: An Enemy State Machine
A patrolling agent that never reacts isn't an enemy โ it's a train on a track. Real enemies decide: patrol until they spot you, chase you down, attack when close, and give up when you escape. That decision-making is a finite state machine โ the same idea as the Animator you learned in Module 1, but this time you build it in C#, and it drives the NavMeshAgent.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain a code-driven finite state machine (FSM) and how it differs from the Animator's
- Model enemy behaviour as an
enumof states: Patrol, Chase, Attack - Write transitions based on distance and line-of-sight, with hysteresis so it doesn't flicker
- Drive the
NavMeshAgentdifferently in each state (patrol route, chase target, stop & face) - Feed the Animator from Module 1 so the visuals match the state
Estimated Time: 50 minutes ยท Prerequisite: Lesson 6.2 (Moving Agents) and Lesson 1.2 (States & Transitions)
In This Lesson
What Is a Code FSM?
A finite state machine is a system that is always in exactly one of a fixed set of states, and moves between them by well-defined transitions. You already met one: the Animator, where states were clips and transitions were parameter conditions. That machine decides which animation plays.
The FSM you build now lives in a plain C# script and decides which behaviour runs โ where the agent goes, whether it attacks, what it's thinking. The two machines cooperate: your code FSM decides "I'm chasing," then tells the Animator "play the run animation." Same concept, two layers.
๐ Definition
Finite State Machine (FSM): a model where the object is in one state at a time, each state has its own per-frame behaviour, and transitions move it to another state when a condition is met. For AI it's the workhorse pattern โ simple to read, debug, and extend.
Every FSM does three things each frame, and it's worth naming them because the code splits along these lines:
- Sense โ gather facts about the world (how far is the player? can I see them?).
- Decide โ given the current state and those facts, should I transition?
- Act โ run the current state's behaviour (patrol, chase, attack).
The Enemy State Diagram
Before any code, draw the machine. This is the single most useful artifact in AI work โ it shows every state, every transition, and the exact condition on each. Here's the enemy we're building, drawn in the dark node-editor style so it reads like the Animator from Module 1:
Decide code checks each frame.
๐ก Draw it before you code it. Every arrow in this diagram becomes one if in your transition logic, and every box becomes one method. If you can't draw the machine, you don't yet understand the behaviour โ and no amount of code will rescue that.
States as an Enum
For a three-state enemy, the simplest solid approach is an enum plus a switch. (Larger AIs graduate to a class-per-state pattern, but an enum is perfectly professional here and far easier to read.) Start with the scaffold:
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyAI : MonoBehaviour
{
enum State { Patrol, Chase, Attack }
State state = State.Patrol; // default state (the orange box)
[Header("References")]
[SerializeField] Transform player;
[SerializeField] Transform[] waypoints;
[Header("Ranges")]
[SerializeField] float sightRange = 12f;
[SerializeField] float attackRange = 2f;
[SerializeField] float giveUpTime = 4f; // seconds out of sight before returning to patrol
NavMeshAgent agent;
Animator animator;
int wpIndex = 0;
float lostTimer = 0f;
void Awake()
{
agent = GetComponent<NavMeshAgent>();
animator = GetComponent<Animator>();
}
void Update()
{
Decide(); // maybe change state
Act(); // run the current state
}
}
The Update loop is deliberately two lines: first Decide whether to switch states, then Act on whatever state we're in. Keeping those separate is what keeps the machine readable as it grows.
โ Pro Tip
Expose sightRange, attackRange, and giveUpTime as serialized fields, not constants. Tuning enemy feel is 90% of AI work, and designers (or future-you) need to slide those numbers at runtime in the Inspector without recompiling.
Deciding: The Transitions
The Decide method is Figure 1 turned into code โ one branch per state, checking that state's outgoing arrows. Notice the small but vital detail: we use different ranges for entering versus leaving chase, and a timer before giving up. That's hysteresis, and it stops the enemy flickering between states when the player hovers right at a boundary.
void Decide()
{
float dist = Vector3.Distance(transform.position, player.position);
bool canSee = CanSeePlayer(); // built in the next lesson; distance-only for now
switch (state)
{
case State.Patrol:
if (canSee && dist < sightRange)
TransitionTo(State.Chase);
break;
case State.Chase:
if (dist < attackRange)
{
TransitionTo(State.Attack);
}
else if (!canSee)
{
// count how long we've lost sight; only give up after giveUpTime
lostTimer += Time.deltaTime;
if (lostTimer >= giveUpTime)
TransitionTo(State.Patrol);
}
else
{
lostTimer = 0f; // still see them โ reset the give-up clock
}
break;
case State.Attack:
if (dist > attackRange)
TransitionTo(State.Chase);
break;
}
}
void TransitionTo(State next)
{
// one place to run "on enter" logic per state
state = next;
lostTimer = 0f;
switch (next)
{
case State.Patrol:
agent.isStopped = false;
agent.SetDestination(waypoints[wpIndex].position);
break;
case State.Chase:
agent.isStopped = false;
break;
case State.Attack:
agent.isStopped = true; // stop moving to swing
break;
}
}
Routing every state change through one TransitionTo method is the trick that keeps FSMs sane. It's the single place for "when I enter this state, do X" โ start the patrol path, stop the agent to attack, reset a timer. Scatter that logic across Decide and you'll forget a reset and spend an evening debugging a stuck enemy.
โ ๏ธ Without hysteresis, your enemy stutters
If ChaseโAttack and AttackโChase used the exact same distance with no margin, an enemy sitting right at attackRange would swap states every frame โ animation snapping, attacks misfiring. The fix is either a small gap between the enter/leave thresholds or (as here) a timer. Always give your transitions a little "stickiness."
Acting: Driving the Agent
Act runs the behaviour of whatever state we're in right now. This is where the NavMeshAgent from the last two lessons finally earns its keep โ each state points it at a different destination:
void Act()
{
switch (state)
{
case State.Patrol:
// reuse the waypoint loop from Lesson 6.2
if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance)
{
wpIndex = (wpIndex + 1) % waypoints.Length;
agent.SetDestination(waypoints[wpIndex].position);
}
break;
case State.Chase:
// keep steering toward the player (throttled, not every frame)
agent.SetDestination(player.position);
break;
case State.Attack:
// stopped; just face the player and let the attack animation play
FacePlayer();
// (fire the actual attack on an Animation Event or a cooldown timer)
break;
}
}
void FacePlayer()
{
Vector3 dir = player.position - transform.position;
dir.y = 0f;
if (dir.sqrMagnitude > 0.001f)
{
Quaternion look = Quaternion.LookRotation(dir);
transform.rotation = Quaternion.Slerp(transform.rotation, look, 8f * Time.deltaTime);
}
}
Three states, three completely different uses of the same agent: Patrol cycles waypoints, Chase continuously re-targets the player, Attack freezes the agent (isStopped) and just rotates to face. The FSM decides which of these runs; the agent handles the actual locomotion.
๐ก Where does the attack happen? Notice Attack doesn't deal damage here. In a real game you'd trigger an attack animation and land the hit on an Animation Event (Module 1) at the exact frame the weapon connects, or gate it behind a cooldown. We'll wire real damage in Module 7 (Health & Damage with Interfaces).
Feeding the Animator
Right now the enemy behaves correctly but looks frozen โ it slides around in a T-pose. The fix ties this lesson back to Module 1: the FSM sets Animator parameters so the visuals track the behaviour. The cleanest hook is right inside TransitionTo and by feeding the agent's own speed to a Float:
void Act()
{
// ... the switch above ...
// Drive the locomotion blend tree with the agent's real speed
// (0 when attacking/idle, up to full speed when chasing).
if (animator != null)
animator.SetFloat("Speed", agent.velocity.magnitude);
}
void TransitionTo(State next)
{
state = next;
lostTimer = 0f;
switch (next)
{
case State.Patrol: agent.isStopped = false;
agent.SetDestination(waypoints[wpIndex].position); break;
case State.Chase: agent.isStopped = false; break;
case State.Attack: agent.isStopped = true;
if (animator != null) animator.SetTrigger("Attack"); // one-shot swing
break;
}
}
This is exactly the SetFloat("Speed", โฆ) / SetTrigger("Attack") pattern from Lesson 1.2 โ the FSM is just the thing setting those parameters now. Feeding agent.velocity.magnitude into the Speed float means your Module 1 blend tree automatically eases from idle to walk to run as the chase speeds up. The two state machines finally click together: your code FSM picks the behaviour, the Animator FSM picks the pose.
๐ Definition
Two cooperating machines: the code FSM (Patrol/Chase/Attack) owns decisions and movement; the Animator FSM (Idle/Walk/Run/Attack) owns what's rendered. The code FSM is the driver; it feeds the Animator through parameters. Keeping them separate means you can retune AI without touching animation, and vice-versa.
Hands-on Challenge
๐๏ธ Exercise 1: Wire the three-state enemy
Objective: An enemy that patrols, chases when you get close, and attacks at melee range.
- On your baked scene, add the
EnemyAIscript to the patrolling agent from Lesson 6.2. - Assign the
playerreference (your player capsule) and thewaypointsarray. - For now, make
CanSeePlayer()returntrue(a distance-only stub โ real vision is next lesson). - Press Play: walk the player toward the enemy and confirm it switches Patrol โ Chase โ Attack, and back to Patrol after you flee for longer than
giveUpTime. - Add a
Debug.LoginTransitionToto print each state change and watch the sequence in the Console.
๐ก Hint: the enemy freezes when it reaches me instead of attacking
Check that attackRange (e.g. 2) is larger than the agent's stoppingDistance. If stopping distance is bigger, the agent halts before it's ever "within attack range," so the ChaseโAttack transition never fires.
โ Success check
The Console prints a clean sequence like Patrol โ Chase โ Attack โ Chase โ Patrol as you approach and retreat, with no rapid flickering between two states while you stand at a boundary.
๐๏ธ Exercise 2: Add a Flee state
Extend the enum with a Flee state entered when the enemy's health is low (fake it with a serialized float health you lower with a key press). In Act, have Flee call SetDestination on the waypoint farthest from the player. Add the transitions to Figure 1 in your head first โ which arrows point into and out of Flee? This proves you can grow the machine cleanly.
๐ฏ Quick Quiz
Question 1: Why route every state change through a single TransitionTo method?
Question 2: An enemy standing right at attackRange swaps between Chase and Attack every frame. What's the fix?
Question 3: How does the code FSM make the enemy actually animate as it chases?
Summary
๐ Key Takeaways
- A code FSM holds one state at a time and switches on defined transitions โ the same idea as the Animator, but it decides behaviour, not pose.
- Every frame it does three things: Sense, Decide, Act โ and splitting
DecidefromActkeeps the code readable. - An
enum+switchis a clean, professional structure for a three-state enemy; route all changes through oneTransitionTofor per-state entry logic. - Hysteresis (a margin or timer, e.g.
giveUpTime) stops the machine flickering at range boundaries. - Each state drives the
NavMeshAgentdifferently โ patrol loop, chase target, stop & face โ and feeds the Animator viaSetFloat/SetTriggerso the visuals match.
๐ What's Next?
The enemy's one cheat right now is that stubbed CanSeePlayer() โ it "sees" through walls and in every direction. In Lesson 6.4: Sensing the Player (Mini-Project) you'll build a real vision cone with distance, Vector3.Angle, and a line-of-sight Raycast, then drop it into this FSM to create a proper patrolling guard.
๐ค Your enemy can think now
Patrol, Chase, Attack โ three states, clean transitions, one agent, one Animator. This exact pattern scales from a single guard to an entire cast of enemies; you'll only ever add states and arrows.