Part of these game systems:
intermediate Touch & Mobile

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.

Unity 2022.3+ · 4.8 KB · EndlessRunnerController.cs

How to Use

1

Attach to your player GameObject and add a CharacterController component

2

Set lane count and lane width to match your level geometry

3

Configure jump force, gravity, and slide duration in the inspector

4

Wire up onJump, onSlide, onDeath, and onRestart events for animations and UI

5

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

EndlessRunnerController.cs
C#
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();
    }
}
Ready for more? Stack Mechanic Tap-to-place stacking game mechanic where a moving block must be stopped to align with the previous block.