Practice — Third-person Platformer with a NavMesh Enemy
Orbit camera, physics controller, double jump, coyote time, a chasing enemy, and checkpoints.
The second practical capstone — we build a third-person platformer: the character runs, jumps (with double jump and coyote time), is chased by an AI enemy, and on death the player respawns at a checkpoint. We use what we covered in previous chapters: Cinemachine, NavMesh, a physics-based Rigidbody, and a ScriptableObject for the config.
Scene hierarchy
World
├── Ground ← Plane or Mesh with a MeshCollider
├── Platforms ← a set of cubes at different heights
├── Checkpoints
│ ├── Checkpoint_01 ← an empty object with a trigger collider
│ └── Checkpoint_02
├── Enemies
│ └── Patroller ← NavMeshAgent + EnemyAI (from the NavMesh chapter)
└── NavMeshSurface ← bake the NavMesh on Ground + Platforms
Player ← Rigidbody + CapsuleCollider + PlayerMotor.cs
├── Body ← the visual model (mesh)
└── (CameraTarget) ← a child Transform at head height
CM FreeLook ← Cinemachine 3rd-person camera, Tracking Target = Player/CameraTarget
Main Camera ← a regular Camera + CinemachineBrain + AudioListener
GameState ← manager: current checkpoint, respawn, HUD references
HUD (Canvas) ← Health Bar, debug text
In the FPS we chose CharacterController — it is predictable and does not “slide off”. In a platformer you want a sense of weight and inertia: Rigidbody provides that for free through physical materials, damping, and collisions with moving platforms. CharacterController can still be used, but Rigidbody usually wins in platformers.
PlayerMotor.cs — the physics controller
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody), typeof(CapsuleCollider))]
public class PlayerMotor : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float moveSpeed = 6f;
[SerializeField] private float airControl = 0.35f; // 0..1, how much input is heeded in the air
[SerializeField] private float turnSpeed = 720f; // deg/s
[SerializeField] private float dampingGrounded = 8f; // fast stop on the ground
[SerializeField] private float dampingAir = 0.2f;
[Header("Jump")]
[SerializeField] private float jumpHeight = 1.6f;
[SerializeField] private int maxJumps = 2; // regular + double
[SerializeField] private float coyoteTime = 0.12f; // jump window after leaving an edge
[SerializeField] private float jumpBuffer = 0.15f; // window for remembering the press before landing
[Header("Ground Check")]
[SerializeField] private Transform groundCheck; // an empty object at the feet
[SerializeField] private float groundCheckRadius = 0.25f;
[SerializeField] private LayerMask groundMask = ~0;
[Header("Camera Reference")]
[SerializeField] private Transform cameraRig; // usually Camera.main.transform
private Rigidbody _rb;
private CapsuleCollider _col;
private Vector2 _moveInput;
private bool _jumpPressedThisFrame;
private float _lastGroundedTime;
private float _lastJumpPressTime = float.NegativeInfinity;
private int _jumpsLeft;
private bool _isGrounded;
private void Awake() {
_rb = GetComponent<Rigidbody>();
_col = GetComponent<CapsuleCollider>();
_rb.freezeRotation = true; // we handle rotation ourselves
}
// Wired up through PlayerInput (Behavior: Invoke Unity Events)
public void OnMove(InputAction.CallbackContext ctx) => _moveInput = ctx.ReadValue<Vector2>();
public void OnJump(InputAction.CallbackContext ctx) {
if (ctx.performed) {
_jumpPressedThisFrame = true;
_lastJumpPressTime = Time.time;
}
}
private void FixedUpdate() {
UpdateGrounded();
UpdateMove();
TryJump();
_jumpPressedThisFrame = false;
}
private void UpdateGrounded() {
Vector3 origin = groundCheck != null ? groundCheck.position : transform.position;
bool wasGrounded = _isGrounded;
_isGrounded = Physics.CheckSphere(origin, groundCheckRadius, groundMask, QueryTriggerInteraction.Ignore);
if (_isGrounded) {
_lastGroundedTime = Time.time;
if (!wasGrounded) _jumpsLeft = maxJumps; // landed — restore jumps
}
_rb.linearDamping = _isGrounded ? dampingGrounded : dampingAir;
}
private void UpdateMove() {
if (_moveInput.sqrMagnitude < 0.01f) return;
// Input direction relative to the camera (projected onto the horizontal plane)
Vector3 camFwd = cameraRig.forward; camFwd.y = 0; camFwd.Normalize();
Vector3 camRight = cameraRig.right; camRight.y = 0; camRight.Normalize();
Vector3 wishDir = (camRight * _moveInput.x + camFwd * _moveInput.y).normalized;
// Rotate the body toward the movement direction
Quaternion targetRot = Quaternion.LookRotation(wishDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, turnSpeed * Time.fixedDeltaTime);
// Apply velocity — full on the ground, reduced in the air
float control = _isGrounded ? 1f : airControl;
Vector3 desiredVel = wishDir * moveSpeed * control;
// Preserve the vertical component from gravity/jump
Vector3 vel = _rb.linearVelocity;
Vector3 horizontalVel = new Vector3(vel.x, 0, vel.z);
Vector3 delta = desiredVel - horizontalVel;
// Clamp it so we don't apply an unrealistically large force
_rb.AddForce(delta, ForceMode.VelocityChange);
}
private void TryJump() {
bool wantsJump = Time.time - _lastJumpPressTime < jumpBuffer;
bool inCoyoteWindow = Time.time - _lastGroundedTime < coyoteTime;
if (!wantsJump) return;
bool canFirstJump = inCoyoteWindow && _jumpsLeft == maxJumps;
bool canAirJump = !_isGrounded && _jumpsLeft > 0 && _jumpsLeft < maxJumps;
if (canFirstJump || canAirJump) {
// Reset the vertical velocity so the jump has a consistent height
Vector3 v = _rb.linearVelocity;
v.y = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
_rb.linearVelocity = v;
_jumpsLeft--;
_lastJumpPressTime = float.NegativeInfinity; // consumed the buffer
}
}
}
A player does not press jump exactly on the frame of touching the ground — they press a little earlier or a little later. Without these two mechanics, a third of jumps “don’t register” and the game feels jerky. With them, the controls become “forgiving” and pleasant. Celeste, Hollow Knight, Mario Odyssey — they all do this.
Cinemachine FreeLook — the camera
In Cinemachine 3, for an orbital third-person camera:
- Create a CinemachineCamera in the scene.
- Add a CinemachineOrbitalFollow component to it (formerly a separate CinemachineFreeLook).
- In Tracking Target, set the Transform at the player’s head height (that same child
CameraTarget). - Configure three horizontal rings (top/mid/bottom rig) with radii of 4, 6, 8 m and heights of 2, 0, -2.
- Enable CinemachineInputAxisController on the same camera — it binds the right stick/mouse to camera control.
Parameters to taste:
- Horizontal Axis Speed — orbit speed.
- Recentering — automatic return behind the character after a pause in input.
- Damping — how smoothly the camera catches up with the target.
In PlayerMotor, the cameraRig field points to the main Camera (with CinemachineBrain). This makes
the player turn toward the movement direction relative to where the camera is looking — the standard
third-person feel.
EnemyAI — chasing along the NavMesh
We already covered this in the NavMesh chapter. For a platformer a variant without melee attacks is enough — just “caught up → dealt damage → delay → again”:
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class ChaserEnemy : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private int contactDamage = 1;
[SerializeField] private float hitCooldown = 0.8f;
[SerializeField] private float catchDistance = 1.2f;
private NavMeshAgent _agent;
private float _nextHitTime;
private void Awake() => _agent = GetComponent<NavMeshAgent>();
private void Update() {
if (target == null) return;
_agent.SetDestination(target.position);
float dist = Vector3.Distance(transform.position, target.position);
if (dist <= catchDistance && Time.time >= _nextHitTime) {
if (target.TryGetComponent<PlayerHealth>(out var hp)) {
hp.TakeDamage(contactDamage);
_nextHitTime = Time.time + hitCooldown;
}
}
}
}
NavMesh can walk over static geometry and, via NavMeshLink, jump between fixed points. But it cannot “truly” jump like a player. If your platforms are spread apart and only the player can make the jump, the enemy gets stuck below. This is part of the design: either place enemies only on a single plane, or use NavMeshLink for predefined jumps, or write your own AI without NavMesh.
Health, checkpoints, and respawn
PlayerHealth.cs with built-in protection against double triggering and i-frames (a brief
invulnerability after taking damage):
using UnityEngine;
using UnityEngine.Events;
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private int maxHearts = 3;
[SerializeField] private float invincibilitySeconds = 0.7f;
[SerializeField] private UnityEvent<int> onHealthChanged; // for the HUD
[SerializeField] private UnityEvent onDied;
private int _hearts;
private float _invincibleUntil;
private void Awake() { _hearts = maxHearts; onHealthChanged?.Invoke(_hearts); }
public void TakeDamage(int amount) {
if (Time.time < _invincibleUntil || _hearts <= 0) return;
_hearts = Mathf.Max(0, _hearts - amount);
_invincibleUntil = Time.time + invincibilitySeconds;
onHealthChanged?.Invoke(_hearts);
if (_hearts == 0) onDied?.Invoke();
}
public void Heal(int amount) {
_hearts = Mathf.Min(maxHearts, _hearts + amount);
onHealthChanged?.Invoke(_hearts);
}
public void ResetHealth() { _hearts = maxHearts; onHealthChanged?.Invoke(_hearts); }
}
Checkpoint.cs — an empty GameObject with a trigger collider:
public class Checkpoint : MonoBehaviour
{
private void OnTriggerEnter(Collider other) {
if (other.CompareTag("Player")) {
GameState.Instance.SetActiveCheckpoint(transform);
}
}
}
GameState.cs — a simple singleton manager with respawning:
using UnityEngine;
public class GameState : MonoBehaviour
{
public static GameState Instance { get; private set; }
[SerializeField] private Transform player;
[SerializeField] private PlayerHealth playerHealth;
[SerializeField] private Transform initialSpawn;
private Transform _activeCheckpoint;
private void Awake() {
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
private void Start() {
_activeCheckpoint = initialSpawn;
playerHealth.onDied.AddListener(Respawn);
}
public void SetActiveCheckpoint(Transform t) => _activeCheckpoint = t;
public void Respawn() {
// In case of a Rigidbody player, zero out inertia
if (player.TryGetComponent<Rigidbody>(out var rb)) {
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
player.position = _activeCheckpoint.position;
player.rotation = _activeCheckpoint.rotation;
playerHealth.ResetHealth();
}
}
GameState.Instance is handy for prototypes, but in large projects it quickly turns into a dumping
ground. The better alternative in Unity is a ScriptableObject as an event channel (shown in the
chapter on Prefab and ScriptableObject). Player died → SO event “OnDied” → GameState, HUD, and
audio manager are subscribed.
HUD — a minimal Health Bar
In the scene:
- Canvas (Screen Space – Overlay).
- A HorizontalLayoutGroup with three
<Image>elements — hearts. - On the
HeartsHud.csscript:
using UnityEngine;
using UnityEngine.UI;
public class HeartsHud : MonoBehaviour
{
[SerializeField] private Image[] hearts;
[SerializeField] private Sprite fullSprite;
[SerializeField] private Sprite emptySprite;
public void OnHealthChanged(int currentHearts) {
for (int i = 0; i < hearts.Length; i++) {
hearts[i].sprite = i < currentHearts ? fullSprite : emptySprite;
}
}
}
In PlayerHealth, wire up onHealthChanged through the Inspector → HeartsHud.OnHealthChanged.
What is MISSING here
This is a minimal playable core. Extensions that beg to be added:
- Animator on the player: idle / run / jump / fall / hit (a Blend Tree by speed).
- Footstep audio through an Animation Event.
- Moving platforms — for these a Rigidbody player is better than CharacterController, but you’ll need to detect “standing on a platform” and add its velocity.
- Wall slide / wall jump — a separate state machine inside
PlayerMotor. - Pause Menu —
Time.timeScale = 0+ a separate Canvas. - Save System — serialization to
PlayerPrefsor JSON after passing a checkpoint. - Landing VFX — a Particle System on the ground at the moment
wasGrounded == false && _isGrounded.
Summary
With 100–150 lines of code and 4–5 ScriptableObjects/prefabs, we get a playable 3D platformer with reasonable controls and AI. This is several times faster than assembling the same set from scratch in Three.js or Babylon — and clearly shows where Unity really gives an advantage as a platform for 3D games.