classic endless runner effect

Here's how to set it up:


Basic Setup Structure

Hierarchy:

- Player (stationary at world position)
- Environment Parent (empty GameObject - this moves)
  ├─ Ground
  ├─ Obstacles
  ├─ Walls
  └─ Props

1. Environment Mover Script

Attach this to your Environment Parent object:

using UnityEngine;

public class EnvironmentMover : MonoBehaviour
{
    public float moveSpeed = 10f;
    
    void Update()
    {
        // Move environment backward (toward camera/player)
        transform.position += Vector3.back * moveSpeed * Time.deltaTime;
        
        // Alternative: move left/right for side-scrolling
        // transform.position += Vector3.left * moveSpeed * Time.deltaTime;
    }
}

2. Player Controller (Stays in Place but can move left/right)

using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public float strafeSpeed = 5f;
    public float minX = -3f;
    public float maxX = 3f;
    
    void Update()
    {
        float horizontal = Input.GetAxis("Horizontal");
        
        // Move player left/right only
        Vector3 newPosition = transform.position + Vector3.right * horizontal * strafeSpeed * Time.deltaTime;
        
        // Clamp position so player stays in bounds
        newPosition.x = Mathf.Clamp(newPosition.x, minX, maxX);
        
        transform.position = newPosition;
    }
}

3. Ground/Platform Looping (Essential for Endless Effect)

For continuous ground that never ends:

using UnityEngine;

public class GroundLoop : MonoBehaviour
{
    public float groundLength = 20f; // Length of your ground piece
    public Transform player;
    
    void Update()
    {
        // When ground piece passes behind player, move it forward
        if (transform.position.z < player.position.z - groundLength)
        {
            transform.position += Vector3.forward * groundLength * 2;
        }
    }
}

Alternative Method - Using multiple ground pieces:

using UnityEngine;

public class GroundLoopSystem : MonoBehaviour
{
    public GameObject groundPrefab;
    public Transform player;
    public float groundLength = 20f;
    public int numberOfGroundPieces = 3;
    
    private GameObject[] groundPieces;
    
    void Start()
    {
        groundPieces = new GameObject[numberOfGroundPieces];
        
        for (int i = 0; i < numberOfGroundPieces; i++)
        {
            Vector3 spawnPos = Vector3.forward * (groundLength * i);
            groundPieces[i] = Instantiate(groundPrefab, spawnPos, Quaternion.identity, transform);
        }
    }
    
    void Update()
    {
        foreach (GameObject ground in groundPieces)
        {
            if (ground.transform.position.z < player.position.z - groundLength)
            {
                // Find furthest ground piece
                float maxZ = float.MinValue;
                foreach (GameObject g in groundPieces)
                {
                    if (g.transform.position.z > maxZ)
                        maxZ = g.transform.position.z;
                }
                
                // Move this ground piece ahead
                ground.transform.position = new Vector3(0, 0, maxZ + groundLength);
            }
        }
    }
}

4. Complete Scene Setup Example

Step-by-step:

  1. Create Player:

    • Create Cube or Capsule
    • Position at (0, 1, 0)
    • Add the PlayerController script
    • Tag as "Player"
  2. Create Environment Parent:

    • Create Empty GameObject named "Environment"
    • Position at (0, 0, 0)
    • Add EnvironmentMover script
  3. Create Ground:

    • Create Plane (scale to 2, 1, 10 or larger)
    • Make it child of Environment
    • Position at (0, 0, 0)
    • Duplicate 2-3 times, space them along Z-axis
    • Add GroundLoop script to each
  4. Add Obstacles:

    • Create cubes/objects as children of Environment
    • They'll move with the environment automatically

5. Advanced: Speed Increase Over Time

using UnityEngine;

public class EnvironmentMoverAdvanced : MonoBehaviour
{
    public float startSpeed = 5f;
    public float maxSpeed = 20f;
    public float acceleration = 0.5f;
    
    private float currentSpeed;
    
    void Start()
    {
        currentSpeed = startSpeed;
    }
    
    void Update()
    {
        // Gradually increase speed
        currentSpeed = Mathf.Min(currentSpeed + acceleration * Time.deltaTime, maxSpeed);
        
        // Move environment
        transform.position += Vector3.back * currentSpeed * Time.deltaTime;
    }
}

6. Camera Setup

For best effect:

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    public Transform player;
    public Vector3 offset = new Vector3(0, 5, -10);
    
    void LateUpdate()
    {
        // Keep camera following player (even though player isn't really moving)
        transform.position = player.position + offset;
        transform.LookAt(player);
    }
}

Key Tips:

Player stays at fixed Z position (like Z = 0)
Environment moves in negative Z (backward)
Ground loops/respawns to create endless feeling
Obstacles are children of Environment so they move automatically
Player can only move left/right (X-axis)


Common Issues & Fixes:

Problem: Ground has visible seams
Fix: Make sure ground pieces are exactly the same length and positioned precisely

Problem: Objects fall through ground
Fix: Make sure Rigidbody on player has "Is Kinematic" checked, or use Character Controller

Problem: Movement feels jerky
Fix: Always use Time.deltaTime and consider using FixedUpdate() for physics-based movement


DETAILED CODE BREAKDOWN


1. ENVIRONMENT MOVER - Line by Line

using UnityEngine;

What it does: Imports Unity's core functionality so you can use classes like MonoBehaviour, Vector3, Time, etc.


public class EnvironmentMover : MonoBehaviour

What it does:

  • public class - Creates a new script component you can attach to GameObjects
  • EnvironmentMover - The name of your script (must match filename)
  • : MonoBehaviour - Inherits from Unity's base class, giving access to Update(), Start(), transform, etc.

public float moveSpeed = 10f;

What it does:

  • public - Makes this variable visible in Unity Inspector so you can adjust it without editing code
  • float - Decimal number (like 10.5, 5.0, etc.)
  • moveSpeed - Variable name
  • = 10f - Default value of 10, the f means "float" (not double)

Why: Controls how fast the environment moves. Higher number = faster movement.


void Update()

What it does: This function runs every single frame (typically 60 times per second)

Why: Perfect for continuous movement since it keeps updating position constantly


transform.position += Vector3.back * moveSpeed * Time.deltaTime;

This is the CORE line. Let's break it down piece by piece:


transform

  • Every GameObject has a transform component
  • Contains position, rotation, and scale
  • transform.position is the current location in 3D space (x, y, z coordinates)

transform.position +=

  • += means "add to the current value"
  • Example: If position is (0, 0, 0) and you add (0, 0, 1), new position becomes (0, 0, 1)
  • This creates relative movement (moving FROM where you are)

Alternative:

transform.position = new Vector3(0, 0, 0); // ABSOLUTE - teleports to exact location
transform.position += Vector3.back; // RELATIVE - moves from current position

Vector3.back

Vector3 is a structure representing direction and magnitude in 3D space (x, y, z)

Unity's built-in directions:

Vector3.forward  = (0, 0, 1)   // Positive Z - toward "north"
Vector3.back     = (0, 0, -1)  // Negative Z - toward "south"
Vector3.right    = (1, 0, 0)   // Positive X - toward "east"
Vector3.left     = (-1, 0, 0)  // Negative X - toward "west"
Vector3.up       = (0, 1, 0)   // Positive Y - toward sky
Vector3.down     = (0, -1, 0)  // Negative Y - toward ground

Why Vector3.back?

  • Player faces forward (positive Z)
  • Environment must come TOWARD player
  • So environment moves in NEGATIVE Z (backward)
  • This creates illusion player is moving forward

Visual:

Player at Z=0 (stays here)
    ↓
    🧍
    ←←←← Environment moving backward (negative Z)
[Ground][Obstacles][Trees]

* moveSpeed

  • Multiplies the direction by speed
  • Vector3.back * 10 = (0, 0, -10)
  • Without this, movement would be only 1 unit per frame (way too fast!)

Example:

Vector3.back * 5 = (0, 0, -5)  // Moves 5 units backward
Vector3.back * 20 = (0, 0, -20) // Moves 20 units backward (faster)

* Time.deltaTime

THIS IS CRITICAL - THE MOST IMPORTANT CONCEPT

Problem: Update() runs at different speeds on different computers

  • Fast PC: 120 frames per second (FPS)
  • Slow PC: 30 frames per second (FPS)

Without Time.deltaTime:

transform.position += Vector3.back * 10; // Moves 10 units PER FRAME
  • On 60 FPS: Moves 10 × 60 = 600 units per second
  • On 30 FPS: Moves 10 × 30 = 300 units per second
  • Result: Different speeds on different computers!

With Time.deltaTime:

transform.position += Vector3.back * 10 * Time.deltaTime;

What is Time.deltaTime?

  • Time elapsed since last frame (in seconds)
  • At 60 FPS: Time.deltaTime ≈ 0.0166 seconds
  • At 30 FPS: Time.deltaTime ≈ 0.0333 seconds

The Math:

  • 60 FPS: 10 * 0.0166 * 60 frames = 10 units per second
  • 30 FPS: 10 * 0.0333 * 30 frames = 10 units per second
  • Result: Same speed on all computers!

Think of it like this:

  • moveSpeed = units per second (not per frame)
  • Time.deltaTime converts it to units per frame

Complete Line Explained:

transform.position += Vector3.back * moveSpeed * Time.deltaTime;

In plain English: "Every frame, move this object backward (negative Z direction) at a speed of moveSpeed units per second"

Numerical Example:

  • Current position: (0, 0, 100)
  • moveSpeed: 10
  • Time.deltaTime: 0.016 (assuming 60 FPS)
  • Calculation: (0, 0, -1) × 10 × 0.016 = (0, 0, -0.16)
  • New position: (0, 0, 100) + (0, 0, -0.16) = (0, 0, 99.84)

After 1 second (60 frames): Position becomes approximately (0, 0, 90) The object moved 10 units backward in 1 second!


2. PLAYER CONTROLLER - Detailed Breakdown

using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public float strafeSpeed = 5f;
    public float minX = -3f;
    public float maxX = 3f;

What these variables do:

  • strafeSpeed - How fast player moves left/right (side-to-side movement is called "strafing")
  • minX - Leftmost boundary (-3 means 3 units to the left of origin)
  • maxX - Rightmost boundary (3 means 3 units to the right of origin)

Why we need boundaries: Without them, player could move infinitely left/right and go off-screen!


void Update()
{
    float horizontal = Input.GetAxis("Horizontal");

Input.GetAxis("Horizontal") - Gets keyboard/controller input:

  • Press A or Left Arrow: Returns -1.0 (move left)
  • Press D or Right Arrow: Returns +1.0 (move right)
  • Press nothing: Returns 0.0 (no movement)
  • Smoothed: Gradually transitions between values (not instant)

Example:

// If pressing D:
horizontal = 1.0f; // Move right

// If pressing A:
horizontal = -1.0f; // Move left

// If pressing nothing:
horizontal = 0.0f; // Don't move

Vector3 newPosition = transform.position + Vector3.right * horizontal * strafeSpeed * Time.deltaTime;

Breaking it down:

  1. transform.position - Current player position, e.g., (0, 1, 0)

  2. Vector3.right - Direction vector (1, 0, 0) - pointing right

  3. Vector3.right * horizontal

    • If horizontal = 1 (pressing D): (1, 0, 0) × 1 = (1, 0, 0) - move right
    • If horizontal = -1 (pressing A): (1, 0, 0) × -1 = (-1, 0, 0) - move left
    • If horizontal = 0 (no input): (1, 0, 0) × 0 = (0, 0, 0) - don't move
  4. * strafeSpeed * Time.deltaTime

    • Same concept as before - makes movement frame-rate independent
    • strafeSpeed controls how fast
  5. transform.position +

    • Adds movement to current position
    • Stores in temporary variable newPosition (doesn't apply yet)

Example Calculation:

  • Current position: (0, 1, 0)
  • Pressing D (horizontal = 1)
  • strafeSpeed = 5
  • Time.deltaTime = 0.016
  • Movement: (1, 0, 0) × 1 × 5 × 0.016 = (0.08, 0, 0)
  • newPosition: (0, 1, 0) + (0.08, 0, 0) = (0.08, 1, 0)

newPosition.x = Mathf.Clamp(newPosition.x, minX, maxX);

Mathf.Clamp(value, min, max) - Restricts a value between min and max

How it works:

Mathf.Clamp(5, 0, 10)   = 5   // Within range, stays same
Mathf.Clamp(15, 0, 10)  = 10  // Too high, clamped to max
Mathf.Clamp(-5, 0, 10)  = 0   // Too low, clamped to min

In our case:

// If newPosition.x = 4 (player went too far right)
// minX = -3, maxX = 3
newPosition.x = Mathf.Clamp(4, -3, 3) = 3; // Stopped at right boundary

// If newPosition.x = -5 (player went too far left)
newPosition.x = Mathf.Clamp(-5, -3, 3) = -3; // Stopped at left boundary

Why: Keeps player within playable area, can't escape boundaries


transform.position = newPosition;

Finally applies the new position to the player

  • We calculated the movement
  • We clamped it to boundaries
  • Now we actually move the player

Why we used a temporary variable:

  • Allows us to modify X coordinate only
  • Keeps Y and Z unchanged
  • If we modified transform.position.x directly, it wouldn't work (Unity doesn't allow it)

3. GROUND LOOP - Detailed Breakdown

public class GroundLoop : MonoBehaviour
{
    public float groundLength = 20f;
    public Transform player;

Variables:

  • groundLength - How long your ground piece is in Unity units
  • Transform player - Reference to player's transform (drag player object here in Inspector)

Why Transform not GameObject?

  • We only need position data
  • Transform is lighter and more direct

void Update()
{
    if (transform.position.z < player.position.z - groundLength)

Breaking down the condition:

  1. transform.position.z - Current Z position of THIS ground piece

  2. player.position.z - Player's Z position (usually stays around 0)

  3. player.position.z - groundLength - Trigger point behind player

Visual explanation:

        Trigger Point          Player
              ↓                  ↓
[-20]=========[-10]=============[0]=========[10]==========
      Ground Piece A         Ground Piece B

When does the condition trigger?

  • Player at Z = 0
  • groundLength = 20
  • Trigger point: 0 - 20 = -20
  • If ground piece A is at Z = -21 (past trigger point), it needs to teleport forward

The Logic: "If this ground piece has passed 20 units behind the player, teleport it ahead"


transform.position += Vector3.forward * groundLength * 2;

Breaking it down:

  1. Vector3.forward - Direction (0, 0, 1) - positive Z

  2. * groundLength * 2 - How far to move forward

    • If groundLength = 20
    • Movement: (0, 0, 1) × 20 × 2 = (0, 0, 40)

Why multiply by 2?

  • You typically have 2-3 ground pieces
  • Moving by groundLength × 2 ensures it goes ahead of other pieces
  • Creates seamless loop

Visual:

BEFORE:
Ground A at Z=-21 (behind player)
Ground B at Z=0
Ground C at Z=20

AFTER (Ground A teleports):
Ground B at Z=0
Ground C at Z=20
Ground A at Z=19 (was -21, moved +40)

4. ADVANCED VERSION - Multiple Ground Pieces

private GameObject[] groundPieces;

GameObject[] - Array (list) of GameObjects

  • Can store multiple ground pieces
  • Access them like: groundPieces[0], groundPieces[1], etc.

void Start()
{
    groundPieces = new GameObject[numberOfGroundPieces];

new GameObject[3] - Creates empty array with 3 slots

  • groundPieces[0] = null
  • groundPieces[1] = null
  • groundPieces[2] = null

for (int i = 0; i < numberOfGroundPieces; i++)
{
    Vector3 spawnPos = Vector3.forward * (groundLength * i);
    groundPieces[i] = Instantiate(groundPrefab, spawnPos, Quaternion.identity, transform);
}

for loop - Repeats code multiple times

Iteration breakdown:

// i = 0:
spawnPos = (0, 0, 1) * (20 * 0) = (0, 0, 0)    // First ground at origin

// i = 1:
spawnPos = (0, 0, 1) * (20 * 1) = (0, 0, 20)   // Second ground 20 units ahead

// i = 2:
spawnPos = (0, 0, 1) * (20 * 2) = (0, 0, 40)   // Third ground 40 units ahead

Instantiate() - Creates a copy of a GameObject

  • groundPrefab - What to copy
  • spawnPos - Where to create it
  • Quaternion.identity - No rotation (0, 0, 0)
  • transform - Parent object (makes it child of this object)

foreach (GameObject ground in groundPieces)

foreach - Loop through each item in array

  • Simpler than for loop when you need to check all items
  • ground is temporary variable representing current ground piece

float maxZ = float.MinValue;
foreach (GameObject g in groundPieces)
{
    if (g.transform.position.z > maxZ)
        maxZ = g.transform.position.z;
}

Purpose: Find the furthest ground piece forward

float.MinValue - Smallest possible float number (like -infinity)

  • Ensures any ground position will be larger
  • Common trick for finding maximum values

Example:

// Ground A at Z=0
// Ground B at Z=20
// Ground C at Z=40

// After loop: maxZ = 40 (furthest forward)

ground.transform.position = new Vector3(0, 0, maxZ + groundLength);

Teleports ground piece ahead of the furthest piece

Example:

  • maxZ = 40 (furthest piece)
  • groundLength = 20
  • New position: (0, 0, 40 + 20) = (0, 0, 60)

Result: Ground that was behind player is now ahead of all other pieces!


KEY CONCEPTS SUMMARY

  1. Time.deltaTime - ALWAYS use for smooth, frame-rate independent movement
  2. += vs = - += adds to current (relative), = sets exact value (absolute)
  3. Vector3 directions - Unity's coordinate system for 3D space
  4. Mathf.Clamp - Restricts values within range
  5. transform.position - Object's location in world space
  6. Arrays - Store multiple objects of same type
  7. Loops - Repeat code multiple times efficiently


Comments