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.