
AAA‑Style Procedural Level Generation Pipeline (Full Guide)
August 19, 2026
GPU Lightmap Baking in Unity (Custom Baking Tools Guide)
August 22, 2026One 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:
- A lightweight Encounter Type system
- A Run Timeline (sequence of stages)
- A Rule‑based Encounter Selector
- 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<WeightedEncounter> ApplyFlowRules(
List<EncounterDefinition> pool,
RunStage stage,
RunState state)
{
var result = new List<WeightedEncounter>();
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<WeightedEncounter> 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<EncounterDefinition> GetNextEncounterChoices(int count)
{
var choices = new List<EncounterDefinition>();
// 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:
- Reads the selected
EncounterDefinition - Chooses a compatible room prefab
- Spawns enemies / shop / NPC / objects according to the encounter tags
- 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.









