~3 min read

NavMesh and AI — Pathfinding and a Simple AI Enemy

Navigation Mesh, NavMeshAgent, a basic state machine for an enemy.

A 3D level rarely consists of a flat floor. An enemy has to go around a table, descend the stairs, find the short path to the player, and not fall off a cliff. Computing all of this by hand with Physics.Raycast is a nightmare. Unity has NavMesh — a built-in navigation system that solves 90% of AI movement tasks for free.

A NavMesh is baked, simplified floor geometry that agents know how to walk on. Unity scans your scene and assembles a polygonal mesh of all surfaces you can stand on. From this mesh it builds a graph for the pathfinding algorithm (A* under the hood).

In Unity 6 the recommended path is the AI Navigation package (a.k.a. com.unity.ai.navigation). The old built-in NavMesh (via Window → Navigation in Unity ≤ 2021) is kept for compatibility, but the new package gives you a component-based workflow: NavMeshSurface, NavMeshModifier, NavMeshLink.

Basic baking workflow

  1. Assemble the static floor and wall geometry.
  2. Add a NavMeshSurface component to an empty GameObject.
  3. In Agent Type select a profile (default “Humanoid”: radius 0.5, height 2, max slope 45°, step height 0.4).
  4. Click Bake. A blue semi-transparent mesh appears in the Scene view — that is the NavMesh.
            __________
           /          \      ← NavMesh covers the floor
          /  Comfy     \
         |   Room       |
          \    [agent]_/
           \__________/

   Walls, pillars, pits → "holes" in the NavMesh
NavMeshSurface vs legacy Navigation window

In a new project use NavMeshSurface — it lets you have multiple meshes in a scene, bake at runtime (for procedural levels), and doesn’t depend on a global scene setting. The old Window → Navigation menu still works, but it’s the deprecated path.

NavMeshAgent is a component that turns a GameObject into a “pedestrian” on the NavMesh:

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class Chaser : MonoBehaviour
{
    [SerializeField] private Transform target;

    private NavMeshAgent _agent;

    private void Awake() => _agent = GetComponent<NavMeshAgent>();

    private void Update() {
        if (target != null) {
            _agent.SetDestination(target.position);
        }
    }
}

The main NavMeshAgent fields:

  • Speed — speed in m/s.
  • Angular Speed — turn speed in degrees/s.
  • Acceleration — m/s².
  • Stopping Distance — the distance from the target at which the agent stops. For melee attacks people set something like 1.5 m.
  • Auto Braking — smoothly decelerates as it approaches the target.
  • Obstacle Avoidance Type — priority in local avoidance when several agents meet.

Doors, crates, fallen trees — dynamic obstacles. NavMeshObstacle has two modes:

  • Without carve — agents go around the obstacle via avoidance, but the NavMesh doesn’t change. Cheap.
  • With carve = true — the obstacle “cuts” a hole into the NavMesh and agents build a new path. More expensive, but correct for closed doors and large obstacles.

A standard NavMesh can’t handle “teleports”: a staircase with a gap, a jump across a chasm, an elevator. NavMeshLink connects two locations — the agent crosses it like a regular edge of the graph.

A simple AI state machine

Let’s move from “walks toward a target” to a minimally alive enemy: it patrols, spots the player and chases, catches up and attacks.

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class EnemyAI : MonoBehaviour
{
    private enum State { Patrol, Chase, Attack }

    [SerializeField] private Transform[] patrolPoints;
    [SerializeField] private Transform player;
    [SerializeField] private float sightRange = 12f;
    [SerializeField] private float attackRange = 2f;
    [SerializeField] private float attackCooldown = 1.2f;

    private NavMeshAgent _agent;
    private State _state = State.Patrol;
    private int _patrolIndex;
    private float _nextAttackTime;

    private void Awake() => _agent = GetComponent<NavMeshAgent>();

    private void Start() {
        if (patrolPoints.Length > 0) {
            _agent.SetDestination(patrolPoints[0].position);
        }
    }

    private void Update() {
        float distToPlayer = Vector3.Distance(transform.position, player.position);

        switch (_state) {
            case State.Patrol:
                if (distToPlayer < sightRange && HasLineOfSight()) {
                    _state = State.Chase;
                } else if (!_agent.pathPending && _agent.remainingDistance < 0.5f) {
                    _patrolIndex = (_patrolIndex + 1) % patrolPoints.Length;
                    _agent.SetDestination(patrolPoints[_patrolIndex].position);
                }
                break;

            case State.Chase:
                _agent.SetDestination(player.position);
                if (distToPlayer < attackRange) _state = State.Attack;
                else if (distToPlayer > sightRange * 1.5f) _state = State.Patrol;
                break;

            case State.Attack:
                _agent.SetDestination(transform.position); // stand still
                if (Time.time >= _nextAttackTime) {
                    Attack();
                    _nextAttackTime = Time.time + attackCooldown;
                }
                if (distToPlayer > attackRange + 0.5f) _state = State.Chase;
                break;
        }
    }

    private bool HasLineOfSight() {
        Vector3 dir = player.position - transform.position;
        if (Physics.Raycast(transform.position + Vector3.up, dir.normalized, out RaycastHit hit, sightRange)) {
            return hit.transform == player;
        }
        return false;
    }

    private void Attack() {
        // Here — the attack animation and dealing damage through your Health
        if (player.TryGetComponent<Health>(out var hp)) hp.TakeDamage(10);
    }
}
Hysteresis in state transitions

Note: the transition back from Chase to Patrol uses sightRange * 1.5f, the one from Chase to Attack uses attackRange, whereas the one from Attack to Chase uses attackRange + 0.5f. This is hysteresis: the enter and exit thresholds don’t coincide, so the enemy doesn’t “jitter” between states right at the boundary. A simple trick that noticeably improves how coherent the AI feels.

What to improve

  • Behavior Trees — for AI more complex than 3 states. Ready-made packages — Behavior Designer, or the new built-in Unity Behavior (Unity 6 brought an official Behavior Trees system).
  • Memory & senses — “see/hear/remember” pairs, where the enemy remembers the player’s last position for a few more seconds.
  • Group coordination — several enemies share attack zones via NavMeshAgent.avoidancePriority.
  • Cover system — finding cover points via NavMesh.SamplePosition and Raycast.

In the next chapter — Particle System and VFX Graph, so that the enemy bursts spectacularly into shards on death.