Building a Dynamic Parkour System in Unity with Character Controller

Posted by Gemma Ellison
./
July 17, 2025

The allure of a fluid, dynamic parkour system in your game is undeniable. Forget canned animations and simplistic movement; let’s talk about building a responsive parkour experience that feels genuinely empowering to the player. This isn’t just about jumping over obstacles; it’s about crafting a system where the environment becomes your playground. We’re diving deep into Unity’s Character Controller to make it happen, and I’ll show you why relying on it is far superior to Rigidbodies for tight, predictable movement.

Character Controller vs. Rigidbody: The Core Choice

Why Character Controller? Simply put: control. Rigidbodies are physics-based, meaning unpredictable results unless meticulously managed. For parkour, precision is paramount. The Character Controller gives us that precision, allowing us to dictate movement and collision responses directly. Think of it as puppet mastering versus letting the doll flail.

Setting up the Foundation

Start with a new Unity project. Create a new C# script called ParkourController. Attach this script, and a Character Controller component to your player GameObject.

using UnityEngine;

public class ParkourController : MonoBehaviour
{
    private CharacterController controller;
    public float moveSpeed = 5f;
    public float gravity = -9.81f;
    private Vector3 velocity;

    void Start()
    {
        controller = GetComponent<CharacterController>();
        if (controller == null)
        {
            Debug.LogError("Character Controller not found!");
            enabled = false; // Disable the script if no controller
        }
    }

    void Update()
    {
        // Ground check (simplified for brevity)
        bool isGrounded = controller.isGrounded;
        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f; // Reset velocity when grounded
        }

        // Movement input
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");

        Vector3 move = transform.right * x + transform.forward * z;
        controller.Move(move * moveSpeed * Time.deltaTime);

        // Gravity
        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);
    }
}

Common Pitfall: Forgetting to assign the Character Controller in the Inspector. Always double-check your references!

Vaulting: Detecting and Animating

Vaulting is where things get interesting. We need to detect vaultable objects and trigger an animation. Raycasting is your friend here.

public float vaultDistance = 1.5f;
public float vaultHeight = 1.0f;
public LayerMask vaultableLayer;
public Animator animator; //Assign in the Inspector.

void Update()
{
    //...Existing code...

    if (Input.GetKeyDown(KeyCode.Space)) //Vault Input
    {
        Vault();
    }
}

void Vault()
{
    RaycastHit hit;
    Vector3 raycastOrigin = transform.position + Vector3.up * 0.1f; //Slightly above ground

    if (Physics.Raycast(raycastOrigin, transform.forward, out hit, vaultDistance, vaultableLayer))
    {
       //First, check if we can even stand on top of the object we're trying to vault.
       Vector3 vaultTopCheck = hit.point + Vector3.up * vaultHeight;
       if(!Physics.CheckSphere(vaultTopCheck, 0.2f)) //Check if there's anything blocking our vault *on top*
       {
           StartCoroutine(VaultAnimation(hit.point));
       } else {
           Debug.Log("Vault blocked above!");
       }


    } else {
        Debug.Log("Nothing to vault!");
    }
}

IEnumerator VaultAnimation(Vector3 vaultPoint)
{
    animator.SetTrigger("Vault"); // Trigger the vault animation. Assumes you have a Vault animation
    //Disable CharacterController during animation
    controller.enabled = false;
    float animationDuration = 1.0f; //Placeholder, get actual animation length if possible.

    float timeElapsed = 0;
    Vector3 startPosition = transform.position;
    Vector3 endPosition = vaultPoint + Vector3.up * vaultHeight;

    while (timeElapsed < animationDuration)
    {
      float t = timeElapsed / animationDuration;
      //Smooth Lerp
      transform.position = Vector3.Lerp(startPosition, endPosition, t);
      timeElapsed += Time.deltaTime;
      yield return null;
    }
    transform.position = endPosition; //Snap just in case.
    controller.enabled = true;
}

Actionable Insight: The vaultableLayer allows you to selectively designate which objects are vaultable. Set up a new Layer in Unity (e.g., “Vaultable”) and assign it to any objects you want the player to be able to vault over.

Animation Notes: Ensure your Animator Controller has a “Vault” trigger and a corresponding animation. The coroutine simply moves the character to the end position, but in a real game you’d use root motion.

The Coroutine and The CharacterController: It is very important to disable the CharacterController when playing animations that move your character. If you don’t you can end up with unexpected results!

Wall Running: A Matter of Perspective

Wall running is more complex. We’ll use raycasts to detect walls and apply a “wall run” force.

public float wallRunDistance = 0.7f;
public float wallRunGravity = 2f;
public float wallRunSpeedMultiplier = 1.2f;

bool isWallRunning = false;

void Update()
{
    //...Existing Code...

    if (!controller.isGrounded)
    {
        CheckWallRun();
    } else {
        isWallRunning = false;
    }
}

void CheckWallRun()
{
    RaycastHit hitLeft;
    RaycastHit hitRight;

    bool wallLeft = Physics.Raycast(transform.position, -transform.right, out hitLeft, wallRunDistance);
    bool wallRight = Physics.Raycast(transform.position, transform.right, out hitRight, wallRunDistance);

    if (wallLeft || wallRight)
    {
        StartWallRun(wallLeft ? hitLeft : hitRight);
    }
    else
    {
        StopWallRun();
    }
}

void StartWallRun(RaycastHit hit)
{
    isWallRunning = true;

    //Rotate Character Towards wall.
    transform.rotation = Quaternion.LookRotation(-hit.normal);

    //Apply WallRun Gravity
    velocity.y -= wallRunGravity * Time.deltaTime;

    //Increase speed
    controller.Move(transform.forward * moveSpeed * wallRunSpeedMultiplier * Time.deltaTime);
}

void StopWallRun()
{
    isWallRunning = false;
    //Reset rotation gradually
    transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.identity, Time.deltaTime * 5);
}

Challenge: Preventing “sticky” wall runs. The player shouldn’t get glued to the wall. Solution: Implement a timer. After a short duration, automatically stop the wall run, forcing the player to jump off.

Maintaining Player Control

The biggest challenge is maintaining control during parkour moves. Disabling the Character Controller during animations allows for smoother transitions and prevents the controller from interfering with the movement. You should also disable player input during animations.

Example:

bool isPerformingAction = false;

void Vault()
{
   if (!isPerformingAction)
   {
       isPerformingAction = true;
       RaycastHit hit;
       Vector3 raycastOrigin = transform.position + Vector3.up * 0.1f; //Slightly above ground

       if (Physics.Raycast(raycastOrigin, transform.forward, out hit, vaultDistance, vaultableLayer))
       {
          //First, check if we can even stand on top of the object we're trying to vault.
          Vector3 vaultTopCheck = hit.point + Vector3.up * vaultHeight;
          if(!Physics.CheckSphere(vaultTopCheck, 0.2f)) //Check if there's anything blocking our vault *on top*
          {
              StartCoroutine(VaultAnimation(hit.point));
          } else {
              Debug.Log("Vault blocked above!");
              isPerformingAction = false; //Important: Reset the flag.
          }


       } else {
           Debug.Log("Nothing to vault!");
           isPerformingAction = false; //Important: Reset the flag.
       }
   } else {
       Debug.Log("Already performing an action.");
   }
}

IEnumerator VaultAnimation(Vector3 vaultPoint)
{
    //Disable CharacterController during animation
    controller.enabled = false;
    float animationDuration = 1.0f; //Placeholder, get actual animation length if possible.

    float timeElapsed = 0;
    Vector3 startPosition = transform.position;
    Vector3 endPosition = vaultPoint + Vector3.up * vaultHeight;

    while (timeElapsed < animationDuration)
    {
      float t = timeElapsed / animationDuration;
      //Smooth Lerp
      transform.position = Vector3.Lerp(startPosition, endPosition, t);
      timeElapsed += Time.deltaTime;
      yield return null;
    }
    transform.position = endPosition; //Snap just in case.
    controller.enabled = true;
    isPerformingAction = false; //Re-enable
}

void Update()
{
    if (!isPerformingAction)
    {
        // Ground check (simplified for brevity)
        bool isGrounded = controller.isGrounded;
        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f; // Reset velocity when grounded
        }

        // Movement input
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");

        Vector3 move = transform.right * x + transform.forward * z;
        controller.Move(move * moveSpeed * Time.deltaTime);

        // Gravity
        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);

        if (Input.GetKeyDown(KeyCode.Space)) //Vault Input
        {
            Vault();
        }
    }
}

Real-World Applications

Imagine a sprawling city environment where players seamlessly flow between rooftops, using vaulting to clear obstacles, wall running to navigate tight spaces, and sliding under low barriers. This system could also be modified for games with stealth mechanics, such as quickly climbing and dropping down to avoid being seen.

Final Thoughts

Building a parkour system in Unity with the Character Controller demands precision, attention to detail, and a willingness to experiment. Start simple, iterate often, and focus on making each movement feel responsive and satisfying. Don’t settle for canned animations; empower your players with a system that reacts dynamically to their environment. The result will be a parkour system that is not only fun to play but also feels genuinely empowering.