~3 min read

Navigation — NavigationServer and AI

NavigationRegion3D, NavigationAgent3D — pathfinding and a chasing enemy.

Navigation in Godot 4 consists of NavigationServer3D (the low-level server) and three high-level nodes: NavigationRegion3D, NavigationAgent3D, NavigationLink3D.

The Idea

A NavigationMesh is a simplified polygonal “walkability map.” It is baked from the scene’s collisions. Agents pathfind across this map (A* under the hood).

World
├── Ground (StaticBody3D + MeshInstance3D)
├── Walls / Platforms
└── NavigationRegion3D
    └── navigation_mesh: NavigationMesh (baked)

Enemy
├── (model + collider)
└── NavigationAgent3D (target_position = player_position)

Baking the NavigationMesh

  1. Assemble the static geometry.
  2. Add a NavigationRegion3D node to the scene.
  3. In the Inspector, create a new NavigationMesh resource on this node.
  4. The Bake NavigationMesh button in the top toolbar → baking.

In Geometry → Source Geometry Mode on the NavigationMesh you choose what to build the mesh from:

  • SOURCE_GEOMETRY_ROOT_NODE_CHILDREN — processes the children of the NavigationRegion3D (place the static geometry inside the region).
  • SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN — nodes from a given group + their children.
  • SOURCE_GEOMETRY_GROUPS_EXPLICIT — only the nodes from the group.

NavigationMesh parameters:

  • agent_radius — the agent’s radius (offset from walls).
  • agent_height — the agent’s height.
  • agent_max_climb — the maximum step height the agent will step over.
  • agent_max_slope — the maximum slope angle in degrees.
  • cell_size / cell_height — the mesh precision.
Regions connect automatically

If you have several NavigationRegion3D nodes, they connect automatically at their borders. Convenient for modular levels: each room is its own region.

NavigationAgent3D is a child node of the body that needs to walk across the mesh. It does not move the body itself; it computes the next path point:

extends CharacterBody3D

@export var speed: float = 4.0
@export var target: Node3D

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

func _ready() -> void:
    nav_agent.path_desired_distance = 0.5
    nav_agent.target_desired_distance = 1.0

func _physics_process(delta: float) -> void:
    if target == null:
        return

    nav_agent.target_position = target.global_position

    if nav_agent.is_navigation_finished():
        return

    var next_point := nav_agent.get_next_path_position()
    var direction := (next_point - global_position).normalized()

    velocity.x = direction.x * speed
    velocity.z = direction.z * speed
    if not is_on_floor():
        velocity += get_gravity() * delta

    move_and_slide()

The main methods:

  • get_next_path_position() — where to go this tick.
  • is_navigation_finished() — the path is complete.
  • is_target_reachable() — whether a path to the target exists.
  • distance_to_target() — the remaining distance.

Signals:

  • target_reached — the target was reached.
  • waypoint_reached(details) — an intermediate point was reached.
  • navigation_finished — the path was traversed completely.
  • velocity_computed(safe_velocity) — when avoidance is enabled.

Avoidance — Agents Stepping Aside

If there are many agents on the scene heading to one point, without avoidance they will pile up into a “stack.” Enable avoidance_enabled = true on the NavigationAgent3D — RVO2 (reciprocal velocity obstacles) will kick in.

nav_agent.avoidance_enabled = true
nav_agent.radius = 0.6           # avoidance radius
nav_agent.neighbor_distance = 5.0 # we see neighbors within this radius
nav_agent.velocity_computed.connect(_on_safe_velocity)

func _on_safe_velocity(safe_vel: Vector3) -> void:
    # Use safe_vel instead of the raw velocity
    velocity.x = safe_vel.x
    velocity.z = safe_vel.z
    move_and_slide()

func _physics_process(delta: float) -> void:
    # ...
    nav_agent.set_velocity(desired_velocity)  # we pass the desired one — we get the safe one via the signal

A NavigationMesh will not understand “you can jump across a gap here.” For that there is NavigationLink3D: a direct connection between two points. Agents travel along it like along a regular graph edge.

In the Inspector:

  • start_position + end_position — the link’s endpoints.
  • bidirectional — whether it is two-way or not.

When traversing such an edge, the agent moves geometrically straight — which means you need to perform the “jump” (animation + physics) yourself in a script, by detecting the passage through the link.

Doors, crates, fallen trees — dynamic obstacles. Two modes:

  • Without affect_navigation_mesh — simply pushes agents away via avoidance.
  • With affect_navigation_mesh = true — “carves” a hole in the navmesh (requires re-baking the region on the fly). Expensive, but precise.

A Simple Enemy with a State Machine

The full example from the Unity NavMesh chapter, rewritten for Godot:

extends CharacterBody3D
class_name EnemyChaser

enum State { PATROL, CHASE, ATTACK }

@export var patrol_points: Array[Node3D] = []
@export var player: Node3D
@export var speed: float = 3.0
@export var sight_range: float = 12.0
@export var attack_range: float = 2.0
@export var attack_cooldown: float = 1.2

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

var state: State = State.PATROL
var patrol_index: int = 0
var next_attack_time: float = 0.0

func _ready() -> void:
    if patrol_points.size() > 0:
        nav_agent.target_position = patrol_points[0].global_position

func _physics_process(delta: float) -> void:
    var dist_to_player = global_position.distance_to(player.global_position)

    match state:
        State.PATROL:
            if dist_to_player < sight_range and _has_line_of_sight():
                state = State.CHASE
            elif nav_agent.is_navigation_finished():
                patrol_index = (patrol_index + 1) % patrol_points.size()
                nav_agent.target_position = patrol_points[patrol_index].global_position
        State.CHASE:
            nav_agent.target_position = player.global_position
            if dist_to_player < attack_range:
                state = State.ATTACK
            elif dist_to_player > sight_range * 1.5:
                state = State.PATROL
        State.ATTACK:
            nav_agent.target_position = global_position  # stand still
            var now = Time.get_ticks_msec() / 1000.0
            if now >= next_attack_time:
                _attack()
                next_attack_time = now + attack_cooldown
            if dist_to_player > attack_range + 0.5:
                state = State.CHASE

    _move(delta)

func _move(delta: float) -> void:
    if state != State.ATTACK and not nav_agent.is_navigation_finished():
        var next_point = nav_agent.get_next_path_position()
        var dir = (next_point - global_position).normalized()
        velocity.x = dir.x * speed
        velocity.z = dir.z * speed
    else:
        velocity.x = move_toward(velocity.x, 0, speed)
        velocity.z = move_toward(velocity.z, 0, speed)

    if not is_on_floor():
        velocity += get_gravity() * delta
    move_and_slide()

func _has_line_of_sight() -> bool:
    var space = get_world_3d().direct_space_state
    var query = PhysicsRayQueryParameters3D.create(
        global_position + Vector3.UP, player.global_position
    )
    query.exclude = [self]
    var hit = space.intersect_ray(query)
    return hit.is_empty() or hit.collider == player

func _attack() -> void:
    if player.has_method("take_damage"):
        player.take_damage(10)
Hysteresis

Note the thresholds: entry into CHASE at sight_range, exit at sight_range * 1.5. This is hysteresis, which keeps the enemy from “jittering” between states at the boundary. The same technique as in the Unity chapter.

The next chapter covers particles.