← Projects
Completed · Bachelor's Thesis 2025 Unity 2022 · URP

Endless Runner

The world scrolls, the player stays fixed — LevelGenerator moves chunks toward a stationary PlayerController, each chunk self-manages its pooled obstacles in OnEnable/OnDisable, and difficulty escalates via an exponentially decaying spawn interval.

Gameplay preview — world scrolling, obstacle pool recycling, checkpoint difficulty spike

Keeping Object Count Constant at Runtime

Spawning a new prefab for every obstacle means a GC allocation every time one appears — and at higher speeds those pauses are noticeable. A Queue-based ObjectPool, managed by a static PoolManager keyed by prefab, pre-warms instances at load time and recycles without calling Instantiate again during play. Each PooledObject component acts as a type tag so PoolManager.Release() can recursively return a chunk and all its children in one call — the caller doesn't need to know what's inside the chunk.

Inverting Motion to Sidestep Float Precision

Moving the player forward accumulates floating-point error over long runs, and you still need a camera-follow system. The simpler fix: keep PlayerController at a fixed world position and move the world toward it. LevelGenerator translates every active chunk by -Vector3.forward * moveSpeed * dt each frame and recycles via PoolManager.Release() when a chunk clears the camera. One direction, one transform operation, no precision drift.

Shaping Difficulty as a Curve, Not a Step Function

Linear difficulty — "add one obstacle every N checkpoints" — produces a staircase the player can feel as sudden jumps. Exponential decay gives you the opposite: early checkpoints bite hard, late ones barely tighten the interval. Each Checkpoint decreases ObstacleSpawner.obstacleSpawnTime by a decaying amount, so the game front-loads pressure and asymptotically approaches a minimum spawn interval rather than reaching it abruptly. Speed increases couple directly to Physics.gravity.z and camera FOV, so the player reads faster as a physical sensation, not just a number.

Architecture

LevelGenerator + Chunk — World Scrolls, Player Stays Fixed

LevelGenerator owns a List<GameObject> of active chunks, moves them each frame, and calls PoolManager.Release() on the rearmost chunk when it clears the camera. Every checkpointChunkInterval-th spawn substitutes a checkpointChunkPrefab for a standard chunk — no external timer, just a modulo check on chunksSpawned. Each Chunk is fully self-contained: OnEnable spawns its fences, apple, and coins; OnDisable is a safety cleanup. No external system coordinates a chunk's contents — the chunk owns its own lifecycle entirely.

// Proc Gen/LevelGenerator.cs (namespace ProceduralGeneration)
public class LevelGenerator : MonoBehaviour
{
    [SerializeField] GameObject[]  chunkPrefabs;
    [SerializeField] GameObject    checkpointChunkPrefab;
    [SerializeField] int           startingChunksAmount = 12;
    [SerializeField] int           checkpointChunkInterval = 8;
    [SerializeField] float         chunkLength = 10f;

    float moveSpeed = 10f;
    readonly List<GameObject> chunks = new();
    int chunksSpawned;

    void Update()
    {
        MoveChunks();
        if (ChunkPassedCamera()) RecycleAndSpawn();
    }

    void MoveChunks()
    {
        foreach (var chunk in chunks)
            chunk.transform.position -= Vector3.forward * moveSpeed * Time.deltaTime;
    }

    GameObject ChooseChunkToSpawn() =>
        chunksSpawned % checkpointChunkInterval == 0
            ? checkpointChunkPrefab
            : chunkPrefabs[Random.Range(0, chunkPrefabs.Length)];

    void SpawnChunk()
    {
        var prefab = ChooseChunkToSpawn();
        var go     = PoolManager.Get(prefab, SpawnPosition(), Quaternion.identity);
        chunks.Add(go);
        chunksSpawned++;
    }

    void RecycleAndSpawn()
    {
        PoolManager.Release(chunks[0]);  // recursive — releases chunk + all children
        chunks.RemoveAt(0);
        SpawnChunk();
    }
}

// Proc Gen/Chunk.cs — self-managing content lifecycle
public class Chunk : MonoBehaviour
{
    float[] lanes = { -2.5f, 0f, 2.5f };  // three fixed X-axis lanes

    void OnEnable()   // called when pool hands this chunk out
    {
        SpawnFences();   // up to 2, random lane selection without replacement
        SpawnApple();    // appleSpawnChance = 0.3f
        SpawnCoins();    // coinSpawnChance  = 0.5f, up to 6 in a line
    }

    void OnDisable()  // called when PoolManager.Release() deactivates this chunk
    {
        // PoolManager.Release() already handles recursive child release
        // clearing the spawnRoot children here is a safety cleanup
    }
}

ObjectPool + PoolManager + PooledObject — Queue-Based, Keyed by Prefab

ObjectPool is a plain class — not a MonoBehaviour — using a Queue<GameObject> for FIFO recycling. The static PoolManager maps each prefab to its pool and creates pools on first access with no pre-registration step.

The key detail is PooledObject. It's stamped onto every instance as a component tag during CreateInstance(), storing the originating prefab as a key. This is what enables recursive release: when you call PoolManager.Release() on a chunk, it iterates the chunk's children, returns any PooledObject children to their own pools first, then returns the chunk itself. The caller passes one GameObject — the pool handles the rest.

// Pooling/ObjectPool.cs (namespace Pooling) — plain class, not MonoBehaviour
public class ObjectPool
{
    readonly GameObject        _prefab;
    readonly Transform         _poolRoot;
    readonly Queue<GameObject> _items = new();

    public ObjectPool(GameObject prefab, Transform poolRoot, int prewarm = 0)
    {
        _prefab   = prefab;
        _poolRoot = poolRoot;
        for (int i = 0; i < prewarm; i++) _items.Enqueue(CreateInstance());
    }

    public GameObject Get(Vector3 position, Quaternion rotation, Transform parent)
    {
        var go = _items.Count > 0 ? _items.Dequeue() : CreateInstance();
        go.transform.SetPositionAndRotation(position, rotation);
        go.transform.SetParent(parent);
        go.SetActive(true);
        return go;
    }

    public void Return(GameObject go)
    {
        go.SetActive(false);
        go.transform.SetParent(_poolRoot);
        _items.Enqueue(go);
    }

    GameObject CreateInstance()
    {
        var go = Object.Instantiate(_prefab, _poolRoot);
        go.SetActive(false);
        if (!go.TryGetComponent<PooledObject>(out _))
            go.AddComponent<PooledObject>().PrefabKey = _prefab;
        return go;
    }
}

// Managers/PoolManager.cs — static registry, one pool per prefab
public static class PoolManager
{
    static readonly Dictionary<GameObject, ObjectPool> Pools = new();
    static Transform _root;
    static Transform Root   // lazy DontDestroyOnLoad container
    {
        get
        {
            if (_root == null)
            {
                _root = new GameObject("[PoolManager]").transform;
                Object.DontDestroyOnLoad(_root.gameObject);
            }
            return _root;
        }
    }

    public static GameObject Get(GameObject prefab, Vector3 pos, Quaternion rot,
                                  Transform parent = null)
    {
        if (!Pools.TryGetValue(prefab, out var pool))
            Pools[prefab] = pool = new ObjectPool(prefab, Root);
        return pool.Get(pos, rot, parent);
    }

    public static void Release(GameObject go)
    {
        // Recursively release pooled children before returning parent
        foreach (Transform child in go.transform)
            ReleaseIfPooled(child.gameObject);

        if (go.TryGetComponent<PooledObject>(out var pooled))
            Pools[pooled.PrefabKey].Return(go);
    }
}

Difficulty Escalation — Checkpoints, Gravity, and Exponential Decay

Each Checkpoint trigger fires two calls: GameManager.IncreaseTime() extends the run, and ObstacleSpawner.DecreaseObstacleSpawnTime() tightens the spawn interval. The interesting part is how it tightens: exponential decay rather than a fixed decrement. The first checkpoint removes a large slice from the interval; each subsequent one removes less. Concretely, currentDecreaseAmount gets multiplied by decayRate (0.8) every time — so checkpoint one hits for 0.1f, checkpoint two for 0.08f, and so on down to the minimumDecreaseAmount floor. The player feels early pressure but the game never becomes mechanically impossible.

Speed increases go through ApplySpeedDelta(), which remaps moveSpeed to a Physics.gravity.z value and calls CameraController.ChangeCameraFOV(). Faster world = stronger Z pull = wider FOV — three systems agreeing that something has changed, so the player reads it as a physical reality rather than a stat update.

// Proc Gen/ObstacleSpawner.cs
public class ObstacleSpawner : MonoBehaviour
{
    [SerializeField] float obstacleSpawnTime    = 1f;
    [SerializeField] float minObstacleSpawnTime = 0.2f;
    [SerializeField] float initialDecreaseAmount = 0.1f;
    [SerializeField] float decayRate             = 0.8f;
    [SerializeField] float minimumDecreaseAmount = 0.01f;

    float currentDecreaseAmount;

    void Start() => currentDecreaseAmount = initialDecreaseAmount;

    // Called by Checkpoint.OnTriggerEnter() each time the player passes a checkpoint
    public void DecreaseObstacleSpawnTime()
    {
        obstacleSpawnTime = Mathf.Max(obstacleSpawnTime - currentDecreaseAmount,
                                      minObstacleSpawnTime);
        // Exponential decay: each call has less impact than the last
        currentDecreaseAmount = Mathf.Max(currentDecreaseAmount * decayRate,
                                           minimumDecreaseAmount);
    }
}

// Proc Gen/Checkpoint.cs
public class Checkpoint : MonoBehaviour
{
    [SerializeField] float checkpointTimeExtension = 5f;

    void OnTriggerEnter(Collider other)
    {
        GameManager.Instance.IncreaseTime(checkpointTimeExtension);
        _obstacleSpawner.DecreaseObstacleSpawnTime();
    }
}

// Proc Gen/LevelGenerator.cs — speed coupled to gravity + FOV
float ApplySpeedDelta(float delta)
{
    float clamped = Mathf.Clamp(moveSpeed + delta, minMoveSpeed, maxMoveSpeed);
    float applied = clamped - moveSpeed;
    moveSpeed     = clamped;

    // Remap speed to gravity.z — faster world = stronger Z pull
    float t = Mathf.InverseLerp(minMoveSpeed, maxMoveSpeed, moveSpeed);
    Physics.gravity = new Vector3(0, Physics.gravity.y,
                                  Mathf.Lerp(minGravityZ, maxGravityZ, t));
    cameraController.ChangeCameraFOV(applied);
    return applied;
}

PlayerController — Analog X Clamping, Not Discrete Lanes

PlayerController reads a Vector2 from InputManager.MoveEvent, applies it to Rigidbody.MovePosition on the X axis only, and clamps to ±xClamp=3f. The obstacles in Chunk sit at fixed X positions (-2.5f, 0f, 2.5f) — the player dodges by sliding into the gap rather than pressing a lane key. There's no snap-to-lane: hold the stick and you drift to the hard edge. It also subscribes to GameManager.OnGameStart and OnGameOver via static events rather than holding a direct reference to GameManager — so canControl flips without any scene coupling.

// Player/PlayerController.cs (namespace Player)
[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour
{
    [SerializeField] float moveSpeed = 5f;
    [SerializeField] float xClamp    = 3f;   // hard edge at ±3 world units

    Vector2   movement;
    Rigidbody rigidBody;
    bool      canControl = false;

    void OnEnable()
    {
        // Subscribes to static events — no direct reference to InputManager scene object
        InputManager.Instance.MoveEvent   += HandleMove;
        GameManager.OnGameStart            += () => canControl = true;
        GameManager.OnGameOver             += () => canControl = false;
    }

    void HandleMove(Vector2 dir) => movement = dir;

    void FixedUpdate()
    {
        if (!canControl) return;
        HandleMovement();
    }

    void HandleMovement()
    {
        Vector3 pos = rigidBody.position;
        pos.x = Mathf.Clamp(pos.x + movement.x * moveSpeed * Time.fixedDeltaTime,
                             -xClamp, xClamp);
        rigidBody.MovePosition(pos);
    }
}

// Chunk obstacle lane positions for reference:
// float[] lanes = { -2.5f, 0f, 2.5f }  →  Chunk.cs

Screenshots

Endless Runner — third-person view during a run: procedurally generated chunks scrolling at full speed

Third-person gameplay — chunks scrolling at full speed

Endless Runner — obstacle density mid-run: spawn interval decreasing after several checkpoints

Mid-run — obstacle spawn interval decreasing after several checkpoints

Retrospective

  • The three systems were designed to be independent of each other. Chunk doesn't know about PoolManager. PlayerController doesn't know about LevelGenerator. That decoupling paid off in practice: swapping chunk prefabs, changing spawn logic, or adding new obstacle types required zero changes to player or input code. The architecture held under extension.
  • Analog movement doesn't give the player a clear lane model. Obstacle fences are placed at -2.5f, 0f, 2.5f, but the player slides freely between them. This means experienced players treat it as analog — which works — but it makes the game harder to learn because there's no satisfying snap-to-lane feedback. Discrete lane input would have made the safe zones more legible without much gameplay cost.
  • Static PoolManager is untestable as written. Because it's a static class with a static dictionary, you can't inject a mock or reset state between tests without reflection hacks. An IPoolManager interface with the current static class as the default would cost one extra line per call site but make the system fully testable.