# 4. Rigidbody Character Implementation
Part of [[Character Controllers]]. Previous: [[3. CharacterController Implementation]]. Next: [[5. Gray-Box Metrics Gym]].
**Goal:** the same metrics and the same feel targets as Part 3, but on a dynamic `Rigidbody` so explosions, joints and physics platforms "just work". Then run both through the gym and compare.
> [!note] Verification status
> `Rigidbody.linearVelocity`, `AddForce(..., ForceMode.VelocityChange)`, `Physics.SphereCastNonAlloc`, `Physics.Raycast`, `RigidbodyInterpolation.Interpolate`, `CollisionDetectionMode.ContinuousSpeculative` and `PhysicsMaterialCombine` are in the 6000.5 reference (checked 2026-11-07). **Not compiled or play-tested.**
## 4.1 Setup
1. Capsule `RBPlayer`: CapsuleCollider (height/radius from metrics, center at half height), **Rigidbody**: mass 70, Interpolate, Collision Detection **Continuous Speculative**, Freeze Rotation X/Y/Z (we turn the body with `MoveRotation` - see note), Use Gravity **off** (we apply metric gravity).
2. Physics Material `PlayerZeroFriction`: dynamic and static friction 0, Friction Combine **Minimum** - stops sticking to walls mid-air.
3. Layers as in Part 3; set `groundMask` to everything the player can stand on.
> [!note] Rotation
> Freezing all rotation and setting `rb.MoveRotation` from script keeps physics from tipping the capsule while still letting you turn. Don't write `transform.rotation` on an interpolated Rigidbody. (Verify in your version that `MoveRotation` still turns a body whose rotation is fully frozen; if not, freeze only X and Z.)
## 4.2 The controller
```csharp
using System;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody), typeof(CapsuleCollider))]
public class RBPlayerController : MonoBehaviour
{
[SerializeField] MovementMetrics metrics;
[SerializeField] InputActionReference move, jump, sprint;
[SerializeField] Transform cameraTransform;
[SerializeField] LayerMask groundMask = ~0;
[SerializeField] float groundProbeExtra = 0.15f;
[SerializeField] float stepCheckDistance = 0.1f;
public event Action Jumped;
public event Action<float> Landed;
public bool Grounded { get; private set; }
public float SpeedMultiplier { get; set; } = 1f;
Rigidbody _rb;
CapsuleCollider _capsule;
readonly RaycastHit[] _hits = new RaycastHit[4];
Vector2 _moveInput;
bool _jumpHeld, _sprintHeld;
float _lastJumpPress = -999f, _lastGrounded = -999f;
bool _isJumping;
Vector3 _groundNormal = Vector3.up;
Rigidbody _groundBody;
Vector3 _externalImpulse; // knockback queued from gameplay
void Awake()
{
_rb = GetComponent<Rigidbody>();
_capsule = GetComponent<CapsuleCollider>();
_capsule.height = metrics.height;
_capsule.radius = metrics.radius;
_capsule.center = new Vector3(0f, metrics.height * 0.5f, 0f);
_rb.useGravity = false;
_rb.interpolation = RigidbodyInterpolation.Interpolate;
_rb.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
_rb.constraints = RigidbodyConstraints.FreezeRotation;
if (cameraTransform == null && Camera.main) cameraTransform = Camera.main.transform;
}
void OnEnable()
{
move.action.Enable(); jump.action.Enable(); sprint.action.Enable();
jump.action.started += OnJump;
}
void OnDisable() => jump.action.started -= OnJump;
void OnJump(InputAction.CallbackContext _) => _lastJumpPress = Time.time;
void Update()
{
_moveInput = move.action.ReadValue<Vector2>();
_jumpHeld = jump.action.IsPressed();
_sprintHeld = sprint.action.IsPressed();
}
/// Gameplay hook: explosions, enemy hits. Applied next physics step.
public void AddKnockback(Vector3 velocityChange) => _externalImpulse += velocityChange;
void FixedUpdate()
{
float dt = Time.fixedDeltaTime;
bool wasGrounded = Grounded;
float fallSpeed = -_rb.linearVelocity.y;
ProbeGround();
if (Grounded) { _lastGrounded = Time.time; if (_rb.linearVelocity.y <= 0.01f) _isJumping = false; }
if (Grounded && !wasGrounded) Landed?.Invoke(Mathf.Max(0f, fallSpeed));
// ---- Desired horizontal velocity, relative to what we stand on ----
Vector3 wish = WishDirection();
float top = (_sprintHeld ? metrics.sprintSpeed : metrics.runSpeed) * SpeedMultiplier;
Vector3 targetVel = wish * top * Mathf.Clamp01(_moveInput.magnitude);
Vector3 groundVel = _groundBody ? _groundBody.GetPointVelocity(_rb.position) : Vector3.zero;
Vector3 v = _rb.linearVelocity;
Vector3 relHoriz = Vector3.ProjectOnPlane(v - groundVel, Vector3.up);
float rate = targetVel.sqrMagnitude > 0.0001f
? metrics.runSpeed / Mathf.Max(metrics.accelTime, 0.0001f)
: metrics.runSpeed / Mathf.Max(metrics.decelTime, 0.0001f);
if (!Grounded) rate *= metrics.airControl;
Vector3 newRelHoriz = Vector3.MoveTowards(relHoriz, targetVel, rate * dt);
// On walkable slopes, move along the surface so we don't launch off crests or push into ramps.
Vector3 deltaV = newRelHoriz - relHoriz;
if (Grounded && !_isJumping)
deltaV = Vector3.ProjectOnPlane(deltaV, _groundNormal);
_rb.AddForce(deltaV, ForceMode.VelocityChange);
// ---- Step up small ledges ----
if (Grounded && wish.sqrMagnitude > 0.01f) TryStepUp(wish);
// ---- Jump ----
bool coyote = Time.time - _lastGrounded <= metrics.coyoteTime;
bool buffered = Time.time - _lastJumpPress <= metrics.jumpBufferTime;
if (buffered && coyote && !_isJumping)
{
Vector3 vel = _rb.linearVelocity;
vel.y = metrics.JumpVelocity + Mathf.Max(0f, groundVel.y);
_rb.linearVelocity = vel;
_isJumping = true; Grounded = false;
_lastJumpPress = _lastGrounded = -999f;
Jumped?.Invoke();
}
// ---- Gravity (metric gravity, not Physics.gravity) ----
if (!Grounded || _isJumping)
{
float g = metrics.Gravity;
if (_rb.linearVelocity.y < 0f) g *= metrics.fallGravityMultiplier;
else if (_isJumping && !_jumpHeld) g *= metrics.jumpCutGravityMultiplier;
_rb.AddForce(Vector3.down * g, ForceMode.Acceleration);
}
else
{
// Stick to ground: cancel velocity into the surface so we don't slide on walkable slopes.
Vector3 vel = _rb.linearVelocity;
float into = Vector3.Dot(vel - groundVel, _groundNormal);
if (into > 0f) _rb.AddForce(-_groundNormal * into, ForceMode.VelocityChange);
_rb.AddForce(-_groundNormal * metrics.Gravity, ForceMode.Acceleration);
}
// Clamp terminal speed
if (_rb.linearVelocity.y < -metrics.terminalFallSpeed)
{
Vector3 vel = _rb.linearVelocity; vel.y = -metrics.terminalFallSpeed; _rb.linearVelocity = vel;
}
// ---- Knockback ----
if (_externalImpulse != Vector3.zero)
{
_rb.AddForce(_externalImpulse, ForceMode.VelocityChange);
if (_externalImpulse.y > 0f) { Grounded = false; _isJumping = true; }
_externalImpulse = Vector3.zero;
}
// ---- Face movement ----
if (wish.sqrMagnitude > 0.0001f)
{
Quaternion look = Quaternion.LookRotation(wish, Vector3.up);
_rb.MoveRotation(Quaternion.RotateTowards(_rb.rotation, look, metrics.turnSpeed * dt));
}
}
void ProbeGround()
{
float r = metrics.radius * 0.95f;
Vector3 origin = _rb.position + Vector3.up * (r + 0.05f);
float dist = 0.05f + groundProbeExtra;
int n = Physics.SphereCastNonAlloc(origin, r, Vector3.down, _hits, dist, groundMask, QueryTriggerInteraction.Ignore);
Grounded = false; _groundBody = null; _groundNormal = Vector3.up;
float best = float.MaxValue;
for (int i = 0; i < n; i++)
{
if (_hits[i].collider.attachedRigidbody == _rb) continue;
if (_hits[i].distance <= 0f) continue; // started overlapping; ignore
float angle = Vector3.Angle(_hits[i].normal, Vector3.up);
if (angle > metrics.maxSlope) continue;
if (_hits[i].distance < best)
{
best = _hits[i].distance;
Grounded = true;
_groundNormal = _hits[i].normal;
_groundBody = _hits[i].rigidbody;
}
}
if (_isJumping && _rb.linearVelocity.y > 0.1f) Grounded = false; // don't re-ground on the way up
}
void TryStepUp(Vector3 wish)
{
// Low ray hits an obstacle, high ray (at step height) is clear -> lift.
Vector3 basePos = _rb.position;
Vector3 dir = wish.normalized;
float reach = metrics.radius + stepCheckDistance;
bool lowHit = Physics.Raycast(basePos + Vector3.up * 0.05f, dir, out RaycastHit low, reach, groundMask, QueryTriggerInteraction.Ignore);
if (!lowHit || Vector3.Angle(low.normal, Vector3.up) < metrics.maxSlope) return; // ramps handled by projection
bool highHit = Physics.Raycast(basePos + Vector3.up * (metrics.stepHeight + 0.02f), dir, reach, groundMask, QueryTriggerInteraction.Ignore);
if (highHit) return;
// Find the step top and lift there.
Vector3 topProbe = basePos + dir * reach + Vector3.up * (metrics.stepHeight + 0.05f);
if (Physics.Raycast(topProbe, Vector3.down, out RaycastHit top, metrics.stepHeight + 0.05f, groundMask, QueryTriggerInteraction.Ignore))
{
float lift = top.point.y - basePos.y;
if (lift > 0.01f && lift <= metrics.stepHeight)
_rb.MovePosition(_rb.position + Vector3.up * lift + dir * 0.02f);
}
}
Vector3 WishDirection()
{
Vector3 input = new Vector3(_moveInput.x, 0f, _moveInput.y);
if (input.sqrMagnitude > 1f) input.Normalize();
if (cameraTransform == null) return input;
Vector3 f = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up).normalized;
Vector3 r = Vector3.ProjectOnPlane(cameraTransform.right, Vector3.up).normalized;
return f * input.z + r * input.x;
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled or play-tested. The step-up uses `MovePosition` (a teleport within the step); on interpolated bodies that can show as a small pop - acceptable for gray-box, smooth it with a visual-only offset later. Ground-velocity inheritance assumes platforms are Rigidbodies (kinematic ones moved with `MovePosition` report velocity).
## 4.3 Side-by-side comparison exercise
Put `CCPlayer` and `RBPlayer` in the same gym (Part 5) with the **same** `MovementMetrics`. Record:
| Test | CC result | RB result |
|---|---|---|
| Full jump clears 100% gap? | | |
| Jump height on a 20 deg ramp vs flat | | |
| Walking down 40 deg ramp - grounded the whole way? | | |
| Pushes a 20 kg crate | | |
| Thrown by an explosion (call `AddKnockback`) | manual | |
| Rides a rotating platform | | |
Expect the CC to match the sheet more exactly and the RB to win on physical interaction - the trade-off from Part 1, now measured. In class, this is a good argument for **"the metrics sheet is the contract"** (Level Up! Level 5) - the controller that keeps the contract is the one you ship levels against.
## Docs
- `Rigidbody.linearVelocity`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody-linearVelocity.html
- `Physics.SphereCastNonAlloc`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Physics.SphereCastNonAlloc.html
- `Physics.Raycast`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Physics.Raycast.html
- `Rigidbody.MovePosition`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody.MovePosition.html
- `PhysicsMaterialCombine`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/PhysicsMaterialCombine.html
- Interpolation: https://docs.unity3d.com/6000.5/Documentation/Manual/rigidbody-interpolation.html