Creating an FPS controller based on Rigidbody
07:20 02 Jan 2026

everyone!

I am trying to make my own FPS controller based on Rigidbody, with “isKinematic” enabled. I am also trying to replicate the same controller mechanics found in games such as Amnesia, Penumbra, Portal, Half-Life, and so on.

The thing is, I don't understand how they work, and my algorithms didn't work. If anyone has done something similar and successfully created a controller, please tell me how you implemented your controller.

Now a little about how I tried to implement it myself:

1. I have two systems - the player state machine and the “Player Motor” service. In the state machine, I simply call the service methods.

// Grounded state
public override void FixedTick()
    {
        base.FixedTick();

        Vector3 inputMove = _inputReader.Move;
        (Vector3 forward, Vector3 right) = _motor.GetPlayerDirs();

        Vector3 desiredMove = forward * inputMove.y + right * inputMove.x;

        _motor.Move(desiredMove * SPEED * Time.fixedDeltaTime);

        if (!_motor.IsGrounded || _inputReader.IsJumping)
        {
            _stateMachine.SwitchState();
            return;
        }
    }

// Airborne state
public override void FixedTick()
    {
        base.FixedTick();

        _verticalVelocity += GRAVITY * Time.fixedDeltaTime;

        Vector3 inputMove = _inputReader.Move;
        (Vector3 forward, Vector3 right) = _motor.GetPlayerDirs();

        Vector3 offsetMove = (forward * inputMove.y + right * inputMove.x).normalized;

        Vector3 downMove = Vector3.down;

        _motor.Move(downMove * _verticalVelocity * Time.fixedDeltaTime + offsetMove * 2f * Time.fixedDeltaTime);

        if (_motor.IsGrounded)
        {
            _stateMachine.SwitchState();
            return;
        }
    }

2. In the service, everything is much more complicated. In general terms, I check whether there is ground underfoot (this is necessary for switching states in the state machine), and then I edit the player's position vector so that they do not climb on surfaces with a slope greater than a certain degree, and are pulled to the ground if the distance between the ground and the player is acceptable.

public void Move(Vector3 desiredMove)
    {
        CheckGround();


        Vector3 position = _playerRigidbody.position;
        position = MoveWithSlide(position, desiredMove);
        position = SnapToGround(position);


        _playerRigidbody.MovePosition(position);
    }

3. I check the ground using SphereCast — nothing complicated here, see the code below.

private void CheckGround()
    {
        float halfHeight = _playerCollider.height * 0.5f;
        float radius = _playerCollider.radius;
        float dist = halfHeight - radius + PROBE_DOWN;


        Vector3 center = _playerCollider.transform.TransformPoint(_playerCollider.center);


        Ray sphereRay = new Ray(center, Vector3.down);
        bool isSupported = Physics.SphereCast(sphereRay, radius, dist,
                                              _worldMask, QTI);


        _isGrounded = isSupported;
    }

4. Collision detection is more complicated: here I check if there is a slope in front of the object, and if so, we move to the point of collision with it. If the slope has a normal degree for us, we also check how far we can move along it, otherwise we treat it as a wall.

private Vector3 MoveWithSlide(Vector3 position, Vector3 desiredMove)
    {
        float desiredDist = desiredMove.magnitude;
        Vector3 desiredDir = desiredMove / desiredDist;


        GetCapsulePoints(position, out Vector3 p1, out Vector3 p2, out float radius);


        if(!Physics.CapsuleCast(p1, p2, radius, desiredDir,
                                out RaycastHit hit,
                                desiredDist + SKIN,
                                _worldMask, QTI))
        {
            return position + desiredMove;
        }


        float travel = Mathf.Max(0f, hit.distance - SKIN);
        position += desiredDir * travel;


        Vector3 left = desiredMove - desiredDir * travel;


        Vector3 slide;


        if(CheckSlopeAngle(hit.normal))
        {
            slide = Vector3.ProjectOnPlane(left, hit.normal);
        }
        else
        {
            Vector3 horiz = Vector3.ProjectOnPlane(left, Vector3.up);


            Vector3 wallNormal = Vector3.ProjectOnPlane(hit.normal, Vector3.up);


            if (wallNormal.sqrMagnitude < EPS)
                return position;


            wallNormal.Normalize();


            slide = Vector3.ProjectOnPlane(horiz, wallNormal);
        }


        float slideDist = slide.magnitude;
        if (slideDist < EPS)
            return position;


        Vector3 slideDir = slide / slideDist;


        GetCapsulePoints(position, out Vector3 sp1, out Vector3 sp2, out float sradius);


        if (Physics.CapsuleCast(sp1, sp2, sradius, slideDir,
                                out RaycastHit shit,
                                slideDist + SKIN, _worldMask, QTI))
        {
            float md2 = Mathf.Max(0f, shit.distance - SKIN);
            return position + slideDir * md2;
        }


        return position + slide;
    }

5. Snapping is also simple: I get the capsule points in a separate method (I won't go into detail, it's unnecessary. I'm sure it works correctly), and I cast the capsule down, checking if snapping is possible.

private Vector3 SnapToGround(Vector3 position)
    {
        GetCapsulePoints(position, out Vector3 p1, out Vector3 p2, out float radius);


        if (Physics.CapsuleCast(p1, p2, radius, Vector3.down,
                                out RaycastHit hit,
                                SNAP_DOWN_DIST + SKIN,
                                _worldMask, QTI)
            && CheckSlopeAngle(hit.normal))
        {
            float down = hit.distance - SKIN;
            position += Vector3.down * down;


            _isGrounded = true;
        } 


        return position;
    }

In general, it works well, but problems arise in specific cases. In games, as long as we don't completely leave the surface, we won't fall at all. But my controller starts to “dangle,” and I don't like that.

dangle in my game

I know that the problem lies in the casts. If I did a regular Raycast, the surface normal would be returned correctly, and then the surface collision would be like in normal games. But I had some problems that I don't remember anymore, to be honest (sorry, I've been working on this controller for a week now).

There was an idea to replace CapsuleCast and SphereCast with rays that go in a circle, with the radius of the player's capsule, downwards, and thus search for the normal for projection. But I'm not sure if that's a good idea.

I would be grateful to anyone who can help!

c# unity-game-engine