A first-person controller is one of the first things many Unity developers build, and one of the easiest to get subtly wrong: jittery mouse look, diagonal speed boosts, characters that float down slopes or can jump in mid-air. This Unity first person controller tutorial builds a clean, complete controller in Unity 6 with the Input System and a CharacterController: smooth mouse look, movement, gravity, jumping, sprinting and crouching, in one script you can drop into any project.
Scene and Hierarchy Setup
Create the player like this:
- Player (empty GameObject at floor level) with a CharacterController component. Set Height 1.8, Radius 0.35 and Center (0, 0.9, 0) so the capsule’s bottom sits at the object’s origin.
- CameraHolder (child) at local position (0, 1.6, 0), roughly eye height.
- Main Camera (child of CameraHolder) at local zero.
The split matters. The Player body rotates left and right (yaw), while only the camera holder rotates up and down (pitch). If you pitch the whole body, movement tilts into the floor when you look down. Set the camera’s near clip plane to around 0.05 so walls don’t clip when you stand close.
Why CharacterController rather than Rigidbody? It gives precise, snappy movement with built-in step and slope handling and no physics jitter. If you want the full trade-off, read CharacterController vs Rigidbody.
Input Actions
Unity 6 projects ship with a default InputSystem_Actions asset that already contains a Player map with Move, Look, Jump, Sprint and Crouch actions. If yours doesn’t, create an Input Actions asset with:
- Move: Value, Vector2, bound to a WASD composite and the left stick
- Look: Value, Vector2, bound to Mouse Delta and the right stick
- Jump: Button, Space and gamepad South
- Sprint: Button, Left Shift and left stick press
- Crouch: Button, C or Left Ctrl and gamepad East
The script below takes InputActionReference fields, so you can drag actions straight from the asset into the Inspector. New to actions? Our Input System beginner’s guide explains maps and bindings.
Mouse Look
Mouse delta is already “how far the mouse moved this frame”, so it must not be multiplied by Time.deltaTime. Doing that is the most common cause of mouse look that feels sluggish at high frame rates and twitchy at low ones. Gamepad sticks are different: they report a held position, so they do need delta time. The full script handles both by checking which device drove the action.
The core idea:
Vector2 look = lookAction.action.ReadValue<Vector2>() * mouseSensitivity;
yaw += look.x;
pitch = Mathf.Clamp(pitch - look.y, -85f, 85f);
transform.localRotation = Quaternion.Euler(0f, yaw, 0f);
cameraHolder.localRotation = Quaternion.Euler(pitch, 0f, 0f);
Storing yaw and pitch as floats and rebuilding rotations from them avoids gimbal problems and drift. Clamping pitch to about 85 degrees stops the camera flipping over when you look straight up. Do the look in Update (not FixedUpdate) so it’s sampled every rendered frame.
Lock the cursor on start with Cursor.lockState = CursorLockMode.Locked. Locked mode also hides the cursor. Press Escape in the Editor to release it.
Movement, Gravity and Jumping
A CharacterController doesn’t apply gravity itself. You keep a vertical velocity, add gravity to it every frame, and pass the combined motion to Move once per frame:
- Convert input to world space with
transform.right * x + transform.forward * y. Clamp the input magnitude to 1 so diagonals aren’t faster. - When grounded and falling, reset vertical velocity to a small negative number like -2 rather than 0, which keeps the controller pressed to the ground so
isGroundedstays true going down slopes. - For a jump of height h, the starting velocity is
Mathf.Sqrt(h * -2f * gravity). This lets you tune jump height in metres instead of guessing forces. - Call
Moveonly once per frame.isGroundedreflects the lastMovecall, and splitting horizontal and vertical into two calls makes it unreliable.
The Full Controller Script
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(CharacterController))]
public class FirstPersonController : MonoBehaviour
{
[Header("References")]
[SerializeField] Transform cameraHolder;
[SerializeField] InputActionReference moveAction, lookAction, jumpAction, sprintAction, crouchAction;
[Header("Look")]
[SerializeField] float mouseSensitivity = 0.1f; // degrees per pixel
[SerializeField] float stickSensitivity = 180f; // degrees per second
[Header("Movement")]
[SerializeField] float walkSpeed = 4.5f;
[SerializeField] float sprintSpeed = 7.5f;
[SerializeField] float crouchSpeed = 2.2f;
[SerializeField] float acceleration = 12f;
[SerializeField] float jumpHeight = 1.2f;
[SerializeField] float gravity = -20f;
[Header("Crouch")]
[SerializeField] float standHeight = 1.8f;
[SerializeField] float crouchHeight = 1.1f;
[SerializeField] float standEyeY = 1.6f;
[SerializeField] float crouchEyeY = 0.95f;
CharacterController controller;
Vector3 horizontalVelocity;
float verticalVelocity, yaw, pitch;
bool crouching;
void Awake()
{
controller = GetComponent<CharacterController>();
yaw = transform.eulerAngles.y;
}
void OnEnable()
{
foreach (var a in new[] { moveAction, lookAction, jumpAction, sprintAction, crouchAction })
a.action.Enable();
Cursor.lockState = CursorLockMode.Locked;
}
void OnDisable() => Cursor.lockState = CursorLockMode.None;
void Update()
{
Look();
HandleCrouch();
Move();
}
void Look()
{
Vector2 raw = lookAction.action.ReadValue<Vector2>();
bool isMouse = lookAction.action.activeControl?.device is Mouse;
Vector2 look = isMouse ? raw * mouseSensitivity
: raw * stickSensitivity * Time.deltaTime;
yaw += look.x;
pitch = Mathf.Clamp(pitch - look.y, -85f, 85f);
transform.localRotation = Quaternion.Euler(0f, yaw, 0f);
cameraHolder.localRotation = Quaternion.Euler(pitch, 0f, 0f);
}
void Move()
{
Vector2 input = Vector2.ClampMagnitude(moveAction.action.ReadValue<Vector2>(), 1f);
bool sprinting = sprintAction.action.IsPressed() && !crouching && input.y > 0.1f;
float speed = crouching ? crouchSpeed : sprinting ? sprintSpeed : walkSpeed;
Vector3 target = (transform.right * input.x + transform.forward * input.y) * speed;
horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, target, acceleration * speed * Time.deltaTime);
if (controller.isGrounded)
{
if (verticalVelocity < 0f) verticalVelocity = -2f;
if (jumpAction.action.WasPressedThisFrame() && !crouching)
verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
verticalVelocity += gravity * Time.deltaTime;
Vector3 motion = horizontalVelocity + Vector3.up * verticalVelocity;
CollisionFlags flags = controller.Move(motion * Time.deltaTime);
// Stop upward velocity when hitting a ceiling
if ((flags & CollisionFlags.Above) != 0 && verticalVelocity > 0f)
verticalVelocity = 0f;
}
void HandleCrouch()
{
if (crouchAction.action.WasPressedThisFrame())
{
if (crouching && !CanStandUp()) return;
crouching = !crouching;
}
float targetHeight = crouching ? crouchHeight : standHeight;
controller.height = Mathf.MoveTowards(controller.height, targetHeight, 6f * Time.deltaTime);
controller.center = new Vector3(0f, controller.height / 2f, 0f);
Vector3 eye = cameraHolder.localPosition;
eye.y = Mathf.MoveTowards(eye.y, crouching ? crouchEyeY : standEyeY, 6f * Time.deltaTime);
cameraHolder.localPosition = eye;
}
bool CanStandUp()
{
float radius = controller.radius * 0.95f;
Vector3 bottom = transform.position + Vector3.up * (radius + 0.05f);
Vector3 top = transform.position + Vector3.up * (standHeight - radius);
return !Physics.CheckCapsule(bottom, top, radius, ~0, QueryTriggerInteraction.Ignore)
|| OnlyHitsSelf(bottom, top, radius);
}
bool OnlyHitsSelf(Vector3 bottom, Vector3 top, float radius)
{
foreach (var c in Physics.OverlapCapsule(bottom, top, radius, ~0, QueryTriggerInteraction.Ignore))
if (c != controller) return false;
return true;
}
}
Drag the CameraHolder and the five actions into the Inspector and press Play. Because a CharacterController is itself a collider, the stand-up check filters it out so the player doesn’t block their own head. In a larger project, put the player on its own layer and exclude it with a LayerMask instead, which is simpler and avoids the allocation from OverlapCapsule. Our collision layers guide shows how.
Sprint and Crouch
A few design choices are baked into the script that are worth understanding:
- Sprint only works moving forward. Sprinting backwards or sideways looks odd in most games. Remove the
input.ycheck if you want omnidirectional sprint. - Crouch is a toggle. For hold-to-crouch, replace the toggle with
crouching = crouchAction.action.IsPressed() || !CanStandUp();. - The ceiling check prevents standing up inside a vent. Without it the capsule grows into geometry and the controller pops on top of the obstacle.
- Height and eye height lerp so the camera slides down smoothly instead of snapping.
Polish: Head Bob, FOV and Slopes
- Sprint FOV: nudge the camera’s field of view from 70 to about 78 while sprinting with
Mathf.Lerp. It sells speed without changing it. - Head bob: add a small sine offset to the camera’s local Y based on distance travelled, only while grounded. Keep it subtle and add a settings toggle, since bob causes motion sickness for some players.
- Slopes and steps: the controller’s Slope Limit (default 45) and Step Offset (around 0.3) control what can be climbed. Keep Step Offset below the controller’s height plus radius or Unity warns about it.
- Coyote time: remember the last time you were grounded and allow a jump for about 0.1 seconds after walking off a ledge. Players perceive it as fair rather than as cheating.
- Moving platforms: CharacterController doesn’t inherit platform motion automatically. See our moving platforms tutorial for the fix.
💻 Need a laptop that handles 3D Unity projects?
First-person games with real-time lighting push integrated graphics hard. These are the budget laptops we’d pick for smooth Play Mode in Unity 6.
Troubleshooting
Mouse look is jittery or frame-rate dependent
You’re multiplying mouse delta by Time.deltaTime, or doing look in FixedUpdate. Use raw delta times a sensitivity, in Update.
Player can jump in mid-air or can’t jump at all
isGrounded is unreliable when Move is called more than once per frame or vertical velocity is reset to exactly 0. Call Move once and keep a small negative value like -2 when grounded.
Nothing happens when pressing keys
The actions aren’t enabled, the references aren’t assigned, or Active Input Handling in Player Settings isn’t set to Input System Package (or Both).
Character slides or floats down slopes
Gravity is too weak or the grounded velocity is zero. Use a stronger gravity like -20 and the -2 grounded value.
The camera clips through walls
Lower the camera’s near clip plane to around 0.05, and keep the controller radius larger than the camera’s distance from the capsule centre.
FAQ
Should a first person controller use Rigidbody or CharacterController?
CharacterController for tight, predictable shooter-style movement. Rigidbody if the player needs to be pushed around by physics.
Why multiply stick look by deltaTime but not mouse look?
Mouse delta is already a per-frame distance. A stick reports a held position, so it needs delta time to become a rate.
How do I add mouse sensitivity in settings?
Expose mouseSensitivity and set it from a UI slider, then save it with PlayerPrefs or your save system.
How do I invert the Y axis?
Use pitch += look.y instead of pitch -= look.y, behind a settings toggle.
Can I use Cinemachine for a first person camera?
Yes. Use a CinemachineCamera with a Pan Tilt rotation control parented at eye height, but keep body yaw on the player so movement direction matches.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply