Need something simpler? See the (basic version).
Part of these game systems:
intermediate AI & Pathfinding PRO

Waypoint Patrol AI Pro

Advanced patrol AI with NavMesh pathfinding, player detection with awareness meter, alert states, group coordination, and patrol schedules.

Unity 2022.3+ · 6.0 KB · WaypointPatrolPro.cs

How to Use

1

Attach to enemy with NavMeshAgent on a baked NavMesh

2

Create empty GameObjects as waypoints, assign to array

3

Choose patrol mode: Loop, PingPong, or Random

4

Set detection radius, angle, and player layer mask

5

Awareness meter fills when player is in sight — triggers alert at threshold

6

In Alert state, AI chases player; if sight is lost, investigates last known position

7

Set Group ID for coordinated patrol — alerted enemies call nearby group members

8

Create PatrolSchedule entries for time-of-day route switching

9

Call SetTimeOfDay() from your DayNightCycle to update schedule

10

Gizmos show detection cone, awareness bar, and waypoint paths in Scene view

Features

  • NavMesh-based patrol with Loop, PingPong, and Random waypoint modes
  • Gradual awareness meter that fills based on player proximity and line-of-sight
  • Alert, Investigate, and Return AI states with smooth transitions
  • Group coordination — alerted enemies call nearby members via static group registry
  • Time-of-day patrol schedules with automatic waypoint route switching
  • Editor gizmos for detection cone, awareness bar, waypoint paths, and alert range

When to Use This

Built for stealth games, RPGs, and tactical shooters where enemies need realistic patrol and detection behavior. Use this when you need guards with awareness meters, coordinated group alerts, and time-based patrol schedules rather than simple chase-on-sight AI.

Common Mistakes

The playerLayer mask must be set to the layer your player is on — leaving it at Nothing means the OverlapSphere detects nothing. The obstacleMask should include your environment layers for line-of-sight checks, but exclude the player layer itself. Group coordination uses a static dictionary, so groupId strings must match exactly across all enemies in a patrol group.

Source Code

WaypointPatrolPro.cs
C#
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Events;
using System.Collections.Generic;

[RequireComponent(typeof(NavMeshAgent))]
public class WaypointPatrolPro : MonoBehaviour
{
    public enum PatrolMode { Loop, PingPong, Random }
    public enum AIState { Patrol, Alert, Investigate, Return }

    [System.Serializable]
    public class PatrolSchedule
    {
        public string scheduleName = "Default";
        [Range(0f, 24f)] public float startHour = 0f;
        [Range(0f, 24f)] public float endHour = 24f;
        public Transform[] waypoints;
    }

    [Header("Patrol")]
    [SerializeField] private Transform[] waypoints;
    [SerializeField] private PatrolMode patrolMode = PatrolMode.Loop;
    [SerializeField] private float waitTimeAtPoint = 1.5f;
    [SerializeField] private float patrolSpeed = 3f;

    [Header("Detection")]
    [SerializeField] private float detectionRadius = 12f;
    [SerializeField] private float detectionAngle = 110f;
    [SerializeField] private float awarenessRate = 1.5f;
    [SerializeField] private float awarenessDrain = 0.8f;
    [SerializeField] private float alertThreshold = 1f;
    [SerializeField] private LayerMask playerLayer;
    [SerializeField] private LayerMask obstacleMask;

    [Header("Alert")]
    [SerializeField] private float alertSpeed = 5f;
    [SerializeField] private float investigateTime = 4f;
    [SerializeField] private float chaseGiveUpDistance = 20f;

    [Header("Group")]
    [SerializeField] private string groupId = "default";
    [SerializeField] private float alertCallRange = 15f;
    [SerializeField] private bool canCallBackup = true;

    [Header("Schedule")]
    [SerializeField] private PatrolSchedule[] schedules;
    [SerializeField] private float currentTimeOfDay = 12f;

    [Header("Events")]
    public UnityEvent<Transform> OnPlayerDetected;
    public UnityEvent OnPlayerLost;
    public UnityEvent OnAlertTriggered;
    public UnityEvent<float> OnAwarenessChanged;

    [Header("Debug")]
    [SerializeField] private bool showDebugGizmos = true;

    private NavMeshAgent agent;
    private AIState currentState = AIState.Patrol;
    private int currentWaypointIndex;
    private int patrolDirection = 1;
    private float waitTimer;
    private float awareness;
    private float investigateTimer;
    private Transform detectedPlayer;
    private Vector3 lastKnownPosition;
    private Vector3 originPosition;

    // Group coordination
    private static readonly Dictionary<string, List<WaypointPatrolPro>> groups
        = new Dictionary<string, List<WaypointPatrolPro>>();

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ClearGroups() { groups.Clear(); }

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        originPosition = transform.position;
    }

    private void OnEnable()
    {
        if (!groups.ContainsKey(groupId))
            groups[groupId] = new List<WaypointPatrolPro>();
        groups[groupId].Add(this);
    }

    private void OnDisable()
    {
        if (groups.ContainsKey(groupId))
            groups[groupId].Remove(this);
    }

    private void Update()
    {
        UpdateSchedule();
        DetectPlayer();

        switch (currentState)
        {
            case AIState.Patrol: UpdatePatrol(); break;
            case AIState.Alert: UpdateAlert(); break;
            case AIState.Investigate: UpdateInvestigate(); break;
            case AIState.Return: UpdateReturn(); break;
        }
    }

    private void UpdatePatrol()
    {
        agent.speed = patrolSpeed;

        if (waypoints == null || waypoints.Length == 0) return;

        Transform target = waypoints[currentWaypointIndex];
        if (target == null) return;

        if (!agent.hasPath || agent.remainingDistance < 0.5f)
        {
            waitTimer -= Time.deltaTime;
            if (waitTimer <= 0f)
            {
                AdvanceWaypoint();
                Transform next = waypoints[currentWaypointIndex];
                if (next != null)
                    agent.SetDestination(next.position);
                waitTimer = waitTimeAtPoint;
            }
        }
    }

    private void AdvanceWaypoint()
    {
        switch (patrolMode)
        {
            case PatrolMode.Loop:
                currentWaypointIndex = (currentWaypointIndex + 1) % waypoints.Length;
                break;
            case PatrolMode.PingPong:
                currentWaypointIndex += patrolDirection;
                if (currentWaypointIndex >= waypoints.Length - 1 || currentWaypointIndex <= 0)
                    patrolDirection *= -1;
                break;
            case PatrolMode.Random:
                int next;
                do { next = Random.Range(0, waypoints.Length); }
                while (next == currentWaypointIndex && waypoints.Length > 1);
                currentWaypointIndex = next;
                break;
        }
    }

    private void DetectPlayer()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, detectionRadius, playerLayer);
        Transform closestPlayer = null;
        float closestDist = float.MaxValue;

        foreach (var hit in hits)
        {
            Vector3 dir = hit.transform.position - transform.position;
            float angle = Vector3.Angle(transform.forward, dir);
            float dist = dir.magnitude;

            if (angle > detectionAngle * 0.5f) continue;

            // Line-of-sight check
            if (Physics.Raycast(transform.position + Vector3.up, dir.normalized,
                dist, obstacleMask)) continue;

            if (dist < closestDist)
            {
                closestDist = dist;
                closestPlayer = hit.transform;
            }
        }

        if (closestPlayer != null)
        {
            // Awareness increases based on proximity
            float distFactor = 1f - (closestDist / detectionRadius);
            awareness += awarenessRate * distFactor * Time.deltaTime;
            OnAwarenessChanged?.Invoke(awareness);

            if (awareness >= alertThreshold)
            {
                detectedPlayer = closestPlayer;
                lastKnownPosition = closestPlayer.position;

                if (currentState == AIState.Patrol)
                {
                    currentState = AIState.Alert;
                    OnPlayerDetected?.Invoke(detectedPlayer);
                    OnAlertTriggered?.Invoke();

                    if (canCallBackup)
                        AlertGroup(closestPlayer);
                }
            }
        }
        else
        {
            awareness = Mathf.Max(0f, awareness - awarenessDrain * Time.deltaTime);
            OnAwarenessChanged?.Invoke(awareness);
        }
    }

    private void UpdateAlert()
    {
        agent.speed = alertSpeed;

        if (detectedPlayer != null && detectedPlayer.gameObject.activeInHierarchy)
        {
            float dist = Vector3.Distance(transform.position, detectedPlayer.position);

            // Check line of sight
            Vector3 dir = detectedPlayer.position - transform.position;
            bool canSee = !Physics.Raycast(transform.position + Vector3.up,
                dir.normalized, dir.magnitude, obstacleMask);

            if (canSee)
            {
                lastKnownPosition = detectedPlayer.position;
                agent.SetDestination(detectedPlayer.position);
            }
            else
            {
                // Lost sight — investigate last known position
                currentState = AIState.Investigate;
                investigateTimer = investigateTime;
                agent.SetDestination(lastKnownPosition);
                return;
            }

            if (dist > chaseGiveUpDistance)
            {
                currentState = AIState.Investigate;
                investigateTimer = investigateTime;
                agent.SetDestination(lastKnownPosition);
            }
        }
        else
        {
            currentState = AIState.Investigate;
            investigateTimer = investigateTime;
            agent.SetDestination(lastKnownPosition);
        }
    }

    private void UpdateInvestigate()
    {
        agent.speed = patrolSpeed;

        if (agent.remainingDistance < 1f)
        {
            // Disable agent rotation so manual look-around works
            agent.updateRotation = false;
            investigateTimer -= Time.deltaTime;

            // Look around
            transform.Rotate(0f, 90f * Time.deltaTime, 0f);

            if (investigateTimer <= 0f)
            {
                agent.updateRotation = true;
                TransitionToReturn();
                OnPlayerLost?.Invoke();
            }
        }
    }

    private void TransitionToReturn()
    {
        currentState = AIState.Return;
        awareness = 0f;
        detectedPlayer = null;

        if (waypoints != null && waypoints.Length > 0)
        {
            Transform wp = waypoints[currentWaypointIndex];
            if (wp != null) agent.SetDestination(wp.position);
        }
        else
        {
            agent.SetDestination(originPosition);
        }
    }

    private void UpdateReturn()
    {
        agent.speed = patrolSpeed;

        if (agent.remainingDistance < 0.5f)
        {
            currentState = AIState.Patrol;
            waitTimer = waitTimeAtPoint;
        }
    }

    private void UpdateSchedule()
    {
        if (schedules == null || schedules.Length == 0) return;

        foreach (var schedule in schedules)
        {
            bool inRange = schedule.startHour < schedule.endHour
                ? (currentTimeOfDay >= schedule.startHour && currentTimeOfDay < schedule.endHour)
                : (currentTimeOfDay >= schedule.startHour || currentTimeOfDay < schedule.endHour);

            if (inRange && schedule.waypoints != null && schedule.waypoints.Length > 0)
            {
                if (waypoints != schedule.waypoints)
                {
                    waypoints = schedule.waypoints;
                    currentWaypointIndex = 0;
                }
                return;
            }
        }
    }

    private void AlertGroup(Transform target)
    {
        if (!groups.ContainsKey(groupId)) return;

        foreach (var member in groups[groupId])
        {
            if (member == this) continue;
            float dist = Vector3.Distance(transform.position, member.transform.position);
            if (dist <= alertCallRange)
                member.ReceiveAlert(target, target.position);
        }
    }

    /// <summary>Receive an alert from a group member.</summary>
    public void ReceiveAlert(Transform target, Vector3 position)
    {
        if (currentState != AIState.Patrol) return;

        detectedPlayer = target;
        lastKnownPosition = position;
        currentState = AIState.Alert;
        awareness = alertThreshold;
        OnAlertTriggered?.Invoke();
    }

    /// <summary>Set the time of day for schedule-based patrol routes.</summary>
    public void SetTimeOfDay(float hour)
    {
        currentTimeOfDay = Mathf.Repeat(hour, 24f);
    }

    /// <summary>Force return to patrol state.</summary>
    public void ForceReturnToPatrol()
    {
        TransitionToReturn();
    }

    /// <summary>Current AI state.</summary>
    public AIState CurrentState => currentState;

    /// <summary>Current awareness level (0 to alertThreshold).</summary>
    public float Awareness => awareness;

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (!showDebugGizmos) return;

        // Detection radius
        Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
        Gizmos.DrawWireSphere(transform.position, detectionRadius);

        // Detection cone
        Vector3 leftDir = Quaternion.Euler(0, -detectionAngle * 0.5f, 0) * transform.forward;
        Vector3 rightDir = Quaternion.Euler(0, detectionAngle * 0.5f, 0) * transform.forward;
        Gizmos.color = new Color(1f, 0.5f, 0f, 0.5f);
        Gizmos.DrawRay(transform.position, leftDir * detectionRadius);
        Gizmos.DrawRay(transform.position, rightDir * detectionRadius);

        // Awareness bar
        if (Application.isPlaying)
        {
            Vector3 barPos = transform.position + Vector3.up * 2.5f;
            Gizmos.color = Color.Lerp(Color.green, Color.red, awareness / Mathf.Max(alertThreshold, 0.01f));
            float barWidth = awareness / Mathf.Max(alertThreshold, 0.01f) * 1f;
            Gizmos.DrawCube(barPos, new Vector3(barWidth, 0.1f, 0.1f));
        }

        // Waypoint path
        if (waypoints != null && waypoints.Length > 1)
        {
            Gizmos.color = new Color(0f, 0.8f, 1f, 0.5f);
            for (int i = 0; i < waypoints.Length - 1; i++)
            {
                if (waypoints[i] != null && waypoints[i + 1] != null)
                    Gizmos.DrawLine(waypoints[i].position, waypoints[i + 1].position);
            }
            if (patrolMode == PatrolMode.Loop && waypoints[0] != null
                && waypoints[waypoints.Length - 1] != null)
            {
                Gizmos.DrawLine(waypoints[waypoints.Length - 1].position,
                    waypoints[0].position);
            }
        }

        // Alert call range
        if (canCallBackup)
        {
            Gizmos.color = new Color(1f, 0f, 0f, 0.05f);
            Gizmos.DrawWireSphere(transform.position, alertCallRange);
        }

        // Last known position
        if (Application.isPlaying && currentState == AIState.Investigate)
        {
            Gizmos.color = Color.yellow;
            Gizmos.DrawSphere(lastKnownPosition, 0.3f);
        }
    }
#endif
}