Procedural World Generation
Build the WorldGenerator that spawns chunks ahead of the player, recycles them behind, and populates spawn points with obstacles and collectibles — creating an endless, seamless world.
The World Generation Flow
Now we bring together the chunk system (Chapter 11) and object pooling (Chapter 12) to create the actual infinite world. The world generation algorithm is surprisingly simple once you understand the flow:
- At game start: Spawn a few chunks ahead of the player to fill the visible area.
- Every frame during gameplay: Check if the player has moved far enough forward that we need to spawn the next chunk ahead.
- When spawning: Take a chunk from the object pool, position it at the end of the last chunk, and populate it with obstacles and collectibles.
- Every frame during gameplay: Check if any chunk has fallen far enough behind the player that it's no longer visible.
- When despawning: Return the chunk (and all its spawned objects) to the object pool.
The player runs forward. Chunks appear ahead and disappear behind. The player sees an endless road, but behind the scenes, the same 5-7 chunk objects are being recycled over and over.
The spawn distance is how far ahead of the player chunks should extend. If the player can see 60 units ahead, your spawn distance should be at least 80 (so new chunks appear before the player can see the edge of the world). The despawn distance is how far behind the player a chunk must be before it's recycled. A despawn distance of 30 means a chunk disappears 30 units behind the player. Keep the despawn distance generous — if the player turns the camera, you don't want them to see chunks vanishing.
Tracking Active Chunks
The WorldGenerator needs to track which chunks are currently active in the world. We use a simple List (or LinkedList) where:
- The first element is the chunk farthest behind the player (the next one to be despawned).
- The last element is the chunk farthest ahead (the most recently spawned).
When we spawn, we add to the end. When we despawn, we remove from the front. This is the classic "sliding window" pattern.
Player moves forward -->
[despawn zone] [visible area] [spawn zone]
| | |
v v v
[Chunk1] [Chunk2] [Chunk3] [Chunk4] [Chunk5] [Chunk6]
^ ^
| |
oldest chunk newest chunk
(return to pool (just spawned from pool)
when behind player)The WorldGenerator Script
Here is the complete WorldGenerator.cs. This is the core system that makes the world infinite. Read through it carefully — we'll break down every section afterward.
using System.Collections.Generic;
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.World;
namespace InfiniteRunner.World
{
/// <summary>
/// Generates the infinite world by spawning chunks ahead of the player
/// and recycling chunks that fall behind.
///
/// Requires: PoolManager (Chapter 12), ChunkRegistry (Chapter 11)
/// </summary>
public class WorldGenerator : MonoBehaviour
{
[Header("References")]
[Tooltip("The player's Transform. Chunks spawn/despawn relative to this.")]
[SerializeField] private Transform playerTransform;
[Tooltip("The chunk registry containing all available chunk configs.")]
[SerializeField] private ChunkRegistry chunkRegistry;
[Header("Generation Settings")]
[Tooltip("How far ahead of the player chunks should be spawned (units).")]
[SerializeField] private float spawnDistance = 100f;
[Tooltip("How far behind the player a chunk must be before despawning.")]
[SerializeField] private float despawnDistance = 30f;
[Tooltip("How many chunks to spawn during initial setup (at game start).")]
[SerializeField] private int initialChunkCount = 5;
[Tooltip("Force the first chunk to be a safe zone (no obstacles).")]
[SerializeField] private bool firstChunkIsSafe = true;
[Header("Difficulty")]
[Tooltip("Current difficulty level. Determines which chunks are available.")]
[SerializeField] private int currentDifficulty = 0;
// ---- Internal State ----
// All currently active chunks, ordered from oldest to newest
private readonly LinkedList<ActiveChunk> activeChunks
= new LinkedList<ActiveChunk>();
// The Z position where the next chunk should be placed
private float nextSpawnZ = 0f;
// Whether generation is currently running
private bool isGenerating = false;
/// <summary>
/// Tracks a chunk that's currently in the world along with its config.
/// We need the config to know which pool to return it to.
/// </summary>
private struct ActiveChunk
{
public ChunkData ChunkData;
public ChunkConfig Config;
public string PoolId;
}
// ---- Lifecycle ----
private void Start()
{
// Don't auto-start. Wait for the GameManager to tell us to begin.
// In the meantime, validate references.
ValidateReferences();
}
private void Update()
{
if (!isGenerating) return;
if (playerTransform == null) return;
float playerZ = playerTransform.position.z;
// Spawn new chunks if the player is approaching the end
SpawnChunksAhead(playerZ);
// Despawn old chunks that are behind the player
DespawnChunksBehind(playerZ);
}
// ---- Public API ----
/// <summary>
/// Start world generation. Called by GameManager when the game begins.
/// Spawns the initial set of chunks.
/// </summary>
public void StartGeneration()
{
if (isGenerating)
{
Debug.LogWarning("WorldGenerator: Already generating!");
return;
}
isGenerating = true;
nextSpawnZ = 0f;
// Spawn the initial batch of chunks
for (int i = 0; i < initialChunkCount; i++)
{
bool forceSafe = (i == 0 && firstChunkIsSafe);
SpawnNextChunk(forceSafe);
}
Debug.Log(
$"WorldGenerator: Started with {initialChunkCount} chunks. " +
$"Next spawn at Z={nextSpawnZ}"
);
}
/// <summary>
/// Stop world generation. Called by GameManager on game over.
/// Active chunks remain in place (for the game over screen).
/// </summary>
public void StopGeneration()
{
isGenerating = false;
}
/// <summary>
/// Reset the world: return all chunks to pools and clear state.
/// Called before starting a new run.
/// </summary>
public void ResetWorld()
{
StopGeneration();
// Return all active chunks to their pools
foreach (ActiveChunk chunk in activeChunks)
{
ReturnChunkToPool(chunk);
}
activeChunks.Clear();
nextSpawnZ = 0f;
Debug.Log("WorldGenerator: World reset complete.");
}
/// <summary>
/// Updates the current difficulty level.
/// Called by the difficulty system (Chapter 18).
/// </summary>
public void SetDifficulty(int difficulty)
{
currentDifficulty = Mathf.Clamp(difficulty, 0, 10);
}
/// <summary>
/// Called by OriginShifter (Chapter 14) to adjust internal positions
/// when the world is shifted back toward the origin.
/// </summary>
public void OnOriginShift(Vector3 shiftAmount)
{
nextSpawnZ += shiftAmount.z;
}
// ---- Spawn Logic ----
/// <summary>
/// Checks if we need to spawn more chunks ahead of the player.
/// </summary>
private void SpawnChunksAhead(float playerZ)
{
// Keep spawning until the world extends far enough ahead
while (nextSpawnZ - playerZ < spawnDistance)
{
SpawnNextChunk(forceSafe: false);
}
}
/// <summary>
/// Spawns a single chunk at the current nextSpawnZ position.
/// </summary>
/// <param name="forceSafe">If true, forces a safe (no obstacle) chunk.</param>
private void SpawnNextChunk(bool forceSafe)
{
// Pick which chunk to spawn
ChunkConfig config;
if (forceSafe)
{
config = GetSafeChunkConfig();
}
else
{
config = chunkRegistry.GetWeightedRandomChunk(currentDifficulty);
}
if (config == null)
{
Debug.LogError("WorldGenerator: Failed to get a chunk config!");
return;
}
// Get a chunk from the pool
string poolId = config.ChunkPrefab.name;
GameObject chunkObj = PoolManager.Instance.Get(poolId);
if (chunkObj == null)
{
Debug.LogError(
$"WorldGenerator: Failed to get chunk '{poolId}' from pool!"
);
return;
}
// Get the ChunkData component
ChunkData chunkData = chunkObj.GetComponent<ChunkData>();
if (chunkData == null)
{
Debug.LogError(
$"WorldGenerator: Chunk '{poolId}' has no ChunkData component!"
);
PoolManager.Instance.Return(poolId, chunkObj);
return;
}
// Position the chunk
// The chunk's local origin should align with nextSpawnZ
Vector3 spawnPos = new Vector3(0f, 0f, nextSpawnZ);
chunkObj.transform.position = spawnPos;
// Track this chunk
ActiveChunk active = new ActiveChunk
{
ChunkData = chunkData,
Config = config,
PoolId = poolId
};
activeChunks.AddLast(active);
// Populate spawn points with obstacles and collectibles
if (!config.IsSafeZone)
{
PopulateObstacles(chunkData, config);
}
PopulateCollectibles(chunkData, config);
// Advance the spawn position for the next chunk
nextSpawnZ += chunkData.ChunkLength;
}
/// <summary>
/// Finds a safe chunk config (one with IsSafeZone = true).
/// Falls back to the first available chunk if no safe zone exists.
/// </summary>
private ChunkConfig GetSafeChunkConfig()
{
foreach (ChunkConfig config in chunkRegistry.AllChunks)
{
if (config != null && config.IsSafeZone)
{
return config;
}
}
Debug.LogWarning(
"WorldGenerator: No safe chunk found! Using first available."
);
// Fallback to any available chunk
return chunkRegistry.GetWeightedRandomChunk(0);
}
// ---- Despawn Logic ----
/// <summary>
/// Checks if the oldest chunk is far enough behind the player to recycle.
/// </summary>
private void DespawnChunksBehind(float playerZ)
{
// Keep checking the oldest chunk
while (activeChunks.Count > 0)
{
ActiveChunk oldest = activeChunks.First.Value;
// Check if the END of this chunk is behind the despawn threshold
float chunkEndZ = oldest.ChunkData.EndPosition.z;
if (playerZ - chunkEndZ > despawnDistance)
{
// This chunk is far behind the player - recycle it
ReturnChunkToPool(oldest);
activeChunks.RemoveFirst();
}
else
{
// This chunk is still close enough. Since chunks are
// ordered by position, all subsequent chunks are even
// closer, so we can stop checking.
break;
}
}
}
/// <summary>
/// Returns a chunk and all its spawned objects to their pools.
/// </summary>
private void ReturnChunkToPool(ActiveChunk chunk)
{
if (chunk.ChunkData == null) return;
// The ChunkData.OnDespawn() method (from Chapter 12) automatically
// returns all registered spawned objects to their pools.
PoolManager.Instance.Return(chunk.PoolId, chunk.ChunkData.gameObject);
}
// ---- Obstacle & Collectible Spawning ----
/// <summary>
/// Spawns obstacles on a chunk's obstacle spawn points.
/// Not every spawn point gets an obstacle - we randomly select some.
/// </summary>
private void PopulateObstacles(ChunkData chunkData, ChunkConfig config)
{
Transform[] spawnPoints = chunkData.ObstacleSpawnPoints;
if (spawnPoints == null || spawnPoints.Length == 0) return;
// Determine how many obstacles to place (up to config.MaxObstacles)
int obstacleCount = Random.Range(1, config.MaxObstacles + 1);
obstacleCount = Mathf.Min(obstacleCount, spawnPoints.Length);
// Shuffle spawn points so we pick random positions
Transform[] shuffled = ShuffleArray(spawnPoints);
for (int i = 0; i < obstacleCount; i++)
{
Transform point = shuffled[i];
if (point == null) continue;
// Pick an obstacle type (for now, just use "Obstacle_Low")
// Chapter 15 will add proper type selection based on difficulty
string obstaclePoolId = "Obstacle_Low";
GameObject obstacle = PoolManager.Instance.Get(obstaclePoolId);
if (obstacle == null) continue;
// Position the obstacle at the spawn point
obstacle.transform.position = point.position;
obstacle.transform.rotation = point.rotation;
// Register with the chunk so it gets returned when chunk despawns
chunkData.RegisterSpawnedObject(obstacle);
}
}
/// <summary>
/// Spawns collectibles on a chunk's collectible spawn points.
/// </summary>
private void PopulateCollectibles(ChunkData chunkData, ChunkConfig config)
{
Transform[] spawnPoints = chunkData.CollectibleSpawnPoints;
if (spawnPoints == null || spawnPoints.Length == 0) return;
// Determine how many collectibles to place
int collectibleCount = Random.Range(
config.MaxCollectibles / 2,
config.MaxCollectibles + 1
);
collectibleCount = Mathf.Min(collectibleCount, spawnPoints.Length);
Transform[] shuffled = ShuffleArray(spawnPoints);
for (int i = 0; i < collectibleCount; i++)
{
Transform point = shuffled[i];
if (point == null) continue;
// Spawn a coin (Chapter 16 will add power-ups)
string coinPoolId = "Coin";
GameObject coin = PoolManager.Instance.Get(coinPoolId);
if (coin == null) continue;
coin.transform.position = point.position;
coin.transform.rotation = Quaternion.identity;
chunkData.RegisterSpawnedObject(coin);
}
}
// ---- Utility ----
/// <summary>
/// Creates a shuffled copy of a Transform array (Fisher-Yates shuffle).
/// Used to randomly select which spawn points to use.
/// </summary>
private Transform[] ShuffleArray(Transform[] original)
{
Transform[] shuffled = new Transform[original.Length];
System.Array.Copy(original, shuffled, original.Length);
for (int i = shuffled.Length - 1; i > 0; i--)
{
int j = Random.Range(0, i + 1);
Transform temp = shuffled[i];
shuffled[i] = shuffled[j];
shuffled[j] = temp;
}
return shuffled;
}
private void ValidateReferences()
{
if (playerTransform == null)
{
Debug.LogError(
"WorldGenerator: Player Transform is not assigned!",
this
);
}
if (chunkRegistry == null)
{
Debug.LogError(
"WorldGenerator: Chunk Registry is not assigned!",
this
);
}
}
// ---- Editor Visualization ----
private void OnDrawGizmosSelected()
{
if (playerTransform == null) return;
float playerZ = Application.isPlaying
? playerTransform.position.z
: 0f;
// Draw spawn distance (green line ahead)
Gizmos.color = Color.green;
Vector3 spawnLine = new Vector3(0, 2, playerZ + spawnDistance);
Gizmos.DrawWireCube(spawnLine, new Vector3(10, 0.1f, 0.1f));
Gizmos.DrawLine(
playerTransform.position,
new Vector3(0, 2, playerZ + spawnDistance)
);
// Draw despawn distance (red line behind)
Gizmos.color = Color.red;
Vector3 despawnLine = new Vector3(0, 2, playerZ - despawnDistance);
Gizmos.DrawWireCube(despawnLine, new Vector3(10, 0.1f, 0.1f));
}
}
}That's the entire world generator. Let's break down the key sections:
Breaking Down: SpawnNextChunk
This is the heart of the system. When called, it:
- Picks a chunk config using weighted random selection from the ChunkRegistry. The current difficulty level filters which chunks are available. If we're at difficulty 0, only easy chunks appear. At difficulty 5, harder variants become eligible.
- Gets a chunk from the pool by looking up the prefab name as the pool ID. The PoolManager returns an inactive chunk and activates it.
- Positions the chunk at
nextSpawnZ. This is the Z position where the previous chunk ended. The first chunk starts at Z = 0. - Populates spawn points with obstacles and collectibles. Not every spawn point gets something — we randomly pick a subset based on the config's max counts.
- Advances nextSpawnZ by the chunk's length, so the next chunk will be placed right at the end of this one.
We use LinkedList<ActiveChunk> instead of List<ActiveChunk> because we frequently add to the end (spawn) and remove from the front (despawn). With a List, removing the first element requires shifting every other element down, which is O(n). With a LinkedList, both operations are O(1). For our 5-7 active chunks, the difference is tiny, but it's a good habit to choose the right data structure.
How Weighted Random Selection Works
The ChunkRegistry.GetWeightedRandomChunk() method (from Chapter 11) uses a technique called weighted random selection. Let's understand it with an example.
Suppose we have three chunks available:
| Chunk | Weight | Probability |
|---|---|---|
| Straight | 10 | 10/16 = 62.5% |
| Dense | 5 | 5/16 = 31.25% |
| Safe | 1 | 1/16 = 6.25% |
The total weight is 16. The algorithm picks a random number between 0 and 15. If the number is 0-9 (range of 10), Straight is chosen. If 10-14 (range of 5), Dense is chosen. If 15 (range of 1), Safe is chosen. This gives each chunk a probability proportional to its weight.
As difficulty increases and new chunks become available, the total weight changes, automatically adjusting all probabilities. This is much more flexible than hard-coded percentages.
Chunk Positioning: End-to-End Placement
Getting chunks to line up seamlessly is critical. If there's even a tiny gap between chunks, the player will see a flickering seam in the ground. Here's how our system ensures perfect alignment:
- We maintain a
nextSpawnZvariable that always holds the Z position where the next chunk should start. - When we spawn a chunk, we set its position to
(0, 0, nextSpawnZ). - After spawning, we add the chunk's length to
nextSpawnZ:nextSpawnZ += chunkData.ChunkLength. - The next chunk will start exactly where this one ends.
If your ground mesh is 20 units long but ChunkData says the chunk is 22 units long, you'll get a 2-unit gap between chunks. Always use the SnapPoint_Start and SnapPoint_End transforms to define chunk length (the OnValidate method in ChunkData calculates it automatically). If you see gaps, double-check that the SnapPoint_End Z position matches the actual end of the ground mesh.
Despawning: Cleaning Up Behind the Player
The DespawnChunksBehind method runs every frame and checks if the oldest chunk (the one farthest behind the player) has passed the despawn threshold. If so, it returns the chunk to the pool.
The key insight is that chunks are ordered by position in our activeChunks list. The first element is always the chunk with the smallest Z position (farthest behind). So we only ever need to check the first element. If it's not far enough behind, none of the others are either, so we can stop checking immediately.
When a chunk is returned to the pool, its OnDespawn() method (which we implemented in Chapter 12) automatically returns all obstacles and collectibles that were spawned on it. This cascading cleanup means we never have orphaned objects floating in the scene.
Connecting to the GameManager
The WorldGenerator doesn't run on its own. It's controlled by the GameManager (Chapter 6), which tells it when to start and stop based on the game state.
// In your GameManager.cs (from Chapter 6):
[SerializeField] private WorldGenerator worldGenerator;
private void OnGameStart()
{
// Player pressed "Play" - start generating the world
worldGenerator.StartGeneration();
}
private void OnGameOver()
{
// Player died - stop generating but don't clear the world
// (the game over screen shows the world in the background)
worldGenerator.StopGeneration();
}
private void OnGameRestart()
{
// Player pressed "Retry" - clear everything and start fresh
worldGenerator.ResetWorld();
worldGenerator.StartGeneration();
}The three-method API (StartGeneration, StopGeneration, ResetWorld) gives the GameManager full control over the world's lifecycle without needing to know how world generation works internally. This is clean separation of concerns.
Instead of the GameManager directly calling WorldGenerator methods, you could use the event system from Chapter 7. The GameManager fires GameEvents.OnGameStarted, and the WorldGenerator subscribes to it. Both approaches work. Direct method calls are simpler and easier to debug. Events are more decoupled and flexible. For our project, either is fine — we'll use direct calls for simplicity.
Setting Up the WorldGenerator in Unity
- Create an empty GameObject in your scene called
WorldGenerator. - Add the
WorldGeneratorscript component to it. - In the Inspector, assign:
- Player Transform: Drag your player GameObject here.
- Chunk Registry: Drag your ChunkRegistry ScriptableObject asset here.
- Configure the generation settings:
- Spawn Distance: 100 (units ahead of player)
- Despawn Distance: 30 (units behind player)
- Initial Chunk Count: 5
- First Chunk Is Safe: checked
- Make sure the PoolManager has pools configured for each chunk prefab. The pool IDs must match the prefab names exactly (e.g., "Chunk_Straight", "Chunk_Dense").
- Also ensure the PoolManager has pools for "Coin" and "Obstacle_Low" (which we'll create properly in Chapters 15 and 16).
Testing: Watch It Work
Let's verify the world generation is working correctly.
Test 1: Initial Spawn
- Enter Play mode.
- Call
StartGeneration()(temporarily add a call inStart()for testing, or use the GameManager). - Look in the Hierarchy window. You should see 5 chunk objects appear under the Pool containers, all now active.
- In the Scene view, zoom out. You should see 5 chunks lined up end-to-end starting from the origin.
- The first chunk should be a safe zone (no obstacle spawns).
Test 2: Continuous Spawning
- Move the player forward (or temporarily give it an auto-movement script).
- Watch the Scene view. As the player approaches the end of the visible chunks, new ones should appear ahead.
- As the player moves past chunks, old ones behind should disappear and their objects should return to inactive state under the Pool containers.
- Open the Pool Debug Overlay (F3) and verify that active counts go up and down as expected.
Test 3: No Gaps
- In the Scene view, zoom in on the boundary between two chunks.
- There should be zero gap between the ground meshes. The end of one chunk should perfectly touch the start of the next.
- If you see gaps, check that your SnapPoint_End positions match your ground mesh dimensions.
If you don't have the GameManager or PlayerController set up yet, create a temporary test script that calls WorldGenerator.StartGeneration() on Start and moves the player forward automatically. This lets you test world generation in isolation before all systems are connected.
using UnityEngine;
namespace InfiniteRunner.World
{
/// <summary>
/// Temporary script for testing world generation in isolation.
/// Attach to the player object. Delete when real player controller is ready.
/// </summary>
public class WorldGenTester : MonoBehaviour
{
[SerializeField] private WorldGenerator worldGenerator;
[SerializeField] private float moveSpeed = 20f;
private void Start()
{
// Start generating after a short delay to let pools initialize
Invoke(nameof(BeginTest), 0.1f);
}
private void BeginTest()
{
if (worldGenerator != null)
{
worldGenerator.StartGeneration();
Debug.Log("WorldGenTester: Generation started!");
}
}
private void Update()
{
// Auto-move forward to test spawning and despawning
transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime);
// Speed controls for testing
if (Input.GetKey(KeyCode.LeftShift))
{
transform.Translate(
Vector3.forward * moveSpeed * 3f * Time.deltaTime
);
}
}
}
}Performance Considerations
Our WorldGenerator is already well-optimized thanks to object pooling, but here are a few additional notes:
- Frame budget: Spawning a chunk and populating its spawn points happens in a single frame. With our simple prefabs, this takes less than 1ms. For more complex chunks with many spawn points, you could spread spawning over multiple frames using a coroutine.
- Check frequency: We check for spawning/despawning every frame. This is fine because the checks are cheap (just comparing float positions). If you had 100+ active chunks, you might want to check less frequently, but with 5-7 active chunks, every-frame checks add essentially zero overhead.
- Shuffle allocation: The
ShuffleArraymethod creates a new array each call. In Chapter 24 (Optimization), we'll replace this with an in-place shuffle using a cached array to eliminate this allocation.
What We Built
The world generation system ties together everything from Part 3. Here's the complete picture:
- WorldGenerator.cs — The main system that spawns chunks ahead of the player and recycles them behind. Uses weighted random selection for variety and difficulty-based filtering for progression.
- Chunk spawning flow: Pick a config from the registry, get a chunk from the pool, position it, populate its spawn points, advance the spawn position.
- Chunk despawning flow: Check if the oldest chunk is behind the player, return it (and all its objects) to their pools.
- GameManager integration: Three clean methods (Start, Stop, Reset) that give the game state controller full authority over world generation.
There's one more critical piece of the world generation puzzle: origin shifting. In the next chapter, we'll solve the floating point precision problem that would otherwise cause visual artifacts after the player runs a few thousand meters.
The world generates infinitely. Commit this to our feature branch.
git add .
git commit -m "Add procedural world generation with chunk spawning and recycling"
One more chapter on this branch — origin shifting — and then we'll merge everything back into main.