# 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