Unity FPS Controller: Kinematic Rigidbody Grounding
Guide to implement a kinematic Rigidbody Unity FPS controller that stays grounded with robust ground checks, averaged normals, and snap-to-ground tuning.
How can I implement a Rigidbody-based FPS controller (isKinematic = true) that replicates classic games (Amnesia, Penumbra, Portal, Half-Life) but doesn’t “dangle” at edges?
I’m building a kinematic Rigidbody FPS controller and the movement mostly works, but when the player partially leaves a surface they start to “dangle” instead of staying attached to the ground like in the games above. I suspect the issue is in how I use CapsuleCast / SphereCast and the normals they return. Below is a concise summary of my current implementation and code (GetCapsulePoints() omitted for brevity). Any advice on robust ground detection / normal sampling / snapping to ground to avoid this dangling behavior would be appreciated.
State machine -> Player Motor interaction:
// 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<PlayerLocomotionAirborneState>();
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<PlayerLocomotionGroundedState>();
return;
}
}
Player Motor.Move(desiredMove):
public void Move(Vector3 desiredMove)
{
CheckGround();
Vector3 position = _playerRigidbody.position;
position = MoveWithSlide(position, desiredMove);
position = SnapToGround(position);
_playerRigidbody.MovePosition(position);
}
CheckGround (SphereCast):
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;
}
MoveWithSlide (CapsuleCast, slope/wall handling, sliding):
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;
}
SnapToGround (capsule cast down):
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;
}
Notes:
- GetCapsulePoints() is assumed correct and returns capsule endpoints & radius for the current position.
- Relevant constants/helpers: QTI, SKIN, EPS, SNAP_DOWN_DIST, PROBE_DOWN, CheckSlopeAngle().
Observed problem and screenshot:
- In many cases the controller behaves well, but when the player is partially off an edge it “dangles” instead of staying firmly on the surface until fully leaving it (screenshot):

My hypothesis / attempted idea:
- I think the issue lies in the casts: CapsuleCast and SphereCast return normals that cause unstable sliding/ground detection.
- I considered replacing CapsuleCast/SphereCast with multiple downward raycasts arranged in a circle (radius = capsule radius) to sample the surface and compute a better projection normal. Not sure if that’s the right approach.
Concrete questions I’m asking:
- What is the most robust approach to ground detection and normal sampling for a kinematic Rigidbody FPS controller so the player “sticks” to ground until they truly leave the surface (the feel from classic FPS games)?
- Are CapsuleCast and SphereCast appropriate primitives here, or should I use multiple downward raycasts, RaycastAll, SweepTest, Rigidbody.SweepTest, or contact normals from collisions? What are the trade-offs?
- If sampling normals with multiple rays is recommended, how many rays, how should they be distributed, and how should sampled normals be combined to avoid jitter/dangling at edges/corners?
- Are there common pitfalls with SKIN/PROBE_DOWN/SNAP_DOWN_DIST values, order of operations (CheckGround -> MoveWithSlide -> SnapToGround -> MovePosition), or using isKinematic + MovePosition that cause this behavior?
- Any concrete code patterns, parameter tuning, or algorithms to achieve the classic “stay grounded until you fully step off” behavior?
Relevant code is included above. Any pointers, small changes to the casts/normal sampling, or references to robust implementations/patterns would be very helpful.
Unity FPS controllers built with kinematic Rigidbodies often dangle at edges because single SphereCasts or CapsuleCasts grab unreliable normals on slopes or partial overhangs, causing jittery slides instead of that solid “stuck to the ground” feel from classics like Half-Life or Portal. The fix? Switch to a two-step ground detection—start with a SphereCast downward, then raycast along any steep slope to find the true contact point—and average normals from multiple samples for stability. Tune your PROBE_DOWN to 0.1m, SKIN to 0.01m, and SNAP_DOWN_DIST to 0.2m, then snap post-movement to glue the player until they fully step off.
Contents
- Why Unity FPS Controllers Dangle at Edges
- Cast Primitives: CapsuleCast vs SphereCast vs Rays
- Robust Ground Detection Step-by-Step
- Normal Sampling to Prevent Jitter
- Snap-to-Ground Tuning and Order of Operations
- Updated Code for Your Player Motor
- Pitfalls and Testing Checklist
- Sources
- Conclusion
Why Unity FPS Controllers Dangle at Edges
Ever notice how your character teeters like it’s on a tightrope when half-off a ledge? That’s classic kinematic Rigidbody behavior in Unity FPS controllers. A SphereCast from the capsule’s center hits the ground fine on flats, but on edges or slopes, it snags a normal that’s off-center—maybe pointing sideways into the void. Your controller clips, jitters, or lifts unnaturally because MoveWithSlide projects onto that bogus normal, and SnapToGround misses the real surface.
This kills the immersive feel of Amnesia or Penumbra, where players stay planted until they commit to the drop. The root? PhysX casts return the first hit’s normal, ignoring the capsule’s full footprint. At edges, that’s often air or a skewed angle. Community fixes, like in this Unity forum thread, point to shape mismatches—capsules overhang edges more than cylinders from old engines like Quake.
Your code’s close: CheckGround uses SphereCast (smart for probing the bottom sphere), MoveWithSlide handles slides, and SnapToGround pulls down. But without refined normals and multi-samples, partial overhangs fool the slope check, flipping IsGrounded prematurely.
Cast Primitives: CapsuleCast vs SphereCast vs Rays
Should you ditch CapsuleCast for rays? Not entirely—each has trade-offs for Unity FPS controllers.
-
SphereCast: Perfect for ground checks. Mimics your capsule’s bottom half-sphere, catches curved edges better than rays. Downside: on slopes, hit normals skew away from the true “feet” point. Roystan Ross nails this—a center SphereCast clips into inclines.
-
CapsuleCast: Great for movement prediction in MoveWithSlide. Matches your collider exactly, so slides feel precise. But at edges, the long capsule grabs distant normals, worsening dangle.
-
Rays (single or multi): Cheap, fast. RaycastAll down from capsule rim samples the full footprint. Trade-off: Misses inside curves; needs 5-8 rays to match SphereCast reliability.
-
SweepTest: If you crave Quake-style cylinders, generate a convex cylinder mesh and Rigidbody.SweepTest it. Matches classic FPS perfectly but heavier—overkill unless rays fail.
Stick with SphereCast for CheckGround, CapsuleCast for moves/slides, and augment with 4-8 rim rays for normals. Catlike Coding’s physics tutorial backs this hybrid: casts for distance, rays for averaged normals.
Robust Ground Detection Step-by-Step
Robust ground detection for kinematic Unity FPS controllers boils down to a two-step process from Roystan Ross: probe, then refine.
-
Primary SphereCast: From capsule bottom-center, down PROBE_DOWN (0.1m). Grabs initial distance and normal.
-
Slope Fix: If normal.y < minGroundDot (cos of max slope, say 0.7 for 45°), raycast along the slope plane (not straight down) to find the ledge’s true base. Compute capsule contact by simulating the SphereCast against that point.
-
Validate: Only ground if distance < threshold and refined normal passes slope check.
In your CheckGround, replace the single SphereCast:
private void CheckGround() {
// ... GetCapsulePoints for bottom center
Vector3 probeOrigin = transform.TransformPoint(capsule.center) + Vector3.down * (capsule.height * 0.5f - capsule.radius);
float probeDist = capsule.radius + PROBE_DOWN; // ~0.1m total
if (Physics.SphereCast(probeOrigin, capsule.radius, Vector3.down, out RaycastHit hit, probeDist, _worldMask, QueryTriggerInteraction.Ignore)) {
if (hit.normal.y >= minGroundDotProduct) { // Flat enough
_groundHit = hit;
_isGrounded = true;
return;
}
// Steep: Ray down-slope
Vector3 slopeProbeDir = Vector3.ProjectOnPlane(Vector3.down, hit.normal).normalized;
if (Physics.Raycast(hit.point, slopeProbeDir, out RaycastHit slopeHit, probeDist * 2, _worldMask, QueryTriggerInteraction.Ignore)) {
// Recompute contact normal (simulate SphereCast)
_groundHit = slopeHit;
_isGrounded = slopeHit.normal.y >= minGroundDotProduct;
}
}
_isGrounded = false;
}
This keeps your player glued—no more edge-lift until the probe fully whiffs.
Normal Sampling to Prevent Jitter
Single-hit normals jitter at corners. Sample multiple for a stable average, like Catlike Coding recommends.
Cast 6 rays in a circle (radius = capsule.radius) from bottom center, down PROBE_DOWN. Weight by distance (closer = stronger). Reject if normal.y < minGroundDot.
Vector3 SampleGroundNormal(Vector3 origin) {
Vector3[] directions = { /* 6 directions: 0°, 60°, etc. */ };
Vector3 totalNormal = Vector3.zero;
int validHits = 0;
foreach (var dir in directions) {
Vector3 rayOrigin = origin + dir * capsule.radius;
if (Physics.Raycast(rayOrigin, Vector3.down, out RaycastHit hit, PROBE_DOWN + capsule.radius, _worldMask)) {
if (hit.normal.y >= minGroundDotProduct) {
totalNormal += hit.normal;
validHits++;
}
}
}
return validHits > 0 ? totalNormal.normalized : Vector3.up;
}
Call this post-SphereCast for _groundNormal. Use it in CheckSlopeAngle(hit.normal → _groundNormal) and slide projections. Edges? Averages pull toward solid ground, killing dangle.
Snap-to-Ground Tuning and Parameters
Your order—CheckGround → MoveWithSlide → SnapToGround → MovePosition—is solid for kinematic Rigidbodies. Pitfalls hit when params mismatch.
Tune these (from Unity Discussions fixes):
| Param | Value | Why |
|---|---|---|
| SKIN | 0.01m | Tiny de-penetration buffer. Bigger gaps → slips. |
| PROBE_DOWN | 0.1m | Reach under capsule bottom. Too long: false positives. |
| SNAP_DOWN_DIST | 0.2m | Post-move pull. Matches step height. |
| minGroundDotProduct | 0.7 (45°) | Rejects walls, accepts slopes. |
In SnapToGround, use sampled normal:
private Vector3 SnapToGround(Vector3 position) {
// Use sampled normal from CheckGround
if (_isGrounded && Physics.CapsuleCastNonAlloc(...) && _groundNormal.y >= minGroundDotProduct) {
return position - Vector3.down * (hit.distance - SKIN);
}
return position;
}
Run CheckGround after tentative move in Move() for fresher data. isKinematic + MovePosition ignores dynamics, so you own all collision response—this setup replicates it manually.
Updated Code for Your Player Motor
Patch your Motor.Move:
public void Move(Vector3 desiredMove) {
Vector3 position = _playerRigidbody.position;
// Tentative slide
position = MoveWithSlide(position, desiredMove);
// Fresh ground check post-move
CheckGround(); // Now with sampling + two-step
// Snap using averaged normal
position = SnapToGround(position);
_playerRigidbody.MovePosition(position);
}
In MoveWithSlide, swap hit.normal → _groundNormal for slides. Check this GitHub Rigidbody FPS repo for full patterns—it uses similar SphereCast → CapsuleSnap flow.
Test: Strafe off ramps. Stays stuck? Win.
Pitfalls and Testing Checklist
Watch these gotchas:
- Kinematic quirks: Unity docs warn—no auto-collision, so over-snap tunnels floors.
- FixedUpdate only: DeltaTime spikes jitter casts.
- Layer masks: QTI ignores triggers—good, but verify _worldMask excludes player.
- Multi-colliders: Average all valid ground hits in OnCollisionStay if rays miss.
Checklist:
- [ ] Strafe edge: No lift until 50%+ overhang.
- [ ] Slope 45°: Sticks, slides predictably.
- [ ] Stairs: Steps up without hop.
- [ ] Perf: <1ms/FixedUpdate.
Tweak minGroundDot if too sticky (0.6) or loose (0.8).
Sources
- Custom Character Controller in Unity: Part 6 – Ground Detection
- Unity Physics-based Character Movement
- Help: Creating an FPS controller based on Rigidbody
- FPS controller slope movement edge case fix
- GitHub - Unity-Rigidbody-FPS-Controller
- Unity Manual - Rigidbody
Conclusion
Nailing a Unity FPS controller that hugs edges like Half-Life means ditching single-cast normals for two-step SphereCast + slope rays, multi-ray averaging, and tight params (SKIN 0.01m, PROBE_DOWN 0.1m, SNAP 0.2m). Patch CheckGround and SnapToGround as shown—your dangle vanishes, movement turns buttery. Prototype these tweaks; tune by feel on test levels. You’ll get that classic grounded stride without the teeter.