# 3. CharacterController Implementation Part of [[Character Controllers]]. Previous: [[2. Movement Metrics Asset]]. Next: [[4. Rigidbody Character Implementation]]. > [!note] Verification status > Uses `CharacterController.Move` (returns `CollisionFlags`), `isGrounded`, `height`, `radius`, `stepOffset`, `slopeLimit`, `skinWidth`, `center`, and `OnControllerColliderHit(ControllerColliderHit)` with `hit.normal`, `hit.moveDirection`, `hit.collider`, `hit.rigidbody` - all present in the 6000.5 reference (checked 2026-11-07). **Not compiled or play-tested in the Editor.** ## 3.1 Setup 1. Capsule GameObject `Player` (delete its CapsuleCollider - CharacterController *is* the collider). Add **Character Controller**. 2. Create a `MovementMetrics` asset (Part 2). The script copies height/radius/step/slope into the controller on Awake so they never drift. 3. Skin Width: 10% of radius (0.03 for r = 0.3) per the Manual guideline. 4. Input Actions asset with a `Player` map: `Move` (Vector2), `Jump` (Button), `Sprint` (Button). Generate InputActionReferences ([[Input System]]). 5. Camera: a [[Cinemachine 3]] orbital/follow camera targeting the Player; assign `Camera.main` transform as `cameraTransform`. 6. Layers: `Player` on the capsule; `Ground`, `Prop`, `Platform` per [[3. Layers Matrix and Queries]]. ## 3.2 The controller ```csharp using System; using UnityEngine; using UnityEngine.InputSystem; [RequireComponent(typeof(CharacterController))] public class CCPlayerController : MonoBehaviour { [Header("Data")] [SerializeField] MovementMetrics metrics; [Header("Input")] [SerializeField] InputActionReference move; [SerializeField] InputActionReference jump; [SerializeField] InputActionReference sprint; [Header("Camera")] [SerializeField] Transform cameraTransform; [Tooltip("Off = character-relative (tank) controls. Level Up! Level 7 comparison.")] [SerializeField] bool cameraRelative = true; [Header("Interaction")] [SerializeField] float pushPower = 2f; [SerializeField] LayerMask platformMask; // Events for audio/FX/animation (decoupled - Observer pattern) public event Action Jumped; public event Action<float> Landed; // impact speed (m/s, positive) public event Action<float> Stepped; // distance-based footstep, value = speed public Vector3 Velocity => _velocity; public bool Grounded => _grounded; public float SpeedMultiplier { get; set; } = 1f; // hook for power-ups CharacterController _cc; Vector3 _velocity; Vector2 _moveInput; bool _jumpHeld, _sprintHeld; bool _grounded, _isJumping; float _lastGroundedTime = -999f, _lastJumpPressTime = -999f; Vector3 _groundNormal = Vector3.up; float _strideAccumulator; // Moving platform carry Transform _platform; Vector3 _platformLocalPoint; Quaternion _platformLastRotation; void Awake() { _cc = GetComponent<CharacterController>(); _cc.height = metrics.height; _cc.radius = metrics.radius; _cc.center = new Vector3(0f, metrics.height * 0.5f, 0f); // pivot at feet _cc.stepOffset = Mathf.Min(metrics.stepHeight, metrics.height - 0.01f); _cc.slopeLimit = metrics.maxSlope; _cc.skinWidth = metrics.radius * 0.1f; if (cameraTransform == null && Camera.main) cameraTransform = Camera.main.transform; } void OnEnable() { move.action.Enable(); jump.action.Enable(); sprint.action.Enable(); jump.action.started += OnJumpStarted; } void OnDisable() => jump.action.started -= OnJumpStarted; void OnJumpStarted(InputAction.CallbackContext _) => _lastJumpPressTime = Time.time; void Update() { float dt = Time.deltaTime; _moveInput = move.action.ReadValue<Vector2>(); _jumpHeld = jump.action.IsPressed(); _sprintHeld = sprint.action.IsPressed(); ApplyPlatformCarry(); // --- Horizontal --- Vector3 wish = WishDirection(); float topSpeed = (_sprintHeld ? metrics.sprintSpeed : metrics.runSpeed) * SpeedMultiplier; // Analog stick magnitude selects walk..run. float inputMag = Mathf.Clamp01(_moveInput.magnitude); Vector3 targetVel = wish * Mathf.Lerp(0f, topSpeed, inputMag); Vector3 horiz = new Vector3(_velocity.x, 0f, _velocity.z); 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; horiz = Vector3.MoveTowards(horiz, targetVel, rate * dt); // Turn body toward motion if (wish.sqrMagnitude > 0.0001f) { Quaternion look = Quaternion.LookRotation(wish, Vector3.up); transform.rotation = Quaternion.RotateTowards(transform.rotation, look, metrics.turnSpeed * dt); } // --- Vertical --- bool coyote = Time.time - _lastGroundedTime <= metrics.coyoteTime; bool buffered = Time.time - _lastJumpPressTime <= metrics.jumpBufferTime; if (buffered && coyote && !_isJumping) { _velocity.y = metrics.JumpVelocity; _isJumping = true; _lastJumpPressTime = -999f; _lastGroundedTime = -999f; DetachPlatform(); Jumped?.Invoke(); } if (_grounded && !_isJumping) { _velocity.y = -2f; // small stick-to-ground force so isGrounded stays true on slopes/steps } else { float g = metrics.Gravity; if (_velocity.y < 0f) g *= metrics.fallGravityMultiplier; else if (_isJumping && !_jumpHeld) g *= metrics.jumpCutGravityMultiplier; _velocity.y = Mathf.Max(_velocity.y - g * dt, -metrics.terminalFallSpeed); } // Steep slope: slide down instead of standing if (_grounded && Vector3.Angle(_groundNormal, Vector3.up) > _cc.slopeLimit) { Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized; horiz += slideDir * (metrics.Gravity * 0.5f * dt); } _velocity.x = horiz.x; _velocity.z = horiz.z; // --- Move --- float preMoveFallSpeed = -_velocity.y; bool wasGrounded = _grounded; _groundNormal = Vector3.up; // refreshed by OnControllerColliderHit CollisionFlags flags = _cc.Move(_velocity * dt); _grounded = (flags & CollisionFlags.Below) != 0; if ((flags & CollisionFlags.Above) != 0 && _velocity.y > 0f) _velocity.y = 0f; // bonk if (_grounded) { _lastGroundedTime = Time.time; if (_velocity.y <= 0f) _isJumping = false; if (!wasGrounded) Landed?.Invoke(Mathf.Max(0f, preMoveFallSpeed)); } // --- Footstep cadence (distance-based, independent of animation) --- if (_grounded) { float speed = new Vector3(_cc.velocity.x, 0f, _cc.velocity.z).magnitude; float stride = Mathf.Lerp(0.6f, 1.3f, Mathf.InverseLerp(metrics.walkSpeed, metrics.sprintSpeed, speed)); _strideAccumulator += speed * dt; if (speed > 0.2f && _strideAccumulator >= stride) { _strideAccumulator = 0f; Stepped?.Invoke(speed); } } RecordPlatform(); } Vector3 WishDirection() { Vector3 input = new Vector3(_moveInput.x, 0f, _moveInput.y); if (input.sqrMagnitude > 1f) input.Normalize(); if (!cameraRelative) { // Character-relative: up = forward, left/right = turn in place feel return transform.TransformDirection(input); } if (cameraTransform == null) return input; Vector3 fwd = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up).normalized; Vector3 right = Vector3.ProjectOnPlane(cameraTransform.right, Vector3.up).normalized; return fwd * input.z + right * input.x; } // Called by CharacterController during Move for every surface touched. void OnControllerColliderHit(ControllerColliderHit hit) { // Ground normal (for slope logic): only surfaces below the capsule centre if (hit.point.y < transform.position.y + _cc.radius) _groundNormal = hit.normal; // Track moving platforms we stand on if (hit.moveDirection.y < -0.3f && ((1 << hit.gameObject.layer) & platformMask) != 0) AttachPlatform(hit.collider.transform); // Push dynamic rigidbodies (adapted from the classic Scripting Reference example) Rigidbody body = hit.rigidbody; if (body == null || body.isKinematic) return; if (hit.moveDirection.y < -0.3f) return; // don't push things we stand on Vector3 pushDir = new Vector3(hit.moveDirection.x, 0f, hit.moveDirection.z); body.linearVelocity = pushDir * pushPower; } // ---- moving platform helpers ---- void AttachPlatform(Transform t) { if (_platform == t) return; _platform = t; RecordPlatform(); } void DetachPlatform() => _platform = null; void RecordPlatform() { if (_platform == null) return; _platformLocalPoint = _platform.InverseTransformPoint(transform.position); _platformLastRotation = _platform.rotation; } void ApplyPlatformCarry() { if (_platform == null) return; if (!_grounded) { DetachPlatform(); return; } Vector3 newWorld = _platform.TransformPoint(_platformLocalPoint); Vector3 delta = newWorld - transform.position; Quaternion rotDelta = _platform.rotation * Quaternion.Inverse(_platformLastRotation); transform.rotation = Quaternion.Euler(0f, rotDelta.eulerAngles.y, 0f) * transform.rotation; if (delta.sqrMagnitude > 0f) _cc.Move(delta); } } ``` > [!warning] Code not compiled in Unity > Not compiled or play-tested. Things to check in the Editor: (1) moving platforms should move in `Update` or with interpolation, otherwise the carry delta steps at 50 Hz - see Part 6; (2) the `-2f` stick force with very high `stepOffset` can make the capsule snap down ledges - that's usually desirable; (3) calling `Move` twice per frame (carry + main) is fine but each call fires hit callbacks. ## 3.3 Walkthrough - why each block exists 1. **Metrics copied in Awake** - the capsule *is* the metrics. If a designer changes height in the asset, the collider follows. 2. **Input in Update, jump press as an event** - `started` timestamps the press so a tap between frames is never lost; that timestamp + `jumpBufferTime` = jump buffering. 3. **Acceleration via `MoveTowards`** - constant acceleration derived from "seconds to top speed" (a number designers can feel and say out loud). 4. **Coyote time** - `_lastGroundedTime` stays valid for `coyoteTime` seconds after running off a ledge. 5. **Variable jump** - releasing jump increases gravity (`jumpCutGravityMultiplier`) rather than zeroing velocity; smoother arc, still gives a short hop. 6. **Stick-to-ground `-2`** - `isGrounded` is only true if the last `Move` touched below. Without a small downward push, walking down a ramp produces airborne frames (grounded flicker) and breaks coyote/footsteps. 7. **`CollisionFlags.Above`** - head bonk zeroes upward speed so you don't "hang" on ceilings. 8. **Distance-based footsteps** - stride length scales with speed, emitting `Stepped` events that the audio system consumes (part 4 of [[Audio Mixers and Character Audio]]). Animation-event footsteps are the alternative; see [[6.Animation Events Reliable Patterns]]. 9. **Pushing** - CharacterController isn't a Rigidbody, so dynamic props only move if you push them in `OnControllerColliderHit`. ## 3.4 Wiring events to feedback (juice) ```csharp using UnityEngine; /// Minimal feedback hookup: landing squash and a camera shake on heavy landings. public class CCFeedback : MonoBehaviour { [SerializeField] CCPlayerController controller; [SerializeField] Transform visual; // child mesh, not the controller root [SerializeField] float squashPerMps = 0.03f; [SerializeField] float recoverSpeed = 10f; Vector3 _baseScale; void Awake() => _baseScale = visual.localScale; void OnEnable() { controller.Landed += OnLanded; } void OnDisable() { controller.Landed -= OnLanded; } void OnLanded(float impactSpeed) { float s = Mathf.Clamp(impactSpeed * squashPerMps, 0f, 0.35f); visual.localScale = new Vector3(_baseScale.x * (1f + s), _baseScale.y * (1f - s), _baseScale.z * (1f + s)); // For camera shake, fire a Cinemachine Impulse Source here (see Cinemachine 3 notes). } void Update() => visual.localScale = Vector3.Lerp(visual.localScale, _baseScale, recoverSpeed * Time.deltaTime); } ``` > [!warning] Code not compiled in Unity > Not compiled. Camera impulses: [[4.Impulse Noise and Extensions]]. ## Docs - `CharacterController.Move`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/CharacterController.Move.html - `CharacterController.isGrounded`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/CharacterController-isGrounded.html - `MonoBehaviour.OnControllerColliderHit`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/MonoBehaviour.OnControllerColliderHit.html - `ControllerColliderHit`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ControllerColliderHit.html - Character Controller component: https://docs.unity.com/en-us/engine/6000.5/manual/physics-section/physics-overview/character-control-section/class-character-controller