Building Enemy AI in Unity: From Patrol to Boss Fights

Build enemy AI in Unity from simple patrol to advanced boss fights. Covers waypoint systems, chase behavior, A* pathfinding, wave spawning, and state machine-driven bosses.

Enemy AI is one of those things that looks simple from the outside and then eats your entire weekend. A guard that walks back and forth? Easy. A guard that spots the player, chases them through a maze, calls for backup, and retreats when low on health? That's a few hundred lines of carefully orchestrated logic. But it doesn't have to be painful.

TL;DR: This tutorial walks through building enemy AI from the ground up — waypoint patrol, detection and chase, NavMesh navigation, A* pathfinding for grid-based games, finite state machines for complex behavior, wave spawning, multi-phase boss fights, and polish like damage numbers and screen shake. Every script referenced is free to download, or grab the full Enemy AI Kit for a drop-in solution.

We'll start with the simplest possible AI — a back-and-forth patrol — and layer on complexity until we've got a multi-phase boss that'd feel at home in a real action game. Each section builds on the last, but they're also independent enough that you can skip to whatever you need. If you're already comfortable with C# design patterns, you'll breeze through the state machine section.

Waypoint Patrol: The Foundation

Every enemy AI starts with movement, and the simplest movement pattern is a waypoint loop. The enemy walks to point A, then point B, then back to A. It's boring on its own, but it's the backbone of stealth games, tower defense, and any game where enemies follow predictable routes. Our Waypoint Patrol AI script handles this cleanly:

C#
using UnityEngine;

public class WaypointPatrol : MonoBehaviour
{
    [SerializeField] private Transform[] waypoints;
    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float waitTime = 1f;
    [SerializeField] private float arrivalThreshold = 0.2f;
    [SerializeField] private bool loop = true;

    private int currentIndex;
    private float waitTimer;
    private bool waiting;
    private int direction = 1;

    void Update()
    {
        if (waypoints.Length == 0) return;

        if (waiting)
        {
            waitTimer -= Time.deltaTime;
            if (waitTimer <= 0f) waiting = false;
            return;
        }

        Transform target = waypoints[currentIndex];
        Vector3 dir = (target.position - transform.position).normalized;
        transform.position += dir * moveSpeed * Time.deltaTime;

        // Face movement direction
        if (dir != Vector3.zero)
            transform.forward = Vector3.Lerp(transform.forward, dir, 10f * Time.deltaTime);

        if (Vector3.Distance(transform.position, target.position) < arrivalThreshold)
        {
            waiting = true;
            waitTimer = waitTime;
            AdvanceWaypoint();
        }
    }

    private void AdvanceWaypoint()
    {
        if (loop)
        {
            currentIndex = (currentIndex + 1) % waypoints.Length;
        }
        else
        {
            currentIndex += direction;
            if (currentIndex >= waypoints.Length || currentIndex < 0)
            {
                direction *= -1;
                currentIndex += direction * 2;
            }
        }
    }
}

Set loop to true for circular patrol routes (guard walks A → B → C → A) or false for ping-pong routes (A → B → C → B → A). The wait timer at each waypoint gives enemies a natural pause that makes patrol patterns readable to the player — essential for stealth games where the player needs to time their movement.

Detection and Chase Behavior

A patrol is useless if the enemy can't react to the player. Detection is the bridge between passive and active AI. The typical approach is a detection radius combined with a line-of-sight check — the enemy needs to be close enough and have an unobstructed view. Our Enemy Chase AI script pairs detection with pursuit:

C#
using UnityEngine;

public class EnemyChase : MonoBehaviour
{
    [SerializeField] private float detectionRange = 8f;
    [SerializeField] private float chaseSpeed = 5f;
    [SerializeField] private float attackRange = 1.5f;
    [SerializeField] private float fieldOfView = 120f;
    [SerializeField] private float loseTargetTime = 3f;
    [SerializeField] private LayerMask obstacleMask;

    private Transform target;
    private float loseTimer;
    private bool isChasing;

    void Update()
    {
        if (!isChasing)
            TryDetectPlayer();
        else
            ChaseTarget();
    }

    private void TryDetectPlayer()
    {
        GameObject player = GameObject.FindWithTag("Player");
        if (player == null) return;

        float dist = Vector3.Distance(transform.position, player.transform.position);
        if (dist > detectionRange) return;

        // Field of view check
        Vector3 dirToPlayer = (player.transform.position - transform.position).normalized;
        float angle = Vector3.Angle(transform.forward, dirToPlayer);
        if (angle > fieldOfView * 0.5f) return;

        // Line of sight check
        if (Physics.Raycast(transform.position + Vector3.up, dirToPlayer, dist, obstacleMask))
            return;

        target = player.transform;
        isChasing = true;
        loseTimer = loseTargetTime;
    }

    private void ChaseTarget()
    {
        if (target == null) { isChasing = false; return; }

        float dist = Vector3.Distance(transform.position, target.position);
        Vector3 dirToTarget = (target.position - transform.position).normalized;

        bool canSee = !Physics.Raycast(transform.position + Vector3.up, dirToTarget, dist, obstacleMask)
                      && dist <= detectionRange * 1.5f;

        if (!canSee)
        {
            loseTimer -= Time.deltaTime;
            if (loseTimer <= 0f) { isChasing = false; return; }
        }
        else
        {
            loseTimer = loseTargetTime;
        }

        if (dist > attackRange)
        {
            transform.position += dirToTarget * chaseSpeed * Time.deltaTime;
            transform.forward = Vector3.Lerp(transform.forward, dirToTarget, 10f * Time.deltaTime);
        }
    }
}

The loseTargetTime parameter is important — it prevents the enemy from instantly giving up the chase when the player ducks behind a pillar. A 2-3 second timer means the enemy will keep heading toward the player's last known position, which feels way more believable than an instant snap back to patrol mode.

NavMesh Navigation

The chase script above moves in a straight line, which falls apart the moment you add walls. Unity's built-in NavMesh system solves this — enemies automatically navigate around obstacles, through doorways, and up ramps. Our NavMesh Click to Move script demonstrates the basics, and here's how to integrate it with AI:

C#
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class NavMeshEnemy : MonoBehaviour
{
    [SerializeField] private float detectionRange = 10f;
    [SerializeField] private float attackRange = 2f;
    [SerializeField] private float updateInterval = 0.25f;

    private NavMeshAgent agent;
    private Transform player;
    private float nextUpdate;

    void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
    }

    void Update()
    {
        if (player == null)
        {
            var go = GameObject.FindWithTag("Player");
            if (go != null) player = go.transform;
            return;
        }

        float dist = Vector3.Distance(transform.position, player.position);

        if (dist <= attackRange)
        {
            agent.isStopped = true;
            // Trigger attack animation / damage here
        }
        else if (dist <= detectionRange)
        {
            agent.isStopped = false;
            if (Time.time >= nextUpdate)
            {
                agent.SetDestination(player.position);
                nextUpdate = Time.time + updateInterval;
            }
        }
        else
        {
            agent.isStopped = true;
        }
    }
}

The updateInterval throttle is a performance trick that matters once you have dozens of enemies. Recalculating a NavMesh path every frame is expensive — updating four times per second is visually indistinguishable and saves significant CPU. Bake your NavMesh in Window → AI → Navigation and make sure your walkable surfaces are tagged correctly.

A* Pathfinding for Grid-Based Games

NavMesh is great for 3D environments, but if you're building a 2D dungeon crawler or a top-down roguelike, grid-based A* pathfinding is usually the better fit. Our A* Pathfinding script provides a clean, reusable implementation that you can drop into any grid-based project. The core algorithm finds the shortest path between two grid cells while respecting obstacles:

C#
using System.Collections.Generic;
using UnityEngine;

public class AStarGrid : MonoBehaviour
{
    [SerializeField] private int width = 20;
    [SerializeField] private int height = 20;
    [SerializeField] private float cellSize = 1f;
    [SerializeField] private LayerMask wallMask;

    private bool[,] walkable;

    void Awake()
    {
        walkable = new bool[width, height];
        for (int x = 0; x < width; x++)
        for (int y = 0; y < height; y++)
        {
            Vector3 worldPos = GridToWorld(x, y);
            walkable[x, y] = !Physics2D.OverlapCircle(worldPos, cellSize * 0.4f, wallMask);
        }
    }

    public List<Vector2Int> FindPath(Vector2Int start, Vector2Int end)
    {
        var open = new SortedSet<PathNode>(new NodeComparer());
        var closed = new HashSet<Vector2Int>();
        var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
        var gScore = new Dictionary<Vector2Int, float>();

        gScore[start] = 0;
        open.Add(new PathNode(start, Heuristic(start, end)));

        while (open.Count > 0)
        {
            PathNode current = open.Min;
            open.Remove(current);

            if (current.Position == end)
                return ReconstructPath(cameFrom, end);

            closed.Add(current.Position);

            foreach (Vector2Int neighbor in GetNeighbors(current.Position))
            {
                if (closed.Contains(neighbor)) continue;
                if (!IsWalkable(neighbor)) continue;

                float tentativeG = gScore[current.Position] + 1f;
                if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
                {
                    gScore[neighbor] = tentativeG;
                    cameFrom[neighbor] = current.Position;
                    open.Add(new PathNode(neighbor, tentativeG + Heuristic(neighbor, end)));
                }
            }
        }
        return null;
    }

    private float Heuristic(Vector2Int a, Vector2Int b)
        => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);

    private bool IsWalkable(Vector2Int pos)
        => pos.x >= 0 && pos.x < width && pos.y >= 0 && pos.y < height && walkable[pos.x, pos.y];

    private List<Vector2Int> GetNeighbors(Vector2Int pos)
    {
        return new List<Vector2Int>
        {
            pos + Vector2Int.up, pos + Vector2Int.down,
            pos + Vector2Int.left, pos + Vector2Int.right
        };
    }

    private List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
    {
        var path = new List<Vector2Int> { current };
        while (cameFrom.ContainsKey(current))
        {
            current = cameFrom[current];
            path.Insert(0, current);
        }
        return path;
    }

    public Vector3 GridToWorld(int x, int y)
        => new Vector3(x * cellSize + cellSize * 0.5f, y * cellSize + cellSize * 0.5f, 0);

    private struct PathNode
    {
        public Vector2Int Position;
        public float FScore;
        public PathNode(Vector2Int pos, float f) { Position = pos; FScore = f; }
    }

    private class NodeComparer : IComparer<PathNode>
    {
        public int Compare(PathNode a, PathNode b)
        {
            int result = a.FScore.CompareTo(b.FScore);
            return result != 0 ? result : a.Position.GetHashCode().CompareTo(b.Position.GetHashCode());
        }
    }
}

This gives your 2D enemies intelligent navigation without Unity's NavMesh system. For larger grids, consider caching paths and only recalculating when the target moves to a new cell. You can also extend this with diagonal movement by adding four more neighbor offsets and using a cost of 1.41 for diagonal steps.

State Machine AI: The Real Deal

Up to this point, we've been using simple if/else logic. That works for basic enemies, but it turns into spaghetti fast once you add multiple behaviors. A finite state machine keeps things clean — each behavior is an isolated state, and transitions between states are explicit. Our State Machine game system provides a full framework, but here's the pattern distilled:

C#
using UnityEngine;

public enum EnemyState { Patrol, Chase, Attack, Flee }

public class StateMachineEnemy : MonoBehaviour
{
    [SerializeField] private float detectionRange = 8f;
    [SerializeField] private float attackRange = 2f;
    [SerializeField] private float fleeHealthThreshold = 20f;
    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float chaseSpeed = 5f;

    private EnemyState currentState = EnemyState.Patrol;
    private Transform player;
    private float health = 100f;

    void Update()
    {
        if (player == null)
        {
            var go = GameObject.FindWithTag("Player");
            if (go != null) player = go.transform;
            return;
        }

        switch (currentState)
        {
            case EnemyState.Patrol:  UpdatePatrol();  break;
            case EnemyState.Chase:   UpdateChase();   break;
            case EnemyState.Attack:  UpdateAttack();  break;
            case EnemyState.Flee:    UpdateFlee();    break;
        }
    }

    private void UpdatePatrol()
    {
        // Insert waypoint patrol logic here
        float dist = Vector3.Distance(transform.position, player.position);
        if (dist < detectionRange)
            TransitionTo(EnemyState.Chase);
    }

    private void UpdateChase()
    {
        float dist = Vector3.Distance(transform.position, player.position);
        Vector3 dir = (player.position - transform.position).normalized;
        transform.position += dir * chaseSpeed * Time.deltaTime;

        if (dist <= attackRange)
            TransitionTo(EnemyState.Attack);
        else if (dist > detectionRange * 1.5f)
            TransitionTo(EnemyState.Patrol);

        if (health <= fleeHealthThreshold)
            TransitionTo(EnemyState.Flee);
    }

    private void UpdateAttack()
    {
        float dist = Vector3.Distance(transform.position, player.position);
        if (dist > attackRange * 1.2f)
            TransitionTo(EnemyState.Chase);
        if (health <= fleeHealthThreshold)
            TransitionTo(EnemyState.Flee);
    }

    private void UpdateFlee()
    {
        Vector3 dir = (transform.position - player.position).normalized;
        transform.position += dir * chaseSpeed * Time.deltaTime;
    }

    private void TransitionTo(EnemyState newState)
    {
        currentState = newState;
    }

    public void TakeDamage(float amount)
    {
        health -= amount;
    }
}

The beauty of this pattern is that adding a new behavior — say, a stunned state — just means adding a new enum value, a new Update method, and the transitions that lead to and from it. No rewriting existing logic. If you want even cleaner separation, the State Machine bundle uses a class-per-state approach where each state is its own ScriptableObject.

Wave Spawning: Escalating Pressure

A single smart enemy is cool. A swarm of them is a game. Our Wave Spawner script handles timed waves of enemies with configurable counts, delays, and spawn points. Pair it with the Health System on each enemy so they can actually be defeated:

C#
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class WaveManager : MonoBehaviour
{
    [System.Serializable]
    public class Wave
    {
        public string name;
        public GameObject enemyPrefab;
        public int count;
        public float spawnInterval = 0.5f;
    }

    [SerializeField] private Wave[] waves;
    [SerializeField] private Transform[] spawnPoints;
    [SerializeField] private float timeBetweenWaves = 5f;

    private int currentWaveIndex;
    private List<GameObject> activeEnemies = new List<GameObject>();

    public void StartWaves()
    {
        StartCoroutine(RunWaves());
    }

    private IEnumerator RunWaves()
    {
        while (currentWaveIndex < waves.Length)
        {
            Wave wave = waves[currentWaveIndex];
            yield return StartCoroutine(SpawnWave(wave));

            while (activeEnemies.Count > 0)
            {
                activeEnemies.RemoveAll(e => e == null);
                yield return null;
            }

            currentWaveIndex++;
            if (currentWaveIndex < waves.Length)
                yield return new WaitForSeconds(timeBetweenWaves);
        }
    }

    private IEnumerator SpawnWave(Wave wave)
    {
        for (int i = 0; i < wave.count; i++)
        {
            Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)];
            GameObject enemy = Instantiate(wave.enemyPrefab, point.position, point.rotation);
            activeEnemies.Add(enemy);
            yield return new WaitForSeconds(wave.spawnInterval);
        }
    }
}

Each wave definition lets you specify a different enemy prefab, so you can start with basic patrollers and escalate to tougher chase enemies as waves progress. The spawner waits until every enemy in the current wave is destroyed before moving to the next — a pattern players intuitively understand from games like Call of Duty Zombies.

Multi-Phase Boss Fight

Boss fights are where all these systems come together. A good boss uses a state machine with phases that change based on health thresholds. Each phase introduces new attacks, speeds, or patterns. Here's a three-phase boss that combines the state machine pattern with our Health System:

C#
using UnityEngine;
using System.Collections;

public class BossController : MonoBehaviour
{
    [Header("Phase Thresholds")]
    [SerializeField] private float phase2Threshold = 0.66f;
    [SerializeField] private float phase3Threshold = 0.33f;

    [Header("Attack Settings")]
    [SerializeField] private GameObject projectilePrefab;
    [SerializeField] private Transform firePoint;
    [SerializeField] private float meleeRange = 3f;
    [SerializeField] private float meleeDamage = 20f;

    private int currentPhase = 1;
    private float maxHealth = 500f;
    private float health;
    private Transform player;
    private bool isAttacking;

    void Start()
    {
        health = maxHealth;
        player = GameObject.FindWithTag("Player")?.transform;
    }

    void Update()
    {
        if (player == null || isAttacking) return;

        CheckPhaseTransition();

        switch (currentPhase)
        {
            case 1: Phase1Behavior(); break;
            case 2: Phase2Behavior(); break;
            case 3: Phase3Behavior(); break;
        }
    }

    private void CheckPhaseTransition()
    {
        float ratio = health / maxHealth;
        int newPhase = ratio <= phase3Threshold ? 3 : ratio <= phase2Threshold ? 2 : 1;

        if (newPhase != currentPhase)
        {
            currentPhase = newPhase;
            StartCoroutine(PhaseTransition());
        }
    }

    private IEnumerator PhaseTransition()
    {
        isAttacking = true;
        // Play rage animation, spawn particles, shake screen
        yield return new WaitForSeconds(1.5f);
        isAttacking = false;
    }

    private void Phase1Behavior()
    {
        MoveToward(player.position, 2f);
        if (Vector3.Distance(transform.position, player.position) < meleeRange)
            StartCoroutine(MeleeAttack());
    }

    private void Phase2Behavior()
    {
        MoveToward(player.position, 4f);
        if (!isAttacking)
            StartCoroutine(ProjectileBurst(3, 0.3f));
    }

    private void Phase3Behavior()
    {
        MoveToward(player.position, 6f);
        if (!isAttacking)
        {
            if (Vector3.Distance(transform.position, player.position) < meleeRange)
                StartCoroutine(MeleeCombo(3));
            else
                StartCoroutine(ProjectileBurst(6, 0.15f));
        }
    }

    private void MoveToward(Vector3 target, float speed)
    {
        Vector3 dir = (target - transform.position).normalized;
        transform.position += dir * speed * Time.deltaTime;
        transform.forward = Vector3.Lerp(transform.forward, dir, 10f * Time.deltaTime);
    }

    private IEnumerator MeleeAttack()
    {
        isAttacking = true;
        yield return new WaitForSeconds(0.5f);
        isAttacking = false;
    }

    private IEnumerator MeleeCombo(int strikes)
    {
        isAttacking = true;
        for (int i = 0; i < strikes; i++)
            yield return new WaitForSeconds(0.25f);
        isAttacking = false;
    }

    private IEnumerator ProjectileBurst(int count, float interval)
    {
        isAttacking = true;
        for (int i = 0; i < count; i++)
        {
            Vector3 dir = (player.position - firePoint.position).normalized;
            GameObject proj = Instantiate(projectilePrefab, firePoint.position, Quaternion.LookRotation(dir));
            proj.GetComponent<Rigidbody>().velocity = dir * 15f;
            Destroy(proj, 5f);
            yield return new WaitForSeconds(interval);
        }
        yield return new WaitForSeconds(0.5f);
        isAttacking = false;
    }

    public void TakeDamage(float amount)
    {
        health -= amount;
        if (health <= 0) Die();
    }

    private void Die()
    {
        Destroy(gameObject);
    }
}

Phase 1 is a slow melee brute. Phase 2 picks up speed and starts shooting. Phase 3 goes berserk with rapid combos and projectile sprays. The PhaseTransition coroutine gives you a window to play a rage animation, flash the boss health bar, and shake the camera — which brings us to polish.

Polish: Damage Numbers and Screen Shake

An enemy can have perfect AI, but if hits don't feel impactful, the combat will fall flat. Two cheap additions make a massive difference: floating damage numbers and camera shake.

Our Damage Popup script spawns a floating number that drifts upward and fades out whenever something takes damage. Combined with the Health Bar UI that smoothly drains on each hit, you get instant visual feedback. Add our Camera Shake script and suddenly every hit feels like it means something:

C#
using UnityEngine;

public class CombatFeedback : MonoBehaviour
{
    [SerializeField] private GameObject damagePopupPrefab;
    [SerializeField] private CameraShake cameraShake;
    [SerializeField] private float shakeIntensity = 0.3f;
    [SerializeField] private float shakeDuration = 0.15f;

    public void OnDamageDealt(Vector3 position, float amount, bool isCritical)
    {
        GameObject popup = Instantiate(damagePopupPrefab, position + Vector3.up * 1.5f, Quaternion.identity);
        var text = popup.GetComponent<TMPro.TextMeshPro>();
        text.text = isCritical ? $"<color=yellow>{amount:F0}!</color>" : $"{amount:F0}";
        text.fontSize = isCritical ? 8f : 5f;
        Destroy(popup, 1f);

        float intensity = isCritical ? shakeIntensity * 2f : shakeIntensity;
        cameraShake.Shake(intensity, shakeDuration);
    }
}

For the damage popup animation, add a simple script that moves the text upward and fades the alpha over its lifetime. If you're spawning lots of popups during wave combat with 20+ enemies, use the object pool pattern instead of Instantiate/Destroy to avoid garbage collection spikes.

Wrapping Up

We've gone from a guard walking between two points all the way to a three-phase boss fight with projectile patterns and combat juice. Here's the full toolkit we covered:

Or skip the assembly and grab the complete Enemy AI Kit, which bundles all of these into a tested, prefab-ready package. Either way, your enemies should feel a lot more alive than they did 17 minutes ago.