Player Controller
Build the character that runs, switches lanes, jumps, and slides — the heart of your infinite runner.
Setting Up the Player in the Scene
Before we write any code, we need a player object in the scene. For now, we will use a simple capsule as a placeholder. You can replace it with a character model later.
-
Create the player root. In the Hierarchy, right-click and select Create Empty. Name it
Player. Set its position to(0, 0, 0). -
Add the visual. Right-click the
Playerobject and select 3D Object > Capsule. This creates a capsule as a child. Name itPlayerModel. Set its local position to(0, 1, 0)so it sits on the ground (the capsule is 2 units tall, centered at its origin). -
Add a Rigidbody. Select the
Playerroot object. Click Add Component > Rigidbody. Configure it:- Mass: 1
- Drag: 0
- Angular Drag: 0
- Use Gravity: Checked
- Is Kinematic: Unchecked
- Constraints > Freeze Rotation: Check all three (X, Y, Z) — we do not want the player toppling over.
-
Add a Collider. The Capsule child already has a CapsuleCollider (Unity adds one automatically). Select the
Playerroot and add a CapsuleCollider there instead. Set Center to(0, 1, 0), Radius to0.5, Height to2. Then remove the collider from the childPlayerModelto avoid double collisions. -
Add a ground plane. Create a 3D Object > Plane at position
(0, 0, 0). Scale it to(10, 1, 100)so you have a long flat surface to test on.
The root object has the Rigidbody, Collider, and scripts. The child has only the visual mesh. This lets you swap the visual model (from a capsule to a character) without touching any of the gameplay components. It also lets you animate the model independently from the physics.
The Lane System
In a 3-lane runner like Subway Surfers, the player can be in one of three lanes:
Left Lane Center Lane Right Lane
x = -3 x = 0 x = +3
[ ] [P] [ ]
─────────────────────────────────────
← 3 units → ← 3 units →
The player starts in the center lane (x = 0).
Pressing LEFT moves to x = -3.
Pressing RIGHT moves to x = +3.
You cannot go further left than -3 or further right than +3.
We track the player's lane as an integer: -1 (left), 0 (center), or 1 (right). Then we multiply by a laneWidth constant to get the actual X position.
Player State Machine
Just like the Game Manager has states, our player has states too. The player is always in one of these states:
| State | Description | Can Transition To |
|---|---|---|
| Running | Normal running on the ground | Jumping, Sliding, Dead |
| Jumping | In the air after a jump | Running (on landing), Dead |
| Sliding | Ducking under obstacles | Running (after slide ends), Dead |
| Dead | Hit an obstacle, game over | None (game restarts) |
namespace InfiniteRunner.Player
{
/// <summary>
/// All possible states the player character can be in.
/// </summary>
public enum PlayerState
{
Running,
Jumping,
Sliding,
Dead
}
}
Save this at Assets/Scripts/Player/PlayerState.cs.
The Full PlayerController Script
This is the main player script. It handles lane switching, jumping, sliding, ground detection, and collision. Every line is commented for beginners.
using System;
using UnityEngine;
using InfiniteRunner.Input;
using InfiniteRunner.Core;
namespace InfiniteRunner.Player
{
/// <summary>
/// Controls the player character: lane switching, jumping,
/// sliding, forward movement, and collision detection.
/// </summary>
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(CapsuleCollider))]
public class PlayerController : MonoBehaviour
{
// ─── Inspector Fields ───────────────────────────────────
[Header("References")]
[Tooltip("The InputReader ScriptableObject asset.")]
[SerializeField] private InputReader inputReader;
[Tooltip("The child object with the visual model (capsule or character).")]
[SerializeField] private Transform playerModel;
[Header("Lane Settings")]
[Tooltip("Distance between lane centers in world units.")]
[SerializeField] private float laneWidth = 3f;
[Tooltip("How fast the player moves between lanes (units/sec).")]
[SerializeField] private float laneSwitchSpeed = 10f;
[Header("Forward Movement")]
[Tooltip("Starting forward speed (units/sec).")]
[SerializeField] private float baseForwardSpeed = 10f;
[Tooltip("Current forward speed. Increases with difficulty.")]
[SerializeField] private float forwardSpeed = 10f;
[Header("Jump Settings")]
[Tooltip("Upward force applied when jumping.")]
[SerializeField] private float jumpForce = 10f;
[Tooltip("Extra gravity multiplier for falling (makes jump feel snappy).")]
[SerializeField] private float fallMultiplier = 2.5f;
[Tooltip("Reduced gravity multiplier when holding jump (for variable height).")]
[SerializeField] private float lowJumpMultiplier = 2f;
[Header("Slide Settings")]
[Tooltip("How long the slide lasts in seconds.")]
[SerializeField] private float slideDuration = 0.8f;
[Tooltip("Collider height during slide.")]
[SerializeField] private float slideColliderHeight = 0.5f;
[Tooltip("Collider center Y during slide.")]
[SerializeField] private float slideColliderCenterY = 0.25f;
[Header("Ground Detection")]
[Tooltip("Length of the ground-check raycast.")]
[SerializeField] private float groundCheckDistance = 0.3f;
[Tooltip("Layers considered as ground.")]
[SerializeField] private LayerMask groundLayer;
// ─── Events ────────────────────────────────────────────
/// <summary>Fired when the player dies (hits an obstacle).</summary>
public event Action OnPlayerDied;
/// <summary>Fired when the player collects a coin.</summary>
public event Action OnCoinCollected;
// ─── Private Fields ────────────────────────────────────
private Rigidbody _rb;
private CapsuleCollider _collider;
// Lane tracking: -1 = left, 0 = center, 1 = right
private int _currentLane = 0;
private float _targetXPosition = 0f;
// State
private PlayerState _currentState = PlayerState.Running;
// Slide timer
private float _slideTimer = 0f;
// Original collider values (saved before slide, restored after)
private float _originalColliderHeight;
private Vector3 _originalColliderCenter;
// Ground check
private bool _isGrounded;
// ─── Properties ────────────────────────────────────────
/// <summary>The current player state.</summary>
public PlayerState CurrentState => _currentState;
/// <summary>Is the player on the ground?</summary>
public bool IsGrounded => _isGrounded;
/// <summary>Current forward speed (read by camera, scoring, etc.).</summary>
public float ForwardSpeed => forwardSpeed;
// ─── Unity Lifecycle ───────────────────────────────────
private void Awake()
{
_rb = GetComponent<Rigidbody>();
_collider = GetComponent<CapsuleCollider>();
// Save original collider dimensions for restoring after slide
_originalColliderHeight = _collider.height;
_originalColliderCenter = _collider.center;
}
private void OnEnable()
{
// Subscribe to input events
if (inputReader != null)
{
inputReader.OnMoveInput += HandleMoveInput;
inputReader.OnJumpInput += HandleJumpInput;
inputReader.OnSlideInput += HandleSlideInput;
}
}
private void OnDisable()
{
// Unsubscribe from input events
if (inputReader != null)
{
inputReader.OnMoveInput -= HandleMoveInput;
inputReader.OnJumpInput -= HandleJumpInput;
inputReader.OnSlideInput -= HandleSlideInput;
}
}
private void Update()
{
// Do nothing if dead
if (_currentState == PlayerState.Dead) return;
// Check if the game is actually playing
if (GameManager.Instance != null &&
GameManager.Instance.CurrentState != GameState.Playing) return;
// Update subsystems
CheckGrounded();
UpdateLanePosition();
UpdateSlide();
}
private void FixedUpdate()
{
// Do nothing if dead
if (_currentState == PlayerState.Dead) return;
if (GameManager.Instance != null &&
GameManager.Instance.CurrentState != GameState.Playing) return;
// Move forward
MoveForward();
// Apply enhanced gravity for snappy jumps
ApplyGravityMultiplier();
}
// ─── Input Handlers ────────────────────────────────────
/// <summary>
/// Called when the player presses left or right.
/// direction: -1 for left, +1 for right.
/// </summary>
private void HandleMoveInput(int direction)
{
if (_currentState == PlayerState.Dead) return;
// Calculate the new lane
int newLane = _currentLane + direction;
// Clamp to valid lane range: -1, 0, or 1
newLane = Mathf.Clamp(newLane, -1, 1);
// If we are already at the edge, do nothing
if (newLane == _currentLane) return;
// Update the current lane and target position
_currentLane = newLane;
_targetXPosition = _currentLane * laneWidth;
Debug.Log($"[Player] Switching to lane {_currentLane} " +
$"(x = {_targetXPosition})");
}
/// <summary>
/// Called when the player presses jump.
/// </summary>
private void HandleJumpInput()
{
// Can only jump if running on the ground
if (_currentState != PlayerState.Running || !_isGrounded) return;
_currentState = PlayerState.Jumping;
// Apply upward force
// ForceMode.Impulse = instant burst of force (good for jumps)
_rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log("[Player] JUMP!");
}
/// <summary>
/// Called when the player presses slide.
/// </summary>
private void HandleSlideInput()
{
// Can only slide if running on the ground
if (_currentState != PlayerState.Running || !_isGrounded) return;
StartSlide();
}
// ─── Forward Movement ──────────────────────────────────
/// <summary>
/// Moves the player forward along the Z axis every physics frame.
/// Called in FixedUpdate for consistent physics.
/// </summary>
private void MoveForward()
{
Vector3 forwardMovement = Vector3.forward * forwardSpeed * Time.fixedDeltaTime;
_rb.MovePosition(_rb.position + forwardMovement);
}
// ─── Lane Switching ────────────────────────────────────
/// <summary>
/// Smoothly moves the player toward the target lane position.
/// Called in Update for smooth visual movement.
/// </summary>
private void UpdateLanePosition()
{
// Get current position
Vector3 position = transform.position;
// Are we already at the target?
if (Mathf.Approximately(position.x, _targetXPosition)) return;
// Move toward target X using MoveTowards (constant speed)
float newX = Mathf.MoveTowards(
position.x,
_targetXPosition,
laneSwitchSpeed * Time.deltaTime
);
// Apply the new position (only changing X)
transform.position = new Vector3(newX, position.y, position.z);
}
// ─── Jump Mechanics ────────────────────────────────────
/// <summary>
/// Applies extra gravity when falling for a snappier jump feel.
/// Without this, jumps feel floaty and slow.
///
/// The idea: when falling (velocity.y is negative), multiply gravity.
/// When rising but not holding jump, also multiply gravity slightly
/// so the jump arc is shorter (variable jump height).
/// </summary>
private void ApplyGravityMultiplier()
{
if (_rb.linearVelocity.y < 0)
{
// Falling: apply extra downward force
// (fallMultiplier - 1) because default gravity is already applied
_rb.linearVelocity += Vector3.up *
Physics.gravity.y * (fallMultiplier - 1f) * Time.fixedDeltaTime;
}
else if (_rb.linearVelocity.y > 0)
{
// Rising: apply slightly less gravity for a higher peak
_rb.linearVelocity += Vector3.up *
Physics.gravity.y * (lowJumpMultiplier - 1f) * Time.fixedDeltaTime;
}
}
// ─── Ground Detection ──────────────────────────────────
/// <summary>
/// Checks if the player is standing on the ground using a SphereCast.
/// SphereCast is like a fat raycast — more reliable on uneven surfaces.
/// </summary>
private void CheckGrounded()
{
// Cast a small sphere downward from the player's feet
Vector3 origin = transform.position + Vector3.up * 0.1f;
float radius = 0.2f;
_isGrounded = Physics.SphereCast(
origin,
radius,
Vector3.down,
out RaycastHit hit,
groundCheckDistance,
groundLayer
);
// If we just landed and were jumping, go back to Running
if (_isGrounded && _currentState == PlayerState.Jumping)
{
_currentState = PlayerState.Running;
Debug.Log("[Player] Landed — back to Running");
}
// Debug visualization (visible in Scene view)
UnityEngine.Debug.DrawRay(
origin,
Vector3.down * groundCheckDistance,
_isGrounded ? Color.green : Color.red
);
}
// ─── Slide Mechanics ───────────────────────────────────
/// <summary>
/// Starts the slide: shrinks the collider and starts the timer.
/// </summary>
private void StartSlide()
{
_currentState = PlayerState.Sliding;
_slideTimer = slideDuration;
// Shrink the collider so the player fits under obstacles
_collider.height = slideColliderHeight;
_collider.center = new Vector3(0f, slideColliderCenterY, 0f);
// Scale down the model visually (temporary — replace with animation later)
if (playerModel != null)
{
playerModel.localScale = new Vector3(1f, 0.5f, 1f);
playerModel.localPosition = new Vector3(0f, 0.5f, 0f);
}
Debug.Log("[Player] SLIDE started!");
}
/// <summary>
/// Updates the slide timer. Ends the slide when time runs out.
/// Called every frame from Update().
/// </summary>
private void UpdateSlide()
{
if (_currentState != PlayerState.Sliding) return;
_slideTimer -= Time.deltaTime;
if (_slideTimer <= 0f)
{
EndSlide();
}
}
/// <summary>
/// Ends the slide: restores the collider and model.
/// </summary>
private void EndSlide()
{
_currentState = PlayerState.Running;
// Restore the original collider dimensions
_collider.height = _originalColliderHeight;
_collider.center = _originalColliderCenter;
// Restore the model scale (temporary — replace with animation later)
if (playerModel != null)
{
playerModel.localScale = Vector3.one;
playerModel.localPosition = new Vector3(0f, 1f, 0f);
}
Debug.Log("[Player] SLIDE ended, back to Running");
}
// ─── Collision Detection ───────────────────────────────
/// <summary>
/// Called by Unity when the player's collider touches a trigger collider.
/// Obstacles and coins should have "Is Trigger" checked on their colliders.
/// </summary>
private void OnTriggerEnter(Collider other)
{
// Check the tag of whatever we collided with
if (other.CompareTag("Obstacle"))
{
Die();
}
else if (other.CompareTag("Coin"))
{
CollectCoin(other.gameObject);
}
}
/// <summary>
/// Handles the player's death: stops movement and notifies the game.
/// </summary>
private void Die()
{
if (_currentState == PlayerState.Dead) return;
_currentState = PlayerState.Dead;
// Stop all movement
_rb.linearVelocity = Vector3.zero;
forwardSpeed = 0f;
Debug.Log("[Player] DIED!");
// Notify listeners (GameManager, UI, etc.)
OnPlayerDied?.Invoke();
// Tell the GameManager the game is over
if (GameManager.Instance != null)
{
GameManager.Instance.EndGame();
}
}
/// <summary>
/// Handles coin collection: destroys the coin and notifies listeners.
/// </summary>
private void CollectCoin(GameObject coin)
{
Debug.Log("[Player] Collected a coin!");
// Disable the coin (we will use object pooling later instead of Destroy)
coin.SetActive(false);
// Notify listeners (ScoreManager, AudioManager, etc.)
OnCoinCollected?.Invoke();
}
// ─── Public Methods ────────────────────────────────────
/// <summary>
/// Resets the player to their starting state.
/// Called when restarting the game.
/// </summary>
public void ResetPlayer()
{
_currentState = PlayerState.Running;
_currentLane = 0;
_targetXPosition = 0f;
forwardSpeed = baseForwardSpeed;
transform.position = Vector3.zero;
_rb.linearVelocity = Vector3.zero;
// Restore collider in case we died during a slide
_collider.height = _originalColliderHeight;
_collider.center = _originalColliderCenter;
if (playerModel != null)
{
playerModel.localScale = Vector3.one;
playerModel.localPosition = new Vector3(0f, 1f, 0f);
}
}
/// <summary>
/// Increases the forward speed. Called by the difficulty system.
/// </summary>
public void SetForwardSpeed(float newSpeed)
{
forwardSpeed = newSpeed;
}
}
}
Save this at Assets/Scripts/Player/PlayerController.cs.
Key Concepts Explained
Smooth Lane Switching with MoveTowards
When the player presses left or right, we do not teleport them instantly. Instead, we use Mathf.MoveTowards() which moves a value toward a target at a constant speed:
// Mathf.MoveTowards(current, target, maxStep)
// current = where we are now (e.g., x = 0)
// target = where we want to be (e.g., x = 3)
// maxStep = maximum distance to move this frame
float newX = Mathf.MoveTowards(
position.x, // current X: 0
_targetXPosition, // target X: 3
laneSwitchSpeed * Time.deltaTime // max step: 10 * 0.016 = 0.16
);
// Result: newX = 0.16 (moved a tiny bit toward 3)
// Next frame: newX = 0.32
// ...continues until newX reaches 3.0
MoveTowards moves at a constant speed — same distance every frame, like a car at cruise control. Lerp moves at a decreasing speed — fast at first, then slower as it approaches the target, like a car braking. MoveTowards feels more responsive for lane switching. Lerp feels smoother but can feel sluggish near the end. We use MoveTowards here for crisp, predictable movement.
Gravity Multiplier for Snappy Jumps
In real life, you go up and come down at the same speed. In games, this feels terrible — floaty and unresponsive. Professional platformers (like Mario) make the player fall faster than they rise. Here is the trick:
Default Physics (floaty): With Gravity Multiplier (snappy):
* * * *
* * * *
* * * *
* * * *
* * * *
────────────── ───────────────────
Symmetric arc Falls faster than rising
Feels "moon-like" Feels responsive and tight
The code adds extra downward force when the player is falling (velocity.y < 0). The fallMultiplier of 2.5 means gravity is 2.5x stronger when falling. Tweak this value until the jump "feels right."
Ground Detection with SphereCast
We need to know if the player is on the ground (to prevent double-jumping). A simple raycast shoots a thin line downward — but if the player is near the edge of a platform, the ray might miss. A SphereCast is like a fat raycast that sweeps a sphere through space, making it much more reliable.
Raycast (thin line): SphereCast (fat sweep):
| ( )
| ( )
| ( )
v ( )
───┼─── ──(─)──
Misses edge! Catches edge!
Setting Up Tags and Layers
Our collision detection uses tags. Let us set them up:
- Go to Edit > Project Settings > Tags and Layers.
- Under Tags, add:
ObstacleandCoin. - Under Layers, add:
Ground(use an empty user layer, e.g., Layer 6). - Select your ground plane and set its Layer to
Ground. - On the
Playerobject'sPlayerControllercomponent, set the Ground Layer mask to include only theGroundlayer.
If you leave the Ground Layer mask empty, the SphereCast will never detect ground, _isGrounded will always be false, and the player will never be able to jump. This is the number one bug beginners encounter with this system.
Wiring Everything Up
- Select the
PlayerGameObject. - Add the
PlayerControllercomponent (Add Component > PlayerController). - Drag the
InputReaderScriptableObject asset into the Input Reader field. - Drag the
PlayerModelchild object into the Player Model field. - Set Ground Layer to the
Groundlayer. - Leave the other values at their defaults for now. You can tweak them later.
Testing the Player
- Press Play.
- Press A or Left Arrow — the player should slide smoothly to the left lane (x = -3).
- Press D or Right Arrow — the player should slide back to center, then to the right lane.
- Press Space — the player should jump and come back down quickly (snappy gravity).
- Press S or Down Arrow — the player should shrink down (slide) for 0.8 seconds, then pop back up.
- The player should be moving forward continuously along the Z axis.
Game feel is everything in a runner. Here are some starting values, but tweak them until it feels right to YOU:
- Jump Force: 10 (higher = jump higher)
- Fall Multiplier: 2.5 (higher = fall faster)
- Lane Switch Speed: 10 (higher = snap to lane faster)
- Forward Speed: 10 (higher = run faster)
- Slide Duration: 0.8 (longer = more forgiving)
Project Files After This Chapter
Assets/
Scripts/
Player/
PlayerState.cs <-- Enum: Running, Jumping, Sliding, Dead
PlayerController.cs <-- Main player logic (this chapter)
Input/
InputReader.cs <-- Input wrapper (Chapter 8)
SwipeDetector.cs <-- Touch input (Chapter 8)
Core/
GameManager.cs <-- Game state manager (Chapter 6)
Singleton.cs <-- Singleton base class (Chapter 6)
Events/
GameEvent.cs <-- Event system (Chapter 7)
Physics Layers & Collision Matrix
Why Layers Matter
Right now, Unity checks every collider against every other collider in the scene. Coins check collision with obstacles. Obstacles check collision with other obstacles. The ground checks collision with coins. All of it is wasteful, and worse, it causes phantom triggers — a coin might accidentally trigger a collision callback on an obstacle, or two obstacles might push each other around in ways you never intended.
Physics layers let you control exactly what collides with what. You assign each GameObject a layer, then use the Collision Matrix to specify which layer pairs should interact. Objects on layers that are unchecked against each other are completely invisible to each other in the physics system — no collision, no triggers, no raycasts (unless you specifically ask for that layer).
Creating Custom Layers
Follow these steps to set up the layers your infinite runner needs:
- Go to Edit > Project Settings > Tags and Layers.
- Open the Layers section. Be careful: there are three sections on this page — Tags, Sorting Layers, and Layers. You want the third one, simply called Layers.
-
Unity reserves Layers 0 through 7 for built-in use (
Default,TransparentFX,Ignore Raycast,Water,UI, etc.). You cannot edit these. Use Layer 8 and above for your custom layers. -
Create the following custom layers:
Layer Number Layer Name Purpose 8 PlayerThe player character 9 GroundGround tiles and platforms 10 ObstacleBarriers, trains, cars — things that kill the player 11 CollectibleCoins, gems, and other pickups 12 PowerUpMagnets, shields, score multipliers
Assigning Layers to GameObjects
Once the layers exist, you need to assign them to your objects:
- Select a GameObject in the Hierarchy or Project window.
- At the very top of the Inspector, you will see a Layer dropdown (next to the Tag dropdown). Click it and choose the correct layer.
- When you set a layer on a parent object, Unity asks: "Do you want to set layer for all child objects?" In most cases, click Yes, change children. This ensures the colliders on child objects are also on the correct layer.
If you set the layer on a scene object but not the prefab, every newly spawned or pooled instance will use the prefab's layer (probably Default) instead of the one you set. Always open the prefab and set the layer there. This is especially important once you start using object pooling in Chapter 12 — pooled objects are instantiated from prefabs, so the prefab must have the right layer.
The Collision Matrix
The collision matrix is where the real power of layers comes in. It is a grid that shows every possible layer-to-layer combination, and you can check or uncheck each one.
- Go to Edit > Project Settings > Physics (for 3D projects).
- Scroll all the way to the bottom. You will see the Layer Collision Matrix — a triangular grid of checkboxes.
- Each checkbox represents a pair of layers. If the box is checked, those two layers can collide. If unchecked, objects on those layers are completely invisible to each other in the physics system.
Here is the recommended collision matrix for our infinite runner. A check mark means the pair should collide; a dash means it should not:
| Player | Ground | Obstacle | Collectible | PowerUp | |
|---|---|---|---|---|---|
| Player | — | ON | ON | ON | ON |
| Ground | ON | — | — | — | — |
| Obstacle | ON | — | — | — | — |
| Collectible | ON | — | — | — | — |
| PowerUp | ON | — | — | — | — |
Notice the pattern: the Player interacts with everything it needs to (Ground for landing, Obstacle for death, Collectible for pickup, PowerUp for activation). But obstacles do not need to detect each other. Coins do not need to detect obstacles. The ground does not care about coins. By turning off these unnecessary pairs, you reduce the physics workload and eliminate phantom collisions.
Layer Masks in Code
Layers are not just for the collision matrix. You can use them in your code to make raycasts and overlap checks more efficient and more accurate. Remember the groundLayer field in our PlayerController?
// Ground check raycast — only check the Ground layer
[SerializeField] private LayerMask groundLayer;
private bool IsGrounded()
{
return Physics.Raycast(
transform.position,
Vector3.down,
1.1f,
groundLayer // Only checks this layer!
);
}
The LayerMask type is a SerializeField, so it appears as a dropdown in the Inspector where you can pick one or more layers. When you pass it to Physics.Raycast, Physics.SphereCast, or any physics query, Unity only checks colliders on the selected layers. Everything else is ignored.
This has two major benefits:
- Performance: The raycast does not waste time checking colliders on irrelevant layers. In a scene with hundreds of coins and obstacles, this matters.
- Correctness: Without a layer mask, a downward raycast might hit a coin floating near the ground and falsely report the player as "grounded." With the mask set to
Groundonly, this cannot happen.
Set up your layers and collision matrix now, before you have dozens of objects in your scene. Retrofitting layers later means going through every prefab and every script that does raycasting. Do it once, do it right. Your future self will thank you.
Game Feel & Juice
What Is "Game Feel"?
Your player controller works. The player runs, switches lanes, jumps, and slides. But does it feel good? Game feel is the difference between a prototype and a game people actually enjoy playing. It is the responsiveness, the weight, the satisfaction of every movement.
Games like Celeste, Hollow Knight, and yes — Subway Surfers — feel incredible not because they have complex mechanics, but because their developers spent an enormous amount of effort tuning the feel of those mechanics. You can have two games with identical features that feel completely different: one sluggish and frustrating, the other tight and addictive. The difference is game feel.
Making Jumps Feel Good
The biggest game-feel problem beginners encounter is that realistic physics makes jumps feel floaty and unresponsive. In reality, gravity pulls you down at the same rate whether you are rising or falling. In games, this symmetric arc feels terrible. Here are three techniques the professionals use:
Technique 1: Gravity Multiplier
We already have this in our PlayerController, but let us understand why it matters. The idea is simple: make the player fall faster than they rise. This creates an asymmetric jump arc that feels snappy and responsive.
// In FixedUpdate, after jump:
if (rb.linearVelocity.y < 0)
{
// Falling — increase gravity for snappy descent
rb.linearVelocity += Vector3.up *
Physics.gravity.y * (fallMultiplier - 1f) * Time.fixedDeltaTime;
}
A fallMultiplier of 2.5 to 3.5 feels great for most runners. Higher values make the descent snappier. Lower values feel floatier. Start at 2.5 and adjust from there. This single tweak transforms a floaty prototype jump into something that feels professional.
Technique 2: Coyote Time
Named after the cartoon coyote who runs off a cliff and hangs in the air for a moment before falling, coyote time lets players jump for a brief window after they have left a platform. Without it, players who press jump one frame too late get no jump, even though they felt like they were still on the ground. The result: they blame the game for being unresponsive.
private float coyoteTimeCounter;
[SerializeField] private float coyoteTimeDuration = 0.15f;
void Update()
{
if (isGrounded)
{
coyoteTimeCounter = coyoteTimeDuration;
}
else
{
coyoteTimeCounter -= Time.deltaTime;
}
// Allow jump if grounded OR within coyote time window
if (jumpPressed && coyoteTimeCounter > 0f)
{
Jump();
coyoteTimeCounter = 0f; // Consume the coyote time
}
}
The sweet spot is 0.1 to 0.2 seconds. Players will not notice it consciously — nobody will say "thanks for the coyote time!" But they will notice its absence. Without it, a game feels punishing. With it, it feels forgiving and fair.
Technique 3: Jump Buffering
Jump buffering solves the opposite problem: what if the player presses jump slightly before they land? Without buffering, the input is "eaten" — the player was in the air, so the jump input was ignored, and by the time they land the button press is gone. The player feels like the game dropped their input.
private float jumpBufferCounter;
[SerializeField] private float jumpBufferDuration = 0.1f;
void Update()
{
if (jumpPressed)
{
jumpBufferCounter = jumpBufferDuration;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
if (isGrounded && jumpBufferCounter > 0f)
{
Jump();
jumpBufferCounter = 0f; // Consume the buffer
}
}
If the player presses jump 0.1 seconds before landing, the jump input is remembered and fires the moment they touch down. Without this, the input vanishes and the player feels the game is unresponsive. A buffer duration of 0.1 seconds is enough — longer and you risk accidental double-jumps.
Coyote time handles "pressed jump slightly after leaving the ground." Jump buffering handles "pressed jump slightly before reaching the ground." Together, they cover both sides of the timing window and make your jumps feel incredibly forgiving without the player ever noticing the assist. Every great platformer uses both.
Lane Switching Feel
Our current lane switching uses Mathf.MoveTowards, which moves at a constant speed. This works, but there are ways to make it feel even better.
Do not teleport between lanes. Instant teleportation looks jarring and makes it hard for the player to track their character. But do not make the transition too slow either — players need to dodge obstacles quickly. The sweet spot for laneSwitchSpeed is 8 to 12 units per second. Test it and adjust based on how fast your game runs.
For extra polish, consider using Mathf.SmoothDamp instead of MoveTowards. SmoothDamp creates a natural ease-in, ease-out motion that feels more organic:
// Instead of linear MoveTowards, use SmoothDamp for natural feel:
private float xVelocity;
float smoothX = Mathf.SmoothDamp(
transform.position.x,
targetLaneX,
ref xVelocity,
0.08f // smooth time — lower = snappier
);
The smoothTime parameter controls how long the transition takes. A value of 0.08 is very snappy. A value of 0.15 is smoother but might feel sluggish in a fast-paced runner. SmoothDamp also produces a slight overshoot-and-settle effect that adds a sense of weight to the movement. Try both MoveTowards and SmoothDamp and pick the one that feels right for your game.
Screen Shake
A brief camera shake adds a sense of weight and impact to events. Keep it short (0.1 to 0.2 seconds) and subtle (small displacement). Overused screen shake is nauseating; well-used screen shake is satisfying. Good candidates for screen shake in an infinite runner:
- Landing from a high jump: Reinforces the feeling of weight and impact.
- Collecting a power-up: Makes the pickup feel rewarding.
- Near-miss with an obstacle: Really advanced, but creates an incredible sense of tension and excitement.
We will implement camera shake properly in Chapter 10 (Camera System). For now, just know that it is one of the most impactful "juice" techniques you can add.
The "Juice" Checklist
Here is a prioritized list of game feel elements for your infinite runner. Implement the "Must have" items now. Save the rest for Part 5 (Polish).
| Element | What It Adds | Priority |
|---|---|---|
| Gravity multiplier | Snappy, responsive jumps | Must have |
| Coyote time | Forgiving jump timing | Must have |
| Jump buffer | Responsive input handling | Must have |
| Smooth lane switch | Polished lateral movement | Must have |
| Landing particles | Visual feedback on landing | Nice to have |
| Screen shake on land | Weight and impact | Nice to have |
| Speed lines | Sense of speed | Nice to have |
| Near-miss feedback | Excitement and tension | Advanced |
| Camera FOV shift at speed | Intensity at high speed | Advanced |
Game feel is not a "nice-to-have." It is the difference between a game people play once and close, and a game they cannot put down. The gravity multiplier, coyote time, jump buffer, and smooth lane switching are must-have features — implement them now. The particles, screen shake, and speed lines can wait until Part 5 (Polish), but do not skip the fundamentals.
Playtesting Your Controller
Building a player controller is iterative. You will not get the feel right on the first try. Here is how to playtest effectively:
- Test with keyboard AND touch/mouse. Your runner will probably ship on mobile. The controller needs to feel good with both input methods. Keyboard input is instantaneous; touch input has inherent latency from swipe detection. Account for both.
- Watch someone else play for the first time. Their struggles reveal feel problems you are blind to because you know how the game works. If they say "I pressed jump!" but did not jump, you need coyote time and jump buffering. If they say "it feels floaty," increase your fall multiplier.
- If lane switching feels slow, increase the
laneSwitchSpeed. If it feels jarring, add smoothing withSmoothDamp. - Record gameplay and watch it back. You will notice things you miss while actively playing — hitches, awkward transitions, moments where the character does not do what you expected.
- Adjust values in the Inspector while in Play mode. Unity lets you tweak serialized fields during gameplay and see the results immediately. This is the fastest way to dial in the feel. Important caveat: changes made in Play mode are lost when you stop playing. Note down the values that feel good, then apply them after exiting Play mode.
What We Built
- Set up the player GameObject with Rigidbody, CapsuleCollider, and a placeholder model.
- Implemented the 3-lane system with smooth lane switching using
MoveTowards. - Jump mechanics with
Rigidbody.AddForceand a gravity multiplier for snappy feel. - Ground detection with
Physics.SphereCast. - Slide mechanics with collider shrinking and a timed duration.
- A player state machine (Running, Jumping, Sliding, Dead) to prevent impossible actions.
- Collision detection for obstacles (death) and coins (collection).
- Constant forward movement along the Z axis.
- Connected everything to the InputReader from Chapter 8 and the GameManager from Chapter 6.
In the next chapter, we will set up the Camera System using Cinemachine to create smooth, professional camera follow behavior.
The player can move, jump, and slide. This is a major feature — definitely save it.
git status
git diff
git add .
git commit -m "Implement player controller with lane switching, jump, and slide"
Notice we're running git diff before git add now. This is a good habit — review what you're about to commit. In professional development, every commit is reviewed.