Character sliding off edge, any way to fix without rewriting the entire code?

I know this is a common issue and while searching for solutions every single one of’em seem to be so complicated… Is there any simple way of fixing this please? I’m using a Capsule Collider (3D), Rigidbody and here is my full movement code if you wanna take a look at it.

using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{

    private PlayerInput _inputManager;
    private InputAction _inputAction;


    [Header("Public References")]
    public LayerMask groundLayer;

    public PhysicsMaterial defaultMat;
    public PhysicsMaterial slopeMat;



    [Header("Movement ")]
    public float speed;

    public float airMultiplier;


    [Header("Jumping")]


    public float gravityValue;
    public float jumpForce;
    public float jumpCooldown;
    bool _readyToJump;

    [Header("Slope Handling")]

    Vector3 moveDirection;
    public float maxSlopeAngle;
    private RaycastHit _slopeHit;
    public float playerHeight;



    [Header("Raycast")]

    public float downForce;
    public float sphereDistance;
    public float radius = 5f;
    public float distance = 1.0f;
    private const string _horizontal = "Horizontal";
    private const string _vertical = "Vertical";
    private const string _lastVertical = "LastVertical";
    private const string _lastHorizontal = "LastHorizontal";


    private Rigidbody _rb;
    private CapsuleCollider _capsuleCollider;
    private Animator _animator;

    private bool isSloped()
    {
        //Cast a ray downwards to detect the slope's angle.
        if (Physics.Raycast(transform.position, Vector3.down, out _slopeHit, playerHeight))
        {
            float angle = Vector3.Angle(Vector3.up, _slopeHit.normal);
            return angle < maxSlopeAngle && angle != 0;
        }

        return false;

    }

    private Vector3 GetSlopeMoveDirection()
    {
        //Projects a Vector3 following the slope's angle
        return Vector3.ProjectOnPlane(_rb.linearVelocity.normalized, _slopeHit.normal).normalized;
    }
    void Start()
    {

        _capsuleCollider = GetComponentInChildren<CapsuleCollider>();
        _readyToJump = true;
        _animator = GetComponent<Animator>();
        _rb = GetComponent<Rigidbody>();
        _inputManager = GetComponent<PlayerInput>();
        _inputAction = _inputManager.actions.FindAction("Move");

    }

    private void Jump(InputAction.CallbackContext context)
    {

        //If you are holding the jump key, you are grounded and ready to jump
        if (context.performed && isGrounded() && _readyToJump)
        {


            _rb.linearVelocity = new Vector3(_rb.linearVelocity.x, jumpForce, _rb.linearVelocity.z);

            Invoke(nameof(ResetJump), jumpCooldown);

        }

        //If you stop pressing the jump key as long as you are jumping. Gotta tweak this a little bit because of the "isGrounded()" part it dont work really well
        if (context.canceled && _rb.linearVelocity.y > 0 && isGrounded())
        {

            _readyToJump = false;

            _rb.linearVelocity = new Vector3(_rb.linearVelocity.x, jumpForce * 0.5f, _rb.linearVelocity.z);
            Invoke(nameof(ResetJump), jumpCooldown);

        }

    }
    private void MovePlayer()
    {

        Vector2 _inputValue = _inputAction.ReadValue<Vector2>();

        //GROUND MOVEMENT
        Vector3 _groundMovement = new Vector3(_inputValue.x * speed * 5, _rb.linearVelocity.y, _inputValue.y * speed * 5) * Time.deltaTime;


        //AIR MOVEMENT
        Vector3 _airMovement = new Vector3(_inputValue.x * speed * 5 * airMultiplier, _rb.linearVelocity.y, _inputValue.y * speed * 5 * airMultiplier) * Time.deltaTime;

        //SLOPE MOVEMENT
        Vector3 _slopeMovement = new Vector3(GetSlopeMoveDirection().x * speed * 5, _rb.linearVelocity.y, GetSlopeMoveDirection().z * speed * 5) * Time.deltaTime;



        //Manage player's walking animations
        _animator.SetFloat(_horizontal, _inputValue.x);
        _animator.SetFloat(_vertical, _inputValue.y);
        if (_inputValue != Vector2.zero)
        {
            _animator.SetFloat(_lastHorizontal, _inputValue.x);
            _animator.SetFloat(_lastVertical, _inputValue.y);
        }




        //Handle both air movement and ground movement
        if (isGrounded())
        {
            _rb.linearVelocity = new Vector3(_groundMovement.x, _rb.linearVelocity.y, _groundMovement.z);
        }

        //If is on the ground and on the slope
        else if (isGrounded() && isSloped())
        {

            _rb.linearVelocity = new Vector3(_slopeMovement.x, _rb.linearVelocity.y, _slopeMovement.z);

        }


        //If is not grounded
        else
        {

            _rb.linearVelocity = new Vector3(_airMovement.x, _rb.linearVelocity.y, _airMovement.z);

        }

        //Handle (fake) gravity for when the player is falling
        if (_rb.linearVelocity.y < 0)

        {
            _rb.AddForce(Vector3.down * gravityValue, ForceMode.Impulse);

        }


    }

    void Update()
    {
        if (isSloped())
        {
            _capsuleCollider.material = slopeMat;
        }
        else
        {
            _capsuleCollider.material = defaultMat;
        }


        Debug.DrawRay(transform.position, -Vector3.up * distance, Color.blue);

        HandleDirection();
    }


    public void HandleDirection()
    {


        //This whole code is a complete mess, but with it I can sorta get the player's facing direction
        if (_rb.linearVelocity.z > 1)
        {
            Debug.DrawRay(transform.position, Vector3.forward, Color.green);
            moveDirection = Vector3.forward;
        }
        else if (_rb.linearVelocity.z < 0)
        {
            Debug.DrawRay(transform.position, -Vector3.forward, Color.green);
            moveDirection = -Vector3.forward;
        }
        else if (_rb.linearVelocity.x > 1)
        {
            moveDirection = Vector3.right;
            Debug.DrawRay(transform.position, Vector3.right, Color.green);
        }
        else if (_rb.linearVelocity.x < 1)
        {
            moveDirection = -Vector3.right;
            Debug.DrawRay(transform.position, -Vector3.right, Color.green);
        }
    }
    void ResetJump()
    {
        _readyToJump = true;

    }
    void FixedUpdate()
    {
        MovePlayer();


    }

    void LateUpdate()
    {

    }

    public bool isGrounded()
    {

        RaycastHit hit;
        Physics.Raycast(transform.position, -Vector3.up, out hit, distance);


        if (hit.collider != null)
        {
            Debug.Log("is Grounded");
            return true;
        }

        return false;
    }
}

Thanks in advance!

I would imagine if the player is grounded, you then ensure there is no downward velocity that would cause the spherical underside to slide off of colliders.

Testing groundedness would probably involve a overlap sphere roughly the same area as the bottom of the capsule collider.

That said, is a rigidbody driven character controller here? The CharacterController component doesn’t have this issue.

Yes this is a Rigidbody character controller. I’ve heard lots of bad thing about the Character Controller and most of tutorials/guides around seem to use the Rigidbody, so that’s why I chose it. Also, I already have some experience with the rigidbody from more basic projects.

Can you elaborate on the “ensuring there is no downward velocity”? You mean I should set _rb.linearVelocity to 0 as long as the player is grounded?
Thanks for the help!

EDIT: I tried setting the y velocity to 0 as long as the player is grounded but that causes new issues as well as not solving the sliding off edges thingy, since the GroundCheck only happens with a ray beneath the player, the spherical underside is usually not grounded when it starts sliding off

Which is why I said:

Hi there,

What you’re experiencing is a byproduct of using a capsule-shaped collider for your character controller. The reason why most games rely on a capsule-shaped collider, is because it’s actually a very versatile shape: it can handle well uneven terrains, flat terrains, and going up (simple) stairs.

However, if left as is, this approach quickly falls apart with some games, such as most platformers, where, well, you’ll spend a lot of time moving around a mostly flat world, with lots of sharp edges, which this capsule-shaped collider has trouble with, since by default, it won’t give you the behavior most platformer’s character controllers have.

There are a few things you can do, the easiest being to swap your capsule-shaped collider for a box-shaped one. But that can come with some issues, such as frequently getting stuck, depending on your environment. Another thing you could do, is to dynamically change colliders depending on your surroundings using raycasting (a boxcast could work well in this case).

You can take a look at this question, on the Game Development Stack Exchange community, which provides some insightful answers.

Important sidenote, there are lots of edge cases to take into account, which makes whatever approach you choose complicated by default, and is the reason why every solution you have found is so complex.