Obstacle System
Design and implement multiple obstacle types that challenge the player with different evasion mechanics — jumping, sliding, and lane switching — with fair patterns that are always beatable.
Obstacle Types for a 3-Lane Runner
Obstacles are what make an infinite runner a game rather than just a screensaver. Each obstacle type should require a different player response, so the player is constantly making split-second decisions. For our 3-lane runner, we need five fundamental obstacle types:
1. Low Barrier (Jump Over)
A short obstacle that sits on the ground, blocking a single lane. The player must jump to clear it, or switch to an adjacent lane. Think of a short wall, a crate, or a fallen log. The collider is short (about 0.5 units tall) so the player's jump arc clears it easily.
2. High Barrier (Slide Under)
A tall obstacle with a gap at the bottom, blocking a single lane. The player must slide to pass underneath it, or switch lanes. Think of a horizontal bar, a hanging sign, or a low bridge. The collider starts about 0.5 units above the ground so the player's slide pose fits underneath.
3. Full-Width Barrier (Force Lane Change)
An obstacle that spans a single lane entirely — too tall to jump and too low to slide. The only option is to switch to a different lane. This creates urgency: the player must react before reaching it. These are the most dangerous single-lane obstacles because jumping and sliding won't help.
4. Moving Obstacle (Lane Changer)
An obstacle that moves sideways between lanes. It might start in the left lane and slide to the center, or oscillate between left and right. The player must time their lane switch to dodge it. These add a dynamic element that static obstacles lack.
5. Ramp (Launch Into Air)
A sloped surface that launches the player into the air when they hit it. While airborne, the player passes over ground-level obstacles. Ramps add variety to the gameplay rhythm and create exciting moments of flight. They're not deadly — they're opportunities.
Each type tests a different player skill: Low Barrier tests jumping reflexes. High Barrier tests sliding reflexes. Full-Width tests lane-switching reflexes. Moving obstacles test timing and prediction. Ramps add variety and reward aggressive play (running into them intentionally). Together, they create a rich decision space from simple mechanics. Games like Subway Surfers use exactly these types and achieve billions of downloads with them.
The ObstacleType Enum
Before writing any complex code, let's define our obstacle types in a clean enum. This gives us type safety and makes the code self-documenting.
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Defines the types of obstacles in the game.
/// Each type requires a different player response to avoid.
/// </summary>
public enum ObstacleType
{
/// <summary>Short obstacle on the ground. Jump to clear.</summary>
LowBarrier,
/// <summary>Tall obstacle with gap underneath. Slide to pass.</summary>
HighBarrier,
/// <summary>Full-height obstacle. Must change lanes to avoid.</summary>
FullWidthBarrier,
/// <summary>Obstacle that moves between lanes over time.</summary>
MovingObstacle,
/// <summary>Ramp that launches player into the air. Not deadly.</summary>
Ramp
}
}The Obstacle Base Class
All obstacle types share common behavior: they can be pooled, they detect collisions with the player, and they have a visual appearance. We define this shared behavior in a base class that individual obstacle types extend.
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Base class for all obstacle types. Handles pooling, collision detection,
/// and visual setup. Specific obstacle behaviors (like moving) are
/// implemented in derived classes.
///
/// Attach this (or a derived class) to every obstacle prefab.
/// The prefab must have a Collider set to "Is Trigger."
/// </summary>
[RequireComponent(typeof(Collider))]
public class Obstacle : MonoBehaviour, IPoolable
{
[Header("Obstacle Settings")]
[Tooltip("What type of obstacle this is. Determines player response.")]
[SerializeField] private ObstacleType obstacleType = ObstacleType.LowBarrier;
[Tooltip("If true, hitting this obstacle kills the player. " +
"If false, it has a special effect (like a ramp).")]
[SerializeField] private bool isLethal = true;
[Header("Visual Variety")]
[Tooltip("Optional: Array of materials to randomly pick from on spawn. " +
"Leave empty to keep the default material.")]
[SerializeField] private Material[] materialVariants;
[Tooltip("Optional: The renderer to apply material variants to.")]
[SerializeField] private MeshRenderer meshRenderer;
// ---- Cached Components ----
private Collider obstacleCollider;
// ---- Public Properties ----
/// <summary>The type of this obstacle.</summary>
public ObstacleType Type => obstacleType;
/// <summary>Whether hitting this obstacle ends the game.</summary>
public bool IsLethal => isLethal;
// ---- Lifecycle ----
protected virtual void Awake()
{
// Cache the collider. This runs once when the object is first
// created (during pool warm-up), NOT on every reuse.
obstacleCollider = GetComponent<Collider>();
if (meshRenderer == null)
{
meshRenderer = GetComponentInChildren<MeshRenderer>();
}
// Ensure the collider is set as a trigger
if (obstacleCollider != null && !obstacleCollider.isTrigger)
{
Debug.LogWarning(
$"Obstacle '{name}': Collider is not set to Trigger! " +
$"Setting it now. Please fix in prefab.",
this
);
obstacleCollider.isTrigger = true;
}
}
// ---- IPoolable Implementation ----
/// <summary>
/// Called when taken from the pool. Resets the obstacle to its
/// default state so it's ready for a new life in the world.
/// </summary>
public virtual void OnSpawn()
{
// Re-enable the collider (it's disabled on despawn)
if (obstacleCollider != null)
{
obstacleCollider.enabled = true;
}
// Apply a random material variant for visual variety
ApplyRandomMaterial();
}
/// <summary>
/// Called when returned to the pool. Cleans up any active state.
/// </summary>
public virtual void OnDespawn()
{
// Disable the collider so it can't accidentally trigger
// while sitting in the pool
if (obstacleCollider != null)
{
obstacleCollider.enabled = false;
}
// Reset transform (position will be set by the spawner)
transform.localRotation = Quaternion.identity;
transform.localScale = Vector3.one;
}
// ---- Collision Detection ----
/// <summary>
/// Called by Unity's physics engine when another collider enters
/// this obstacle's trigger volume.
/// </summary>
protected virtual void OnTriggerEnter(Collider other)
{
// Only react to the player
if (!other.CompareTag("Player")) return;
if (isLethal)
{
HandleLethalCollision(other);
}
else
{
HandleNonLethalCollision(other);
}
}
/// <summary>
/// Handles a lethal collision (player hit a deadly obstacle).
/// Notifies the GameManager that the player has died.
/// </summary>
protected virtual void HandleLethalCollision(Collider playerCollider)
{
Debug.Log($"Player hit lethal obstacle: {name} ({obstacleType})");
// Disable collider to prevent multiple collision events
if (obstacleCollider != null)
{
obstacleCollider.enabled = false;
}
// Notify the game that the player died.
// Using the event system from Chapter 7:
// GameEvents.OnPlayerDeath?.Invoke();
// For now, we'll use a direct approach until the event system
// is fully connected. Replace this with event invocation.
GameManager gameManager = FindFirstObjectByType<GameManager>();
if (gameManager != null)
{
gameManager.OnPlayerDied();
}
else
{
Debug.LogWarning(
"Obstacle: No GameManager found to report player death!"
);
}
}
/// <summary>
/// Handles a non-lethal collision (like hitting a ramp).
/// Override in derived classes for specific behavior.
/// </summary>
protected virtual void HandleNonLethalCollision(Collider playerCollider)
{
Debug.Log($"Player hit non-lethal obstacle: {name} ({obstacleType})");
// Derived classes override this for specific effects
}
// ---- Visual Variety ----
/// <summary>
/// Applies a random material from the variants array to add
/// visual variety. Each time this obstacle spawns, it might
/// look slightly different (different color, texture, etc).
/// </summary>
private void ApplyRandomMaterial()
{
if (materialVariants == null || materialVariants.Length == 0) return;
if (meshRenderer == null) return;
int index = Random.Range(0, materialVariants.Length);
meshRenderer.material = materialVariants[index];
}
}
}Let's examine the important design decisions:
RequireComponent
The [RequireComponent(typeof(Collider))] attribute ensures Unity automatically adds a Collider when you add the Obstacle script. This prevents the "forgot to add a collider" bug that would make obstacles invisible to the player.
Virtual Methods
Notice that OnSpawn, OnDespawn, OnTriggerEnter, and the collision handlers are all marked virtual. This means derived classes (like MovingObstacle) can override them to add specialized behavior while still calling the base implementation with base.OnSpawn().
Is Trigger, Not Collider
We use trigger colliders (not regular physics colliders) for obstacles. Triggers detect overlaps without physically blocking movement. The player's CharacterController handles physics; obstacles just detect when the player touches them. This prevents weird physics interactions where obstacles push the player around.
Our collision detection uses CompareTag("Player") to ensure we only react to the player, not to other obstacles or collectibles. Make sure your player GameObject has the "Player" tag set. Go to the player in the Inspector, click the Tag dropdown at the top, and select "Player." If it's not there, Unity includes it by default — just make sure it's selected.
Collider Setups for Each Obstacle Type
Each obstacle type needs a collider shaped specifically for its evasion mechanic. Getting these right is essential for fair gameplay.
Low Barrier
Collider: Box Collider
- Size: (1.5, 0.5, 0.5) — Slightly less than lane width, short, thin
- Center: (0, 0.25, 0) — Sits on the ground
- The player's jump raises them above Y = 0.5, so they clear the collider
Box Collider:
Is Trigger: checked
Center: (0, 0.25, 0)
Size: (1.5, 0.5, 0.5)
Visual Mesh (child object):
Cube scaled to (1.5, 0.5, 0.5)
Position: (0, 0.25, 0)High Barrier
Collider: Box Collider
- Size: (1.5, 1.5, 0.5) — Tall, spanning most of the player's height
- Center: (0, 1.25, 0) — Raised up, leaving a gap at the bottom
- The player's slide reduces their height below Y = 0.5, so they fit under the collider
Box Collider:
Is Trigger: checked
Center: (0, 1.25, 0)
Size: (1.5, 1.5, 0.5)
Visual: Two vertical posts with a horizontal bar on top
Post_Left: Position (-0.6, 1.0, 0), Scale (0.2, 2.0, 0.2)
Post_Right: Position (0.6, 1.0, 0), Scale (0.2, 2.0, 0.2)
Bar: Position (0, 1.25, 0), Scale (1.5, 0.15, 0.3)Full-Width Barrier
Collider: Box Collider
- Size: (1.5, 2.0, 0.5) — Spans the full player height
- Center: (0, 1.0, 0) — Starts at ground, extends above jump height
- Cannot be jumped or slid under — player MUST change lanes
Box Collider:
Is Trigger: checked
Center: (0, 1.0, 0)
Size: (1.5, 2.0, 0.5)
Visual: Solid wall or tall crate
Cube: Position (0, 1.0, 0), Scale (1.5, 2.0, 0.5)Moving Obstacle
Same collider as Full-Width Barrier, but with an additional script for movement. We'll implement this as a derived class below.
Ramp
Collider: Mesh Collider or Box Collider (angled)
- A sloped surface. The collider matches the visible ramp mesh.
- Not a trigger — the ramp uses a regular collider so the player physically runs up it.
- A separate trigger at the base detects the player and applies an upward force.
In the Unity editor, select an obstacle and look at the Scene view. Colliders are drawn as green wireframes. Make sure the collider covers exactly the area that should be deadly. If the collider is too big, the player will die unfairly. If too small, they'll pass through obstacles. Use Play mode and watch the player interact with obstacles to fine-tune sizes.
Moving Obstacle: A Derived Class
The moving obstacle is interesting because it has behavior beyond just sitting in a lane. It moves sideways between lanes over time. We implement this as a class that extends Obstacle.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// An obstacle that moves sideways between lanes.
/// Extends the base Obstacle class with movement behavior.
/// </summary>
public class MovingObstacle : Obstacle
{
[Header("Movement Settings")]
[Tooltip("How fast the obstacle moves between lanes (units per second).")]
[SerializeField] private float moveSpeed = 3f;
[Tooltip("The X positions this obstacle oscillates between.")]
[SerializeField] private float leftBound = -2f;
[SerializeField] private float rightBound = 2f;
[Tooltip("If true, starts moving right. If false, starts moving left.")]
[SerializeField] private bool startMovingRight = true;
// Internal state
private int moveDirection;
private float currentX;
// ---- IPoolable Overrides ----
public override void OnSpawn()
{
base.OnSpawn(); // Call base to re-enable collider, apply material
// Randomize starting direction for variety
moveDirection = startMovingRight ? 1 : -1;
// Start at the spawn point's X position
currentX = transform.position.x;
}
public override void OnDespawn()
{
base.OnDespawn(); // Call base to disable collider
}
// ---- Movement ----
private void Update()
{
// Move sideways
currentX += moveDirection * moveSpeed * Time.deltaTime;
// Bounce off bounds
if (currentX >= rightBound)
{
currentX = rightBound;
moveDirection = -1;
}
else if (currentX <= leftBound)
{
currentX = leftBound;
moveDirection = 1;
}
// Apply new position (keep Y and Z from current position)
Vector3 pos = transform.position;
pos.x = currentX;
transform.position = pos;
}
}
}The moving obstacle starts at whatever lane it was spawned in and oscillates left and right between the lane boundaries. The player needs to time their lane switch to dodge it. Notice how it calls base.OnSpawn() and base.OnDespawn() to maintain the pooling behavior from the parent class.
Ramp Obstacle
The ramp is unique because it's not lethal — instead, it launches the player into the air.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// A ramp that launches the player into the air when they run over it.
/// Not lethal - it's an opportunity, not a threat.
/// </summary>
public class RampObstacle : Obstacle
{
[Header("Ramp Settings")]
[Tooltip("Upward force applied to the player when hitting the ramp.")]
[SerializeField] private float launchForce = 15f;
[Tooltip("Forward boost applied in addition to upward force.")]
[SerializeField] private float forwardBoost = 5f;
/// <summary>
/// Override non-lethal collision to launch the player upward.
/// </summary>
protected override void HandleNonLethalCollision(Collider playerCollider)
{
base.HandleNonLethalCollision(playerCollider);
Debug.Log($"Ramp hit! Launching player with force {launchForce}");
// Apply launch force to the player.
// The PlayerController (Chapter 9) should have a public method
// for applying external forces:
// PlayerController pc = playerCollider.GetComponent<PlayerController>();
// if (pc != null)
// {
// pc.ApplyLaunchForce(launchForce, forwardBoost);
// }
// Alternative: use the event system
// GameEvents.OnRampHit?.Invoke(launchForce, forwardBoost);
}
}
}The ObstacleSpawner
The ObstacleSpawner is the system that reads a chunk's spawn points and decides which obstacles to place and where. It integrates with the WorldGenerator (Chapter 13) — every time a chunk is spawned, the ObstacleSpawner populates it.
using System.Collections.Generic;
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.World;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Configuration for an obstacle variant, linking a type to a pool ID.
/// </summary>
[System.Serializable]
public class ObstacleVariant
{
[Tooltip("What type of obstacle this is.")]
public ObstacleType type;
[Tooltip("The pool ID in the PoolManager for this obstacle.")]
public string poolId;
[Tooltip("Relative spawn weight. Higher = more common.")]
[Range(1, 100)]
public int weight = 10;
[Tooltip("Minimum difficulty level before this variant can appear.")]
[Range(0, 10)]
public int minimumDifficulty = 0;
}
/// <summary>
/// Handles spawning obstacles on chunk spawn points.
/// Ensures fair patterns (never blocks all lanes) and respects difficulty.
///
/// Used by the WorldGenerator when a new chunk is activated.
/// </summary>
public class ObstacleSpawner : MonoBehaviour
{
[Header("Obstacle Variants")]
[Tooltip("All obstacle types available for spawning.")]
[SerializeField] private ObstacleVariant[] obstacleVariants;
[Header("Spawn Rules")]
[Tooltip("Maximum obstacles per row (same Z position). " +
"Set to 2 for a 3-lane runner to always leave one lane open.")]
[Range(1, 3)]
[SerializeField] private int maxObstaclesPerRow = 2;
[Tooltip("Minimum Z distance between obstacle rows on a single chunk.")]
[SerializeField] private float minimumRowSpacing = 3f;
[Header("Difficulty")]
[Tooltip("Current difficulty level. Affects which obstacles appear.")]
[SerializeField] private int currentDifficulty = 0;
// Lane positions (must match your chunk spawn points)
private const float LANE_LEFT = -2f;
private const float LANE_CENTER = 0f;
private const float LANE_RIGHT = 2f;
private const float LANE_TOLERANCE = 0.5f;
// ---- Public API ----
/// <summary>
/// Spawns obstacles on a chunk's obstacle spawn points.
/// Ensures fair patterns: never blocks all 3 lanes in the same row.
/// </summary>
/// <param name="chunkData">The chunk to populate.</param>
/// <param name="maxCount">Maximum obstacles to spawn on this chunk.</param>
public void SpawnObstacles(ChunkData chunkData, int maxCount)
{
Transform[] spawnPoints = chunkData.ObstacleSpawnPoints;
if (spawnPoints == null || spawnPoints.Length == 0) return;
if (maxCount <= 0) return;
// Group spawn points by their Z position (rows)
Dictionary<float, List<Transform>> rows = GroupByRow(spawnPoints);
int spawned = 0;
foreach (KeyValuePair<float, List<Transform>> row in rows)
{
if (spawned >= maxCount) break;
List<Transform> rowPoints = row.Value;
// Decide how many obstacles in this row (respect max per row)
int countInRow = Random.Range(0, maxObstaclesPerRow + 1);
countInRow = Mathf.Min(countInRow, rowPoints.Count);
countInRow = Mathf.Min(countInRow, maxCount - spawned);
if (countInRow == 0) continue;
// Validate: don't block all lanes
if (countInRow >= 3)
{
countInRow = 2; // Force at least one lane open
}
// Pick random spawn points in this row
ShuffleList(rowPoints);
// Track which lanes are used in this row for pattern validation
List<int> usedLanes = new List<int>();
for (int i = 0; i < countInRow; i++)
{
Transform point = rowPoints[i];
int lane = GetLaneIndex(point.position.x);
// Pattern validation: skip if this would block all lanes
usedLanes.Add(lane);
if (!IsPatternValid(usedLanes))
{
usedLanes.RemoveAt(usedLanes.Count - 1);
continue;
}
// Pick an obstacle variant
ObstacleVariant variant = GetWeightedRandomVariant();
if (variant == null) continue;
// Spawn from pool
GameObject obstacleObj =
PoolManager.Instance.Get(variant.poolId);
if (obstacleObj == null) continue;
// Position the obstacle
obstacleObj.transform.position = point.position;
obstacleObj.transform.rotation = point.rotation;
// Register with chunk for automatic cleanup
chunkData.RegisterSpawnedObject(obstacleObj);
spawned++;
}
}
}
/// <summary>
/// Update the current difficulty level.
/// Called by the difficulty system (Chapter 18).
/// </summary>
public void SetDifficulty(int difficulty)
{
currentDifficulty = Mathf.Clamp(difficulty, 0, 10);
}
// ---- Pattern Validation ----
/// <summary>
/// Checks if a set of used lanes in a row creates a fair pattern.
/// Returns false if all 3 lanes are blocked (unbeatable).
/// </summary>
private bool IsPatternValid(List<int> usedLanes)
{
// In a 3-lane runner, the player MUST have at least one open lane
if (usedLanes.Count >= 3)
{
// Check if all three lanes are covered
bool hasLeft = usedLanes.Contains(-1);
bool hasCenter = usedLanes.Contains(0);
bool hasRight = usedLanes.Contains(1);
if (hasLeft && hasCenter && hasRight)
{
return false; // All lanes blocked - unfair!
}
}
return true;
}
/// <summary>
/// Converts an X position to a lane index (-1, 0, or 1).
/// Uses tolerance to handle floating point imprecision.
/// </summary>
private int GetLaneIndex(float xPosition)
{
if (Mathf.Abs(xPosition - LANE_LEFT) < LANE_TOLERANCE)
return -1;
if (Mathf.Abs(xPosition - LANE_CENTER) < LANE_TOLERANCE)
return 0;
if (Mathf.Abs(xPosition - LANE_RIGHT) < LANE_TOLERANCE)
return 1;
Debug.LogWarning(
$"ObstacleSpawner: Spawn point at X={xPosition} " +
$"doesn't match any lane!"
);
return 0; // Default to center
}
// ---- Weighted Selection ----
/// <summary>
/// Picks a random obstacle variant using weighted selection,
/// filtered by current difficulty.
/// </summary>
private ObstacleVariant GetWeightedRandomVariant()
{
// Filter by difficulty
List<ObstacleVariant> available = new List<ObstacleVariant>();
int totalWeight = 0;
foreach (ObstacleVariant variant in obstacleVariants)
{
if (variant.minimumDifficulty <= currentDifficulty)
{
available.Add(variant);
totalWeight += variant.weight;
}
}
if (available.Count == 0)
{
Debug.LogWarning(
"ObstacleSpawner: No variants available at difficulty " +
currentDifficulty
);
return null;
}
// Weighted random pick
int randomValue = Random.Range(0, totalWeight);
int cumulative = 0;
foreach (ObstacleVariant variant in available)
{
cumulative += variant.weight;
if (randomValue < cumulative)
{
return variant;
}
}
return available[available.Count - 1];
}
// ---- Utility ----
/// <summary>
/// Groups spawn points by their Z position (rounded to minimumRowSpacing).
/// Points at similar Z positions are in the same "row."
/// </summary>
private Dictionary<float, List<Transform>> GroupByRow(Transform[] points)
{
Dictionary<float, List<Transform>> rows =
new Dictionary<float, List<Transform>>();
foreach (Transform point in points)
{
if (point == null) continue;
// Round Z to nearest row spacing to group nearby points
float rowZ = Mathf.Round(
point.position.z / minimumRowSpacing
) * minimumRowSpacing;
if (!rows.ContainsKey(rowZ))
{
rows[rowZ] = new List<Transform>();
}
rows[rowZ].Add(point);
}
return rows;
}
/// <summary>
/// Fisher-Yates shuffle for a list of Transforms.
/// </summary>
private void ShuffleList(List<Transform> list)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = Random.Range(0, i + 1);
Transform temp = list[i];
list[i] = list[j];
list[j] = temp;
}
}
}
}Let's break down the most important parts of the ObstacleSpawner:
Row-Based Spawning
Spawn points at the same Z position (or close to it) are grouped into "rows." Each row represents a cross-section of the road that the player reaches at the same time. By thinking in rows, we can apply the most critical rule: never block all three lanes in the same row.
Pattern Validation
The IsPatternValid method enforces fairness. Before placing each obstacle, we check if it would create an impossible pattern. If the left and center lanes already have obstacles, we won't place one in the right lane — that would give the player zero escape routes. This is absolutely essential. Players will tolerate dying to hard obstacles, but they won't tolerate dying to impossible ones.
This is the single most important rule of obstacle placement in a 3-lane runner. If all three lanes are blocked at the same Z position with no way to jump or slide past, the player WILL die, and there was NOTHING they could do about it. This feels terrible and destroys player trust. Our pattern validation ensures this never happens. Always leave at least one safe lane or one obstacle that can be jumped/slid under.
Difficulty Filtering
Like our chunk system, obstacle variants have a minimumDifficulty. At the start of the game, only basic obstacles appear. As the player survives longer and difficulty increases, moving obstacles and tighter patterns become possible. This creates a natural learning curve.
Obstacle Patterns: Combinations That Are Fair
Beyond the basic rule of "never block all lanes," good obstacle design uses patterns — specific combinations that test different skills. Here are some examples:
The Zigzag
Obstacles alternate between left and right lanes across consecutive rows, forcing the player to zigzag through them.
Row 1: [X] [ ] [ ] (obstacle in left lane)
Row 2: [ ] [ ] [X] (obstacle in right lane)
Row 3: [X] [ ] [ ] (obstacle in left lane)
Player path: Right -> Left -> RightThe Funnel
Two obstacles in the outer lanes, forcing the player to the center. Then an obstacle in the center, forcing them to a side.
Row 1: [X] [ ] [X] (outer lanes blocked)
Row 2: [ ] [X] [ ] (center blocked)
Player path: Center -> Left or RightThe Jump-Slide Combo
A low barrier followed immediately by a high barrier in the same lane, requiring a jump then an immediate slide.
Row 1: [ ] [LOW] [ ] (jump over)
Row 2: [ ] [HIGH] [ ] (slide under - close behind row 1)
Player path: Jump, then immediately slideEvery pattern should have at least one "escape route." The player should die because they made the wrong choice or reacted too slowly, never because there was no correct choice. When designing new patterns, always play through them mentally: "if I were running forward, could I survive this?" If not, make it easier. If it's too easy, add another obstacle to a different lane — but always leave an escape.
Integrating with the WorldGenerator
Now we need to update the WorldGenerator (Chapter 13) to use the ObstacleSpawner instead of its temporary inline spawning code.
// Add this field to WorldGenerator:
[Header("Spawners")]
[SerializeField] private ObstacleSpawner obstacleSpawner;
// Replace the old PopulateObstacles method with:
private void PopulateObstacles(ChunkData chunkData, ChunkConfig config)
{
if (obstacleSpawner == null)
{
Debug.LogWarning("WorldGenerator: No ObstacleSpawner assigned!");
return;
}
obstacleSpawner.SpawnObstacles(chunkData, config.MaxObstacles);
}Creating Obstacle Prefabs Step by Step
- Create a folder
Assets/Prefabs/Obstacles. - For the Low Barrier:
- Create an empty GameObject named
Obstacle_Low. - Add a child Cube. Set Scale to (1.5, 0.5, 0.5) and Position to (0, 0.25, 0).
- On the root, add a Box Collider: Center (0, 0.25, 0), Size (1.5, 0.5, 0.5), Is Trigger checked.
- Add the
Obstaclescript. Set Type to LowBarrier, Is Lethal checked. - Drag to
Prefabs/Obstaclesto create the prefab.
- Create an empty GameObject named
- For the High Barrier:
- Create
Obstacle_Highwith two post cubes and a bar cube (see collider setup section above). - Add Box Collider: Center (0, 1.25, 0), Size (1.5, 1.5, 0.5), Is Trigger checked.
- Add Obstacle script. Type: HighBarrier.
- Save as prefab.
- Create
- For the Full-Width Barrier:
- Create
Obstacle_Fullwith a large cube: Scale (1.5, 2.0, 0.5), Position (0, 1.0, 0). - Box Collider: Center (0, 1.0, 0), Size (1.5, 2.0, 0.5), Is Trigger checked.
- Obstacle script. Type: FullWidthBarrier.
- Save as prefab.
- Create
- For the Moving Obstacle:
- Create
Obstacle_Movingsame as Full-Width but add theMovingObstaclescript instead of the baseObstaclescript. - Set Move Speed to 3, Left Bound to -2, Right Bound to 2.
- Save as prefab.
- Create
- For the Ramp:
- Create
Obstacle_Ramp. Use a cube rotated to create a slope (Rotation X: -20, Scale: 1.5, 0.1, 2.0). - Add a trigger collider at the base for detection.
- Add the
RampObstaclescript. Set Is Lethal to false. - Save as prefab.
- Create
- In the PoolManager, add pools for each obstacle prefab:
- "Obstacle_Low" — Initial Size: 15
- "Obstacle_High" — Initial Size: 10
- "Obstacle_Full" — Initial Size: 10
- "Obstacle_Moving" — Initial Size: 5
- "Obstacle_Ramp" — Initial Size: 5
- On the ObstacleSpawner component, configure the Obstacle Variants array with each type, its pool ID, weight, and minimum difficulty.
Here is a balanced starting configuration for the ObstacleSpawner:
- LowBarrier: poolId = "Obstacle_Low", weight = 30, minDifficulty = 0
- HighBarrier: poolId = "Obstacle_High", weight = 20, minDifficulty = 1
- FullWidthBarrier: poolId = "Obstacle_Full", weight = 15, minDifficulty = 2
- MovingObstacle: poolId = "Obstacle_Moving", weight = 5, minDifficulty = 4
- Ramp: poolId = "Obstacle_Ramp", weight = 10, minDifficulty = 0
This means Low Barriers appear most often and from the start, while Moving Obstacles are rare and only appear after difficulty reaches 4. Adjust these values based on playtesting.
Adding Visual Variety
Seeing the exact same gray cube hundreds of times gets boring fast. The materialVariants array on the Obstacle base class lets you add visual variety without creating separate prefabs for each color.
- Create a folder
Assets/Materials/Obstacles. - Create 3-4 materials with different colors:
Mat_Obstacle_Red— A warning redMat_Obstacle_Orange— A caution orangeMat_Obstacle_Yellow— A bright yellowMat_Obstacle_Metal— A metallic gray
- On each obstacle prefab, drag these materials into the Material Variants array.
- Now every time an obstacle spawns, it randomly picks one of the materials, making the world feel more varied.
What We Built
The obstacle system is the core of our gameplay. Here's what we now have:
- ObstacleType enum — Five obstacle types, each requiring a different player response (jump, slide, lane switch, timing, or none for ramps).
- Obstacle.cs — A poolable base class that handles collision detection, visual variety, and clean integration with the pool system.
- MovingObstacle.cs — A derived class that adds sideways movement between lanes.
- RampObstacle.cs — A derived class that launches the player into the air instead of killing them.
- ObstacleSpawner.cs — An intelligent spawner that places obstacles on chunk spawn points using weighted random selection, difficulty filtering, and pattern validation to ensure fairness.
- Five obstacle prefabs with properly sized colliders and pool configurations.
- Pattern validation — The critical rule that ensures at least one lane is always open, preventing unfair deaths.
In the next chapter, we'll build the collectible system — coins and power-ups that reward the player and add positive reinforcement to balance out the danger of obstacles.
Obstacles are in. Let's save and start a new feature branch for the gameplay systems.
git checkout -b feature/gameplay
git add .
git commit -m "Add obstacle system with patterns and spawning"
New feature branch for Part 4: Gameplay. Same pattern as before — we'll work here through Chapter 18 and merge back.