# 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