Chapter 11

Tile / Chunk System

Learn how to break your infinite world into modular, reusable sections called chunks — the fundamental building blocks of procedural world generation.

What Is a "Chunk"?

In an infinite runner, the world stretches out endlessly in front of the player. But you obviously can't build an infinitely long level in the Unity editor — that would require infinite memory and infinite time. Instead, we use a clever trick: we build a handful of short sections of the world, and then stitch them together end-to-end at runtime, recycling old ones as the player passes them. Each of these sections is called a chunk (also sometimes called a "tile" or "segment").

Think of chunks like LEGO bricks. Each brick is a self-contained piece — it has its own ground surface, its own obstacle positions, and its own collectible positions. You snap them together in a line to create a road. When the player runs past a brick, you pick it up from behind them and place it at the front of the line. The player never notices that the world behind them is disappearing, because they're always looking forward.

What's Inside a Chunk?

A typical chunk in our 3-lane infinite runner contains:

Why Empty GameObjects as Spawn Points?

Empty GameObjects are invisible in the game but visible in the editor. They act as position markers — "put an obstacle here" or "put a coin here." When the chunk spawns at runtime, our code reads these positions and decides what to actually place there. This gives us enormous flexibility: the same chunk can have different obstacles every time it appears, because the spawn points are just positions, not specific objects.

Why Modular Chunks?

You might wonder: why not just generate the world procedurally, placing ground tiles and obstacles one at a time? The chunk-based approach has several major advantages:

How Many Chunks Do You Need?

For a polished game, aim for 10-20 chunk variants. For our tutorial, we'll start with 3-4 and add more later. Even with just a few chunks, the randomized obstacle and collectible placement makes each playthrough feel different. Remember: the chunks define where things can appear, but the spawner system (Chapter 15) decides what actually appears there.

Designing the Chunk Prefab Structure

Let's establish a clear hierarchy for our chunk prefabs. Every chunk follows the same structure so our code can work with any variant.

Here is the GameObject hierarchy for a single chunk:

Chunk Prefab HierarchyUnity
Chunk_Straight          (parent - has ChunkData script)
  |-- Ground            (3D mesh - stretched cube or plane)
  |-- SpawnPoints
  |     |-- Obstacle_L_1    (empty GO at left lane, position 1)
  |     |-- Obstacle_C_1    (empty GO at center lane, position 1)
  |     |-- Obstacle_R_1    (empty GO at right lane, position 1)
  |     |-- Obstacle_L_2    (empty GO at left lane, position 2)
  |     |-- Obstacle_C_2    (empty GO at center lane, position 2)
  |     |-- Obstacle_R_2    (empty GO at right lane, position 2)
  |     |-- Coin_L_1        (empty GO at left lane, coin position 1)
  |     |-- Coin_C_1        (empty GO at center lane, coin position 1)
  |     |-- Coin_R_1        (empty GO at right lane, coin position 1)
  |-- SnapPoint_Start   (empty GO at Z = 0, the "back" of the chunk)
  |-- SnapPoint_End     (empty GO at Z = chunkLength, the "front")

Let's break down each part:

Lane Width Consistency

Make sure your lane positions are consistent across all chunk variants. If the left lane is at X = -2 in one chunk, it must be at X = -2 in every chunk. If they don't match, players will see objects "jump" sideways when transitioning between chunks. We'll define lane positions as constants: Left = -2f, Center = 0f, Right = 2f.

The ChunkData Script

Every chunk prefab needs a script that tells our code what's inside it. The ChunkData script holds references to the chunk's spawn points, its length, and its snap points. This is the script that the World Generator (Chapter 13) reads when it spawns a chunk.

ChunkData.csC#
using UnityEngine;

namespace InfiniteRunner.World
{
    /// <summary>
    /// Holds all metadata for a single world chunk.
    /// Attach this to the root GameObject of every chunk prefab.
    /// </summary>
    public class ChunkData : MonoBehaviour
    {
        [Header("Chunk Dimensions")]
        [Tooltip("The length of this chunk along the Z-axis (forward direction).")]
        [SerializeField] private float chunkLength = 20f;

        [Header("Snap Points")]
        [Tooltip("The transform at the very start (back) of this chunk.")]
        [SerializeField] private Transform snapPointStart;

        [Tooltip("The transform at the very end (front) of this chunk.")]
        [SerializeField] private Transform snapPointEnd;

        [Header("Spawn Points")]
        [Tooltip("Positions where obstacles can be placed on this chunk.")]
        [SerializeField] private Transform[] obstacleSpawnPoints;

        [Tooltip("Positions where collectibles (coins, power-ups) can be placed.")]
        [SerializeField] private Transform[] collectibleSpawnPoints;

        // ---- Public Properties ----

        /// <summary>
        /// How long this chunk is along the forward (Z) axis.
        /// Used by the WorldGenerator to know where to place the next chunk.
        /// </summary>
        public float ChunkLength => chunkLength;

        /// <summary>
        /// The world-space position of the back edge of this chunk.
        /// </summary>
        public Vector3 StartPosition => snapPointStart.position;

        /// <summary>
        /// The world-space position of the front edge of this chunk.
        /// The next chunk's StartPosition should align with this.
        /// </summary>
        public Vector3 EndPosition => snapPointEnd.position;

        /// <summary>
        /// All obstacle spawn point transforms on this chunk.
        /// The ObstacleSpawner reads these to decide where to place obstacles.
        /// </summary>
        public Transform[] ObstacleSpawnPoints => obstacleSpawnPoints;

        /// <summary>
        /// All collectible spawn point transforms on this chunk.
        /// The CollectibleSpawner reads these to decide where to place coins.
        /// </summary>
        public Transform[] CollectibleSpawnPoints => collectibleSpawnPoints;

        // ---- Validation ----

        private void OnValidate()
        {
            // Auto-calculate chunk length from snap points if both are assigned
            if (snapPointStart != null && snapPointEnd != null)
            {
                float calculatedLength = snapPointEnd.localPosition.z
                                       - snapPointStart.localPosition.z;

                if (calculatedLength > 0f)
                {
                    chunkLength = calculatedLength;
                }
            }
        }

        // ---- Editor Visualization ----

        private void OnDrawGizmos()
        {
            // Draw obstacle spawn points in red
            if (obstacleSpawnPoints != null)
            {
                Gizmos.color = Color.red;
                foreach (Transform point in obstacleSpawnPoints)
                {
                    if (point != null)
                    {
                        Gizmos.DrawWireSphere(point.position, 0.3f);
                        Gizmos.DrawLine(point.position,
                            point.position + Vector3.up * 2f);
                    }
                }
            }

            // Draw collectible spawn points in yellow
            if (collectibleSpawnPoints != null)
            {
                Gizmos.color = Color.yellow;
                foreach (Transform point in collectibleSpawnPoints)
                {
                    if (point != null)
                    {
                        Gizmos.DrawWireSphere(point.position, 0.2f);
                    }
                }
            }

            // Draw chunk bounds in green
            if (snapPointStart != null && snapPointEnd != null)
            {
                Gizmos.color = Color.green;
                Gizmos.DrawLine(snapPointStart.position, snapPointEnd.position);

                // Draw start marker
                Gizmos.color = Color.cyan;
                Gizmos.DrawWireCube(snapPointStart.position,
                    new Vector3(6f, 0.1f, 0.1f));

                // Draw end marker
                Gizmos.color = Color.magenta;
                Gizmos.DrawWireCube(snapPointEnd.position,
                    new Vector3(6f, 0.1f, 0.1f));
            }
        }
    }
}

Let's walk through what each part does:

What Is OnValidate?

OnValidate() is a special Unity method that runs whenever you change a value in the Inspector (and when the script first loads). We use it to automatically calculate the chunk length from the snap point positions. This means if you move a snap point, the length updates instantly — no manual math needed. It only runs in the editor, never in a built game, so there's zero performance cost.

ChunkConfig ScriptableObject

Each chunk variant has properties beyond what's stored in the prefab itself — things like how likely it is to appear, what difficulty level it requires, and a human-readable name. We store these in a ScriptableObject called ChunkConfig.

If you haven't used ScriptableObjects before: they're data containers that live as assets in your project. Unlike MonoBehaviours, they don't need to be attached to a GameObject. Think of them as configuration files that you can edit in the Inspector.

ChunkConfig.csC#
using UnityEngine;

namespace InfiniteRunner.World
{
    /// <summary>
    /// Configuration data for a chunk variant.
    /// Create instances via: Assets > Create > Infinite Runner > Chunk Config
    /// </summary>
    [CreateAssetMenu(
        fileName = "NewChunkConfig",
        menuName = "Infinite Runner/Chunk Config",
        order = 1
    )]
    public class ChunkConfig : ScriptableObject
    {
        [Header("Identity")]
        [Tooltip("A human-readable name for this chunk variant.")]
        [SerializeField] private string chunkName = "New Chunk";

        [Tooltip("The prefab to instantiate for this chunk.")]
        [SerializeField] private GameObject chunkPrefab;

        [Header("Spawn Settings")]
        [Tooltip("How likely this chunk is to be selected. Higher = more common. " +
                 "A weight of 10 is twice as likely as a weight of 5.")]
        [Range(1, 100)]
        [SerializeField] private int spawnWeight = 10;

        [Tooltip("The minimum difficulty level before this chunk can appear. " +
                 "0 = available from the start, 5 = only appears later in the game.")]
        [Range(0, 10)]
        [SerializeField] private int minimumDifficulty = 0;

        [Header("Chunk Properties")]
        [Tooltip("Maximum number of obstacles that can spawn on this chunk.")]
        [Range(0, 10)]
        [SerializeField] private int maxObstacles = 3;

        [Tooltip("Maximum number of collectibles that can spawn on this chunk.")]
        [Range(0, 20)]
        [SerializeField] private int maxCollectibles = 8;

        [Tooltip("If true, this chunk will never have obstacles " +
                 "(useful for starting chunks or rest chunks).")]
        [SerializeField] private bool isSafeZone = false;

        // ---- Public Properties ----

        public string ChunkName => chunkName;
        public GameObject ChunkPrefab => chunkPrefab;
        public int SpawnWeight => spawnWeight;
        public int MinimumDifficulty => minimumDifficulty;
        public int MaxObstacles => maxObstacles;
        public int MaxCollectibles => maxCollectibles;
        public bool IsSafeZone => isSafeZone;

        // ---- Validation ----

        private void OnValidate()
        {
            if (chunkPrefab != null)
            {
                // Verify the prefab has a ChunkData component
                ChunkData data = chunkPrefab.GetComponent<ChunkData>();
                if (data == null)
                {
                    Debug.LogWarning(
                        $"ChunkConfig '{chunkName}': Assigned prefab " +
                        $"'{chunkPrefab.name}' is missing a ChunkData component!",
                        this
                    );
                }
            }
        }
    }
}

Key points about this ScriptableObject:

ScriptableObjects Are Your Best Friend

ScriptableObjects let designers tweak game parameters without touching code. Once you have the ChunkConfig system set up, adding a new chunk variant is entirely a Unity Editor workflow: create the prefab, create a ChunkConfig asset, fill in the values, and drag the prefab in. Zero lines of code. This is the kind of production workflow that separates hobby projects from professional ones.

Creating Your First Chunk Prefabs

Now let's build actual chunk prefabs in Unity. We'll create four variants: a basic straight chunk, a chunk with lots of spawn points, a narrow passage chunk, and a safe starting chunk.

Step 1: Set Up the Folder Structure

  1. In the Project window, navigate to Assets/Prefabs. If this folder doesn't exist, create it.
  2. Inside Prefabs, create a subfolder called Chunks.
  3. Inside Assets, create a folder called ScriptableObjects, and inside that, create ChunkConfigs.

Your folder structure should look like:

Project Folder StructureFolders
Assets/
  Prefabs/
    Chunks/
      Chunk_Straight.prefab
      Chunk_Dense.prefab
      Chunk_Narrow.prefab
      Chunk_Safe.prefab
  ScriptableObjects/
    ChunkConfigs/
      ChunkConfig_Straight.asset
      ChunkConfig_Dense.asset
      ChunkConfig_Narrow.asset
      ChunkConfig_Safe.asset
  Scripts/
    World/
      ChunkData.cs
      ChunkConfig.cs

Step 2: Build the Basic Straight Chunk

  1. In the Hierarchy, right-click and choose Create Empty. Name it Chunk_Straight. Set its position to (0, 0, 0).
  2. Add the ChunkData script to Chunk_Straight (Component > Add Component > search for ChunkData).
  3. Right-click Chunk_Straight and choose 3D Object > Cube. Name it Ground.
  4. Select Ground and set its Transform:
    • Position: (0, -0.05, 10)
    • Scale: (6, 0.1, 20)
    This creates a flat platform 6 units wide and 20 units long. The Y position of -0.05 places the top surface exactly at Y = 0, so the player runs on a flat plane.
  5. Right-click Chunk_Straight and choose Create Empty. Name it SpawnPoints.
  6. Inside SpawnPoints, create the following empty GameObjects with these positions:
    • Obstacle_L_1 — Position: (-2, 0, 5)
    • Obstacle_C_1 — Position: (0, 0, 5)
    • Obstacle_R_1 — Position: (2, 0, 5)
    • Obstacle_L_2 — Position: (-2, 0, 15)
    • Obstacle_C_2 — Position: (0, 0, 15)
    • Obstacle_R_2 — Position: (2, 0, 15)
    • Coin_L_1 — Position: (-2, 1, 3)
    • Coin_C_1 — Position: (0, 1, 8)
    • Coin_R_1 — Position: (2, 1, 13)
  7. Right-click Chunk_Straight and create two more empty GameObjects:
    • SnapPoint_Start — Position: (0, 0, 0)
    • SnapPoint_End — Position: (0, 0, 20)
  8. Select Chunk_Straight and in the ChunkData component in the Inspector:
    • Drag SnapPoint_Start into the Snap Point Start field.
    • Drag SnapPoint_End into the Snap Point End field.
    • Drag the six Obstacle GameObjects into the Obstacle Spawn Points array.
    • Drag the three Coin GameObjects into the Collectible Spawn Points array.
  9. Drag Chunk_Straight from the Hierarchy into the Assets/Prefabs/Chunks folder to create the prefab.
  10. Delete the Chunk_Straight from the scene (the prefab is saved in the Project window).
Verify Your Gizmos

After creating the prefab, double-click it in the Project window to open it in Prefab Edit Mode. You should see red wire spheres at obstacle spawn points, yellow wire spheres at collectible spawn points, and colored lines marking the chunk boundaries. If you don't see them, make sure Gizmos are enabled (the Gizmos button in the top-right of the Scene view).

Step 3: Build the Dense Chunk

This variant has more spawn points, creating opportunities for tighter obstacle patterns.

  1. Duplicate the Chunk_Straight prefab (select it in Project window, Ctrl+D). Rename it to Chunk_Dense.
  2. Double-click Chunk_Dense to open it in Prefab Edit Mode.
  3. Add additional obstacle spawn points inside the SpawnPoints object:
    • Obstacle_L_3 — Position: (-2, 0, 10)
    • Obstacle_C_3 — Position: (0, 0, 10)
    • Obstacle_R_3 — Position: (2, 0, 10)
  4. Add more collectible spawn points:
    • Coin_L_2 — Position: (-2, 1, 6)
    • Coin_C_2 — Position: (0, 1, 11)
    • Coin_R_2 — Position: (2, 1, 16)
  5. Update the ChunkData component arrays to include the new spawn points.
  6. Save the prefab (Ctrl+S) and exit Prefab Edit Mode.

Step 4: Build the Narrow Chunk

This variant only has spawn points on the left and right lanes, forcing the player to stay in the center or dodge side-to-side.

  1. Duplicate Chunk_Straight again. Rename it Chunk_Narrow.
  2. Open in Prefab Edit Mode. Remove all center-lane obstacle spawn points (delete Obstacle_C_1 and Obstacle_C_2).
  3. Move the collectible spawn points so they form a line down the center lane, rewarding the player for staying in the middle.
  4. Update the ChunkData arrays. Save and exit.

Step 5: Build the Safe Chunk

This is used at the very start of a run. No obstacles, just collectibles to teach the player to collect coins.

  1. Duplicate Chunk_Straight. Rename it Chunk_Safe.
  2. Open in Prefab Edit Mode. Delete ALL obstacle spawn points.
  3. Leave the collectible spawn points (or add a nice line of coins down the center).
  4. Clear the Obstacle Spawn Points array in ChunkData (set its size to 0).
  5. Save and exit.

Tags and Layers for Spawn Points

To help our spawner scripts identify what kind of spawn point they're dealing with, we use Unity's Tag system. Tags are string labels you can attach to any GameObject.

  1. Go to Edit > Project Settings > Tags and Layers.
  2. Under Tags, click the + button and add:
    • ObstacleSpawn
    • CollectibleSpawn
  3. Under Layers, add a new layer:
    • SpawnPoint (pick any unused layer number, e.g., layer 8)
  4. Open each chunk prefab and assign:
    • All Obstacle_* spawn points: Tag = ObstacleSpawn, Layer = SpawnPoint
    • All Coin_* spawn points: Tag = CollectibleSpawn, Layer = SpawnPoint
Why Both Tags AND a Layer?

Tags let us distinguish between different types of spawn points (obstacle vs. collectible). The shared SpawnPoint layer lets us find all spawn points quickly using layer masks, and ensures they don't interfere with physics. Spawn point GameObjects have no colliders, but setting them to their own layer is a good organizational practice that prevents accidental raycast hits.

SpawnPoint Helper Script

While tags work fine, a small helper script on each spawn point gives us more type safety and extra configuration per point.

SpawnPoint.csC#
using UnityEngine;

namespace InfiniteRunner.World
{
    /// <summary>
    /// Defines what kind of object can spawn at this point.
    /// </summary>
    public enum SpawnPointType
    {
        Obstacle,
        Collectible
    }

    /// <summary>
    /// Identifies which lane this spawn point belongs to.
    /// </summary>
    public enum Lane
    {
        Left   = -1,
        Center =  0,
        Right  =  1
    }

    /// <summary>
    /// Attach this to every spawn point empty GameObject inside a chunk.
    /// Provides type-safe metadata about the spawn point.
    /// </summary>
    public class SpawnPoint : MonoBehaviour
    {
        [SerializeField] private SpawnPointType pointType = SpawnPointType.Obstacle;
        [SerializeField] private Lane lane = Lane.Center;

        [Tooltip("Optional: Override the spawn height. " +
                 "0 = use default for the object type.")]
        [SerializeField] private float spawnHeight = 0f;

        public SpawnPointType PointType => pointType;
        public Lane Lane => lane;
        public float SpawnHeight => spawnHeight;

        /// <summary>
        /// Returns the world position where an object should be spawned,
        /// accounting for the optional height override.
        /// </summary>
        public Vector3 GetSpawnPosition()
        {
            Vector3 pos = transform.position;
            if (spawnHeight > 0f)
            {
                pos.y = spawnHeight;
            }
            return pos;
        }

        private void OnDrawGizmos()
        {
            // Color-code by type
            Gizmos.color = pointType == SpawnPointType.Obstacle
                ? Color.red
                : Color.yellow;

            Gizmos.DrawWireSphere(transform.position, 0.25f);

            // Draw a small label showing the lane
            #if UNITY_EDITOR
            UnityEditor.Handles.Label(
                transform.position + Vector3.up * 0.5f,
                $"{pointType} ({lane})"
            );
            #endif
        }
    }
}

This script is optional but recommended. Instead of relying on string-based tags, we now have strongly-typed enums. The spawner code in later chapters can do things like:

Usage ExampleC#
// Get all spawn points on a chunk
SpawnPoint[] points = chunk.GetComponentsInChildren<SpawnPoint>();

// Filter to just obstacle points on the left lane
foreach (SpawnPoint point in points)
{
    if (point.PointType == SpawnPointType.Obstacle && point.Lane == Lane.Left)
    {
        // Spawn an obstacle here
        SpawnObstacle(point.GetSpawnPosition());
    }
}

Creating ChunkConfig Assets

Now let's create the ScriptableObject assets that configure each chunk variant.

  1. In the Project window, navigate to Assets/ScriptableObjects/ChunkConfigs.
  2. Right-click and choose Create > Infinite Runner > Chunk Config.
  3. Name it ChunkConfig_Straight. Select it and fill in:
    • Chunk Name: "Straight"
    • Chunk Prefab: drag in Chunk_Straight from Prefabs/Chunks
    • Spawn Weight: 10 (most common)
    • Minimum Difficulty: 0 (available from start)
    • Max Obstacles: 3
    • Max Collectibles: 8
    • Is Safe Zone: unchecked
  4. Create ChunkConfig_Dense:
    • Chunk Name: "Dense"
    • Chunk Prefab: Chunk_Dense
    • Spawn Weight: 5 (less common)
    • Minimum Difficulty: 2 (appears after difficulty ramps up)
    • Max Obstacles: 5
    • Max Collectibles: 10
    • Is Safe Zone: unchecked
  5. Create ChunkConfig_Narrow:
    • Chunk Name: "Narrow"
    • Chunk Prefab: Chunk_Narrow
    • Spawn Weight: 3 (rare)
    • Minimum Difficulty: 3 (mid-game and later)
    • Max Obstacles: 4
    • Max Collectibles: 6
    • Is Safe Zone: unchecked
  6. Create ChunkConfig_Safe:
    • Chunk Name: "Safe Zone"
    • Chunk Prefab: Chunk_Safe
    • Spawn Weight: 1 (very rare — occasional rest)
    • Minimum Difficulty: 0
    • Max Obstacles: 0
    • Max Collectibles: 12
    • Is Safe Zone: checked

Chunk Registry: Collecting All Configs

The World Generator needs a way to know about all available chunk configs. We'll create a simple registry ScriptableObject that holds an array of all ChunkConfig assets.

ChunkRegistry.csC#
using System.Collections.Generic;
using UnityEngine;

namespace InfiniteRunner.World
{
    /// <summary>
    /// Central registry of all available chunk configurations.
    /// The WorldGenerator references this to know what chunks it can spawn.
    /// Create via: Assets > Create > Infinite Runner > Chunk Registry
    /// </summary>
    [CreateAssetMenu(
        fileName = "ChunkRegistry",
        menuName = "Infinite Runner/Chunk Registry",
        order = 2
    )]
    public class ChunkRegistry : ScriptableObject
    {
        [Tooltip("All chunk configurations available for world generation.")]
        [SerializeField] private ChunkConfig[] chunkConfigs;

        /// <summary>
        /// Returns all registered chunk configurations.
        /// </summary>
        public ChunkConfig[] AllChunks => chunkConfigs;

        /// <summary>
        /// Returns only chunks that are eligible at the given difficulty level.
        /// </summary>
        /// <param name="currentDifficulty">The current game difficulty (0-10).</param>
        /// <returns>List of chunk configs whose minimumDifficulty is met.</returns>
        public List<ChunkConfig> GetAvailableChunks(int currentDifficulty)
        {
            List<ChunkConfig> available = new List<ChunkConfig>();

            foreach (ChunkConfig config in chunkConfigs)
            {
                if (config == null)
                {
                    Debug.LogWarning("ChunkRegistry contains a null entry!", this);
                    continue;
                }

                if (config.ChunkPrefab == null)
                {
                    Debug.LogWarning(
                        $"ChunkConfig '{config.ChunkName}' has no prefab assigned!",
                        config
                    );
                    continue;
                }

                if (currentDifficulty >= config.MinimumDifficulty)
                {
                    available.Add(config);
                }
            }

            if (available.Count == 0)
            {
                Debug.LogError(
                    "No chunks available at difficulty " + currentDifficulty +
                    "! Returning all chunks as fallback.",
                    this
                );

                // Fallback: return everything so the game doesn't break
                foreach (ChunkConfig config in chunkConfigs)
                {
                    if (config != null && config.ChunkPrefab != null)
                    {
                        available.Add(config);
                    }
                }
            }

            return available;
        }

        /// <summary>
        /// Returns a random ChunkConfig using weighted selection.
        /// Chunks with higher SpawnWeight are more likely to be selected.
        /// </summary>
        /// <param name="currentDifficulty">Current game difficulty level.</param>
        /// <returns>A randomly selected ChunkConfig.</returns>
        public ChunkConfig GetWeightedRandomChunk(int currentDifficulty)
        {
            List<ChunkConfig> available = GetAvailableChunks(currentDifficulty);

            if (available.Count == 0)
            {
                Debug.LogError("No chunks available at all!", this);
                return null;
            }

            // Calculate total weight
            int totalWeight = 0;
            foreach (ChunkConfig config in available)
            {
                totalWeight += config.SpawnWeight;
            }

            // Pick a random value within the total weight
            int randomValue = Random.Range(0, totalWeight);

            // Walk through the list, subtracting weights until we find our pick
            int cumulativeWeight = 0;
            foreach (ChunkConfig config in available)
            {
                cumulativeWeight += config.SpawnWeight;
                if (randomValue < cumulativeWeight)
                {
                    return config;
                }
            }

            // Fallback (should never reach here due to math, but safety first)
            return available[available.Count - 1];
        }

        // ---- Validation ----

        private void OnValidate()
        {
            if (chunkConfigs == null || chunkConfigs.Length == 0)
            {
                Debug.LogWarning(
                    "ChunkRegistry has no chunks configured!",
                    this
                );
            }
        }
    }
}

The ChunkRegistry gives us two key capabilities:

Always Handle Edge Cases

Notice how the code checks for null entries, missing prefabs, and empty available lists at every step. In a real project, someone will accidentally leave a field empty or delete a prefab without updating the registry. Defensive coding like this prevents cryptic NullReferenceExceptions and instead gives clear, actionable error messages. Never assume your data is correct — always validate.

Testing Your Chunks in the Editor

Before moving on to the World Generator, let's verify everything works by placing a few chunks manually in the scene.

  1. Drag Chunk_Straight from the Project window into the Scene. Position it at (0, 0, 0).
  2. Drag another Chunk_Straight into the Scene. Position it at (0, 0, 20) — this places it right at the end of the first chunk.
  3. Drag Chunk_Dense into the Scene. Position it at (0, 0, 40).
  4. Switch to the Scene view and look at the gizmos. You should see:
    • Three flat ground surfaces lined up end-to-end with no gaps.
    • Red spheres at obstacle spawn points.
    • Yellow spheres at collectible spawn points.
    • Cyan and magenta lines at chunk boundaries.
  5. If there are gaps between chunks, adjust your SnapPoint_End positions so they match the next chunk's SnapPoint_Start.
  6. Once verified, delete all chunks from the scene. The World Generator will handle spawning at runtime.

What We Built

In this chapter, we created the fundamental building block of our infinite world: the chunk system. Here's what we now have:

In the next chapter, we'll build the Object Pooling system that lets us reuse these chunks efficiently instead of creating and destroying them at runtime.

Git Branching: The Production Workflow

Up to now, we've been committing straight to main. That works for solo beginners, but it's not how professional teams work. Starting with this chapter, we'll use branches — the feature that makes Git truly powerful.

What is a Branch?

A branch is a parallel timeline of your project. You create a branch, make changes on it, and your main branch stays completely untouched. If your experiment works, you merge it back into main. If it doesn't, you delete the branch and nothing was harmed.

Think of it like this: main is your "known working" version. Branches are where you build new things. You never break main.

Production Branch Naming Conventions

Professional teams use a prefix/description naming pattern for branches. The prefix tells you what kind of work is happening:

Prefix When to Use Example
feature/ Adding new functionality feature/world-generation
fix/ Fixing a bug fix/player-falls-through-ground
hotfix/ Urgent fix on a released build hotfix/crash-on-startup
refactor/ Restructuring code without changing behavior refactor/pool-manager-cleanup
experiment/ Trying something that might not work experiment/new-obstacle-type
Naming rules

Branch names use lowercase with hyphens (not spaces or underscores). Keep them short but descriptive. feature/world-generation is good. feature/adding-the-new-world-generation-system-to-the-game is too long. feature/stuff is too vague.

The Two-Branch Model

For our project, we'll use a simple but effective workflow:

main: A---B---C-----------G---H-----------K---L \ / \ / feature/world: D---E---F | | \ / feature/gameplay: I---J---K

Each letter is a commit. Feature branches split off from main, get several commits, then merge back. Main only moves forward when complete, tested features are merged in.

The Branch Workflow (Step by Step)

  1. Create a branch from main when starting a new feature:
    git checkout -b feature/world-generation

    This creates the branch AND switches to it. You're now on feature/world-generation.

  2. Work and commit on the branch. Multiple commits are fine — commit after each logical step:
    git add .
    git commit -m "Add chunk system with modular tile design"
    # ... more work ...
    git add .
    git commit -m "Implement object pooling for chunks"
  3. Check which branch you're on at any time:
    git branch

    The current branch has an asterisk (*) next to it.

  4. Merge back to main when the feature is complete and working:
    git checkout main
    git merge feature/world-generation

    This brings all your feature commits into main.

  5. Delete the branch (optional, keeps things clean):
    git branch -d feature/world-generation
What if I'm on the wrong branch?

If you accidentally commit to main instead of your feature branch, don't panic. Git has tools to fix this. But the easiest fix is prevention: always run git branch before you start working to confirm you're on the right branch. Make it a habit.

When to Use Branches in This Course

We'll create a new feature branch for each Part of the course:

Branch Chapters Merge After
feature/world-generation Ch 11–14 Ch 14 (Origin Shifting)
feature/gameplay Ch 15–18 Ch 18 (Difficulty)
Direct to main Ch 19–25 Each chapter (polish & ship are small, self-contained changes)

In professional projects, you'd branch for every individual feature, no matter how small. For learning purposes, we're grouping related chapters to practice the full branch-commit-merge cycle twice before going back to simpler main-only commits.

Save Your Progress

Let's put this into practice right now. Create your first feature branch and commit the chunk system:

git checkout -b feature/world-generation
git add .
git commit -m "Add chunk system with modular tile design"

Verify you're on the new branch by running git branch. You should see:

  main
* feature/world-generation

The asterisk (*) shows your current branch. Everything we commit from here through Chapter 14 stays on this branch, safe from main.