Input System, Movement & Cameras (Cinemachine)
Turn a player's keyboard, mouse, gamepad or touchscreen into movement on screen: Unity's Input System from devices to actions, bindings and callbacks; the PlayerInput component and generated classes; rebinding and pausing; camera-relative movement with a CharacterController, with jumps that feel right; camera basics; a hand-written follow camera; and Cinemachine 3 for orbiting, switching and shaking cameras. The guided build lets the lighthouse keeper walk the island.
- Study time
- 10–14 hours
- Reading time
- 116 min read · 8,956 words
- Prerequisites
- T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable, T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation
- Courses merged
- 5 course files
- Your progress
- —
- Explain the path from a device to your code: controls, bindings, actions, maps and control schemes
- Read input by polling devices, by polling actions and with callbacks, and choose between them
- Use the project-wide actions, the PlayerInput component and generated C# classes, and know the trade-offs
- Switch action maps for menus and pauses, and let players rebind controls and keep their choices
- Move a character relative to the camera with acceleration, turning, gravity, jumping, coyote time and a jump buffer
- Convert between screen, viewport and world space and pick things under the mouse
- Write a follow camera by hand, and set up Cinemachine 3 cameras that orbit, switch by priority and shake
Input is where your game meets a person. A player presses a key, tilts a stick or swipes a screen, and something should happen on screen at once, the same way every time, on every device they own. This chapter follows that path from the hardware to your code, then turns the input into movement that feels good and a camera that shows it well.
Every script here compiles against Unity 6.3 and the real Input System 1.20.1 package with no warnings, and is in the sandbox under Assets/Handbook/T07. The Cinemachine scripts are checked against stand-ins built from the Cinemachine 3.1 API reference, because that package isn't bundled with the editor; they're compiled for real at the phase review. You need T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable's lifecycle and T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation's vectors and rotations.
The big picture#
Unity has two input systems. The old one, the Input Manager, is the UnityEngine.Input class with calls such as Input.GetKey and Input.GetAxis. The current one is the Input System package, which Unity's 6.3 templates use by default. This handbook uses the Input System throughout.
The Input System separates what the player does from what the game means by it. Your gameplay code asks for "Jump" or "Move", never "the space bar". In between sits a layer you configure, mostly without code:
- A device is a piece of hardware: a keyboard, a mouse, a gamepad, a touchscreen, a VR controller. Each has controls: keys, buttons, sticks, the mouse's movement.
- An action is something the player can do in your game: Move, Look, Jump, Attack. It has a value type: a button (pressed or not), a float, or a
Vector2for directions. - A binding connects a control to an action, written as a path such as
<Keyboard>/spaceor<Gamepad>/buttonSouth. An action can have many bindings, so Jump can be Space and the gamepad's south button at once. - Action maps group actions that are used together: a Player map for walking around and a UI map for menus. You switch whole maps on and off.
- Control schemes tag bindings by device family (Keyboard&Mouse, Gamepad, Touch), so a game can tell which kind of controller the player is using.
Setting up#
A project made from a Unity 6.3 template is ready to go. To check, or to set up an older project:
- The package. Open Window → Package Management → Package Manager and select Input System under In Project. This handbook is checked against version 1.20.1. The 6.3 templates were made with an older 1.x version, so if the Package Manager offers an update, take it.
- Active Input Handling. Edit → Project Settings → Player → Other Settings → Configuration → Active Input Handling should be Input System Package (New). Changing it restarts the editor.
- Project-wide actions. Edit → Project Settings → Input System Package shows the project's actions. The 3D template already assigns an asset called
InputSystem_Actions(inAssets; the 2D template keeps it inAssets/Settings). If the page offers to create one, accept: you get the same default actions.
The default actions cover a typical action game, and you can edit them freely in that settings page or by double-clicking the asset:
| Action (Player map) | Type | Keyboard & mouse | Gamepad |
|---|---|---|---|
| Move | Value, Vector2 | WASD and arrow keys | Left stick |
| Look | Value, Vector2 | Mouse movement | Right stick |
| Attack | Button | Left mouse button, Enter | West button (X on Xbox, Square on PlayStation) |
| Interact | Button with a Hold interaction | E | North button |
| Crouch | Button | C | East button |
| Jump | Button | Space | South button (A, Cross) |
| Previous, Next | Button | 1, 2 | D-pad left, right |
| Sprint | Button | Left Shift | Left stick press |
The UI map holds Navigate, Submit, Cancel, Point, Click, RightClick, MiddleClick, ScrollWheel and two XR tracking actions; Unity's UI uses those (T20 · UI: uGUI and UI Toolkit, HUDs, Menus & Data Binding). The Player map also has bindings for touch, joysticks and XR controllers, and five control schemes: Keyboard&Mouse, Gamepad, Touch, Joystick and XR.
Reading devices directly#
The quickest way to read input is to ask a device. Keyboard.current, Mouse.current and Gamepad.current are the most recently used device of each kind, and every control has properties for its state:
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Reads devices directly, with no actions. Fine for debug keys and quick tests.
public class DevicePoller : MonoBehaviour
{
void Update()
{
// Each ".current" is null when no such device is connected, so check first.
Keyboard keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.spaceKey.wasPressedThisFrame) Debug.Log("Space went down this frame");
if (keyboard.spaceKey.wasReleasedThisFrame) Debug.Log("Space came up this frame");
if (keyboard.f1Key.isPressed) Debug.Log("F1 is held");
}
Mouse mouse = Mouse.current;
if (mouse != null && mouse.leftButton.wasPressedThisFrame)
Debug.Log($"Click at pixel {mouse.position.ReadValue()}");
Gamepad pad = Gamepad.current;
if (pad != null)
{
Vector2 stick = pad.leftStick.ReadValue();
if (stick.sqrMagnitude > 0.25f) Debug.Log($"Left stick {stick}");
if (pad.buttonSouth.wasPressedThisFrame) Debug.Log("South button (A on Xbox, Cross on PlayStation)");
}
}
}
}isPressed: held down now.wasPressedThisFrameandwasReleasedThisFrame: changed during this frame, so they're true for exactly one frame per press. Read them inUpdate; inFixedUpdate, which may run zero or several times a frame, you'd miss presses or see them twice.ReadValue()on a stick or the mouse position returns aVector2; on a trigger, a float from 0 to 1.- Each
.currentis null when no such device is connected. A game that readsGamepad.current.buttonSouthwithout a check throws aNullReferenceExceptionon every machine without a gamepad. - Keyboard keys are named by position on a US keyboard, not by the letter printed on them.
Keyboard.current.wKeyis the key above S, which is Z on a French AZERTY keyboard. That's what you want for WASD movement. To show the player a key's name, use itsdisplayName, which follows their layout.
Direct polling is fine for debug keys, quick prototypes and editor tools. For gameplay it's the wrong tool: every new device means new code, players can't rebind anything, and supporting two players on one machine is awkward. Actions solve all three.
Actions#
Action types and phases#
Each action has a type that controls how it reacts to input:
- Button: for presses. Jump, Attack, Interact.
- Value: for continuous input, such as Move and Look. It tracks one control at a time (if a stick and the keys both move, it picks the one pushed furthest) and reports every change.
- Pass Through: like Value, but reports every change from every bound control, without picking one. Rarely needed.
An action moves through phases, and you can be told about each change:
- started: input began (a button went down, a stick left the centre).
- performed: the action happened. For a plain button, at the same moment as started; for a Value action, on every change; with a Hold interaction, only once the button has been held long enough.
- canceled: input stopped (released, stick back to the centre), or the interaction gave up (released too early for a Hold).
Bindings, composites, interactions and processors#
- Composite bindings build one value from several controls. Move's keyboard binding is a 2D Vector composite: W, A, S and D become up, left, down and right, so the action gets a
Vector2just like a stick. By default the composite normalizes diagonals, so W + D gives (0.71, 0.71), not (1, 1). A 1D Axis composite makes −1 to 1 from two keys, and Button With One Modifier requires, for example, Ctrl + S. - Interactions decide when an action is performed. Press (on press, release or both), Hold (held for a time, 0.4 s by default), Tap (released quickly), Slow Tap (released after a minimum time) and Multi Tap (double-click). The default Interact action uses Hold, so "hold E to open" needs no timer code.
- Processors change the value on its way: Invert, Scale, Normalize, Stick Deadzone (ignore tiny stick movements and rescale the rest), Clamp. "Invert Y" in an options menu is an Invert processor on the Look binding.
All of this lives in the actions asset and can be changed in the editor without touching code, or at runtime through rebinding, below.
Project-wide actions in code#
The project-wide actions asset is available everywhere as InputSystem.actions, and Unity enables all its actions automatically when you enter Play mode. Look an action up once, in Awake, by its map and name, then read it every frame:
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Moves on the ground with the project-wide Move action, and logs Jump.
// The same code works with a keyboard, a gamepad or a touchscreen stick.
public class ActionMover : MonoBehaviour
{
[SerializeField] float speed = 4f;
InputAction move;
InputAction jump;
void Awake()
{
// Look the actions up once, not every frame. "Map/Action" avoids name clashes.
if (InputSystem.actions == null) return; // no project-wide actions assigned
move = InputSystem.actions.FindAction("Player/Move");
jump = InputSystem.actions.FindAction("Player/Jump");
}
void Update()
{
if (move == null) return;
Vector2 input = move.ReadValue<Vector2>(); // x: left/right, y: forward/back
Vector3 step = new Vector3(input.x, 0f, input.y) * (speed * Time.deltaTime);
transform.Translate(step, Space.World);
if (jump != null && jump.WasPressedThisFrame())
Debug.Log("Jump pressed");
}
}
}Member of InputAction | Returns |
|---|---|
ReadValue<Vector2>(), ReadValue<float>() | The current value, of the action's control type |
IsPressed() | True while a button action is held |
WasPressedThisFrame(), WasReleasedThisFrame() | True on the frame the press or release happened |
WasPerformedThisFrame() | True on the frame it was performed: for a Hold, the frame the hold completed |
triggered | Like WasPerformedThisFrame |
activeControl | The control driving it now, so you can tell a mouse from a stick |
enabled, Enable(), Disable() | Whether it reacts to input at all |
FindAction returns null if the name is wrong, and the error appears later, as a NullReferenceException in Update. Pass throwIfNotFound: true to fail at once with a clear message, as the guided build does.
Callbacks#
Instead of asking every frame, you can subscribe to an action's started, performed and canceled events. The handler receives an InputAction.CallbackContext with the value (context.ReadValue<T>()), the control that caused it, and timing. To choose the action in the Inspector rather than by name, use an InputActionReference field: the picker lists the actions in your assets, including the project-wide ones.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Logs the three callbacks of any action you pick in the Inspector.
// Try it with Player/Jump (a plain button) and Player/Interact (a Hold interaction).
public class ActionEvents : MonoBehaviour
{
[SerializeField] InputActionReference action;
void OnEnable()
{
if (action == null) return;
action.action.started += OnStarted;
action.action.performed += OnPerformed;
action.action.canceled += OnCanceled;
}
void OnDisable()
{
if (action == null) return;
action.action.started -= OnStarted;
action.action.performed -= OnPerformed;
action.action.canceled -= OnCanceled;
}
void OnStarted(InputAction.CallbackContext context)
{
Debug.Log($"{context.action.name} started by {context.control.path}");
}
void OnPerformed(InputAction.CallbackContext context)
{
Debug.Log($"{context.action.name} performed after {context.duration:F2} s");
}
void OnCanceled(InputAction.CallbackContext context)
{
Debug.Log($"{context.action.name} canceled after {context.duration:F2} s");
}
}
}Put it on any object, pick Player/Jump and press Space: started and performed arrive together, and canceled on release. Then pick Player/Interact and tap E: started, then canceled after a fraction of a second. Hold E: started, then performed after 0.4 s, then canceled when you let go.
Subscribe in OnEnable and unsubscribe in OnDisable, as with any C# event (T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable). The action outlives your component; if you forget, a destroyed object's handler keeps being called and throws.
Polling or callbacks?#
- Poll continuous values (Move, Look) every frame with
ReadValue. You need the current value anyway, and the code reads top to bottom. - Poll button presses with
WasPressedThisFramewhen the reaction happens in yourUpdateanyway, as with a jump that the movement code handles. - Use callbacks for one-off reactions that don't belong to a frame loop (open the map, ring a bell), and when you care about phases, such as a hold that charges up.
Callbacks for presses arrive before Update runs that frame, by default. Don't move things from inside them; record what happened and act on it in Update.
The PlayerInput component#
PlayerInput is a component that connects an actions asset to a GameObject without code to look actions up. Add it, set its Actions to an asset, pick a Default Map, and choose a Behavior, which says how it tells your scripts about input:
- Send Messages: calls a method named "On" plus the action's name on the same GameObject, such as
OnMoveandOnJump, like Unity's own event functions. - Broadcast Messages: the same, but also on child objects.
- Invoke Unity Events: shows an event per action in the Inspector, which you wire to methods by dragging. The handlers take a
CallbackContext. - Invoke C# Events: one C# event,
onActionTriggered, for every action.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Receives input from a PlayerInput component whose Behavior is "Send Messages".
// PlayerInput calls a method named "On" + the action's name on this GameObject.
[RequireComponent(typeof(PlayerInput))]
public class PlayerInputReceiver : MonoBehaviour
{
[SerializeField] float speed = 4f;
Vector2 moveInput;
// Value actions arrive when the value changes, and once more (zero) when released.
void OnMove(InputValue value) => moveInput = value.Get<Vector2>();
// Button actions arrive when pressed. Releases aren't sent.
void OnJump(InputValue value) => Debug.Log("Jump");
void OnAttack(InputValue value) => Debug.Log("Attack");
void Update()
{
Vector3 step = new Vector3(moveInput.x, 0f, moveInput.y) * (speed * Time.deltaTime);
transform.Translate(step, Space.World);
}
}
}Two things about Send Messages that surprise people. It only reports performed for buttons, so you hear about presses but never releases; a "hold to charge" needs another approach. For Value actions it also sends the canceled value, which is why OnMove receives (0, 0) when you let go, and the receiver stops. Message methods are found by name, like Update, so a typo means silence, not an error.
PlayerInput's real strength is local multiplayer. With a PlayerInputManager in the scene, each new device that presses a button spawns a player prefab with its own PlayerInput, its own copy of the actions and its own device. Each player's input stays separate without any code of yours. For a single-player game, the project-wide actions are simpler.
Generated C# classes#
Select an .inputactions asset, tick Generate C# Class in the Inspector and apply, and Unity writes a C# class with a property for every map and action. You get compile-time names instead of strings, so a renamed action breaks the build instead of failing silently at runtime.
Controls controls;
void Awake() => controls = new Controls();
void OnEnable()
{
controls.Player.Enable();
controls.Player.Jump.performed += OnJump;
}
void OnDisable()
{
controls.Player.Jump.performed -= OnJump;
controls.Player.Disable();
}
void OnDestroy() => controls.Dispose();
void Update()
{
Vector2 move = controls.Player.Move.ReadValue<Vector2>();
}Each new Controls() makes its own copy of the actions, separate from the project-wide ones: they start disabled, and rebinding one copy doesn't change the other. That's ideal for local multiplayer, where each player wants their own copy, and a trap if you rebind the project-wide actions in a settings menu and read a generated copy in gameplay.
| Approach | Good for | Watch out for |
|---|---|---|
| Device polling | Debug keys, prototypes, editor tools | No rebinding, no device independence, null devices |
| Project-wide actions | Most single-player games | String names; one shared set of actions |
InputActionReference fields | Reusable components where the designer picks the action | Actions from other assets must be enabled |
| PlayerInput | Local multiplayer, quick wiring without code | Send Messages hides releases; names found by string |
| Generated class | Large projects that want compile-time safety | Each instance is a separate copy of the actions |
Switching maps and rebinding#
Switching action maps#
When a menu opens, the player character shouldn't jump every time the player presses the button that also means "select". The clean solution is to switch action maps: FindActionMap("Player").Disable() on InputSystem.actions while the menu is open, and Enable() again when it closes. A disabled map's actions read as zero and fire no callbacks. With PlayerInput, playerInput.SwitchCurrentActionMap("UI") does the same. Exercise 6 builds a pause toggle this way.
Rebinding#
Players expect to change controls, and for some it's what makes a game playable at all. The Input System has rebinding built in:
PerformInteractiveRebinding(index)waits for the player to press something and makes it the new binding. The action must be disabled while it runs. You can exclude controls (the mouse, for a keyboard binding) and choose a cancel key.- A rebinding is stored as an override on top of the asset's binding, so the original is never lost.
RemoveAllBindingOverrides()resets an action. SaveBindingOverridesAsJson()turns all of an asset's overrides into a string you can save, andLoadBindingOverridesFromJsonapplies them at start-up.action.GetBindingDisplayString()gives a readable name for the current binding, such as "Space" or "Left Shift", for showing in a menu.
Exercise 7 builds a complete rebinding button that saves the result. The Input System's package samples include a more complete rebinding UI you can import from the Package Manager.
The mouse, the cursor and touch#
Mouse movement is already per frame. The Look action's mouse binding reads the mouse's delta: how many pixels it moved since the last frame. If the frame took twice as long, the player moved the mouse twice as far in it. So mouse look must not be multiplied by Time.deltaTime; that would make turning slower at low frame rates. A stick is different: it reports a position, held as long as the player holds it, so it's a rate and does need deltaTime. Since Look carries both, code that turns a camera needs to know which device drove it; the follow camera below uses activeControl for this.
The cursor. For mouse-look games, lock the cursor to the centre of the window and hide it with Cursor.lockState = CursorLockMode.Locked and Cursor.visible = false. A locked cursor still reports movement. In the editor, Escape always frees it, so you can reach the Unity UI. A common pattern is to lock on a click and unlock on Escape:
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Locks and hides the cursor on a click, and frees it with Escape,
// so the mouse can steer the camera without leaving the window.
public class CursorLocker : MonoBehaviour
{
void Update()
{
Mouse mouse = Mouse.current;
Keyboard keyboard = Keyboard.current;
if (mouse != null && mouse.leftButton.wasPressedThisFrame) SetLocked(true);
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) SetLocked(false);
}
void OnDisable() => SetLocked(false);
static void SetLocked(bool locked)
{
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
Cursor.visible = !locked;
}
}
}Touch. Touchscreen.current gives raw touches, and the EnhancedTouch API tracks fingers across frames. The default actions already bind a tap to Attack and include a Touch control scheme. For virtual sticks and buttons, the package's On-Screen Stick and On-Screen Button components pretend to be a gamepad, so a touch-screen stick drives the same Move action as a real one. Mobile input in depth is in T47 · Mobile: Touch Input, Builds, Optimisation & Store Publishing.
From input to movement#
Reading the stick is the easy half. Turning it into movement that feels good takes four more decisions: which way is "forward", how quickly the character reaches full speed, how it turns, and what moves it.
Camera-relative movement#
In a third-person game, pushing the stick up should move the character away from the camera, whichever way the camera faces. The ActionMover above moves along the world axes, which only feels right while the camera looks along +Z. The fix is the vector maths from T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation: take the camera's forward and right, flatten them onto the ground with ProjectOnPlane, normalize, and combine them with the input.
If the camera looks almost straight down, its flattened forward is nearly zero and normalizing it gives nonsense. Top-down cameras use the camera's up flattened instead; the guided build's KeeperController handles both.
Acceleration and turning#
Setting the velocity straight from the stick makes a character that starts and stops instantly. That's precise, and right for some games, but most feel better with a little weight. Keep a velocity and move it towards the wanted velocity with Vector3.MoveTowards, by the acceleration times dt each frame, at a constant rate as T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation recommended. An acceleration of 25 m/s² reaches a 3.5 m/s walk in 0.14 s: responsive, but not robotic. Use a bigger value for stopping than for starting if you want crisp stops.
For turning, face the movement direction with Quaternion.LookRotation(direction), and get there with Quaternion.RotateTowards at a few hundred degrees a second, so a sudden reversal turns visibly instead of snapping. Skip turning when the input is near zero, or the character snaps back to face forward whenever the stick is released.
What moves the character?#
| Option | Collides with walls | Gravity and slopes | Good for |
|---|---|---|---|
Setting transform.position | No; walks through everything | Write it all yourself | Things that can't collide: cameras, UI, cursors, flying spirits |
| CharacterController | Yes, slides along walls | Slopes and steps built in; gravity is yours | Most player characters, especially third- and first-person |
| Rigidbody | Yes, fully simulated | Gravity built in; slopes and stairs need care | Physical characters: rolling balls, vehicles, ragdolls (T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers) |
A CharacterController gives you exact control and needs no tuning of friction or mass, which is why most character code starts with it. A Rigidbody character interacts naturally with other physics objects, but you must fight the simulation to make it feel precise. T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers compares them in depth.
The CharacterController#
A CharacterController is a capsule-shaped collider with a Move method. You tell it how far to go this frame; it moves as far as it can, sliding along walls, stepping up small ledges and refusing slopes that are too steep. It doesn't do gravity, momentum or pushing. You write those, which is the point: nothing happens that you didn't ask for.
| Setting | Meaning |
|---|---|
| Height, Radius, Center | The capsule. For a 2 m Capsule primitive with its pivot in the middle, the defaults (2, 0.5, centre 0) fit |
| Slope Limit | The steepest slope it climbs, in degrees (45 by default) |
| Step Offset | The tallest step it climbs without jumping (0.3 m by default) |
| Skin Width | A thin margin around the capsule that prevents it sticking in walls. About 10% of the radius |
| Min Move Distance | Moves shorter than this are ignored. Leave it at 0 or tiny, or slow movement stutters |
Move(motion)takes a distance for this frame, so passvelocity * Time.deltaTime. It returnsCollisionFlagssaying whether it touched something below, above or at the sides. Call it once per frame with everything combined; several calls makeisGroundedunreliable.SimpleMove(speed)takes a velocity, applies gravity itself and ignores any upward part, so it can't jump. It's fine for a first test and then outgrown.isGroundedsays whether the lastMovetouched the ground. It's only true if that move pushed downwards, which is why controllers keep a small downward speed (−2 m/s in the guided build) while standing.OnControllerColliderHitis called on the controller's scripts for each collider it touches while moving: the place to push crates or detect what's underfoot.
Jumps that feel right#
A jump is an upward speed that gravity eats away. To reach a height h with gravity g, the launch speed is v = √(2gh), and the time to the top is v / g. Designers think in heights and times, not speeds, so compute the speed from the height rather than tuning it by hand. Earth's 9.81 m/s² makes most game jumps feel floaty; games commonly use two or three times that.
using System;
// Jump numbers from the designer's side: height, gravity, launch speed and time to the top.
static class Program
{
// Steps a jump frame by frame: add gravity to the speed, then move.
// simple: move by the new speed. averaged: move by the average of the old and new speeds.
static float PeakAt(int framesPerSecond, float jumpSpeed, float gravity, bool averaged)
{
float dt = 1f / framesPerSecond, height = 0f, speed = jumpSpeed, peak = 0f;
while (height >= 0f)
{
float before = speed;
speed -= gravity * dt;
height += (averaged ? (before + speed) * 0.5f : speed) * dt;
peak = MathF.Max(peak, height);
}
return peak;
}
static void Main()
{
Console.WriteLine("Height Gravity Jump speed Time to top");
foreach (var (h, g) in new[] { (1.2f, 9.81f), (1.2f, 20f), (1.2f, 30f), (2.5f, 20f) })
{
float v = MathF.Sqrt(2f * g * h); // v = sqrt(2gh)
Console.WriteLine($"{h,4:F1} m {g,5:F1} {v,5:F2} m/s {v / g,5:F2} s");
}
// Choose the height and the time to reach it; work out gravity and speed.
float height = 1.2f, timeToTop = 0.35f;
float gravity = 2f * height / (timeToTop * timeToTop);
float launch = 2f * height / timeToTop;
Console.WriteLine($"{height} m in {timeToTop} s needs gravity {gravity:F1} and speed {launch:F2}");
// How high a 1.2 m jump really goes when it's stepped once per frame.
float speed = MathF.Sqrt(2f * 20f * 1.2f);
foreach (int fps in new[] { 30, 60, 144 })
Console.WriteLine($"{fps,3} fps: simple {PeakAt(fps, speed, 20f, false):F3} m, averaged {PeakAt(fps, speed, 20f, true):F3} m");
}
}Height Gravity Jump speed Time to top
1.2 m 9.8 4.85 m/s 0.49 s
1.2 m 20.0 6.93 m/s 0.35 s
1.2 m 30.0 8.49 m/s 0.28 s
2.5 m 20.0 10.00 m/s 0.50 s
1.2 m in 0.35 s needs gravity 19.6 and speed 6.86
30 fps: simple 1.087 m, averaged 1.198 m
60 fps: simple 1.143 m, averaged 1.200 m
144 fps: simple 1.176 m, averaged 1.200 mThe last three lines show a subtle problem. The usual code adds gravity to the speed, then moves by the new speed. Each frame it moves a little less than it should, and at 30 fps the 1.2 m jump only reaches 1.087 m, so a ledge that's reachable at 144 fps is out of reach on a slow laptop. Moving by the average of the speed at the start and end of the frame is exact for constant gravity, and the guided build does that.
Two tricks make jumping feel fair, and players notice when they're missing:
- Coyote time. Allow a jump for a short time, around 0.1 s, after walking off an edge. Players press jump when they see the edge, which is often a frame or two after they've left it.
- Jump buffering. Remember a jump press for a short time before landing, and jump as soon as the feet touch. Otherwise a press a few frames early is simply lost.
Both are timestamps: record Time.time when the character was last grounded and when jump was last pressed, and jump when both are recent enough. Then clear both, so one press can't jump twice. For variable jump height, where a tap gives a hop and a hold gives a full jump, cut the upward speed (for example, halve it) on the frame the button is released while still rising. Exercise 4 works with the jump numbers.
Cameras#
Camera basics#
A Camera component draws the scene from its position, looking along its forward axis. Its main settings:
- Projection: Perspective, where distant things look smaller, or Orthographic, where they don't (2D games, strategy maps, UI). An orthographic camera's Size is half the height of what it shows, in metres.
- Field of View: how wide a perspective camera sees, 60° vertically by default. Wider shows more and feels faster; narrower looks zoomed in.
- Clipping Planes: nothing nearer than Near (0.3 m) or farther than Far (1,000 m) is drawn. Don't make Near tiny or Far huge without reason: their ratio decides depth precision, and a bad ratio makes distant surfaces flicker through each other.
The camera tagged MainCamera is returned by Camera.main. Unity caches it, so it's cheap to call, but storing it in a field is still tidy.
Screen, viewport and world#
Three coordinate systems meet at the camera. Screen space is pixels, from (0, 0) at the bottom-left of the window to (width, height) at the top-right; the mouse position from Mouse.current.position is in screen space. Viewport space is the same rectangle measured from 0 to 1, independent of resolution. World space is the scene. The camera converts between them:
| Method | Does |
|---|---|
ScreenPointToRay(pixel) | A ray from the camera through a pixel, for picking what's under the mouse |
WorldToScreenPoint(position) | Where a world point appears on screen, for health bars over heads. Its z is the distance in front of the camera; negative means behind it |
ScreenToWorldPoint(new Vector3(x, y, distance)) | The world point at that pixel, distance metres from the camera. With z = 0 a perspective camera returns its own position |
WorldToViewportPoint, ViewportToWorldPoint, ViewportPointToRay | The same with 0–1 coordinates; (0.5, 0.5) is the centre of the view |
To find what the mouse points at, cast the ray from ScreenPointToRay. Against colliders, use Physics.Raycast (T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers); against flat ground with no colliders, Plane.Raycast from T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation is enough, as exercise 5 shows. With a locked cursor, the mouse position is the centre of the screen, so the same code aims from the crosshair.
A follow camera by hand#
Writing one camera yourself shows what any camera system has to solve. This one orbits a target with the Look action and follows it smoothly:
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// A hand-written third-person camera: orbits the target with the Look action
// and follows it smoothly. Put it on the Camera object.
public class FollowCamera : MonoBehaviour
{
[SerializeField] Transform target;
[SerializeField] float distance = 6f;
[SerializeField] float focusHeight = 1.6f; // look at this far above the target's pivot
[SerializeField] float mouseDegreesPerPixel = 0.15f;
[SerializeField] float stickDegreesPerSecond = 180f;
[SerializeField] float minPitch = -20f, maxPitch = 70f;
[SerializeField] float smoothTime = 0.08f;
InputAction look;
float yaw, pitch = 20f;
Vector3 velocity;
void Awake()
{
if (InputSystem.actions != null)
look = InputSystem.actions.FindAction("Player/Look");
}
void Start()
{
if (target != null) yaw = target.eulerAngles.y; // start behind the target
}
// LateUpdate: the target has finished moving for this frame.
void LateUpdate()
{
if (target == null) return;
if (look != null)
{
Vector2 input = look.ReadValue<Vector2>();
// Mouse delta is already "how far it moved this frame", so no deltaTime.
// A stick is a rate, so it needs deltaTime.
InputControl control = look.activeControl;
bool fromPointer = control != null && control.device is Pointer;
Vector2 degrees = fromPointer
? input * mouseDegreesPerPixel
: input * (stickDegreesPerSecond * Time.deltaTime);
yaw += degrees.x;
pitch = Mathf.Clamp(pitch - degrees.y, minPitch, maxPitch); // mouse up looks up
}
Vector3 focus = target.position + Vector3.up * focusHeight;
Quaternion orbit = Quaternion.Euler(pitch, yaw, 0f);
Vector3 wanted = focus - orbit * Vector3.forward * distance;
transform.position = Vector3.SmoothDamp(transform.position, wanted, ref velocity, smoothTime);
transform.rotation = Quaternion.LookRotation(focus - transform.position);
}
}
}- It runs in
LateUpdate, after the target moved inUpdate. A camera that moves inUpdatesometimes runs before its target and sometimes after, and the target appears to jitter. - Yaw and pitch are kept as two floats and turned into a rotation each frame, which avoids the Euler drift from T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation. Pitch is clamped below 90° so the camera can't flip over the top.
- The orbit position is "behind the focus point": the focus minus the orbit's forward times the distance.
SmoothDampeases the camera towards it, andLookRotationaims it at the focus. - Mouse and stick are scaled differently, using
activeControl, because mouse delta is already per frame.
What it doesn't do: avoid walls (the camera ends up inside them), zoom, blend to other cameras, shake, or frame two targets at once. Exercise 8 adds wall avoidance and zoom. For the rest, use Cinemachine.
Cinemachine 3#
Cinemachine is Unity's camera system: a package of camera behaviours that you configure instead of writing. Install it from the Package Manager (Unity Registry → Cinemachine). This handbook uses version 3.1.7.
How it works: the Brain and its cameras#
Cinemachine doesn't replace your Unity camera. It adds a Cinemachine Brain component to it, and you create any number of Cinemachine Cameras: lightweight objects that each describe a shot ("follow the player from behind", "look at the door from the balcony"). They don't render anything. Each frame, the Brain picks the live camera and moves the real camera to match it. When the live camera changes, the Brain blends smoothly from the old shot to the new one.
- Priority: the live camera is the active one with the highest priority (0 by default). Equal priorities go to the one activated most recently. Switching shots is therefore just raising a priority, or enabling a camera.
- Brain settings: Update Method (Smart Update suits almost everything), Default Blend (the curve and duration of blends, Ease In Out by default; Cut means no blend), and an optional Custom Blends asset for specific pairs of cameras.
- The first Cinemachine Camera you create adds a Brain to the Main Camera automatically.
A Cinemachine Camera#
Create one with GameObject → Cinemachine → Cinemachine Camera, or a preset from GameObject → Cinemachine → Targeted Cameras (Follow Camera, FreeLook Camera, Third Person Aim Camera, 2D Camera, Target Group Camera). Its Inspector has:
- Tracking Target: the object it follows, and by default also looks at. Use an empty child at head height rather than the character's pivot at its feet.
- Lens: field of view, clipping planes, and so on, copied to the real camera while this camera is live.
- Position Control: how it moves. Follow (a fixed offset), Orbital Follow (on a sphere or rings around the target, steered by input), Third Person Follow (over the shoulder), Position Composer (keeps the target at a screen position, good for 2D), Hard Lock to Target, Spline Dolly.
- Rotation Control: how it aims. Rotation Composer (keeps the target framed, with dead zones), Hard Look At, Pan Tilt (rotated by input, for first-person views), Rotate With Follow Target.
- Noise: procedural handheld wobble from a Basic Multi Channel Perlin component with a noise profile.
- Extensions (the Add Extension dropdown): Deoccluder (moves the camera in front of walls that block the view), Confiner 2D and 3D (keep it inside a shape), Impulse Listener (reacts to shakes), and more.
A FreeLook camera is just a Cinemachine Camera with Orbital Follow, Rotation Composer and a Cinemachine Input Axis Controller. Cinemachine components don't read input themselves; the Input Axis Controller reads Input System actions and drives their axes (for Orbital Follow: Look Orbit X, Look Orbit Y and Orbit Scale). Each axis has an Input Action, a Gain, acceleration and deceleration times, and Cancel Delta Time, which you tick when the action is a mouse delta that's already per frame.
Cinemachine from code#
Most Cinemachine work happens in the Inspector, but switching cameras from gameplay is code. The namespace is Unity.Cinemachine, and Priority converts to and from an int, so raising a camera is one assignment:
using Unity.Cinemachine;
using UnityEngine;
namespace Handbook.T07
{
// Raises a Cinemachine camera's priority while the keeper stands near this point,
// so the Brain blends to it, and lowers it again when the keeper leaves.
public class LookoutZone : MonoBehaviour
{
[SerializeField] Transform keeper;
[SerializeField] CinemachineCamera lookoutCamera;
[SerializeField, Min(0.5f)] float radius = 3f;
[SerializeField] int activePriority = 20;
int restingPriority;
bool inside;
void Start()
{
if (lookoutCamera != null) restingPriority = lookoutCamera.Priority; // PrioritySettings converts to int
}
void Update()
{
if (keeper == null || lookoutCamera == null) return;
Vector3 offset = keeper.position - transform.position;
offset.y = 0f; // a cylinder: ignore height
bool nowInside = offset.sqrMagnitude <= radius * radius;
if (nowInside == inside) return;
inside = nowInside;
lookoutCamera.Priority = inside ? activePriority : restingPriority;
}
void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
}Impulses are Cinemachine's camera shake. A Cinemachine Impulse Source component emits a shake when you call GenerateImpulse() or GenerateImpulseWithForce(force); every camera with an Impulse Listener extension within range reacts. A Collision Impulse Source does it automatically when its object collides. Shake is a strong signal: use it for heavy events, scaled by how heavy they are.
using Unity.Cinemachine;
using UnityEngine;
namespace Handbook.T07
{
// Shakes the camera when the keeper lands hard. Needs a Cinemachine Impulse Source
// on this object and a Cinemachine Impulse Listener extension on the camera.
[RequireComponent(typeof(KeeperController), typeof(CinemachineImpulseSource))]
public class LandingShake : MonoBehaviour
{
[SerializeField] float minSpeed = 9f; // slower landings (about a 2 m drop) don't shake
[SerializeField] float maxSpeed = 16f; // this fast or more gives the full shake
[SerializeField] float maxForce = 1f;
KeeperController keeper;
CinemachineImpulseSource impulse;
void Awake()
{
keeper = GetComponent<KeeperController>();
impulse = GetComponent<CinemachineImpulseSource>();
}
void OnEnable() => keeper.Landed += OnLanded;
void OnDisable() => keeper.Landed -= OnLanded;
void OnLanded(float speed)
{
if (speed < minSpeed) return;
float strength = Mathf.Lerp(0.3f, 1f, Mathf.InverseLerp(minSpeed, maxSpeed, speed));
impulse.GenerateImpulseWithForce(strength * maxForce);
}
}
}Guided build: Lighthouse Island, shore leave#
The keeper finally gets off duty. You'll put a walking, running, jumping keeper on the island, with a FreeLook camera that orbits with the mouse or right stick, a lookout rock where the view switches to a dramatic shot of the harbour, and a camera shake when the keeper jumps down from it. The build uses KeeperController, CursorLocker, LookoutZone and LandingShake.
Step 1 · Packages and ground#
- Check the setup from the start of the chapter: Input System 1.20.1, Active Input Handling set to the new system, and project-wide actions assigned. Install Cinemachine from the Package Manager.
- Open
IslandNightand copy the four scripts into_Project/Scripts. - The ground needs fixing before anyone can walk on it. Select
Beachand look at its Capsule Collider in the Scene view: it's a huge sphere, not a flat disc. A Capsule Collider can't be flatter than a sphere, so on a cylinder scaled (34, 0.3, 34) it becomes a ball 34 m across. Remove the Capsule Collider and add a Mesh Collider, which uses the cylinder's real shape. Do the same forMeadow. (T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers explains collider shapes.)
Step 2 · The keeper#
using System;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Walks, runs and jumps with a CharacterController, relative to the camera.
// Reads the Move, Jump and Sprint actions from the project-wide actions asset.
[RequireComponent(typeof(CharacterController))]
public class KeeperController : MonoBehaviour
{
[Header("Moving")]
[SerializeField] float walkSpeed = 3.5f;
[SerializeField] float sprintSpeed = 6.5f;
[SerializeField] float acceleration = 25f; // metres per second, per second
[SerializeField] float turnSpeed = 720f; // degrees per second
[Header("Jumping")]
[SerializeField] float jumpHeight = 1.2f;
[SerializeField] float gravity = -20f;
[SerializeField] float coyoteTime = 0.12f; // seconds after leaving an edge when a jump still works
[SerializeField] float jumpBuffer = 0.15f; // seconds before landing when a press still counts
[Tooltip("Movement is relative to this. Uses the main camera if empty.")]
[SerializeField] Transform cameraTransform;
// Raised on touchdown with the downward speed in metres per second.
public event Action<float> Landed;
CharacterController controller;
InputAction move, jump, sprint;
Vector3 horizontalVelocity;
float verticalVelocity;
float lastGroundedTime = float.NegativeInfinity;
float lastJumpPressTime = float.NegativeInfinity;
bool wasGrounded = true;
void Awake()
{
controller = GetComponent<CharacterController>();
InputActionAsset actions = InputSystem.actions;
if (actions == null)
{
Debug.LogError("No project-wide actions. Assign them in Project Settings > Input System Package.", this);
enabled = false;
return;
}
move = actions.FindAction("Player/Move", throwIfNotFound: true);
jump = actions.FindAction("Player/Jump", throwIfNotFound: true);
sprint = actions.FindAction("Player/Sprint", throwIfNotFound: true);
}
void Start()
{
if (cameraTransform == null && Camera.main != null)
cameraTransform = Camera.main.transform;
}
void Update()
{
float dt = Time.deltaTime;
// 1. Read the input. Clamp, so a diagonal on the keyboard isn't faster.
Vector2 input = Vector2.ClampMagnitude(move.ReadValue<Vector2>(), 1f);
if (jump.WasPressedThisFrame()) lastJumpPressTime = Time.time;
float topSpeed = sprint.IsPressed() ? sprintSpeed : walkSpeed;
// 2. Turn it into a direction on the ground, relative to the camera.
Vector3 wanted = CameraRelative(input) * topSpeed;
// 3. Speed up and slow down at a limited rate, and turn to face the way we move.
horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, wanted, acceleration * dt);
if (wanted.sqrMagnitude > 0.01f)
{
Quaternion facing = Quaternion.LookRotation(wanted);
transform.rotation = Quaternion.RotateTowards(transform.rotation, facing, turnSpeed * dt);
}
// 4. Ground, jump and gravity.
bool grounded = controller.isGrounded; // set by last frame's Move
if (grounded)
{
if (!wasGrounded) Landed?.Invoke(-verticalVelocity);
lastGroundedTime = Time.time;
if (verticalVelocity < 0f) verticalVelocity = -2f; // keep pressing down so isGrounded stays true
}
wasGrounded = grounded;
bool canJump = Time.time - lastGroundedTime <= coyoteTime;
bool wantsJump = Time.time - lastJumpPressTime <= jumpBuffer;
if (canJump && wantsJump)
{
verticalVelocity = Mathf.Sqrt(2f * jumpHeight * -gravity); // v = √(2gh)
lastJumpPressTime = float.NegativeInfinity; // use up the press
lastGroundedTime = float.NegativeInfinity; // and the coyote time
}
float startSpeed = verticalVelocity;
verticalVelocity += gravity * dt;
// 5. Move once, with everything combined. Rising by the average of the start and end
// speeds makes the jump reach the same height at any frame rate.
float rise = (startSpeed + verticalVelocity) * 0.5f * dt;
CollisionFlags hits = controller.Move(horizontalVelocity * dt + Vector3.up * rise);
if ((hits & CollisionFlags.Above) != 0 && verticalVelocity > 0f)
verticalVelocity = 0f; // bumped our head
}
Vector3 CameraRelative(Vector2 input)
{
if (cameraTransform == null)
return new Vector3(input.x, 0f, input.y);
// Flatten the camera's axes onto the ground. A camera looking straight down
// has no flat forward, so use its up instead.
Vector3 forward = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up);
if (forward.sqrMagnitude < 0.0001f)
forward = Vector3.ProjectOnPlane(cameraTransform.up, Vector3.up);
forward.Normalize();
Vector3 right = Vector3.Cross(Vector3.up, forward);
return forward * input.y + right * input.x;
}
}
}- Inside Gameplay, create a Capsule named
Keeperat (2, 1.6, −6), on the meadow near the cottages. Give it a material of its own colour. - Remove its Capsule Collider, and add a CharacterController. The defaults (height 2, radius 0.5) fit the capsule.
- Add a child Cube named
Noseat local (0, 0.5, 0.45), scale (0.3, 0.15, 0.2), so you can see which way the keeper faces. Remove its Box Collider: a collider on a CharacterController's child moves without a Rigidbody, and the controller can catch on it. - Add a child empty named
CameraTargetat local (0, 0.6, 0): head height, for the camera to follow. - Add
KeeperControllerandCursorLockerto the Keeper.
Press Play. The keeper drops onto the meadow. WASD or the left stick walks, Shift or a stick click runs, Space or the south button jumps. With the default Main Camera, which doesn't move, the controls already work relative to the camera: up on the stick walks away from it. Try walking off the edge of the meadow onto the beach, and press jump just after: coyote time lets you.
Step 3 · The FreeLook camera#
- Choose GameObject → Cinemachine → Targeted Cameras → FreeLook Camera. Name it
KeeperCam. The Main Camera gains a Cinemachine Brain. - Drag
Keeper/CameraTargetinto its Tracking Target. Set its Priority to 10. - Expand the Cinemachine Input Axis Controller. For Look Orbit X and Look Orbit Y, check that the Input Action is a Look action; if it's empty or you want rebinding to apply, drag in Player/Look from
InputSystem_Actions. If you play with a mouse, tick Cancel Delta Time on both, since mouse delta is already per frame. (With a gamepad, leave it off. A game that supports both properly uses separate actions or processors per device.) - In Orbital Follow, adjust the orbits until the keeper fills about a third of the screen height.
Press Play and click in the Game view to lock the cursor. The mouse orbits the camera around the keeper, and the keeper walks relative to wherever the camera looks, because KeeperController reads the Main Camera's transform, which the Brain moves. Press Escape to free the cursor.
Step 4 · The lookout rock#
- Inside Environment, create a Cube named
LookoutRockat (12, 2, −2), scale (4, 3, 4), with the Rock material. Its top is at 3.5 m. - Add a ramp up to it: a Cube named
Rampat (12, 2, −7.2), rotation (−25, 0, 0), scale (2, 0.2, 7.1). A negative X rotation tips the far (+Z) end up, onto the rock. The 25° slope is under the controller's 45° limit. - Create GameObject → Cinemachine → Cinemachine Camera, named
LookoutCam. Place it high above the rock, around (12, 9, 2), and rotate it to look out over the harbour and the jetty, for example (25, 200, 0). Leave its Priority at 0 and its Position and Rotation Control empty: it's a fixed shot. - Create an empty named
LookoutZoneon top of the rock at (12, 3.5, −2). AddLookoutZone, drag in the Keeper and LookoutCam, and set the radius to 2.
Walk up the ramp onto the rock. When the keeper reaches the middle, the view blends over two seconds to the harbour shot, with the ships sailing in and the beam sweeping. Walk off the zone and it blends back. Select the Main Camera during Play to see the Brain's Live Camera change.
Step 5 · A hard landing#
- Add a Cinemachine Impulse Source to the Keeper, then
LandingShake. ItsRequireComponentwould add the source for you if you forgot. - Select
KeeperCam, open Add Extension at the bottom of the Cinemachine Camera, and choose Cinemachine Impulse Listener. - Press Play, climb the rock and jump off the side. The fall is about 3 m, so the keeper lands at about 11 m/s and the camera thumps. A normal jump on flat ground lands at under 7 m/s, below the 9 m/s threshold, so it doesn't shake.
Step 6 · Break it on purpose#
- Lose the ground. In
KeeperController, change the −2 that keeps the keeper pressed down to 0. Walk down the ramp: the keeper bounces in the air in tiny hops,isGroundedflickers, and jumping becomes unreliable. Put it back. - No coyote time. Set Coyote Time to 0, run off the rock and press jump as you leave: most of the time nothing happens. Set it back to 0.12.
- World-relative movement. In
CameraRelative, returnnew Vector3(input.x, 0f, input.y)unconditionally. Orbit the camera to face the keeper and try walking: up moves towards the camera. Undo it. - Mouse delta scaled by deltaTime. Untick Cancel Delta Time and limit the frame rate (in the Game view's toolbar, or with
Application.targetFrameRate = 20in a test script). Turning feels different at different frame rates. Tick it again.
Step 7 · Save and commit#
Save, check the scripts, the Cinemachine package change in Packages/manifest.json and the scene are in Git, and commit: git commit -m "Shore leave".
Check your work. You've finished when: the keeper walks, runs and jumps, relative to the camera, on keyboard and gamepad; the camera orbits with the mouse or right stick and the cursor locks and unlocks; the view blends to the lookout shot on top of the rock and back when you leave; jumping off the rock shakes the camera, but ordinary jumps don't; and the Console has no errors.
Going further#
An input layer#
In larger projects, gameplay code doesn't read actions directly. A small input reader, often a ScriptableObject, reads the actions and exposes plain C# events and values ("MoveInput", "JumpPressed"). Characters listen to the reader, so the same character can be driven by the player, by AI or by a replay, and tests can feed it fake input. T13 · ScriptableObjects & Data-Driven Architecture and T14 · Events, Messaging & Decoupled Systems build this.
More Cinemachine#
Cinemachine has more camera types for specific jobs. A State-Driven Camera picks a child camera from the current Animator state (aiming, running). The Sequencer Camera plays a list of shots for cutscenes, and Timeline can drive cameras precisely (T55 · Animation Rigging, Procedural Animation, Timeline & Cinematics). A Target Group frames several targets at once, for two-player games and boss fights. The Clear Shot camera picks whichever child has the best unobstructed view. For 2D, a Position Composer with a Confiner 2D gives a follow camera that stops at the level's edges (T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights). T62 · Camera Direction: Camera Modes & States, Blends, Interaction Close-Ups, Cutscene Hand-Offs & Camera Language puts these together into a camera system for a whole game: modes that change with what the player is doing, close-ups when they interact with something, and hand-offs into cutscenes and back.
Accessible controls#
Rebinding is the start. Also consider: invert-Y and sensitivity settings for both mouse and stick; toggles instead of holds (sprint, aim, crouch); generous coyote and buffer windows; no essential rapid button-mashing; and one-handed layouts. Each is a binding, a processor or a small change in your input layer, which is another reason to keep gameplay code reading actions rather than devices. T60 · Reaching Every Player: Localization, Accessibility & Settings Menus covers accessibility across the whole game, from subtitles to colour and text size.
Common mistakes & debugging#
Input does nothing, or the wrong thing#
- InvalidOperationException about the
Inputclass: old Input Manager code with Active Input Handling set to the new system. Port it to the Input System (or set Both while migrating). - An action never fires: it isn't enabled. Project-wide actions are enabled in Play mode; actions from any other asset, and generated classes, need
Enable(). FindActionreturns null: a typo in the name or map, or no project-wide actions assigned. PassthrowIfNotFound: trueto find out at once.- A NullReferenceException on a machine without a gamepad:
Gamepad.currentused without a null check. - A press is missed, or counted twice:
WasPressedThisFrameread inFixedUpdate. Read it inUpdateand pass the result on. - The player jumps while a menu is open: the Player map wasn't disabled. Switch maps when opening menus.
- A PlayerInput message method never runs: its name doesn't match "On" plus the action's name, or the script is on another GameObject (use Broadcast Messages for children).
- Input works in the editor but not in a build, or the other way round: in the Input System's Project Settings, check the background behaviour and the supported devices, and that the actions asset is assigned there, not just in a scene.
Movement and cameras#
- Mouse turning speed changes with the frame rate: mouse delta multiplied by
Time.deltaTime. isGroundedflickers: no downward speed while standing, orMovecalled more than once a frame.- The character falls through the floor or floats on an invisible dome: the ground has no collider, or a Capsule Collider on a flattened shape.
- The character snaps back to facing forward when the stick is released: the facing direction is updated with a zero direction. Only turn when the input is above a small threshold.
- Teleporting a CharacterController does nothing: it overwrote the new position. Disable it, move, enable it.
- The camera jitters: it moves in
Updatebefore the target has moved. Move it inLateUpdate, or let the Cinemachine Brain handle the timing. - A Cinemachine camera doesn't take over: its priority isn't the highest among active cameras, or there's no Brain on the camera that renders.
ScreenToWorldPointalways returns the camera's position: the z coordinate was 0. Pass the distance from the camera, or use a ray.
Exercises#
The Unity solutions are in the sandbox under Assets/Handbook/T07/Exercises, compiled against Unity 6.3 and Input System 1.20.1.
1 · A light switch
Without actions, make the L key or the gamepad's left shoulder button switch a light on and off. It must not throw on a machine with no keyboard or no gamepad. Fill the light slot automatically when the component is added.
Solution
Read each device into a local, check it for null, and combine the two presses. Reset runs when the component is added, so it's a good place to find the light.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Switches a light on and off with L on the keyboard or the left shoulder button.
public class LightToggle : MonoBehaviour
{
[SerializeField] Light target;
void Reset() => target = GetComponentInChildren<Light>();
void Update()
{
if (target == null) return;
Keyboard keyboard = Keyboard.current;
Gamepad pad = Gamepad.current;
bool pressed = (keyboard != null && keyboard.lKey.wasPressedThisFrame)
|| (pad != null && pad.leftShoulder.wasPressedThisFrame);
if (pressed) target.enabled = !target.enabled;
}
}
}2 · Hold to ring
Using the default Interact action, which has a Hold interaction, ring a bell (log "Ding!" and play an optional sound) when the player holds E or the north button long enough. A quick tap should log a hint instead. Use callbacks, not polling.
Solution
With a Hold interaction, started fires on press, performed when the hold time is reached, and canceled on release in both cases. So remember whether performed happened, and give the hint on canceled only if it didn't.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Rings when the Interact action is held long enough; a quick tap gets a hint.
// The template's Interact action has a Hold interaction (0.4 s by default):
// started on press, performed once held long enough, canceled on release either way.
public class BellRinger : MonoBehaviour
{
[SerializeField] InputActionReference interact; // Player/Interact
[SerializeField] AudioSource bell; // optional
bool rang;
void OnEnable()
{
if (interact == null) return;
interact.action.started += OnStarted;
interact.action.performed += OnPerformed;
interact.action.canceled += OnCanceled;
}
void OnDisable()
{
if (interact == null) return;
interact.action.started -= OnStarted;
interact.action.performed -= OnPerformed;
interact.action.canceled -= OnCanceled;
}
void OnStarted(InputAction.CallbackContext context) => rang = false;
void OnPerformed(InputAction.CallbackContext context)
{
rang = true;
Debug.Log("Ding!", this);
if (bell != null) bell.Play();
}
void OnCanceled(InputAction.CallbackContext context)
{
if (!rang) Debug.Log("Hold the button to ring the bell.", this);
}
}
}3 · A new action
Add a Wave button action to the Player map, bound to Q and the gamepad's right shoulder, in both the Keyboard&Mouse and Gamepad control schemes. Then write a component that waves an arm (any Transform pivoting at the shoulder) while it's held.
Solution
In Project Settings → Input System Package, select the Player map, click + in the Actions column, name it Wave, and leave its type as Button. Click + on the action to add a binding, use Path to pick Q [Keyboard] and tick Keyboard&Mouse under Use in control scheme. Add a second binding for Right Shoulder [Gamepad], ticked for Gamepad. Changes to the project-wide asset save automatically. The component looks the action up by name and polls IsPressed; the wave is a sine wave on top of a raised arm, from T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Waves an arm while the new Player/Wave action is held.
public class Waver : MonoBehaviour
{
[SerializeField] Transform arm; // pivot at the shoulder, hanging down at rest
[SerializeField] float wavesPerSecond = 2f;
[SerializeField] float swing = 40f; // degrees either side
InputAction wave;
Quaternion rest;
void Awake()
{
if (InputSystem.actions != null)
wave = InputSystem.actions.FindAction("Player/Wave");
if (wave == null)
Debug.LogWarning("Add a Wave action to the Player map first.", this);
}
void Start()
{
if (arm != null) rest = arm.localRotation;
}
void Update()
{
if (arm == null || wave == null) return;
if (wave.IsPressed())
{
// Raise the arm, then swing it side to side with a sine wave.
float side = Mathf.Sin(Time.time * wavesPerSecond * 2f * Mathf.PI) * swing;
arm.localRotation = rest * Quaternion.Euler(0f, 0f, 150f + side);
}
else
{
arm.localRotation = Quaternion.RotateTowards(arm.localRotation, rest, 360f * Time.deltaTime);
}
}
}
}4 · Designing a jump
A designer wants a jump that reaches 1.2 m in 0.35 s. Work out the gravity and the launch speed. Then compare four gravities for the same 1.2 m jump and explain which feels floaty. Finally, show how far short a frame-by-frame jump falls at 30, 60 and 144 fps, and how to fix it.
Solution
With constant gravity, height h = ½gt² at the top, so g = 2h / t² = 19.6 m/s², and v = gt = 2h / t = 6.86 m/s. With Earth gravity a 1.2 m jump takes half a second to rise, which feels slow; 20–30 m/s² feels snappy. Stepping by the end-of-frame speed loses height at low frame rates; moving by the average of the start and end speeds is exact for constant acceleration. It's the JumpMaths program in the jumping section.
5 · Click on the ground
Right-click to move a marker object to the point on the ground under the mouse, without any colliders. What happens when the cursor is locked?
Solution
Build a ray through the mouse pixel with ScreenPointToRay and intersect it with a mathematical Plane at ground height. Plane.Raycast returns false if the ray points away from the plane (at the sky). With a locked cursor, the mouse position is the centre of the screen, so the marker goes where the camera looks.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Right-click to put a marker where the mouse points on flat ground.
// Uses a mathematical plane, so the ground needs no collider.
public class ClickMarker : MonoBehaviour
{
[SerializeField] Transform marker;
[SerializeField] float groundHeight = 0.3f;
Camera cam;
void Start() => cam = Camera.main;
void Update()
{
Mouse mouse = Mouse.current;
if (mouse == null || cam == null || marker == null) return;
if (!mouse.rightButton.wasPressedThisFrame) return;
// A ray from the camera through the mouse's pixel position.
Ray ray = cam.ScreenPointToRay(mouse.position.ReadValue());
Plane ground = new Plane(Vector3.up, new Vector3(0f, groundHeight, 0f));
if (ground.Raycast(ray, out float distance))
marker.position = ray.GetPoint(distance);
}
}
}6 · Pause
Make P or the gamepad's Start button pause the game: stop time, stop the keeper responding, and free the cursor. Pressing again resumes. Disabling the component while paused must not leave the game frozen.
Solution
Set Time.timeScale to 0, disable the Player action map so movement and jump read as nothing, and unlock the cursor. Polling the devices directly is reasonable here, because the pause key must work whichever map is active. OnDisable unpauses.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// P or the gamepad's Start button pauses: time stops, the Player map is switched off
// (so the keeper can't jump while paused) and the cursor is freed for menus.
public class PauseToggle : MonoBehaviour
{
InputActionMap player;
bool paused;
void Awake()
{
if (InputSystem.actions != null)
player = InputSystem.actions.FindActionMap("Player");
}
void Update()
{
Keyboard keyboard = Keyboard.current;
Gamepad pad = Gamepad.current;
bool pressed = (keyboard != null && keyboard.pKey.wasPressedThisFrame)
|| (pad != null && pad.startButton.wasPressedThisFrame);
if (pressed) SetPaused(!paused);
}
void OnDisable()
{
if (paused) SetPaused(false); // never leave the game frozen
}
void SetPaused(bool value)
{
paused = value;
Time.timeScale = paused ? 0f : 1f;
if (player != null)
{
if (paused) player.Disable();
else player.Enable();
}
Cursor.lockState = paused ? CursorLockMode.None : CursorLockMode.Locked;
Cursor.visible = paused;
Debug.Log(paused ? "Paused" : "Resumed");
}
}
}7 · Rebind jump
Show a button with Jump's current keyboard key. Clicking it waits for a new key (Escape cancels; mouse buttons aren't allowed) and makes that the Jump key. A second button resets it. The choice must survive quitting and restarting.
Solution
Find the Keyboard&Mouse binding's index with a group mask, disable the action, and start an interactive rebinding with the exclusions and cancel key. Whether it completes or is cancelled, dispose of the operation, enable the action again, and save all overrides as JSON in PlayerPrefs. Load them in Awake. The buttons use IMGUI to keep the example self-contained.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// Lets the player change the keyboard key for Jump, and remembers it between sessions.
// Uses an IMGUI button to stay self-contained; a real menu would use UI Toolkit or uGUI.
public class JumpRebinder : MonoBehaviour
{
const string SaveKey = "binding-overrides";
[SerializeField] InputActionReference jump; // Player/Jump
InputActionRebindingExtensions.RebindingOperation operation;
void Awake()
{
// Overrides are stored as JSON and applied on top of the asset's own bindings.
string json = PlayerPrefs.GetString(SaveKey, "");
if (!string.IsNullOrEmpty(json) && InputSystem.actions != null)
InputSystem.actions.LoadBindingOverridesFromJson(json);
}
void OnGUI()
{
if (jump == null) return;
InputAction action = jump.action;
GUILayout.BeginArea(new Rect(20, 20, 320, 120));
if (operation != null)
{
GUILayout.Label("Press a key for Jump, or Escape to cancel");
}
else
{
if (GUILayout.Button($"Jump: {action.GetBindingDisplayString()} (click to change)"))
StartRebind(action);
if (GUILayout.Button("Reset to default"))
{
action.RemoveAllBindingOverrides();
Save();
}
}
GUILayout.EndArea();
}
void StartRebind(InputAction action)
{
int index = action.GetBindingIndex(InputBinding.MaskByGroup("Keyboard&Mouse"));
if (index < 0)
{
Debug.LogWarning("Jump has no Keyboard&Mouse binding to change.", this);
return;
}
action.Disable(); // an action can't be rebound while enabled
operation = action.PerformInteractiveRebinding(index)
.WithControlsExcluding("<Mouse>")
.WithCancelingThrough("<Keyboard>/escape")
.OnComplete(op => Finish(action))
.OnCancel(op => Finish(action))
.Start();
}
void Finish(InputAction action)
{
operation.Dispose();
operation = null;
action.Enable();
Save();
}
void Save()
{
if (InputSystem.actions != null)
PlayerPrefs.SetString(SaveKey, InputSystem.actions.SaveBindingOverridesAsJson());
}
void OnDestroy() => operation?.Dispose();
}
}8 · A camera that respects walls
Extend the hand-written follow camera so that it zooms in and out with the scroll wheel between 2 and 10 m, and never ends up inside or behind a wall: if something is between the target and the camera, the camera moves in front of it. Ignore trigger colliders.
Solution
Sweep a small sphere from the focus point towards where the camera wants to be with Physics.SphereCast; if it hits, place the camera at the hit distance instead. A sphere rather than a thin ray keeps the camera's near plane from clipping into the wall. Scroll values differ between platforms, so use only their sign for fixed zoom steps. Physics queries are covered in T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T07
{
// FollowCamera plus two things every third-person camera needs:
// it zooms with the scroll wheel and it doesn't go through walls.
public class OrbitCamera : MonoBehaviour
{
[SerializeField] Transform target;
[SerializeField] float focusHeight = 1.6f;
[SerializeField] float minDistance = 2f, maxDistance = 10f, zoomStep = 1f;
[SerializeField] float mouseDegreesPerPixel = 0.15f;
[SerializeField] float stickDegreesPerSecond = 180f;
[SerializeField] float minPitch = -20f, maxPitch = 70f;
[SerializeField] float probeRadius = 0.25f; // keep this far from walls
[SerializeField] LayerMask obstacles = ~0;
InputAction look;
float yaw, pitch = 20f, distance = 6f;
void Awake()
{
if (InputSystem.actions != null)
look = InputSystem.actions.FindAction("Player/Look");
}
void Start()
{
if (target != null) yaw = target.eulerAngles.y;
}
void LateUpdate()
{
if (target == null) return;
if (look != null)
{
Vector2 input = look.ReadValue<Vector2>();
InputControl control = look.activeControl;
bool fromPointer = control != null && control.device is Pointer;
Vector2 degrees = fromPointer
? input * mouseDegreesPerPixel
: input * (stickDegreesPerSecond * Time.deltaTime);
yaw += degrees.x;
pitch = Mathf.Clamp(pitch - degrees.y, minPitch, maxPitch);
}
// Scroll sizes differ between platforms, so use only the direction.
Mouse mouse = Mouse.current;
if (mouse != null)
{
float scroll = mouse.scroll.ReadValue().y;
if (scroll != 0f)
distance = Mathf.Clamp(distance - Mathf.Sign(scroll) * zoomStep, minDistance, maxDistance);
}
Vector3 focus = target.position + Vector3.up * focusHeight;
Quaternion orbit = Quaternion.Euler(pitch, yaw, 0f);
Vector3 back = orbit * Vector3.back;
// Sweep a small sphere from the focus towards the camera. If it hits something,
// stop just in front of it, so the view is never blocked by a wall.
float allowed = distance;
if (Physics.SphereCast(focus, probeRadius, back, out RaycastHit hit, distance,
obstacles, QueryTriggerInteraction.Ignore))
allowed = hit.distance;
transform.position = focus + back * allowed;
transform.rotation = orbit;
}
}
}Check yourself#
Why should gameplay code read actions such as "Jump" rather than keys?
- Actions are faster to read
- Keys can't be read in Unity 6
- Bindings can change, and new devices can be added, without changing the code
- Actions work in
FixedUpdate
Actions sit between devices and code. Rebinding, a new gamepad or a touch control is a change to bindings, not to gameplay scripts.
Which actions does Unity enable automatically in Play mode?
- All actions in every asset
- The project-wide actions
- Actions in generated C# classes
- None; you must always call
Enable()
The asset assigned in Project Settings → Input System Package is enabled on entering Play mode. Other assets and generated classes start disabled.
The Interact action has a Hold interaction. The player taps E briefly. Which callbacks fire?
- performed only
- started and performed
- performed and canceled
- started and canceled
The press starts the hold; releasing before the hold time cancels it. performed only fires once the button has been held long enough.
Mouse look multiplies the Look value by Time.deltaTime. What goes wrong?
- Turning is slower at low frame rates, because mouse delta is already per frame
- Nothing; it makes it frame-rate independent
- The cursor unlocks
- The value is always zero
A longer frame already contains more mouse movement. Multiplying by deltaTime scales it a second time. Sticks report a rate, so they do need deltaTime.
Keyboard.current.wKey on a French AZERTY keyboard is…
- The key labelled W
- Null
- The key in the W position, labelled Z
- Whichever key types "w"
Keys are identified by physical position on a US layout, which keeps WASD in the right place everywhere. Use displayName to show the key's local name.
A PlayerInput uses Send Messages. Which method receives the Jump action?
Jump()OnJump(InputValue value)OnJumpPerformed()Update, throughInputValue
PlayerInput sends "On" plus the action's name. For buttons it reports presses only.
What launch speed reaches a 2 m jump with gravity 20 m/s²?
- 2 m/s
- 10 m/s
- 40 m/s
- √80 ≈ 8.94 m/s
v = √(2gh) = √(2 × 20 × 2) = √80.
Why does a CharacterController-based character keep a small downward speed while standing?
- So
isGroundedstays true; it's only set when a move pushes into the ground - To stick to moving platforms
- To stop it sliding down slopes
- To save performance
With zero vertical movement, Move doesn't touch the ground, and isGrounded becomes false on the next frame.
Two Cinemachine Cameras are active with priorities 10 and 20. Which is live?
- The one with 10
- The one created first
- The one with 20
- Neither, until you call
Prioritize()
The Brain makes the active camera with the highest priority live, and blends when that changes.
Where should a hand-written follow camera move?
FixedUpdateLateUpdateAwakeOnGUI
LateUpdate runs after every Update, so the target has finished moving for the frame and the camera never lags a frame behind it.
Cheat sheet#
Setup
- Package: Input System 1.20.1
- Player → Active Input Handling: New
- Project Settings → Input System Package
- Debug: Window → Analysis → Input Debugger
Devices
Keyboard.current(null-check it)key.wasPressedThisFramekey.isPressedstick.ReadValue()- Keys by US position
Actions
InputSystem.actions, auto-enabledFindAction("Player/Move")onceReadValue<Vector2>()WasPressedThisFrame()IsPressed()- started → performed → canceled
Wiring
InputActionReferencefield- Subscribe in OnEnable, unsubscribe in OnDisable
- Other assets:
Enable()yourself - PlayerInput:
OnJump(InputValue v) - Menus: disable the Player map
Rebinding
- Disable the action first
PerformInteractiveRebinding(i)SaveBindingOverridesAsJson()LoadBindingOverridesFromJson(json)GetBindingDisplayString()
Movement
- Flatten camera axes with
ProjectOnPlane MoveTowardsfor accelerationRotateTowardsto face movement- One
controller.Moveper frame - Jump: v = √(2gh)
- Coyote 0.1 s, buffer 0.15 s
Cameras
- Move cameras in
LateUpdate - Mouse delta: no deltaTime
ScreenPointToRay(mouse)WorldToScreenPoint: z < 0 is behind- Lock:
Cursor.lockState
Cinemachine 3
using Unity.Cinemachine;- Brain on Main Camera; highest priority is live
cam.Priority = 20;- FreeLook = Orbital Follow + Rotation Composer + Input Axis Controller
- Shake: Impulse Source + Listener
Courses that cover this#
Glossary#
- Input System
- Unity's current input package, built around devices, actions and bindings. It replaces the old Input Manager (
UnityEngine.Input). - Device
- A piece of input hardware, such as a keyboard, mouse, gamepad or touchscreen. Its keys, buttons and sticks are its controls.
- Action
- Something the player can do, such as Move or Jump, with a value type. Code reads actions instead of devices.
- Binding
- A connection from a control path, such as
<Keyboard>/space, to an action. - Composite binding
- A binding that builds one value from several controls, such as WASD into a
Vector2. - Interaction
- A rule that decides when an action is performed: press, hold, tap, slow tap, multi-tap.
- Processor
- A step that changes an input value on its way to the action: invert, scale, dead zone, normalize.
- Action map
- A named group of actions used together, such as Player or UI, enabled and disabled as a unit.
- Control scheme
- A named device family, such as Keyboard&Mouse or Gamepad, used to group bindings.
- Project-wide actions
- The actions asset assigned in Project Settings, available as
InputSystem.actionsand enabled automatically in Play mode. - PlayerInput
- A component that connects an actions asset to a GameObject and notifies its scripts, used especially for local multiplayer.
- CharacterController
- A capsule collider moved by code with
Move, which slides along walls and climbs steps and slopes, without physics forces. - Coyote time
- A short grace period after leaving a ledge during which a jump is still allowed.
- Jump buffer
- Remembering a jump press for a short time so it takes effect when the character lands.
- Screen space
- Pixel coordinates in the game window, from (0, 0) at the bottom-left. Viewport space is the same area from 0 to 1.
- Cinemachine
- Unity's camera package: Cinemachine Cameras describe shots, and a Brain on the real camera follows the live one and blends between them.
- Priority (Cinemachine)
- The number that decides which active Cinemachine Camera is live: the highest wins.
- Impulse
- Cinemachine's camera shake, sent by an Impulse Source and received by cameras with an Impulse Listener.