~3 min read

Practice — Third-person Platformer with a NavMesh Enemy

Orbit camera, physics controller, double jump, coyote time, a chasing enemy, and checkpoints.

Иллюстрация: Practice — Third-person Platformer with a NavMesh Enemy

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
Why Rigidbody and not CharacterController

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
        }
    }
}
Coyote time + jump buffering — the secret of a good platformer

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:

  1. Create a CinemachineCamera in the scene.
  2. Add a CinemachineOrbitalFollow component to it (formerly a separate CinemachineFreeLook).
  3. In Tracking Target, set the Transform at the player’s head height (that same child CameraTarget).
  4. Configure three horizontal rings (top/mid/bottom rig) with radii of 4, 6, 8 m and heights of 2, 0, -2.
  5. 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 ≠ platformer

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();
    }
}
Singleton — a compromise, not a pattern

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:

  1. Canvas (Screen Space – Overlay).
  2. A HorizontalLayoutGroup with three <Image> elements — hearts.
  3. On the HeartsHud.cs script:
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 MenuTime.timeScale = 0 + a separate Canvas.
  • Save System — serialization to PlayerPrefs or 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.