Difficulty Progression
Build a data-driven difficulty system that gradually ramps up speed, obstacle density, and complexity using ScriptableObjects and AnimationCurves for designer-friendly tuning.
Why Difficulty Progression Matters
Imagine playing an infinite runner where the speed, obstacle density, and challenge never change. The first 30 seconds might be fun, but after a minute it becomes monotonous. Conversely, if the game starts at maximum difficulty, new players will die immediately and never come back.
Difficulty progression solves both problems. It starts easy so newcomers can learn the controls, then gradually ramps up to challenge experienced players. This creates what game designers call flow — a state where the challenge perfectly matches the player's skill level, keeping them engaged and wanting "just one more run."
In this chapter, we will build a difficulty system that:
- Increases the player's forward speed over time
- Spawns more obstacles per chunk as the game progresses
- Introduces more complex obstacle types at higher difficulties
- Slightly increases coin value to reward skilled players
- Uses a smooth curve (not sudden jumps) for all transitions
- Is fully configurable in the Unity Inspector without touching code
What Scales with Difficulty
Let us define exactly what changes as difficulty increases. Each parameter has a minimum (start of game) and maximum (endgame) value:
1. Player Speed
The most impactful change. Speed starts comfortable (around 8 m/s) and increases to a challenging pace (around 20 m/s). The increase must be gradual — players should barely notice it happening until they realize they are going much faster than before.
2. Obstacle Density
Early game: maybe 1-2 obstacles per chunk. Late game: 3-5 obstacles per chunk. More obstacles means less safe space, forcing the player to react faster and plan further ahead.
3. Obstacle Complexity
Early game: only simple obstacles (single barriers, low hurdles). Mid game: introduce double barriers, sliding obstacles. Late game: complex combinations that require precise timing. We control this with weighted probabilities per difficulty tier.
4. Coin Value
A subtle but rewarding touch: coins are worth slightly more at higher difficulties. This rewards skilled players who survive longer and offsets the increased challenge. The increase is small (perhaps 1.0x to 1.5x) to avoid inflation.
Never increase difficulty in sudden jumps. Players perceive gradual increases as "I am getting better at this game." They perceive sudden jumps as "the game is cheating." Always use smooth curves for difficulty ramping.
DifficultyConfig ScriptableObject
We store all difficulty parameters in a ScriptableObject so designers can tweak values in the Inspector without modifying code. This also lets you create multiple difficulty presets (Easy Mode, Hard Mode) by creating different config assets.
Step 1: Create the ScriptableObject
- Navigate to
Assets/Scripts/Gameplayin the Project window. - Right-click > Create > C# Script and name it
DifficultyConfig. - Replace the contents with the code below.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// ScriptableObject that defines all difficulty progression parameters.
/// Create instances via Assets > Create > Infinite Runner > Difficulty Config.
///
/// All values interpolate between min and max based on the difficulty
/// curve, which maps distance traveled (or time) to a 0-1 difficulty value.
/// </summary>
[CreateAssetMenu(
fileName = "NewDifficultyConfig",
menuName = "Infinite Runner/Difficulty Config"
)]
public class DifficultyConfig : ScriptableObject
{
// ---------------------------------------------------------------
// Difficulty Curve
// ---------------------------------------------------------------
[Header("Difficulty Curve")]
[Tooltip("Maps distance traveled (X axis, in meters) to difficulty " +
"level (Y axis, 0 to 1). The shape of this curve controls " +
"how quickly the game gets harder.\n\n" +
"A gentle S-curve is recommended: slow start, faster " +
"ramp in the middle, and a plateau near the end.")]
public AnimationCurve difficultyCurve = new AnimationCurve(
new Keyframe(0f, 0f), // Start at 0 difficulty
new Keyframe(500f, 0.25f), // 25% at 500m
new Keyframe(1500f, 0.6f), // 60% at 1500m
new Keyframe(3000f, 0.85f), // 85% at 3000m
new Keyframe(5000f, 1.0f) // Max at 5000m
);
[Tooltip("The maximum distance value on the curve's X axis. " +
"Beyond this distance, difficulty stays at 1.0.")]
public float maxDistance = 5000f;
// ---------------------------------------------------------------
// Speed Settings
// ---------------------------------------------------------------
[Header("Player Speed")]
[Tooltip("Starting speed at difficulty 0 (beginning of game).")]
public float minSpeed = 8f;
[Tooltip("Maximum speed at difficulty 1 (endgame).")]
public float maxSpeed = 22f;
[Tooltip("Optional curve for speed progression. If set, overrides " +
"linear interpolation. Leave as default linear for simple behavior.")]
public AnimationCurve speedCurve = AnimationCurve.Linear(0f, 0f, 1f, 1f);
// ---------------------------------------------------------------
// Obstacle Settings
// ---------------------------------------------------------------
[Header("Obstacle Density")]
[Tooltip("Minimum obstacles per chunk at difficulty 0.")]
public int minObstaclesPerChunk = 1;
[Tooltip("Maximum obstacles per chunk at difficulty 1.")]
public int maxObstaclesPerChunk = 5;
[Header("Obstacle Type Weights")]
[Tooltip("Weight configuration for each difficulty tier. " +
"Defines which obstacle types appear and how often.")]
public ObstacleTierConfig[] obstacleTiers;
// ---------------------------------------------------------------
// Coin Settings
// ---------------------------------------------------------------
[Header("Coin Value")]
[Tooltip("Coin value multiplier at difficulty 0 (1.0 = normal).")]
public float minCoinValueMultiplier = 1.0f;
[Tooltip("Coin value multiplier at difficulty 1 (e.g., 1.5 = 50% bonus).")]
public float maxCoinValueMultiplier = 1.5f;
// ---------------------------------------------------------------
// Difficulty Tiers
// ---------------------------------------------------------------
[Header("Named Difficulty Tiers (for display/logging)")]
[Tooltip("Human-readable tier definitions. Used for logging " +
"and potentially for UI display (e.g., 'HARD' warning).")]
public DifficultyTier[] tiers;
// ---------------------------------------------------------------
// Evaluation Methods
// ---------------------------------------------------------------
/// <summary>
/// Evaluates the difficulty level (0-1) for a given distance.
/// This is the core method that drives all difficulty calculations.
/// </summary>
/// <param name="distance">How far the player has traveled in meters.</param>
/// <returns>Difficulty level from 0 (easiest) to 1 (hardest).</returns>
public float EvaluateDifficulty(float distance)
{
// Clamp distance to our curve's range.
float clampedDistance = Mathf.Clamp(distance, 0f, maxDistance);
// Evaluate the curve to get difficulty 0-1.
return Mathf.Clamp01(difficultyCurve.Evaluate(clampedDistance));
}
/// <summary>
/// Returns the target speed for a given difficulty level.
/// Interpolates between minSpeed and maxSpeed using the speed curve.
/// </summary>
public float GetSpeed(float difficulty)
{
float t = speedCurve.Evaluate(difficulty);
return Mathf.Lerp(minSpeed, maxSpeed, t);
}
/// <summary>
/// Returns the target obstacle count for a given difficulty level.
/// Uses simple linear interpolation and rounds to the nearest integer.
/// </summary>
public int GetObstacleCount(float difficulty)
{
float count = Mathf.Lerp(
minObstaclesPerChunk,
maxObstaclesPerChunk,
difficulty
);
return Mathf.RoundToInt(count);
}
/// <summary>
/// Returns the coin value multiplier for a given difficulty level.
/// </summary>
public float GetCoinValueMultiplier(float difficulty)
{
return Mathf.Lerp(
minCoinValueMultiplier,
maxCoinValueMultiplier,
difficulty
);
}
/// <summary>
/// Returns the name of the current difficulty tier based on distance.
/// Example: "Easy" for 0-500m, "Medium" for 500-2000m, etc.
/// </summary>
public string GetTierName(float distance)
{
if (tiers == null || tiers.Length == 0) return "Normal";
// Find the highest tier whose threshold has been passed.
for (int i = tiers.Length - 1; i >= 0; i--)
{
if (distance >= tiers[i].distanceThreshold)
{
return tiers[i].tierName;
}
}
return tiers[0].tierName;
}
}
// -------------------------------------------------------------------
// Supporting Data Structures
// -------------------------------------------------------------------
/// <summary>
/// Defines a named difficulty tier with a distance threshold.
/// Used for logging and potential UI display.
/// </summary>
[System.Serializable]
public struct DifficultyTier
{
[Tooltip("Human-readable name for this tier (e.g., 'Easy', 'Hard').")]
public string tierName;
[Tooltip("The distance (in meters) at which this tier begins.")]
public float distanceThreshold;
}
/// <summary>
/// Defines obstacle type weights for a specific difficulty range.
/// Controls which obstacle types appear and how likely they are.
/// </summary>
[System.Serializable]
public struct ObstacleTierConfig
{
[Tooltip("The minimum difficulty (0-1) for this tier to be active.")]
[Range(0f, 1f)]
public float minDifficulty;
[Tooltip("The maximum difficulty (0-1) for this tier.")]
[Range(0f, 1f)]
public float maxDifficulty;
[Tooltip("Obstacle type names and their spawn weights within this tier.")]
public ObstacleWeight[] obstacleWeights;
}
/// <summary>
/// Maps an obstacle type name to a spawn weight.
/// Higher weights mean the obstacle appears more frequently.
/// </summary>
[System.Serializable]
public struct ObstacleWeight
{
[Tooltip("Name/identifier for the obstacle type.")]
public string obstacleId;
[Tooltip("Relative spawn weight. Higher = more common.")]
public float weight;
}
}Step 2: Create a Difficulty Config Asset
- In the Project window, navigate to
Assets/Data(create this folder if needed). - Right-click > Create > Infinite Runner > Difficulty Config.
- Name it
DefaultDifficulty. - Select it and configure the values in the Inspector.
Click the curve field in the Inspector to open the Curve Editor. Click to add keyframes and drag them to shape the curve. A good starting curve: put a keyframe at (0, 0), one at (500, 0.25), one at (1500, 0.6), one at (3000, 0.85), and one at (5000, 1.0). Right-click each keyframe and set the tangent mode to "Auto" for smooth transitions.
Setting Up Difficulty Tiers
In the Inspector, expand the Tiers array and add three entries:
- Easy: Distance Threshold =
0 - Medium: Distance Threshold =
500 - Hard: Distance Threshold =
2000
Understanding AnimationCurve
AnimationCurve is one of Unity's most powerful tools for game designers. It is a curve that maps an input value (X axis) to an output value (Y axis). You define keyframes (points on the curve) and Unity smoothly interpolates between them.
Why AnimationCurve Instead of Linear Math?
With linear interpolation (Mathf.Lerp), difficulty increases at a constant rate. This feels unnatural — the jump from 0 to 10% feels the same as 90% to 100%, even though the latter is much harder. AnimationCurve lets you shape the progression precisely:
- Slow start: A flat beginning lets new players settle in.
- Gradual ramp: The middle section increases steadily.
- Plateau: The curve flattens near the top so the game does not become impossibly hard.
// Define a curve with keyframes
AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, 0f), // At input 0, output 0
new Keyframe(500f, 0.25f), // At input 500, output 0.25
new Keyframe(5000f, 1.0f) // At input 5000, output 1.0
);
// Evaluate the curve at any input value
float difficulty = curve.Evaluate(1200f);
// Returns approximately 0.45 (smoothly interpolated)
// The curve automatically smooths between keyframes
// No additional math needed - just Evaluate()!When you click an AnimationCurve field in the Unity Inspector, a visual curve editor opens. You can click to add points, drag points to reshape the curve, and right-click points to change their tangent mode (smooth, linear, stepped). This visual editor is why AnimationCurve is so powerful — designers can tune the feel of the game without writing a single line of code.
The DifficultyManager
The DifficultyManager reads the DifficultyConfig each frame, evaluates the current difficulty based on distance traveled, and broadcasts the resulting values to other systems (speed to the PlayerController, obstacle density to the WorldGenerator, etc.).
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Reads the DifficultyConfig ScriptableObject and applies
/// difficulty-scaled values to game systems based on the
/// player's distance traveled.
///
/// This is the central hub that connects the difficulty curve
/// to actual gameplay parameters. Other systems listen for
/// events from this manager rather than calculating difficulty
/// themselves.
///
/// Attach to a persistent manager GameObject.
/// </summary>
public class DifficultyManager : MonoBehaviour
{
// ---------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------
public static DifficultyManager Instance { get; private set; }
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Configuration")]
[Tooltip("The difficulty configuration asset. Drag your " +
"DifficultyConfig ScriptableObject here.")]
[SerializeField] private DifficultyConfig config;
[Header("Update Settings")]
[Tooltip("How often (in seconds) to recalculate difficulty. " +
"Lower = more responsive, higher = less CPU usage.")]
[SerializeField] private float updateInterval = 0.25f;
[Header("Debug")]
[Tooltip("Enable to log difficulty changes to the console.")]
[SerializeField] private bool debugLogging = false;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// The current difficulty level (0 to 1).
private float currentDifficulty;
// The current difficulty tier name (for logging/display).
private string currentTierName = "";
// Distance traveled by the player (received via events).
private float currentDistance;
// Whether the difficulty system is active (only during gameplay).
private bool isActive;
// Timer for throttling difficulty updates.
private float updateTimer;
// Cached previous values to detect changes (avoids firing
// events when nothing has changed).
private float previousSpeed;
private int previousObstacleCount;
private float previousCoinMultiplier;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
// Validate config.
if (config == null)
{
Debug.LogError("[DifficultyManager] No DifficultyConfig " +
"assigned! Difficulty will not progress.");
}
}
private void OnEnable()
{
GameEvents.OnGameStarted += HandleGameStarted;
GameEvents.OnGameOver += HandleGameOver;
GameEvents.OnScoreUpdated += HandleScoreUpdated;
}
private void OnDisable()
{
GameEvents.OnGameStarted -= HandleGameStarted;
GameEvents.OnGameOver -= HandleGameOver;
GameEvents.OnScoreUpdated -= HandleScoreUpdated;
}
private void Update()
{
if (!isActive || config == null) return;
// Throttle updates to save performance.
updateTimer += Time.deltaTime;
if (updateTimer < updateInterval) return;
updateTimer = 0f;
// Recalculate and apply difficulty.
UpdateDifficulty();
}
// ---------------------------------------------------------------
// Core Difficulty Calculation
// ---------------------------------------------------------------
/// <summary>
/// Evaluates the difficulty curve based on current distance,
/// calculates all derived values, and broadcasts changes.
/// </summary>
private void UpdateDifficulty()
{
// Evaluate the main difficulty curve.
float newDifficulty = config.EvaluateDifficulty(currentDistance);
// Check if difficulty changed enough to warrant updates.
// Using a small epsilon avoids firing events for tiny fluctuations.
if (Mathf.Abs(newDifficulty - currentDifficulty) < 0.001f) return;
currentDifficulty = newDifficulty;
// Broadcast the raw difficulty value.
GameEvents.OnDifficultyChanged?.Invoke(currentDifficulty);
// Calculate and broadcast derived values.
ApplySpeed();
ApplyObstacleDensity();
ApplyCoinValue();
CheckTierChange();
if (debugLogging)
{
Debug.Log($"[DifficultyManager] Distance: {currentDistance:F0}m " +
$"| Difficulty: {currentDifficulty:F2} " +
$"| Tier: {currentTierName} " +
$"| Speed: {config.GetSpeed(currentDifficulty):F1}");
}
}
// ---------------------------------------------------------------
// Individual Parameter Application
// ---------------------------------------------------------------
/// <summary>
/// Calculates the target speed and fires an event if it changed.
/// </summary>
private void ApplySpeed()
{
float targetSpeed = config.GetSpeed(currentDifficulty);
// Only fire event if speed actually changed.
if (Mathf.Abs(targetSpeed - previousSpeed) > 0.01f)
{
previousSpeed = targetSpeed;
GameEvents.OnSpeedChanged?.Invoke(targetSpeed);
}
}
/// <summary>
/// Calculates the target obstacle density and fires an event
/// if it changed.
/// </summary>
private void ApplyObstacleDensity()
{
int targetCount = config.GetObstacleCount(currentDifficulty);
if (targetCount != previousObstacleCount)
{
previousObstacleCount = targetCount;
GameEvents.OnObstacleDensityChanged?.Invoke(targetCount);
}
}
/// <summary>
/// Calculates the coin value multiplier and fires an event
/// if it changed.
/// </summary>
private void ApplyCoinValue()
{
float multiplier = config.GetCoinValueMultiplier(currentDifficulty);
if (Mathf.Abs(multiplier - previousCoinMultiplier) > 0.01f)
{
previousCoinMultiplier = multiplier;
GameEvents.OnCoinValueMultiplierChanged?.Invoke(multiplier);
}
}
/// <summary>
/// Checks if we have entered a new difficulty tier and
/// fires an event if so (for UI notifications like "HARD MODE").
/// </summary>
private void CheckTierChange()
{
string newTier = config.GetTierName(currentDistance);
if (newTier != currentTierName)
{
string previousTier = currentTierName;
currentTierName = newTier;
GameEvents.OnDifficultyTierChanged?.Invoke(currentTierName);
if (debugLogging)
{
Debug.Log($"[DifficultyManager] Tier changed: " +
$"{previousTier} -> {currentTierName}");
}
}
}
// ---------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------
/// <summary>
/// Resets difficulty when a new game starts.
/// </summary>
private void HandleGameStarted()
{
currentDifficulty = 0f;
currentDistance = 0f;
currentTierName = "";
updateTimer = 0f;
previousSpeed = 0f;
previousObstacleCount = 0;
previousCoinMultiplier = 0f;
isActive = true;
// Apply initial difficulty values.
UpdateDifficulty();
Debug.Log("[DifficultyManager] Difficulty system started.");
}
/// <summary>
/// Stops difficulty progression when the game ends.
/// </summary>
private void HandleGameOver()
{
isActive = false;
Debug.Log($"[DifficultyManager] Game over at difficulty " +
$"{currentDifficulty:F2}, tier: {currentTierName}");
}
/// <summary>
/// Receives distance updates from the ScoreManager.
/// This is how the DifficultyManager knows how far the
/// player has traveled.
/// </summary>
private void HandleScoreUpdated(ScoreData data)
{
currentDistance = data.DistanceTraveled;
}
// ---------------------------------------------------------------
// Public API
// ---------------------------------------------------------------
/// <summary>
/// Returns the current difficulty level (0 to 1).
/// </summary>
public float GetCurrentDifficulty()
{
return currentDifficulty;
}
/// <summary>
/// Returns the name of the current difficulty tier.
/// </summary>
public string GetCurrentTierName()
{
return currentTierName;
}
/// <summary>
/// Returns the current target speed based on difficulty.
/// </summary>
public float GetCurrentSpeed()
{
if (config == null) return 8f;
return config.GetSpeed(currentDifficulty);
}
/// <summary>
/// Returns the current obstacle count based on difficulty.
/// </summary>
public int GetCurrentObstacleCount()
{
if (config == null) return 1;
return config.GetObstacleCount(currentDifficulty);
}
/// <summary>
/// Returns the DifficultyConfig for external inspection.
/// Useful for testing and debugging.
/// </summary>
public DifficultyConfig GetConfig()
{
return config;
}
}
}New Events for Difficulty
Add these event declarations to your GameEvents class:
using System;
namespace InfiniteRunner.Core
{
public static partial class GameEvents
{
// --- Difficulty Events ---
/// <summary>
/// Fired when the difficulty level changes (value is 0-1).
/// </summary>
public static Action<float> OnDifficultyChanged;
/// <summary>
/// Fired when the difficulty enters a new named tier
/// (e.g., "Easy" -> "Medium").
/// </summary>
public static Action<string> OnDifficultyTierChanged;
/// <summary>
/// Fired when the target obstacle count per chunk changes.
/// </summary>
public static Action<int> OnObstacleDensityChanged;
/// <summary>
/// Fired when the coin value multiplier changes due to difficulty.
/// </summary>
public static Action<float> OnCoinValueMultiplierChanged;
}
}Connecting to WorldGenerator and ObstacleSpawner
The DifficultyManager does not modify other systems directly. Instead, it broadcasts events that those systems listen for. Here is how each system reacts:
PlayerController or MovementSystem
Listens for OnSpeedChanged and updates the player's forward movement speed:
private void OnEnable()
{
GameEvents.OnSpeedChanged += HandleSpeedChanged;
}
private void OnDisable()
{
GameEvents.OnSpeedChanged -= HandleSpeedChanged;
}
private void HandleSpeedChanged(float newSpeed)
{
// Smoothly transition to the new speed to avoid jarring jumps.
targetSpeed = newSpeed;
}
private void Update()
{
// Lerp current speed toward the target for a smooth transition.
currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, Time.deltaTime * 2f);
// Apply movement.
transform.Translate(Vector3.forward * currentSpeed * Time.deltaTime);
}ObstacleSpawner
Listens for OnObstacleDensityChanged and adjusts how many obstacles are placed per chunk:
private int targetObstacleCount = 1;
private void OnEnable()
{
GameEvents.OnObstacleDensityChanged += HandleDensityChanged;
}
private void HandleDensityChanged(int newCount)
{
targetObstacleCount = newCount;
Debug.Log($"[ObstacleSpawner] Now placing {newCount} obstacles per chunk.");
}
// When spawning obstacles on a new chunk:
private void SpawnObstaclesOnChunk(Transform chunk, Transform[] spawnPoints)
{
// Only use up to targetObstacleCount spawn points.
int count = Mathf.Min(targetObstacleCount, spawnPoints.Length);
for (int i = 0; i < count; i++)
{
// ... spawn obstacle at spawnPoints[i] ...
}
}CoinSpawner
Listens for OnCoinValueMultiplierChanged if you want coins placed at higher difficulty tiers to be worth more inherently:
// In the Coin class, adjust value based on difficulty multiplier:
private float valueMultiplier = 1f;
private void OnEnable()
{
GameEvents.OnCoinValueMultiplierChanged += m => valueMultiplier = m;
}
// When collected, multiply the base value:
private void Collect()
{
int adjustedValue = Mathf.RoundToInt(coinValue * valueMultiplier);
GameEvents.OnCoinCollected?.Invoke(adjustedValue);
// ...
}Testing and Visualizing the Difficulty Curve
Getting difficulty right requires iteration. Here are strategies for testing and tuning:
Debug Overlay
Create a debug display that shows current difficulty values in real-time:
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;
namespace InfiniteRunner.Debug
{
/// <summary>
/// Temporary debug script that shows difficulty values on screen.
/// Remove once the game is ready for release.
/// </summary>
public class DifficultyDebugDisplay : MonoBehaviour
{
private float difficulty;
private float speed;
private int obstacles;
private string tier = "";
private void OnEnable()
{
GameEvents.OnDifficultyChanged += d => difficulty = d;
GameEvents.OnSpeedChanged += s => speed = s;
GameEvents.OnObstacleDensityChanged += o => obstacles = o;
GameEvents.OnDifficultyTierChanged += t => tier = t;
}
private void OnGUI()
{
// Show in the top-right corner.
GUILayout.BeginArea(new Rect(Screen.width - 310, 10, 300, 150));
GUI.color = Color.yellow;
GUILayout.Label($"DIFFICULTY DEBUG");
GUILayout.Label($"Level: {difficulty:F3} ({difficulty * 100:F1}%)");
GUILayout.Label($"Tier: {tier}");
GUILayout.Label($"Speed: {speed:F1} m/s");
GUILayout.Label($"Obstacles/Chunk: {obstacles}");
GUI.color = Color.white;
GUILayout.EndArea();
}
}
}Inspector Curve Adjustments
One of the best things about using AnimationCurve is that you can adjust it while the game is running in the Editor:
- Enter Play Mode.
- Select the DifficultyConfig asset in the Project window.
- Click the difficulty curve to open the Curve Editor.
- Drag keyframes around and watch the game respond in real-time.
- When you find values you like, note them down (changes in Play Mode are lost when you exit).
Any changes you make in the Unity Inspector while in Play Mode are reverted when you exit Play Mode. This is a common beginner trap. If you find difficulty values you like while testing, write them down or take a screenshot before exiting Play Mode, then re-enter the values in Edit Mode.
Fast-Forward Testing
Waiting 5 minutes to reach maximum difficulty is slow for testing. Add a debug key to artificially advance distance:
// Add to DifficultyManager.Update() for testing:
#if UNITY_EDITOR
if (Input.GetKey(KeyCode.F5))
{
// Hold F5 to simulate traveling 100m per second
currentDistance += 100f * Time.deltaTime;
Debug.Log($"[DEBUG] Fast-forward distance: {currentDistance:F0}m");
}
if (Input.GetKeyDown(KeyCode.F6))
{
// Press F6 to jump to max difficulty instantly
currentDistance = config.maxDistance;
Debug.Log("[DEBUG] Jumped to max difficulty!");
}
#endif#if UNITY_EDITOR is a preprocessor directive that tells the C# compiler to only include the enclosed code when building for the Unity Editor. This code is automatically stripped from your final game build, so you do not need to worry about removing debug keys before publishing.
Chapter Summary
In this chapter, you built a complete, designer-friendly difficulty progression system:
- DifficultyConfig ScriptableObject — Stores all difficulty parameters (speed, obstacle density, coin value, tiers) in a data asset editable in the Inspector.
- AnimationCurve — Maps distance traveled to a 0-1 difficulty value using a smooth, customizable curve.
- DifficultyManager.cs — Evaluates difficulty each frame, calculates derived values, and broadcasts changes via events.
- Difficulty Tiers — Named stages (Easy, Medium, Hard) with distance thresholds for logging and UI display.
- Event-driven connections — PlayerController, ObstacleSpawner, and CoinSpawner all react to difficulty events without tight coupling.
- Testing tools — Debug overlays, live curve editing, and fast-forward keys for efficient iteration.
The difficulty system is the final gameplay system in Part 4. In the next chapter, we move to Part 5: Polish, starting with the UI system that displays all the scores, power-ups, and game states we have been building.
Difficulty progression complete — and that wraps up Part 4. Merge time!
git add .
git commit -m "Add difficulty progression with ScriptableObject config"
# Merge gameplay branch into main
git checkout main
git merge feature/gameplay
Same merge workflow as before. main now has everything through Chapter 18. Run git log --oneline to see the full project history — it should be a satisfying list of clear, descriptive commits.