Endless Runner Tutorial for Unity

In video games, no matter how large the world is, it always has an end. But some games try to emulate the infinite world, such games fall under the category called Endless Runner.

Endless Runner is a type of game where the player is constantly moving forward while collecting points and avoiding obstacles. The main objective is to reach the end of the level without falling into or colliding with the obstacles, but oftentimes, the level repeats itself infinitely, gradually increasing the difficulty, until the player collides with the obstacle.

Subway Surfers Gameplay

Considering that even modern computers/gaming devices have limited processing power, it's impossible to make a truly infinite world.

So how do some games create an illusion of an infinite world? The answer is by reusing the building blocks (a.k.a. object pooling), in other words, as soon as the block goes behind or outside the Camera view, it's moved to the front.

To make an endless-runner game in Unity, we will need to make a platform with obstacles and a player controller.

Step 1: Create The Platform

We begin by creating a tiled platform that will be later stored in the Prefab:

  • Create a new GameObject and call it "TilePrefab"
  • Create new Cube (GameObject -> 3D Object -> Cube)
  • Move the Cube inside the "TilePrefab" object, change its position to (0, 0, 0), and scale to (8, 0.4, 20)

  • Optionally you can add Rails to the sides by creating additional Cubes, like this:

For the obstacles, I will have 3 obstacle variations, but you can make as many as needed:

  • Create 3 GameObjects inside the "TilePrefab" object and name them "Obstacle1", "Obstacle2" and "Obstacle3"
  • For the first obstacle, create a new Cube and move it inside the "Obstacle1" object
  • Scale the new Cube to around the same width as the platform and scale its height down (the player will need to jump to avoid this obstacle)
  • Create a new Material, name it "RedMaterial" and change its color to Red, then assign it to the Cube (this is just so the obstacle is distinguished from the main platform)

  • For the "Obstacle2" create a couple of cubes and place them in a triangular shape, leaving one open space at the bottom (the player will need to crouch to avoid this obstacle)

  • And lastly, "Obstacle3" is going to be a duplicate of "Obstacle1" and "Obstacle2", combined together

  • Now select all the Objects inside Obstacles and change their tag to "Finish", this will be needed later to detect the collision between Player and Obstacle.

To generate an infinite platform we will need a couple of scripts that will handle Object Pooling and Obstacle activation:

  • Create a new script, call it "SC_PlatformTile" and paste the code below inside it:

SC_PlatformTile.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SC_PlatformTile : MonoBehaviour
{
    public Transform startPoint;
    public Transform endPoint;
    public GameObject[] obstacles; //Objects that contains different obstacle types which will be randomly activated

    public void ActivateRandomObstacle()
    {
        DeactivateAllObstacles();

        System.Random random = new System.Random();
        int randomNumber = random.Next(0, obstacles.Length);
        obstacles[randomNumber].SetActive(true);
    }

    public void DeactivateAllObstacles()
    {
        for (int i = 0; i < obstacles.Length; i++)
        {
            obstacles[i].SetActive(false);
        }
    }
}
  • Create a new script, call it "SC_GroundGenerator" and paste the code below inside it:

SC_GroundGenerator.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SC_GroundGenerator : MonoBehaviour
{
    public Camera mainCamera;
    public Transform startPoint; //Point from where ground tiles will start
    public SC_PlatformTile tilePrefab;
    public float movingSpeed = 12;
    public int tilesToPreSpawn = 15; //How many tiles should be pre-spawned
    public int tilesWithoutObstacles = 3; //How many tiles at the beginning should not have obstacles, good for warm-up

    List<SC_PlatformTile> spawnedTiles = new List<SC_PlatformTile>();
    int nextTileToActivate = -1;
    [HideInInspector]
    public bool gameOver = false;
    static bool gameStarted = false;
    float score = 0;

    public static SC_GroundGenerator instance;

    // Start is called before the first frame update
    void Start()
    {
        instance = this;

        Vector3 spawnPosition = startPoint.position;
        int tilesWithNoObstaclesTmp = tilesWithoutObstacles;
        for (int i = 0; i < tilesToPreSpawn; i++)
        {
            spawnPosition -= tilePrefab.startPoint.localPosition;
            SC_PlatformTile spawnedTile = Instantiate(tilePrefab, spawnPosition, Quaternion.identity) as SC_PlatformTile;
            if(tilesWithNoObstaclesTmp > 0)
            {
                spawnedTile.DeactivateAllObstacles();
                tilesWithNoObstaclesTmp--;
            }
            else
            {
                spawnedTile.ActivateRandomObstacle();
            }
            
            spawnPosition = spawnedTile.endPoint.position;
            spawnedTile.transform.SetParent(transform);
            spawnedTiles.Add(spawnedTile);
        }
    }

    // Update is called once per frame
    void Update()
    {
        // Move the object upward in world space x unit/second.
        //Increase speed the higher score we get
        if (!gameOver && gameStarted)
        {
            transform.Translate(-spawnedTiles[0].transform.forward * Time.deltaTime * (movingSpeed + (score/500)), Space.World);
            score += Time.deltaTime * movingSpeed;
        }

        if (mainCamera.WorldToViewportPoint(spawnedTiles[0].endPoint.position).z < 0)
        {
            //Move the tile to the front if it's behind the Camera
            SC_PlatformTile tileTmp = spawnedTiles[0];
            spawnedTiles.RemoveAt(0);
            tileTmp.transform.position = spawnedTiles[spawnedTiles.Count - 1].endPoint.position - tileTmp.startPoint.localPosition;
            tileTmp.ActivateRandomObstacle();
            spawnedTiles.Add(tileTmp);
        }

        if (gameOver || !gameStarted)
        {
            if (Input.GetKeyDown(KeyCode.Space))
            {
                if (gameOver)
                {
                    //Restart current scene
                    Scene scene = SceneManager.GetActiveScene();
                    SceneManager.LoadScene(scene.name);
                }
                else
                {
                    //Start the game
                    gameStarted = true;
                }
            }
        }
    }

    void OnGUI()
    {
        if (gameOver)
        {
            GUI.color = Color.red;
            GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 100, 200, 200), "Game Over\nYour score is: " + ((int)score) + "\nPress 'Space' to restart");
        }
        else
        {
            if (!gameStarted)
            {
                GUI.color = Color.red;
                GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 100, 200, 200), "Press 'Space' to start");
            }
        }


        GUI.color = Color.green;
        GUI.Label(new Rect(5, 5, 200, 25), "Score: " + ((int)score));
    }
}
  • Attach the SC_PlatformTile script to the "TilePrefab" object
  • Assign "Obstacle1", "Obstacle2" and "Obstacle3" object to Obstacles array

For the Start Point and End Point, we need to create 2 GameObjects that should be placed at the start and the end of the platform respectively:

  • Assign Start Point and End Point variables in SC_PlatformTile

  • Save the "TilePrefab" object to Prefab and remove it from the Scene
  • Create a new GameObject and call it "_GroundGenerator"
  • Attach the SC_GroundGenerator script to the "_GroundGenerator" object
  • Change the Main Camera position to (10, 1, -9) and change its rotation to (0, -55, 0)
  • Create a new GameObject, call it "StartPoint" and change its position to (0, -2, -15)
  • Select the "_GroundGenerator" object and in SC_GroundGenerator assign Main Camera, Start Point, and Tile Prefab variables

Now press Play and observe how the platform moves. As soon as the platform tile goes out of the camera view, it's moved back to the end with a random obstacle being activated, creating an illusion of an infinite level (Skip to 0:11).

The Camera must be placed similarly to the video, so the platforms go towards the Camera and behind it, otherwise the platforms won't repeat.

Sharp Coder Video Player

Step 2: Create The Player

The player Instance will be a simple Sphere using a controller with the ability to jump and crouch.

  • Create a new Sphere (GameObject -> 3D Object -> Sphere) and remove its Sphere Collider component
  • Assign previously created "RedMaterial" to it
  • Create a new GameObject and call it "Player"
  • Move the Sphere inside the "Player" object and change its position to (0, 0, 0)
  • Create a new script, call it "SC_IRPlayer" and paste the code below inside it:

SC_IRPlayer.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Rigidbody))]

public class SC_IRPlayer : MonoBehaviour
{
    public float gravity = 20.0f;
    public float jumpHeight = 2.5f;

    Rigidbody r;
    bool grounded = false;
    Vector3 defaultScale;
    bool crouch = false;

    // Start is called before the first frame update
    void Start()
    {
        r = GetComponent<Rigidbody>();
        r.constraints = RigidbodyConstraints.FreezePositionX | RigidbodyConstraints.FreezePositionZ;
        r.freezeRotation = true;
        r.useGravity = false;
        defaultScale = transform.localScale;
    }

    void Update()
    {
        // Jump
        if (Input.GetKeyDown(KeyCode.W) && grounded)
        {
            r.velocity = new Vector3(r.velocity.x, CalculateJumpVerticalSpeed(), r.velocity.z);
        }

        //Crouch
        crouch = Input.GetKey(KeyCode.S);
        if (crouch)
        {
            transform.localScale = Vector3.Lerp(transform.localScale, new Vector3(defaultScale.x, defaultScale.y * 0.4f, defaultScale.z), Time.deltaTime * 7);
        }
        else
        {
            transform.localScale = Vector3.Lerp(transform.localScale, defaultScale, Time.deltaTime * 7);
        }
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        // We apply gravity manually for more tuning control
        r.AddForce(new Vector3(0, -gravity * r.mass, 0));

        grounded = false;
    }

    void OnCollisionStay()
    {
        grounded = true;
    }

    float CalculateJumpVerticalSpeed()
    {
        // From the jump height and gravity we deduce the upwards speed 
        // for the character to reach at the apex.
        return Mathf.Sqrt(2 * jumpHeight * gravity);
    }

    void OnCollisionEnter(Collision collision)
    {
        if(collision.gameObject.tag == "Finish")
        {
            //print("GameOver!");
            SC_GroundGenerator.instance.gameOver = true;
        }
    }
}
  • Attach the SC_IRPlayer script to the "Player" object (you'll notice that it added another component called Rigidbody)
  • Add the BoxCollider component to the "Player" object

  • Place the "Player" object slightly above the "StartPoint" object, right in front of the Camera

Press Play and use the W key to jump and the S key to crouch. The objective is to avoid red Obstacles:

Sharp Coder Video Player

Check this Horizon Bending Shader.

Links
Unity