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
Third-person gameplay — chunks scrolling at full speed
Mid-run — obstacle spawn interval decreasing after several checkpoints
Retrospective
- — The three systems were designed to be independent of each
other.
Chunkdoesn't know aboutPoolManager.PlayerControllerdoesn't know aboutLevelGenerator. 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
IPoolManagerinterface with the current static class as the default would cost one extra line per call site but make the system fully testable.