MarbleMaze: Deep Dive — Adaptive Difficulty Engine
MarbleMaze: Deep Dive — Adaptive Difficulty Engine

MarbleMaze: Deep Dive — Adaptive Difficulty Engine

Created on:
Team Size: 2
Time Frame: 3 Months
Tool Used: Unity/C#

Generating infinite levels is only half the problem. The other half is making sure those levels feel appropriately hard — not so easy the player disengages, not so punishing they quit. This deep dive covers how I built an adaptive difficulty system that scales across three independent axes and responds to player performance in real time.

The Problem

A fixed difficulty curve works fine for a small set of hand-crafted levels. For a procedural game with no upper bound on level count, it falls apart. Players start the same place but their skill trajectories diverge quickly — one player breezes through the first 50 levels, another struggles at level 10. A single curve can’t serve both.

The system I built scales along three independent axes:

AxisWhat scalesControlled by
Grid sizeLevel dimensions (maze complexity)Level index
Hazard compositionWhich tile types appear and how denselyCycle archetype + multiplier
Difficulty multiplierHow aggressively hazards are appliedPlayer performance

All three are computed in a single call at the start of each level:

// LevelManager.cs — GenerateRuntimeLevel()
RuntimeLevelParameters runtimeParams =
    RuntimeLevelProgression.GetParametersForLevel(
        levelIndex,
        tileDatabase_SO,
        levelCycleProgression_SO,
        levelsPerCycle,
        previousLivesLostToThisLevel,   // local performance
        failedTimes,                     // failure count
        globalDifficultyModifier.difficultyDebt  // cross-session debt
    );

The result is a RuntimeLevelParameters struct that overwrites the base GeneratorParameters_SO before the maze generator runs.

Axis 1 — Grid Size Progression

The most visible form of difficulty scaling is the physical size of the maze. A 5×10 grid is a short, narrow corridor. A 15×30 grid is a sprawling multi-path maze.

Size grows across five discrete phases, each lerped smoothly within its range:

// RuntimeLevelParameters.cs
int lvl = levelIndex + 1;

void LerpSize(int startLevel, int endLevel,
              int startW, int endW,
              int startH, int endH)
{
    float t = Mathf.InverseLerp(startLevel, endLevel, lvl);
    width  = Mathf.RoundToInt(Mathf.Lerp(startW, endW, t));
    height = Mathf.RoundToInt(Mathf.Lerp(startH, endH, t));
}

if      (lvl <= 10) LerpSize(1,  10,  5,  5,  10, 10);  // fixed intro size
else if (lvl <= 20) LerpSize(11, 20,  5,  7,  10, 15);  // width begins
else if (lvl <= 35) LerpSize(21, 35,  7,  10, 15, 20);  // height grows fast
else if (lvl <= 50) LerpSize(36, 50,  10, 15, 20, 25);  // both expand
else if (lvl <= 70) LerpSize(51, 70,  10, 15, 25, 30);  // height pushes further
else
{
    // Endgame: caps out around level 100
    float t = Mathf.Clamp01((lvl - 70) / 30f);
    width  = Mathf.RoundToInt(Mathf.Lerp(10, 15, t));
    height = Mathf.RoundToInt(Mathf.Lerp(25, 30, t));
}

Phase boundaries were chosen to front-load the feeling of growth — the player experiences the most dramatic change between levels 10 and 35, when height more than doubles. After level 70 the grid caps, and difficulty comes purely from hazard composition.

Grid size across the five progression phases

Grid size progression — a line chart showing how width and height grow across the five phases (levels 1–100), making the front-loaded height explosion between levels 10–35 visually obvious, and the cap after level 70 clear.

Star spacing also derives from height so that stars never feel trivially close on large mazes:

int minStarDistance = Mathf.Max(1, height / 3);

Axis 2 — Cycle-Based Archetype Selection

Grid size controls how much space the player navigates; archetypes control what that space is filled with. An archetype is a ScriptableObject that declares which hazard types should appear in a level and at what base weight:

// LevelArchetypeData_SO.cs
public class LevelArchetypeData_SO : ScriptableObject
{
    public string archetypeName;

    [Serializable]
    public struct ModifierWeight
    {
        public GroundType groundType; // Ice, Piques, MovingPlatform …
        [Range(0f, 1f)]
        public float weight;          // 0 = inactive, 1 = dominant
    }

    public ModifierWeight[] modifiers;
    public int maxActiveModifiers = 2;
}

Archetypes are organised into cycles — groups of levelsPerCycle levels that share the same set of allowed archetypes. A LevelCycleProgression_SO holds the ordered list of cycles that the designer composes in the Unity inspector:

// LevelCycleProgression_SO.cs
public class LevelCycleProgression_SO : ScriptableObject
{
    public LevelArchetypeData_SO recoveryArchetype;
    public List<LevelCycleDefinition> cycles;
}

// LevelCycleDefinition.cs
public class LevelCycleDefinition
{
    public string cycleName;                          // e.g. "Precision Intro"
    public List<LevelArchetypeData_SO> allowedArchetypes;
}

The archetype for any level is selected deterministically from the cycle’s allowed list — no randomness here, because the designer needs to reason about what the player will encounter at each position within a cycle:

static LevelArchetypeData_SO SelectArchetype(
    LevelCycleProgression_SO progression,
    int cycleIndex, int cycleLevel, bool isRecovery)
{
    if (isRecovery) return progression.recoveryArchetype;

    int safeCycleIndex = Mathf.Clamp(cycleIndex, 0, progression.cycles.Count - 1);
    var cycle = progression.cycles[safeCycleIndex];

    // Rotate through the allowed archetypes by position within the cycle
    int archetypeIndex = cycleLevel % cycle.allowedArchetypes.Count;
    return cycle.allowedArchetypes[archetypeIndex];
}

cycleIndex = levelIndex / levelsPerCycle — the current cycle block.
cycleLevel = levelIndex % levelsPerCycle — the position within that block.

Once the last defined cycle is reached, safeCycleIndex clamps to it, so late-game levels keep drawing from the final cycle’s archetypes indefinitely.

Cycle/archetype structure

Cycle/archetype structure — a containment diagram showing LevelCycleProgression holding multiple cycles, each with its allowed archetypes, plus the recovery archetype sitting separately. The cycleLevel % count rotation logic is shown at the bottom.

Axis 3 — The Adaptive Difficulty Multiplier

The multiplier is what makes the system responsive. It is computed fresh every level from two independent signals: local stress (what happened this specific level) and global debt (accumulated pressure across the session).

Local Multiplier — Per-Level Stress

The local multiplier is derived from how many lives the player lost on the previous attempt and how many times they have failed the same level outright:

// RuntimeLevelParameters.cs
static float GetDifficultyMultiplier(int livesLost, int failedTimes)
{
    float multiplier = 1f;

    if      (livesLost >= 3) multiplier = 0.5f;   // game over → big relief
    else if (livesLost == 2) multiplier = 0.7f;   // moderate relief
    else if (livesLost == 1) multiplier = 0.85f;  // small nudge

    // Each failure shaves 5%, capped at 3 failures (−15%)
    int cappedFailures = Mathf.Min(failedTimes, 3);
    multiplier *= 1f - (0.05f * cappedFailures);

    return Mathf.Clamp(multiplier, 0.5f, 1f);
}

The failure penalty uses diminishing returns — the third failure has the same impact as the first, but you can never accumulate more than −15% from failures alone. This prevents the game from becoming trivially easy on a level the player keeps quitting immediately.

Global Debt — Cross-Session Pressure

The local multiplier resets every level. The global multiplier persists across multiple levels, carrying forward a difficulty debt that accumulates whenever the player loses lives:

// LevelManager.cs
void UpdateGlobalDifficulty(int livesLost)
{
    if (livesLost <= 0) return;

    float addedDebt = livesLost * 0.1f;  // 1 life = +0.1, 3 lives = +0.3

    globalDifficultyModifier.difficultyDebt =
        Mathf.Clamp01(globalDifficultyModifier.difficultyDebt + addedDebt);

    globalDifficultyModifier.remainingLevels = 4; // effect lasts 4 levels
}

void ConsumeGlobalDifficulty()
{
    if (globalDifficultyModifier.remainingLevels <= 0)
    {
        globalDifficultyModifier.difficultyDebt = 0f; // debt expires
        return;
    }
    globalDifficultyModifier.remainingLevels--;
}

The debt is then converted to a multiplier via a simple Lerp:

static float GetGlobalDifficultyMultiplier(float difficultyDebt)
{
    float maxRelief = 0.8f; // global can reduce difficulty by at most 20%
    return Mathf.Lerp(1f, maxRelief, difficultyDebt);
}

A difficultyDebt of 0 → multiplier 1.0 (no effect).
A difficultyDebt of 1 → multiplier 0.8 (maximum 20% relief).

The debt state is stored in a GlobalDifficultyState_SO ScriptableObject — a simple two-field container that lives in the project and survives scene loads:

// GlobalDifficultyState_SO.cs
public class GlobalDifficultyState_SO : ScriptableObject
{
    public float difficultyDebt;   // 0 → no easing, 1 → max easing
    public int remainingLevels;    // how many more levels the debt persists
}

Combining Both Signals

The two multipliers multiply together to produce the final value passed into archetype application:

float localDifficultyModifier  = GetDifficultyMultiplier(livesLostThisLevel, failedTimes);
float globalDifficultyModifier = GetGlobalDifficultyMultiplier(globalDifficultyDebt);
float finalMultiplier          = localDifficultyModifier * globalDifficultyModifier;

At their minimum:
0.5 (local) × 0.8 (global) = 0.4 — hazard weights reduced to 40% of their designed value.

Difficulty modifiers

Multiplier combination — shows the two independent signals (local stress from lives lost, global debt accumulated over sessions), how they each produce a sub-multiplier in their own range, and how they multiply together to produce the final hazard density factor (minimum 0.40).

Applying the Multiplier — Archetype to Tile Ratios

The finalMultiplier is not applied to the level size or star count — it only affects hazard tile ratios inside the active TileDatabase_SO. ApplyArchetypeData first zeros every hazard’s ratio, then re-weights only the ones listed in the archetype:

static void ApplyArchetypeData(
    LevelArchetypeData_SO data, TileDatabase_SO tileDatabase,
    float cycleT, float difficultyModifier)
{
    // Clear all ratios first
    foreach (var hazardTile in tileDatabase.HazardTiles)
        hazardTile.ratio = 0;

    if (data == null || data.modifiers == null) return;

    foreach (var modifier in data.modifiers)
    {
        // Scale: archetype weight × position within cycle × difficulty multiplier
        float scaled = Mathf.Clamp01(modifier.weight * cycleT * difficultyModifier);

        HazardTileDefinition_SO hazardTile =
            tileDatabase.GetHazardByGroundType(modifier.groundType);

        // Never exceed the tile's designer-set maximum ratio
        hazardTile.ratio = Mathf.Min(hazardTile.maxRatio, scaled * hazardTile.maxRatio);
    }
}

cycleT is cycleLevel / (levelsPerCycle - 1) — the normalised position within the current cycle (0 at the cycle’s first level, 1 at the last). This means hazards ramp up smoothly across each cycle and reset to zero at the start of the next, giving the player a sense of escalation and then relief.

The maxRatio cap on each HazardTileDefinition_SO is the designer’s safety valve — no matter how the runtime modifies weights, a tile can never exceed the density that was playtested as acceptable.

Hazard Variety Unlocking

Beyond density, the number of distinct hazard types active at once also scales with level. ApplyHazardVariation runs after archetype application and trims the active hazard set down to a target count, randomly disabling whichever ones are over the cap:

static void ApplyHazardVariation(int levelIndex, TileDatabase_SO tileDatabase)
{
    int lvl = levelIndex + 1;
    int targetActive;

    if      (lvl < 40)  targetActive = 1;
    else if (lvl < 70)  targetActive = UnityEngine.Random.Range(1, 3); // 1–2
    else if (lvl < 100) targetActive = UnityEngine.Random.Range(2, 4); // 2–3
    else
    {
        float r = UnityEngine.Random.value;
        if      (r < 0.6f) targetActive = 3;
        else if (r < 0.9f) targetActive = 2;
        else               targetActive = 4;
    }

    var active = tileDatabase.HazardTiles
        .Where(h => h.ratio > 0).ToList();

    int toDisable = active.Count - targetActive;
    for (int i = 0; i < toDisable; i++)
    {
        int idx = UnityEngine.Random.Range(0, active.Count);
        active[idx].ratio = 0;
        active.RemoveAt(idx);
    }
}

Early levels introduce hazards one at a time — the player learns each type in isolation before combinations appear. The endgame weighted distribution (60% / 30% / 10%) keeps three hazard types as the norm while leaving room for the occasional four-hazard surprise.

Recovery Levels

When a player is under maximum stress — both local and global debt at their ceiling — the system can inject a recovery level: a deliberately gentle maze generated from a dedicated archetype with minimal or no hazards.

Recovery is gated behind two conditions being true simultaneously:

static bool HasMaxDifficultyRelief(int livesLost, int failedTimes, float globalDifficultyDebt)
{
    bool maxLocalRelief  = livesLost >= 3;       // game-over territory
    bool maxGlobalRelief = globalDifficultyDebt >= 1f; // debt is maxed out
    return maxLocalRelief && maxGlobalRelief;
}

Requiring both prevents a single bad level from immediately triggering recovery — the player has to be consistently struggling. When the condition holds, recovery levels appear at a frequency that itself decreases as cycles progress:

static bool IsRecoveryLevel(int cycleIndex, int cycleLevel, bool hasMaxRelief)
{
    if (!hasMaxRelief) return false;

    // Frequency shrinks as the player advances: 6 → 5 → 4 → 3 (minimum)
    int recoveryFrequency = Mathf.Clamp(6 - cycleIndex, 3, 6);
    return (cycleLevel % recoveryFrequency) == recoveryFrequency - 1;
}

In early cycles a recovery level can appear every 6 levels; by cycle 3+ the minimum is every 3 levels. This ensures a struggling late-game player gets more frequent relief than a struggling early-game one — because late-game levels are objectively harder.

How It All Connects

Every piece feeds into LevelManager.GenerateRuntimeLevel:

// LevelManager.cs
private Grid GenerateRuntimeLevel(int levelIndex, LevelDatabase_SO database,
    GeneratorParameters_SO baseParameters, out int usedSeed)
{
    // Pre-authored levels take priority
    LevelData_SO existing = database.GetLevelDataAtIndex(levelIndex);
    if (existing != null) { usedSeed = existing.usedSeed; return existing.ToGrid(); }

    // Compute all runtime parameters
    RuntimeLevelParameters runtimeParams =
        RuntimeLevelProgression.GetParametersForLevel(
            levelIndex, tileDatabase_SO, levelCycleProgression_SO,
            levelsPerCycle,
            previousLivesLostToThisLevel,           // from previous attempt
            failedTimes,                             // total failures on this level
            globalDifficultyModifier.difficultyDebt  // cross-session debt
        );

    // Patch the base ScriptableObject with runtime values
    baseParameters.gridWidth       = runtimeParams.width;
    baseParameters.gridHeight      = runtimeParams.height;
    baseParameters.tileDatabase_SO = runtimeParams.tileDatabase_SO;
    baseParameters.minStarDistance = runtimeParams.minStarDistance;
    baseParameters.inputSeed       = -1; // force random seed

    return PxP.PCG.Generator.GenerateMaze(levelIndex, baseParameters, out usedSeed);
}

After a level ends, ProcessLevelData and MarkLevelAsFailed update the debt for the next call:

// On level complete:
ConsumeGlobalDifficulty();        // decrement remaining debt levels
UpdateGlobalDifficulty(livesLostThisLevel); // add new debt if lives were lost

// On level failed:
UpdateGlobalDifficulty(currentLivesLostToThisLevel);
currentLevelData.failedTimes++;

The debt is stored in a ScriptableObject, so it persists across scene loads without needing any explicit serialisation on level transition.

Difficulty modifiers

Recovery level gate — a flowchart showing the AND condition (both local stress AND global debt must be maxed), the frequency formula clamp(6 − cycleIndex, 3, 6), and the note that later cycles trigger recovery more often — because later levels are objectively harder.

Telemetry

Every level generates a LevelTelemetryEvent that logs the archetype, dominant modifier, outcome, and duration to Firebase. This data was essential for validating the difficulty curve during playtesting — particularly for confirming that recovery levels were triggering at the right frequency and that debt was decaying at a reasonable pace.

// LevelTelemetryEvent.cs
public struct LevelTelemetryEvent
{
    public int    levelIndex;
    public int    cycleIndex;
    public string archetypeName;
    public string dominantModifier; // which GroundType dominated this level
    public string result;           // "success" / "fail" / "quit"
    public int    attemptNumber;
    public float  duration;
}

Summary

ComponentRole
RuntimeLevelProgressionStateless calculator — takes performance data, returns parameters
GlobalDifficultyState_SOPersistent debt store (ScriptableObject, survives scene loads)
LevelArchetypeData_SODesigner-authored hazard composition per archetype
LevelCycleProgression_SOOrdered sequence of cycles with their allowed archetypes
LevelManagerOwns player performance state, accumulates debt, drives generation
HazardTileDefinition_SO.maxRatioPer-tile safety cap — playtested ceiling the runtime never exceeds

Key design decisions:

  • RuntimeLevelProgression is a static, stateless class. It receives all the inputs it needs and returns a struct. No singleton, no side effects — easy to unit test, easy to call from an editor window.
  • Debt lives in a ScriptableObject. This sidesteps serialisation ceremony on scene transition and makes it inspectable in the editor during playtesting.
  • Local and global signals multiply, not add. Additive relief could stack into a combined value below the intended floor; multiplication keeps both signals proportional and the output bounded without extra clamping logic.
  • Recovery requires both signals at maximum. A single bad level doesn’t trigger a recovery; the player has to be genuinely struggling across multiple levels before the system steps in.
← Back to Project Overview Next: GC-Friendly Grid Data Structure →
© 2026 Samuel Styles