~3 min read

Practice — a third-person platformer with a NavMesh enemy

PhantomCamera FreeLook, CharacterBody3D, double jump, coyote time, a chasing enemy, checkpoints.

Иллюстрация: Practice — a third-person platformer with a NavMesh enemy

The second practical Godot capstone — a parallel to the Unity TPS chapter. We’re building a third-person platformer: a character runs, jumps (with double jump and coyote time), is chased by an AI enemy along a NavMesh, and on death the player respawns at a checkpoint.

We’ll use what we covered earlier: CharacterBody3D with move_and_slide, NavigationAgent3D, the Phantom Camera plugin for the third-person view, and a Resource as a ScriptableObject analog.

Scene hierarchy

World (Node3D)
├── Ground               ← StaticBody3D + CollisionShape3D + MeshInstance3D
├── Platforms            ← a set of StaticBody3D at different heights
├── Checkpoints
│   ├── Checkpoint_01    ← Area3D + CollisionShape3D + script
│   └── Checkpoint_02
├── Enemies
│   └── Patroller        ← CharacterBody3D + NavigationAgent3D + EnemyAI
├── NavigationRegion3D   ← we bake the NavMesh from Ground + Platforms
└── WorldEnvironment

Player (CharacterBody3D)
├── CollisionShape3D     ← CapsuleShape3D (height=2, radius=0.4)
├── Body (Node3D)         ← the visual model + MeshInstance3D
├── GroundCheck (Marker3D)← position at the feet for is_on_floor
└── CameraTarget (Node3D) ← at head level, the target for PhantomCamera

PhantomCamera3D            ← the following third-person camera
                              tracking_target = Player/CameraTarget
                              follow_mode = ThirdPerson

PhantomCameraHost          ← a child of the main Camera3D
Camera3D                   ← the real scene camera

GameState (Node)           ← a singleton via autoload
HUD (CanvasLayer)
CharacterBody3D instead of Rigidbody

In the Unity chapter we used Rigidbody for the platformer for the sake of inertia. In Godot, CharacterBody3D with move_and_slide() is smart enough — it “slides” along walls itself, handles slopes and steps. Plus it’s as predictable as the Unity CharacterController. RigidBody3D only makes sense if you need full physics on the player (ragdoll, kicking boxes).

player_motor.gd — the controller

extends CharacterBody3D
class_name PlayerMotor

@export_group("Movement")
@export var move_speed: float = 6.0
@export var air_control: float = 0.35
@export var turn_speed: float = 12.0           # rad/s

@export_group("Jump")
@export var jump_velocity: float = 5.5
@export var max_jumps: int = 2                 # regular + double
@export var coyote_time: float = 0.12           # jump window after leaving an edge
@export var jump_buffer: float = 0.15           # "pre-press" window before landing

@export_group("References")
@export var camera_rig: Node3D                  # usually the main Camera3D

@onready var body: Node3D = $Body

var _jumps_left: int = 0
var _last_grounded_time: float = -1.0
var _last_jump_press_time: float = -1.0
var _was_on_floor: bool = false

func _physics_process(delta: float) -> void:
    _update_grounded(delta)
    _update_jump(delta)
    _update_move(delta)
    move_and_slide()

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("jump"):
        _last_jump_press_time = _now()

func _update_grounded(_delta: float) -> void:
    var on_floor := is_on_floor()
    if on_floor:
        _last_grounded_time = _now()
        if not _was_on_floor:
            _jumps_left = max_jumps  # landed — restore jumps
    _was_on_floor = on_floor

func _update_jump(delta: float) -> void:
    var wants_jump := (_now() - _last_jump_press_time) < jump_buffer
    var in_coyote := (_now() - _last_grounded_time) < coyote_time

    if not wants_jump:
        # Gravity
        if not is_on_floor():
            velocity += get_gravity() * delta
        return

    var can_first_jump := in_coyote and _jumps_left == max_jumps
    var can_air_jump := not is_on_floor() and _jumps_left > 0 and _jumps_left < max_jumps

    if can_first_jump or can_air_jump:
        velocity.y = jump_velocity     # fixed jump height
        _jumps_left -= 1
        _last_jump_press_time = -1.0    # consumed the buffer
    else:
        if not is_on_floor():
            velocity += get_gravity() * delta

func _update_move(delta: float) -> void:
    var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")

    if input_dir == Vector2.ZERO:
        # Decay of horizontal velocity on the ground
        var damp: float = 0.85 if is_on_floor() else 0.99
        velocity.x *= damp
        velocity.z *= damp
        return

    # Direction relative to the camera (projected onto the horizontal plane)
    var cam_basis := camera_rig.global_transform.basis
    var cam_fwd := -cam_basis.z
    cam_fwd.y = 0
    cam_fwd = cam_fwd.normalized()
    var cam_right := cam_basis.x
    cam_right.y = 0
    cam_right = cam_right.normalized()

    var wish_dir := (cam_right * input_dir.x + cam_fwd * -input_dir.y).normalized()

    # Turn the character's body toward the movement direction
    var target_yaw := atan2(wish_dir.x, wish_dir.z)
    body.rotation.y = lerp_angle(body.rotation.y, target_yaw, turn_speed * delta)

    # Applying velocity: on the ground — instantly (lerp with t=1.0 = snap to target),
    # in the air — partially (t=air_control ≈ 0.35). This gives the classic FPS feel.
    var t := 1.0 if is_on_floor() else air_control
    var target_horizontal := wish_dir * move_speed
    velocity.x = lerp(velocity.x, target_horizontal.x, t)
    velocity.z = lerp(velocity.z, target_horizontal.z, t)

func _now() -> float:
    return Time.get_ticks_msec() / 1000.0
Coyote time + jump buffering

The same 0.12 / 0.15 seconds as in the Unity chapter. The player doesn’t press jump exactly on the frame they touch the ground — they press a bit earlier or a bit later. Without these windows, up to a third of jumps “don’t register.” Celeste, Hollow Knight, and Mario Odyssey all do this.

Phantom Camera for third-person

Install the Phantom Camera plugin (AssetLib → “Phantom Camera” → Install → activate in Project Settings → Plugins).

Camera hierarchy

Player (CharacterBody3D)
└── CameraTarget (Node3D, y=1.6)    ← the follow target

PhantomCamera3D                       ← the virtual camera
   tracking_target = "../Player/CameraTarget"
   follow_mode = THIRD_PERSON
   third_person_settings:
     follow_distance: 4.5
     pitch_min: -45
     pitch_max: 70
     enable_collision: true           ← don't clip through walls

PhantomCameraHost (on the main Camera3D)
Camera3D (current = true)

Parameters:

  • follow_distance — distance from the target.
  • pitch_min / pitch_max — the vertical angle range.
  • enable_collision — the camera doesn’t clip through walls (SpringArm3D-style).
  • damping — the smoothness of catching up.

Mouse control

PhantomCamera3D in third-person mode handles the mouse automatically if mouse_look_enabled is on. Sensitivity is configured in the Inspector.

If you want to do it manually:

# In a script on PhantomCamera3D — or use the plugin's input_axis_x/y bindings
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseMotion:
        var orbit := global_rotation
        orbit.y -= event.relative.x * 0.003
        orbit.x = clamp(orbit.x - event.relative.y * 0.003, deg_to_rad(-45), deg_to_rad(70))
        global_rotation = orbit

EnemyChaser — chasing along the NavMesh

From the Navigation chapter, but simplified for the platformer (no melee attack — just contact damage):

extends CharacterBody3D
class_name ChaserEnemy

@export var target: Node3D
@export var speed: float = 3.5
@export var contact_damage: int = 1
@export var hit_cooldown: float = 0.8
@export var catch_distance: float = 1.2

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

var _next_hit_time: float = 0.0

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

    nav_agent.target_position = target.global_position

    if 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()

    # Contact damage
    var dist := global_position.distance_to(target.global_position)
    var now := Time.get_ticks_msec() / 1000.0
    if dist <= catch_distance and now >= _next_hit_time:
        if target.has_method("take_damage"):
            target.take_damage(contact_damage)
            _next_hit_time = now + hit_cooldown
NavMesh doesn't jump

If your platforms are spread apart and only the player can jump up to them, the enemy will get stuck below. The fix: either place enemies in an area where they can physically reach the player, or use NavigationLink3D for predefined jumps, or write AI without a NavMesh.

PlayerHealth — with i-frames

extends Node
class_name PlayerHealth

signal damaged(hp_left: int)
signal died

@export var max_hearts: int = 3
@export var invincibility_seconds: float = 0.7

var _hp: int
var _invincible_until: float = 0.0

func _ready() -> void:
    _hp = max_hearts

func take_damage(amount: int) -> void:
    var now := Time.get_ticks_msec() / 1000.0
    if now < _invincible_until or _hp <= 0:
        return

    _hp = max(0, _hp - amount)
    _invincible_until = now + invincibility_seconds
    damaged.emit(_hp)

    if _hp == 0:
        died.emit()

func reset() -> void:
    _hp = max_hearts
    damaged.emit(_hp)

Checkpoints and respawn via autoload

In Godot, singletons are made via Autoload (Project → Project Settings → Autoload). It’s more convenient than the Instance.set/get pattern.

# game_state.gd
extends Node

var active_checkpoint: Transform3D = Transform3D.IDENTITY

func set_active_checkpoint(t: Transform3D) -> void:
    active_checkpoint = t

func respawn(player: Node3D, health: PlayerHealth) -> void:
    if player is CharacterBody3D:
        (player as CharacterBody3D).velocity = Vector3.ZERO
    player.global_transform = active_checkpoint
    health.reset()

Registration: Project Settings → Autoload → Path = res://scripts/game_state.gd, Name = GameState. After that GameState is accessible from any script as a global.

checkpoint.gd — an Area3D trigger on the checkpoint:

extends Area3D
class_name Checkpoint

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node3D) -> void:
    if body.is_in_group("player"):
        GameState.set_active_checkpoint(global_transform)

The key thing: tag the player in the player group (Inspector → Node → Groups → add “player”).

Wire death to respawn:

# The player's script, in _ready
$Health.died.connect(_on_died)

func _on_died() -> void:
    GameState.respawn(self, $Health)

Hearts HUD

hud.tscn:

CanvasLayer
└── MarginContainer
    └── HBoxContainer
        ├── TextureRect (heart_full.png)
        ├── TextureRect (heart_full.png)
        └── TextureRect (heart_full.png)

hearts_hud.gd:

extends HBoxContainer

@export var heart_full: Texture2D
@export var heart_empty: Texture2D

@onready var heart_nodes: Array[TextureRect] = []

func _ready() -> void:
    for child in get_children():
        if child is TextureRect:
            heart_nodes.append(child)

func on_health_changed(current: int) -> void:
    for i in heart_nodes.size():
        heart_nodes[i].texture = heart_full if i < current else heart_empty

Connect the PlayerHealth.damaged signal to this method via Inspector → Node → Signals or from code.

What to add next

  • Animator via AnimationTree: BlendSpace2D for locomotion (forward/strafe), a one-shot for the jump.
  • Footstep audio via a Method Track in the AnimationPlayer.
  • Moving platforms — AnimatableBody3D with a velocity property (applied to whatever stands on it).
  • Wall slide / wall jump — a separate state machine with an is_on_wall_only() check.
  • Pause Menu — a separate scene with get_tree().paused = true.
  • Save System — via FileAccess or ConfigFile in user://saves.cfg.

Comparison with the complexity of the Unity TPS

An equivalent Unity project takes roughly the same amount of code. The main differences:

  • move_and_slide() does more for us than CharacterController.Move() — slope handling and slide-along-wall are built in.
  • Phantom Camera gives a Cinemachine-style third-person view from a single plugin.
  • Autoload is simpler than the Unity Singleton pattern.
  • Time.get_ticks_msec() / 1000.0 instead of Time.time — a bit more verbose.
  • Resource + class_name give the same ScriptableObject-style config.

Overall — on the order of 150–200 lines of code for a playable TPS. The same 1.5–2 hours to build as the Unity equivalent.