How to Build a Hades‑Style Encounter Flow Generator in Unity

AAA‑Style Procedural Level Generation Pipeline (Full Guide)
August 19, 2026
GPU Lightmap Baking in Unity (Custom Baking Tools Guide)
August 22, 2026
AAA‑Style Procedural Level Generation Pipeline (Full Guide)
August 19, 2026
GPU Lightmap Baking in Unity (Custom Baking Tools Guide)
August 22, 2026

How to Build a Hades‑Style Encounter Flow Generator in Unity

One of the secret weapons behind Hades is its encounter flow system — not just random rooms, but a carefully controlled sequence of fights, rewards, shops, story beats, and boss battles.

In this guide, you will learn how to design and implement a Hades‑style encounter flow generator in Unity using C#, ScriptableObjects, and a small rule engine. The goal is to make your runs feel:

  • Structured but unpredictable
  • Rewarding without being broken
  • Progressively harder but fair
  • Story‑aware and system‑driven

1. What Is an Encounter Flow?

In Hades, a “run” is not just a set of rooms. It’s a flow of encounters:

  • Combat rooms with different enemy patterns
  • Reward rooms (boons, gold, shop, health, Chaos, etc.)
  • Mini‑bosses and bosses at specific points
  • Occasional narrative or event rooms

The encounter flow generator decides, for each step:

  • What type of room you get
  • What reward it offers
  • How difficult it is
  • How it fits into the long‑term pacing of a run

We’ll model this with:

  1. A lightweight Encounter Type system
  2. A Run Timeline (sequence of stages)
  3. A Rule‑based Encounter Selector
  4. A Reward Generator

2. Core Data Model: Encounter Types

First, define the categories of encounters. Keep this simple and designer‑friendly.

public enum EncounterType
{
    Combat,
    EliteCombat,
    Boss,
    Shop,
    Treasure,
    Story,
    Healing,
    Challenge // e.g. Chaos rooms, trials, etc.
}

Next, create a ScriptableObject to describe an encounter template:

using UnityEngine;

[CreateAssetMenu(menuName = "Game/EncounterDefinition")]
public class EncounterDefinition : ScriptableObject
{
    public string encounterId;
    public EncounterType type;

    [Range(0f, 1f)]
    public float baseWeight = 1f;

    [Header("Difficulty")]
    public int minDangerLevel;
    public int maxDangerLevel;

    [Header("Tags")]
    public string[] tags; 
    // e.g. { "ranged", "aoe", "armor", "elite", "trap-heavy" }

    [Header("Reward Profile")]
    public bool canDropBoons = true;
    public bool canDropGold = true;
    public bool canDropHealth = false;
}

This gives you designer‑friendly knobs for controlling availability and weighting of each encounter.


3. Run Stage & Timeline Model

Hades doesn’t just throw encounters randomly; it thinks in stages:

  • Tartarus (early game)
  • Asphodel
  • Elysium
  • Temple of Styx

Each stage has:

  • Approximate number of rooms
  • Target difficulty curve
  • Boss at the end
  • Specific allowed encounter types
[System.Serializable]
public class RunStage
{
    public string stageId;          // "Tartarus"
    public int roomCount = 10;      // how many rooms before boss
    public int baseDangerLevel = 1; // starting difficulty
    public int dangerGrowth = 1;    // difficulty increase per room

    public EncounterDefinition bossEncounter;

    public EncounterType[] allowedTypes; // e.g. Combat, EliteCombat, Shop, Treasure, Healing, Challenge
}

Create a ScriptableObject representing a whole “run plan”:

[CreateAssetMenu(menuName = "Game/RunDefinition")]
public class RunDefinition : ScriptableObject
{
    public RunStage[] stages;
}

This decouples stage design from code, and lets you tweak pacing like a designer.


4. Tracking Run State

To generate a good flow, you must track what happened previously:

  • How many combat rooms in a row?
  • How many rewards of each type?
  • When was the last shop?
  • Current danger level?
public class RunState
{
    public int currentStageIndex = 0;
    public int roomIndexInStage = 0;

    public int currentDangerLevel = 1;

    public int combatStreak = 0;
    public int nonCombatStreak = 0;

    public int roomsSinceLastShop = 0;
    public int roomsSinceLastHealing = 0;

    public bool bossDefeated = false;

    public void Advance(RunStage stage)
    {
        roomIndexInStage++;
        currentDangerLevel = stage.baseDangerLevel 
                             + stage.dangerGrowth * roomIndexInStage;
    }
}

5. Encounter Selection Rules (The Heart of It)

Now we build a rule‑driven selector that chooses the next encounter type and specific encounter.

5.1. Base Selection Flow

public class EncounterFlowGenerator : MonoBehaviour
{
    public RunDefinition runDefinition;
    public EncounterDefinition[] allEncounters;

    private RunState state = new RunState();

    public EncounterDefinition GetNextEncounter()
    {
        RunStage stage = runDefinition.stages[state.currentStageIndex];

        // Last room before boss?
        bool isLastRoom = (state.roomIndexInStage >= stage.roomCount - 1);
        if (isLastRoom)
        {
            return stage.bossEncounter;
        }

        var allowedPool = FilterByStage(stage);
        allowedPool = ApplyFlowRules(allowedPool, stage, state);

        var selected = WeightedRandom(allowedPool, state);
        UpdateStatePostSelection(selected, stage, state);

        return selected;
    }
}

We’ll fill in FilterByStage, ApplyFlowRules, and WeightedRandom next.


5.2. Filter by Stage & Difficulty

using System.Collections.Generic;
using System.Linq;

public List<EncounterDefinition> FilterByStage(RunStage stage)
{
    var allowedTypes = new HashSet<EncounterType>(stage.allowedTypes);

    return allEncounters
        .Where(e => allowedTypes.Contains(e.type))
        .Where(e => state.currentDangerLevel >= e.minDangerLevel 
                    && state.currentDangerLevel <= e.maxDangerLevel)
        .ToList();
}

5.3. Flow Rules: Avoid Boring Patterns

Hades‑style flow avoids:

  • Too many combat rooms with no reward
  • Shops too early or too often
  • Healing too often (removes tension)

We adjust candidate weights dynamically based on run state:

public List&lt;WeightedEncounter&gt; ApplyFlowRules(
    List&lt;EncounterDefinition&gt; pool, 
    RunStage stage,
    RunState state)
{
    var result = new List&lt;WeightedEncounter&gt;();

    foreach (var enc in pool)
    {
        float weight = enc.baseWeight;

        // Example flow rules:

        // 1) Reduce shop chance if we just had a shop
        if (enc.type == EncounterType.Shop)
        {
            if (state.roomsSinceLastShop < 3)
                weight *= 0.1f; // strongly reduce
            else if (state.roomsSinceLastShop > 6)
                weight *= 2.0f; // increase when it's been a while
        }

        // 2) Limit healing frequency
        if (enc.type == EncounterType.Healing)
        {
            if (state.roomsSinceLastHealing < 5)
                weight *= 0.2f;
        }

        // 3) Encourage non-combat after long combat streaks
        if (enc.type == EncounterType.Combat || enc.type == EncounterType.EliteCombat)
        {
            // Too much combat? less weight.
            if (state.combatStreak >= 3)
                weight *= 0.5f;
        }
        else
        {
            // Non-combat room: increase after long combat streak.
            if (state.combatStreak >= 3)
                weight *= 2.0f;
        }

        // 4) Gradually introduce more elites at higher danger levels
        if (enc.type == EncounterType.EliteCombat)
        {
            if (state.currentDangerLevel > 5) weight *= 1.5f;
            if (state.currentDangerLevel > 10) weight *= 2.0f;
        }

        if (weight > 0f)
        {
            result.Add(new WeightedEncounter
            {
                encounter = enc,
                weight = weight
            });
        }
    }

    return result;
}
public struct WeightedEncounter
{
    public EncounterDefinition encounter;
    public float weight;
}

5.4. Weighted Random Selection

public EncounterDefinition WeightedRandom(List&lt;WeightedEncounter&gt; list, RunState state)
{
    if (list == null || list.Count == 0)
        return null;

    float total = 0f;
    foreach (var we in list)
        total += we.weight;

    float r = Random.value * total;
    float cumulative = 0f;

    foreach (var we in list)
    {
        cumulative += we.weight;
        if (r <= cumulative)
            return we.encounter;
    }

    return list[list.Count - 1].encounter;
}

5.5. Updating Run State After Selection

public void UpdateStatePostSelection(
    EncounterDefinition selected,
    RunStage stage,
    RunState state)
{
    bool isCombat = selected.type == EncounterType.Combat 
                    || selected.type == EncounterType.EliteCombat
                    || selected.type == EncounterType.Boss;

    if (isCombat)
    {
        state.combatStreak++;
        state.nonCombatStreak = 0;
    }
    else
    {
        state.nonCombatStreak++;
        state.combatStreak = 0;
    }

    if (selected.type == EncounterType.Shop)
        state.roomsSinceLastShop = 0;
    else
        state.roomsSinceLastShop++;

    if (selected.type == EncounterType.Healing)
        state.roomsSinceLastHealing = 0;
    else
        state.roomsSinceLastHealing++;

    state.Advance(stage);
}

6. Reward Flow: Boons, Gold, Upgrades

Hades also tightly controls reward flow:

  • Not too many overpowered boons
  • Balanced economic growth (gold)
  • Occasional “spike” rewards

You can create a simple RewardProfile ScriptableObject and a RewardGenerator:

public enum RewardType
{
    Boon,
    Gold,
    MaxHealth,
    Reroll,
    MetaCurrency,
    None
}

[System.Serializable]
public class RewardProfile
{
    public RewardType primary;
    public RewardType secondary;
    public int value;
}

Then attach a reward profile per EncounterDefinition or generate one dynamically per room based on run state.


7. Room Choices & Hades‑Style Door Icons

A key part of Hades is that you see the next reward type on each door, and choose which room to enter.

We can generate 2–3 candidates for the next encounters instead of just one.

public List&lt;EncounterDefinition&gt; GetNextEncounterChoices(int count)
{
    var choices = new List&lt;EncounterDefinition&gt;();

    // Make a temporary copy of state so choices don't mutate the real run state.
    var tempState = CloneState(state);

    for (int i = 0; i < count; i++)
    {
        var stage = runDefinition.stages[tempState.currentStageIndex];
        bool isLastRoom = (tempState.roomIndexInStage >= stage.roomCount - 1);

        EncounterDefinition enc;

        if (isLastRoom)
        {
            enc = stage.bossEncounter;
        }
        else
        {
            var pool = FilterByStage(stage);
            var weighted = ApplyFlowRules(pool, stage, tempState);
            enc = WeightedRandom(weighted, tempState);
            UpdateStatePostSelection(enc, stage, tempState);
        }

        choices.Add(enc);
    }

    return choices;
}

private RunState CloneState(RunState s)
{
    return new RunState
    {
        currentStageIndex = s.currentStageIndex,
        roomIndexInStage = s.roomIndexInStage,
        currentDangerLevel = s.currentDangerLevel,
        combatStreak = s.combatStreak,
        nonCombatStreak = s.nonCombatStreak,
        roomsSinceLastShop = s.roomsSinceLastShop,
        roomsSinceLastHealing = s.roomsSinceLastHealing,
        bossDefeated = s.bossDefeated
    };
}

You can then show the EncounterDefinition’s icon/reward info on each door UI.


8. Integrating With Actual Rooms

The encounter flow system doesn’t care about geometry. It just says:

  • Next encounter type = Combat / Shop / Treasure / Boss
  • Difficulty / tags / reward profile

Your level system then:

  1. Reads the selected EncounterDefinition
  2. Chooses a compatible room prefab
  3. Spawns enemies / shop / NPC / objects according to the encounter tags
  4. Marks the room as completed, then asks FlowGenerator for the next encounter

9. Advanced Extensions (Very Powerful)

To get closer to Hades‑tier richness, consider adding:

9.1. Narrative Conditions

  • Disallow certain encounters until story flags are set
  • Guarantee special narrative rooms every X runs
public interface IRunCondition
{
    bool IsMet(RunState state, PlayerMeta meta);
}

Attach conditions to EncounterDefinition so encounters appear only under specific meta‑progress states.

9.2. Meta‑Progress & Heat‑Like System

  • Add a “Heat” rating that increases difficulty or encounter frequency
  • More elites, “curse” rooms, or challenge modifiers

9.3. Dynamic Difficulty Adjustment

  • Track how often the player dies per stage
  • Adjust encounter weights to be slightly more forgiving or more punishing

10. Summary

A Hades‑style encounter flow generator is more than random rooms; it’s a stateful, rule‑driven system that controls:

  • Encounter types and pacing
  • Difficulty progression
  • Reward distribution
  • Player choices via visible future rewards

By separating RunDefinition, RunStage, EncounterDefinition, and a RunState‑driven rule engine, you get a flexible, designer‑friendly system that can produce highly replayable runs with strong, curated feel — just like Hades.

From here, you can connect this system to your procedural room generator, enemy spawn system, and meta‑progression layer to build a full roguelite experience.

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *


Skip to toolbar