Endless Runner Controller
Complete endless runner character controller with auto-forward movement, lane switching, jump and slide mechanics. Speed increases over time with death and restart handling.
How to Use
Attach to your player GameObject and add a CharacterController component
Set lane count and lane width to match your level geometry
Configure jump force, gravity, and slide duration in the inspector
Wire up onJump, onSlide, onDeath, and onRestart events for animations and UI
Call Die() from obstacle collisions and RestartGame() from your retry button
Features
- Auto-forward movement with speed ramp
- Lane switching (3 lanes)
- Jump and slide mechanics
- Speed increases over time
- Death and restart handling
When to Use This
Built for Subway Surfers-style endless runners, hyper-casual auto-run games, and any mobile game with lane-based obstacle avoidance. Use this when you need a complete runner controller with swipe input, progressive difficulty, and collision-based death.
Common Mistakes
The laneWidth must match your actual level geometry spacing — if lanes are 3 units apart but laneWidth is 2.5, the player won't align with the track. Obstacles must be tagged "Obstacle" with trigger colliders for OnTriggerEnter to detect them. The slideColliderHeight directly modifies the CharacterController height, so set it to match your slide animation or the player will clip through overhead obstacles.
Source Code
using UnityEngine;
using UnityEngine.Events;
[RequireComponent(typeof(CharacterController))]
public class EndlessRunnerController : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float initialSpeed = 8f;
[SerializeField] private float maxSpeed = 25f;
[SerializeField] private float speedIncreaseRate = 0.1f;
[SerializeField] private float laneSwitchSpeed = 10f;
[Header("Lanes")]
[SerializeField] private float laneWidth = 2.5f;
[SerializeField] private int totalLanes = 3;
[Header("Jump & Slide")]
[SerializeField] private float jumpForce = 10f;
[SerializeField] private float gravity = -30f;
[SerializeField] private float slideDuration = 0.6f;
[SerializeField] private float slideColliderHeight = 0.5f;
[Header("Swipe Input")]
[SerializeField] private float minSwipeDistance = 50f;
[Header("Events")]
public UnityEvent onJump;
public UnityEvent onSlide;
public UnityEvent onLaneSwitch;
public UnityEvent onDeath;
public UnityEvent onRestart;
public float CurrentSpeed { get; private set; }
public int CurrentLane { get; private set; }
public bool IsAlive { get; private set; }
public float DistanceRun { get; private set; }
private CharacterController controller;
private Vector3 moveDirection;
private int targetLane;
private float verticalVelocity;
private float originalColliderHeight;
private float originalColliderCenterY;
private float slideTimer;
private bool isSliding;
// Swipe tracking
private Vector2 swipeStart;
private bool swiping;
private void Awake()
{
controller = GetComponent<CharacterController>();
originalColliderHeight = controller.height;
originalColliderCenterY = controller.center.y;
}
private void Start()
{
StartRun();
}
public void StartRun()
{
IsAlive = true;
CurrentSpeed = initialSpeed;
targetLane = totalLanes / 2;
CurrentLane = targetLane;
DistanceRun = 0f;
verticalVelocity = 0f;
isSliding = false;
controller.height = originalColliderHeight;
controller.center = new Vector3(0f, originalColliderCenterY, 0f);
}
private void Update()
{
if (!IsAlive) return;
HandleSwipeInput();
UpdateMovement();
UpdateSlide();
CurrentSpeed = Mathf.Min(CurrentSpeed + speedIncreaseRate * Time.deltaTime, maxSpeed);
DistanceRun += CurrentSpeed * Time.deltaTime;
}
private void HandleSwipeInput()
{
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
swipeStart = touch.position;
swiping = true;
}
else if (touch.phase == TouchPhase.Ended && swiping)
{
swiping = false;
ProcessSwipe(touch.position - swipeStart);
}
}
// Keyboard fallback
if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.A))
SwitchLane(-1);
if (Input.GetKeyDown(KeyCode.RightArrow) || Input.GetKeyDown(KeyCode.D))
SwitchLane(1);
if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKeyDown(KeyCode.W))
Jump();
if (Input.GetKeyDown(KeyCode.DownArrow) || Input.GetKeyDown(KeyCode.S))
Slide();
}
private void ProcessSwipe(Vector2 delta)
{
if (delta.magnitude < minSwipeDistance) return;
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
{
SwitchLane(delta.x > 0 ? 1 : -1);
}
else
{
if (delta.y > 0) Jump();
else Slide();
}
}
private void SwitchLane(int direction)
{
targetLane = Mathf.Clamp(targetLane + direction, 0, totalLanes - 1);
CurrentLane = targetLane;
onLaneSwitch?.Invoke();
}
private void Jump()
{
if (!controller.isGrounded) return;
verticalVelocity = jumpForce;
if (isSliding) EndSlide();
onJump?.Invoke();
}
private void Slide()
{
if (isSliding || !controller.isGrounded) return;
isSliding = true;
slideTimer = slideDuration;
controller.height = slideColliderHeight;
controller.center = new Vector3(0f, slideColliderHeight * 0.5f, 0f);
onSlide?.Invoke();
}
private void UpdateSlide()
{
if (!isSliding) return;
slideTimer -= Time.deltaTime;
if (slideTimer <= 0f)
EndSlide();
}
private void EndSlide()
{
isSliding = false;
controller.height = originalColliderHeight;
controller.center = new Vector3(0f, originalColliderCenterY, 0f);
}
private void UpdateMovement()
{
// Forward movement
moveDirection.z = CurrentSpeed;
// Lane position
int centerLane = totalLanes / 2;
float targetX = (targetLane - centerLane) * laneWidth;
float currentX = Mathf.Lerp(transform.position.x, targetX, Time.deltaTime * laneSwitchSpeed);
moveDirection.x = (currentX - transform.position.x) / Time.deltaTime;
// Gravity
if (controller.isGrounded && verticalVelocity < 0f)
verticalVelocity = -2f;
verticalVelocity += gravity * Time.deltaTime;
moveDirection.y = verticalVelocity;
controller.Move(moveDirection * Time.deltaTime);
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Obstacle"))
{
Die();
}
}
private void Die()
{
if (!IsAlive) return;
IsAlive = false;
CurrentSpeed = 0f;
onDeath?.Invoke();
}
public void Restart()
{
transform.position = Vector3.zero;
StartRun();
onRestart?.Invoke();
}
}