FPS controller slope movement edge case fix

Hi

I’m working on a Rigidbody-based first person character controller.
I’ve gotten the character to move nicely on slopes already, but there are a few cases related to exiting and entering slopes, where the movement could use some work.
I’ve also decided to not use a raycast/sphere check to detect if the player is grounded or not because I believe that way of doing ground checks is imprecise and there’s just a whole bunch of edge cases where the ground might not be detected. I’m checking the ground and its normals by looping through the contacts of the player’s collider in OnCollisionStay.

The issue is that the character is not “sticking” to the ground when a slope suddenly turns into a flat surface, the same goes for if the flat surface suddenly turns into a slope. This results in the player getting “launched” in the air. And at this point, when the player is being launched, no contacts are being registered in OnCollisionStay either.

ScreenRecording2025-06-20183350-ezgif.com-video-to-gif-converter

I’ve tried to force the player to the ground by adding some force when the launch happens, though this results in sudden, non-smooth movement, and I’d rather not do it like that.

My movement code:

using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    [Header("References")]
    public Rigidbody rb;
    public Transform orientation;
    public CapsuleCollider capsuleCollider;

    [Header("Movement")]
    public float walkSpeed = 3.5f;
    public float groundDrag = 7f;
    public float jumpForce = 5f;
    public float airMoveMultiplier = 0.4f;
    public float maxSlopeAngle = 45f;

    private Vector2 inputVector;
    private bool jumpInput;

    private float moveSpeed;

    private bool grounded;
    private GameObject currentGroundObject;
    private Vector3 currentGroundNormal = Vector3.up;

    private void Start()
    {
        moveSpeed = walkSpeed;
    }

    private void Update()
    {
        UpdateInput();
        SpeedControl();

        if (jumpInput && grounded)
        {
            Jump();
        }
    }

    private void FixedUpdate()
    {
        Movement();
    }

    private void UpdateInput()
    {
        //create input vector
        inputVector = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
        inputVector.Normalize();

        //jump input
        jumpInput = Input.GetKeyDown(KeyCode.Space);
    }

    private void Movement()
    {
        //calculate movement direction
        Vector3 moveDirection = orientation.forward * inputVector.y + orientation.right * inputVector.x;

        //on slope
        if (currentGroundNormal != Vector3.up)
        {
            rb.useGravity = false;
        }
        else
        {
            rb.useGravity = true;
        }

        //add force
        if (grounded)
        {
            rb.AddForce(AdjustDirectionToSlope(moveDirection, currentGroundNormal) * moveSpeed * 10f, ForceMode.Force);
        }
        else
        {
            rb.AddForce(moveDirection * moveSpeed * 10f * airMoveMultiplier, ForceMode.Force);
        }

        Debug.DrawRay(transform.position, AdjustDirectionToSlope(moveDirection, currentGroundNormal), Color.red);
    }

    private void SpeedControl()
    {
        //apply drag
        if (grounded)
        {
            rb.linearDamping = groundDrag;
        }
        else
        {
            rb.linearDamping = 0f;
        }

        if (currentGroundNormal != Vector3.up)
        {
            //limit speed on slope
            if (rb.linearVelocity.magnitude > moveSpeed)
            {
                rb.linearVelocity = rb.linearVelocity.normalized * moveSpeed;
            }
        }
        else
        {
            //limit speed on flat ground
            Vector3 flatVel = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);

            if (flatVel.magnitude > moveSpeed)
            {
                Vector3 limitedVel = flatVel.normalized * moveSpeed;
                rb.linearVelocity = new Vector3(limitedVel.x, rb.linearVelocity.y, limitedVel.z);
            }
        }
    }

    private void Jump()
    {
        //reset y velocity then jump
        rb.linearVelocity = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);

        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
    }

    private Vector3 AdjustDirectionToSlope(Vector3 direction, Vector3 normal)
    {
        if (grounded)
        {
            //prevent shifting from just using ProjectOnPlane
            Vector3 movementProjectedOnPlane = Vector3.ProjectOnPlane(direction, normal);
            Vector3 axisToRotateAround = Vector3.Cross(direction, Vector3.up);
            float angle = Vector3.SignedAngle(direction, movementProjectedOnPlane, axisToRotateAround);
            Quaternion rotation = Quaternion.AngleAxis(angle, axisToRotateAround);

            return (rotation * direction).normalized;
        }
        return direction;
    }

    private bool IsFloor(Vector3 v)
    {
        //compare surface normal to max slope angle
        float angle = Vector3.Angle(Vector3.up, v);
        return angle <= maxSlopeAngle;
    }

    private void OnCollisionStay(Collision collision)
    {
        //go through contacts and check if we are on the ground
        foreach (ContactPoint contact in collision.contacts)
        {
            //this is a valid floor
            if (IsFloor(contact.normal))
            {
                grounded = true;
                currentGroundObject = contact.otherCollider.gameObject;
                currentGroundNormal = contact.normal;
                return;
            }
            else if (currentGroundObject == contact.otherCollider.gameObject)
            {
                grounded = false;
                currentGroundObject = null;
                currentGroundNormal = Vector3.up;
            }
        }
    }

    private void OnCollisionExit(Collision collision)
    {
        //check if we left the ground
        if (collision.gameObject == currentGroundObject)
        {
            grounded = false;
            currentGroundObject = null;
            currentGroundNormal = Vector3.up;
        }
    }
}

Thanks!

I like realistic physics and so I would be totally fine with jumping at the top of a ramp. Some games like Quake for example prefer not to take off at the top of ramps. I’ve never tried pinning a dynamic rigidbody to the ground but perhaps it’s possible to check the ground ahead and add a constant downwards (ground aligned) force to prevent it from taking off. This is what the BMX riders would do when racing over jumps and they didn’t want to leave the ground because it would mean losing speed because they can’t pedal in the air.

BTW - adding the force after the character has left the ground is too late.

If you can’t get it work with a dynamic rigidbody then you may need to switch to a kinematic rigidbody so then you’ll have full control over it. I hear there’s some good kinematic controllers on the asset store.

I’m trying my best to make a very “pure” physics based controller. This slope stuff is where I’m willing to cheat a bit, for example sticking the player to the ground by not using physics directly. And yeah, I agree you on the liking realistic physics part, though I’m trying my best to make this controller versatile and being able to be customized to your preference.

Good for a learning experience. Not so good because it’s reinventing the wheel (sometimes called “not my code” syndrome).

Either way, you should check out Kinematic Character Controller. It’s an excellent controller yet managable in its complexity with less than 3,000 lines of code (yes, that is “simple” for a character controller) and plenty of edge cases handled. At least to see what you have ahead of you, and possibly how to solve it.

Slopes is but one of them challenges, usually the first most people encounter. But then … a sloped ceiling appears. Then a bumpy surface appears. Then two sloped planes appear. Then we have stairs appearing. Then … incoming: a moving platform. Which also rotates. And they’re all trying to fight you. And they never surrender. You think you have the stairs solved? Try the slope again.

Rule of thumb: character controllers are hard. Non-Kinematic (physics-based) character controllers are exceptionally hard, and are best left to “whacky” games because so many edge cases one cannot possibly solve. Have you considered what happens to that player if external forces push on it? And whether that is desirable and feels good? And how many extra edge cases this generates?

One thing I noticed: You’re already doing some physics updates during Update, this can mess with the character’s behaviour. During Update consider all Rigidbody properties and methods off-limits. Consider that Update runs one or more times between a FixedUpdate, while FixedUpdate may run several times per Update if the framerate is low.

I would definitely split a character controller into three separate pieces: input handling, visual updates (interpolation, animation, etc), and actual controller (movement, rotation, collision). Because if you entangle one of them with the other in the wrong order, it can get nasty. Processing should be in this order: Input => (Physics) => Visuals .. where (Physics) runs on a different update loop ie FixedUpdate which both Input and Visuals need to take into account.

You will also need to enable Rigidbody interpolation since you’re likely going to have the camera follow that character. Best to do this now so you get to see the jitter effects and resolve any camera latency issues.

Character controllers can be fun to work on .. for some. But if it’s a game you want to make, that time and effort is better spent elsewhere. You have just seen the tip of the iceberg. :wink:

I just realized something. How are you handling steps/stairs?. Steps can be tricky for rigidbody controllers but one good method is to make the rigidbody hover slightly above the ground so then it smoothly glides over small obstacles including steps. So using this method could also be applied to your slope situation and your rigidbody could be made to aggressively hover above the ground resulting in it never taking off on slopes.

I’m making this controller with a specific game idea in mind, I don’t have the stepping up/down stairs implemented and I probably don’t even need that. I’m planning on stairs to appear in the game but they will probably just have an invisible sloped collider on them. I’m not even a huge fan of how that kind of stepping looks in game.

Okay I’ve had a look at this and simply projecting the velocity onto the ground seems to work quite well.

    void FixedUpdate()
    {
        if (Physics.SphereCast(transform.position, 0.5f, -transform.up, out RaycastHit hit, 0.6f))
        {
            rb.velocity = Vector3.ProjectOnPlane(rb.velocity, hit.normal);
            Vector3 force = (transform.right * Input.GetAxisRaw("Horizontal") + transform.forward * Input.GetAxisRaw("Vertical")).normalized * 20;
            force = Vector3.ProjectOnPlane(force, hit.normal);
            rb.AddForce(force);
        }
    }