Practice — a third-person platformer with a NavMesh enemy
PhantomCamera FreeLook, CharacterBody3D, double jump, coyote time, a chasing enemy, checkpoints.
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)
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
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
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
velocityproperty (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
FileAccessorConfigFileinuser://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 thanCharacterController.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.0instead ofTime.time— a bit more verbose.- Resource +
class_namegive 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.