Dungeon System¶
Summary: The Dungeon System uses topology-based procedural generation to create deterministic dungeon layouts.
UDungeonSubsystemmanages runtime state whileUEternalDungeonGeneratorproducesFDungeonInstancefromUDomainDungeonConfig. The plugin bridge (AEternalDungeonActor) extends the ProceduralDungeon plugin to handle spatial room placement from our abstract topology.
Table of Contents¶
- Why Topology-Based Generation
- Architecture Overview
- Core Concepts
- Topology Templates
- Room Configuration
- Landmark / POI (Sub-Dungeons)
- Remnant Realm Room Injection
- Environment Tiles / Forest Domain Generation
- Generation Pipeline
- Plugin Bridge
- Runtime State Management
- WorldMap Integration
- Public Contracts
- Source References
- Related Systems
- Recent Changes
Why Topology-Based Generation¶
Design Philosophy¶
Our dungeon system separates what rooms exist from where they are placed:
| Concern | Our System | Plugin System |
|---|---|---|
| Room roles, slots, count | Topology template | N/A |
| Room connections | Topology edges | Door matching |
| Physical placement | N/A | Spatial algorithm |
| Level streaming | N/A | Built-in |
Key Benefits¶
- Determinism - Same seed + config = identical dungeon (critical for multiplayer)
- Designer Control - Topologies define critical path, branching, boss placement
- Domain Identity - Each domain has distinct room pools and topology options
- Clean Separation - Our topology logic, plugin's spatial logic
Standard vs HQ Dungeons¶
| Aspect | Standard Dungeon | HQ Dungeon |
|---|---|---|
| Terminal Room | Exit | Boss |
| Cleared When | Exit reached | Boss defeated |
| Triggers | Node cleared | Node cleared + Domain liberated |
| Topologies | StandardTopologies |
HQTopologies |
Architecture Overview¶
┌──────────────────────────────────────────────────────────────────┐
│ UDomainDungeonConfig │
│ (Data Asset - Per Domain) │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ StandardTopologies │ │ HQTopologies │ │
│ │ (Exit endings) │ │ (Boss endings) │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │
│ ┌─────────┴───────────────────────┴──────────┐ │
│ │ RoomPools: TMap<FGameplayTag, Pool> │ │
│ │ (Room.Start, Room.Combat, Room.Boss, ...) │ │
│ └────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ UEternalDungeonGenerator │
│ (Pure Function Object) │
│ GenerateDungeon(Config, Seed, bIsHQ) → FDungeonInstance │
└───────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ UDungeonSubsystem │
│ (GameInstance Subsystem - Runtime Manager) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ FDungeonInstance CurrentDungeon │ │
│ │ • TArray<FDungeonFloor> Floors │ │
│ │ • FGuid StartRoomID, BossRoomID, ExitRoomID │ │
│ │ • FGuid CurrentRoomID │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
│ OnDungeonGenerated
▼
┌──────────────────────────────────────────────────────────────────┐
│ AEternalDungeonActor │
│ (Plugin Bridge - Extends ADungeonGenerator) │
│ • ChooseFirstRoomData() → Start room from topology │
│ • ChooseNextRoomData() → Next room based on connections │
│ • OnPostGeneration() → Register room mappings │
└──────────────────────────────────────────────────────────────────┘
Component Ownership¶
| Component | Owner | Why |
|---|---|---|
UDungeonSubsystem |
GameInstance | Persists across level transitions |
UEternalDungeonGenerator |
DungeonSubsystem | Stateless generation logic |
AEternalDungeonActor |
World | Plugin-spawned, per-dungeon |
Core Concepts¶
FDungeonRoom¶
A single room instance within a generated dungeon.
| Field | Type | Purpose |
|---|---|---|
RoomID |
FGuid | Unique identifier |
TopologyNodeIndex |
int32 | Link to source topology node |
Role |
ERoomRole | Structural role: Start, Normal, Terminal |
SlotTag |
FGameplayTag | Content slot (Room.*), selects the room pool |
State |
ERoomState | Locked, Available, Current, Cleared |
RoomSeed |
int32 | Deterministic content generation |
ConnectedRoomIDs |
TArray\<FGuid> | Adjacent rooms |
RoomLevel |
TSoftObjectPtr\<UWorld> | Level to load |
MapPosition |
FVector2D | UI display position |
FloorIndex |
int32 | Vertical organization |
ERoomRole + SlotTag (Content Model)¶
The content model separates structure from content. ERoomRole describes only where a node sits in the
graph; what fills the node is a FGameplayTag slot (Room.*) resolved against the domain config's tag-addressed
pool map. Adding a new content kind is a data change (new tag + new pool entry), not a code change.
| Role | Description |
|---|---|
Start |
Entry room — exactly one per topology, no input pin |
Normal |
Everything in between |
Terminal |
Boss (HQ) or exit (standard) — no output pin |
Junction and Optional are not roles: junction is derived (>= 3 connections), optional is
bOnCriticalPath == false.
Well-known slot tags currently in use (Room.Start, Room.Combat, Room.Treasure, Room.Junction,
Room.Exit, Room.Boss, Room.Optional, Room.RemnantRealm, Room.Landmark) are just tags — the generator
does an exact-match lookup into UDomainDungeonConfig::RoomPools and attaches no code meaning to them, with
three exceptions: Room.Boss terminal validation, Room.Landmark placement constraints, and
Room.RemnantRealm (injected rather than authored — see
Remnant Realm Room Injection).
ERoomState¶
┌──────────────────┐
│ Locked │ Cannot enter, doors sealed
└────────┬─────────┘
│ Connected room cleared
▼
┌──────────────────┐
│ Available │ Can be entered
└────────┬─────────┘
│ Player enters
▼
┌──────────────────┐
│ Current │ Player is inside
└────────┬─────────┘
│ Objectives complete
▼
┌──────────────────┐
│ Cleared │ Completed, can revisit
└──────────────────┘
FDungeonFloor¶
Groups rooms by vertical position (depth from start).
| Field | Type | Purpose |
|---|---|---|
FloorIndex |
int32 | 0 = top/start |
Rooms |
TArray\<FDungeonRoom> | Rooms at this depth |
DomainTag |
FGameplayTag | Theming tag |
FDungeonInstance¶
Complete generated dungeon state.
| Field | Type | Purpose |
|---|---|---|
DungeonID |
FGuid | Unique run identifier |
DungeonSeed |
int32 | Generation seed |
Floors |
TArray\<FDungeonFloor> | All floors |
StartRoomID |
FGuid | Entry room |
BossRoomID |
FGuid | HQ dungeons only |
ExitRoomID |
FGuid | Standard dungeons only |
bIsDomainHQ |
bool | Has boss vs exit |
CurrentRoomID |
FGuid | Player's current room |
Topology Templates¶
FTopologyNode¶
A single node in an abstract topology graph.
| Field | Type | Purpose |
|---|---|---|
NodeIndex |
int32 | Unique index (0, 1, 2...) |
Role |
ERoomRole | Structural role (Start/Normal/Terminal) |
SlotTag |
FGameplayTag | Content slot (Room.*) — which domain pool fills this node |
ConnectedNodeIndices |
TArray\<int32> | Edges to other nodes |
Depth |
int32 | BFS distance from start |
bOnCriticalPath |
bool | Required to reach terminal |
FDungeonTopology¶
Complete topology template.
| Field | Type | Purpose |
|---|---|---|
TopologyName |
FName | Debug identifier |
Nodes |
TArray\<FTopologyNode> | All graph nodes |
StartNodeIndex |
int32 | Entry point (default: 0) |
bIsHQTopology |
bool | Has boss vs exit |
BossNodeIndex |
int32 | HQ only, terminal |
ExitNodeIndex |
int32 | Standard only, terminal |
Example Topology¶
Standard Dungeon (Linear with Branch):
[Start] Node 0: Start / Room.Start
│ Node 1: Normal / Room.Combat (critical)
▼ Node 2: Normal / Room.Combat (critical)
[Combat] Node 3: Normal / Room.Treasure (optional)
│ Node 4: Normal / Room.Junction (critical)
▼ Node 5: Normal / Room.Combat (critical)
[Combat]──────[Treasure] Node 6: Terminal / Room.Exit (critical)
│
▼
[Junction]
│
▼
[Combat]
│
▼
[Exit]
Connections:
0 → 1, 1 → 2, 2 → 3, 2 → 4, 4 → 5, 5 → 6
Topology Validation¶
| Check | Purpose |
|---|---|
IsValid() |
Basic structure validation |
AreAllNodesReachable() |
No orphaned nodes |
CalculateDepths() |
BFS depth assignment |
GetMaxDepth() |
Floor count calculation |
Room Configuration¶
UDomainDungeonConfig¶
Per-domain configuration combining topologies and room pools.
| Category | Fields |
|---|---|
| Identity | DomainTag, DomainName, MapMaterial |
| Topologies | StandardTopologies, HQTopologies |
| Room Pools | RoomPools — TMap<FGameplayTag, FEternalRoomPool> keyed by content slot tag (Room.*); LandmarkMinCellsFromStart |
| Remnant Realm | RemnantRealmChance (0..1, default 0 — rolled off the descent seed, overridable per world-map node), RemnantMinCellsFromStart, RemnantMaxCellsFromStart (0 = no maximum) |
| Encounters | DefaultEncounter, SlotEncounters (per-slot-tag overrides), BossEncounter, DefaultContainerLootTable |
| Modifiers | ModifierPool (UDungeonModifierPoolDataAsset) |
FEternalRoomPool is currently just a room array; the struct is the seam where per-entry weight and
min/max-occurrence knobs land later. Adding a content kind = adding a RoomPools entry — no code change.
Room Pool Resolution¶
SelectRoomTemplate(SlotTag, Config, Random)
│
├─ Get pool: Config->GetRoomsBySlot(SlotTag) (exact tag match into RoomPools)
│
├─ Pool empty?
│ └─ Return nullptr (generation fails)
│
└─ Return Random selection from pool
Encounters resolve the same way: GetEncounterForSlot(SlotTag) checks SlotEncounters, falling back to
DefaultEncounter when the slot has no override.
UEternalRoomData¶
Extends plugin's URoomData with our metadata. Each room in a pool is a data asset containing:
- Level reference
- Door socket configuration
- SlotTag (self-identification + editor validation; resolves the spawner's encounter)
- Sub-dungeon entrance config (Landmark rooms only — see Landmark / POI)
- Visual theming
Room Resolution Helper¶
UEternalRoomData::GetRoomDataForActor(const AActor* Actor) is the canonical way to resolve which room an
actor is standing in. It walks from the actor to its owning ARoomLevel (the plugin's room-level script) and
returns that level's UEternalRoomData, or nullptr if the actor is not inside a generated room. Prefer this
over manual level/room lookups so encounter, portal, and gate actors bind to a room consistently.
Landmark / POI (Sub-Dungeons)¶
A Landmark is an off-path POI room (Room.Landmark slot tag) whose entrance descends into its own
sub-dungeon — a full dungeon generated from a separate UDomainDungeonConfig. It is Option A (run-ender):
the sub-dungeon's AEndPortalActor returns the player to the world map and marks the current (parent) node
cleared; there is no return trip up through the parent dungeon.
Authoring Model¶
"What's behind this entrance" is authored on the room data, not the placed actor. The Landmark room's
UEternalRoomData carries the sub-dungeon configuration:
| Field | Type | Purpose |
|---|---|---|
SubDungeonConfig |
UDomainDungeonConfig | The dungeon this entrance descends into |
bSubDungeonIsHQ |
bool | Sub-dungeon ends in a boss (HQ topology) vs exit |
bSubDungeonRequiresKey |
bool | Descent consumes a key item |
SubDungeonKeyItemId |
FPrimaryAssetId | Key item consumed on descent |
ASubDungeonEntrance¶
An interactable actor (cave mouth, castle gate, stairs, portal) placed inside the Landmark room level. It needs no per-instance config — it reads its owning room:
Player interacts (server-only)
│
├─ GetOwningRoomData() — resolve UEternalRoomData via GetRoomDataForActor
│
├─ TryConsumeKey() — no key required, or key found and consumed from interactor's inventory
│
└─ WorldMapSubsystem->EnterSubDungeon(SubDungeonConfig, bSubDungeonIsHQ)
│ reuses the dungeon-entry flow (transition + generate)
│ WITHOUT changing the current world-map node
▼
Sub-dungeon's AEndPortalActor → back to world map, parent node marked cleared
Placement Constraint¶
UDomainDungeonConfig::LandmarkMinCellsFromStart sets a minimum cell distance between the Room.Start room
and any Room.Landmark room. During generation the server rejects and retries layouts that place a landmark
closer than this (0 = no constraint).
Reference: ImplementationDocs/LandmarkPOI.plan.md.
Remnant Realm Room Injection¶
Room.RemnantRealm is the one content slot that no topology template authors. It is injected into the
topology-derived room set after Step 3, so a domain can grow an optional Remnant portal room on any of its
existing topologies without forking them.
Roll¶
UDungeonSubsystem::ResolveRemnantRealmChance resolves the chance before generation (node override →
domain RemnantRealmChance), and UEternalDungeonGenerator::TryInjectRemnantRealmRoom rolls it.
The roll runs on its own FRandomStream, salted off the descent seed with the hash of the slot tag's
string (never the FGameplayTag/FName, whose hash varies per process and would break seed replay).
Because the stream is separate, the roll neither consumes nor perturbs the layout stream — a domain that
turns Remnants on does not reshuffle its existing dungeons — and the same descent always answers the same way.
Host Selection and Retag¶
The injected room hangs off a host road room, not a free field cell:
| Step | Rule |
|---|---|
| Pool guard | Both Room.RemnantRealm and Room.Junction pools must be non-empty, or injection is skipped entirely (no remnant beats a dungeon that retries itself to death) |
| Host candidates | ERoomRole::Normal rooms with slot Room.Combat and exactly 2 connections — plain road segments. Critical-path candidates preferred; off-path road rooms are the fallback so a guaranteed chance still lands on all-optional topologies |
| Pick | Deterministic — topology node order, indexed by the Remnant stream |
| Injection | New FDungeonRoom on the host's floor, TopologyNodeIndex = INDEX_NONE, bidirectionally connected to the host |
| Host retag | The host becomes 3-way, so its slot is retagged to Room.Junction — only the junction pool has a third door socket and fork art. Side effect: the host's encounter now resolves through the junction slot |
Never hosted by Start, Terminal, an existing junction, or any slot carrying its own authored content (Landmark, Treasure, …) — the retag would silently erase that content and change its encounter.
Distance Band¶
AEternalDungeonActor::SatisfiesRemnantPlacement gates the spatial result the same way landmarks are
gated: after the plugin places rooms, the Manhattan distance from the Start room to each placed
Room.RemnantRealm room must sit inside [RemnantMinCellsFromStart, RemnantMaxCellsFromStart]. Layouts
outside the band are rejected and retried, not relaxed.
Keep the band generous. The remnant's graph host is fixed per seed, so a band the layout cannot reach fails the whole dungeon once the plugin exhausts its retry budget.
Realm World Placement¶
The room hosts an ARemnantPortalActor; the realm itself is a sub-level streamed into the persistent world
at URemnantRealmSubsystem's reserved world offset (0, 500000, 0) — lateral and far beyond any generated
dungeon's extent, at the same Z so height-dependent sky/fog reads as authored. A portal that leaves
RealmSpawnLocation at zero resolves to that offset, which is why procedurally placed portals need no
hand-authored world position. A future multi-realm allocator hands out reserved-offset + N × stride slots
from the same origin. See Remnant System.
Environment Tiles / Forest Domain Generation¶
Dungeon generation runs as a two-phase pipeline. The same pipeline serves sealed dungeons and open D3/PoE-style zones (first target: a Forest domain); "open vs closed" is authored tile content plus a fill policy, not a separate algorithm.
| Phase | What | Where |
|---|---|---|
| A — Content placement | Plugin door-walks the topology graph and places designer-authored CONTENT rooms (Start, Combat, Treasure, Boss, Exit …), collision-checked and streamed. Unchanged. | ChooseFirstRoomData / ChooseNextRoomData |
| B — Space fill | Places doorless environment tiles (Filler interior, Edge border) into empty grid cells around the content footprint so the world reads as one continuous place instead of floating slabs. | AEternalDungeonActor fill pass |
Fill runs server-only during generation, before the Load state (invoked from the generator's
OnStateEnd(Generation) hook, once). Tiles are placed as first-class plugin Graph rooms, so they ride the
existing Graph subobject replication and clients stream them from the replicated list — determinism is by
construction, not by re-rolling a seed.
UEternalEnvironmentTile¶
A doorless space tile, subclassed from the plugin's URoomData so it rides AddRoomToDungeon natively, but
kept conceptually separate from content rooms (own pools, own canvas node, never a Room.* content slot).
| Field | Type | Purpose |
|---|---|---|
TileKind |
ETileKind |
Filler (interior walkable ground) or Edge (blocking border) |
DisplayName |
FText | Editor/UI label |
EdgeKeyNorth/East/South/West |
FGameplayTag | Reserved for future edge-key matching / WFC (coherent rivers, coastlines); unused in v1 |
ETileKind = { Filler, Edge }; EEdgeStyle = { Treeline, Cliff, Water } selects which authored edge
variant a border prefers. The tile overrides IsDataValid to drop the plugin's >=1-door requirement
(environment tiles are intentionally doorless) while keeping the other validations.
The exemption is environment tiles only. Content rooms — Room.RemnantRealm included — are
door-connected and validate normally; UEternalRoomData::IsDataValid adds only a SlotTag-is-set check on
top of the plugin's.
FFillPolicy (config knobs)¶
The per-domain policy for Phase B — the "field generator" knobs. Global scalars only; no spatial/directional rules (those belong to edge keys / future WFC).
| Field | Default | Purpose |
|---|---|---|
bFillEnclosedGaps |
true | Always fill cells fully enclosed by occupied neighbors (fixes self-fold voids), regardless of FillRadius |
FillRadius |
0 | Cells within this many tiles of any content room become walkable Filler (0 = enclosed gaps only; 1-2 = road shoulders; large = open wander-field) |
BorderThickness |
1 | Tiles of blocking Edge placed as a ring beyond the filled field |
EdgeStyle |
Treeline | Which EEdgeStyle variant to prefer from the domain's Edge pool |
FillRadius + BorderThickness span the whole spectrum: tight corridor -> road shoulders -> open field with
a guiding road (PoE model), with one placement strategy.
UDomainDungeonConfig Environment Fields¶
| Field | Type | Purpose |
|---|---|---|
FillerTiles |
TArray\<UEternalEnvironmentTile*> | Interior walkable ground pool |
EdgeTiles |
TArray\<UEternalEnvironmentTile*> | Blocking border pool (becomes the playable bounds) |
FillPolicy |
FFillPolicy | Fill radius / border / edge style |
GetEnvironmentTiles(Kind) |
accessor | Returns the Filler or Edge pool for a given ETileKind |
Fill Data Flow¶
AEternalDungeonActor (server) generates
│
├─ Phase A: plugin door-walks CONTENT rooms (ChooseFirst/NextRoomData)
│
└─ OnStateEnd(Generation): FillEnvironmentTiles()
│ build Occupied from CONTENT rooms only (Cast-skip env tiles)
│
├─ Pass 1 (Filler): dilate(Occupied, FillRadius) + enclosed gaps
│
└─ Pass 2 (Edge): ring around (content ∪ filler), BorderThickness deep
│ each placed via CreateRoomInstance + AddRoomToDungeon(bFailIfNotConnected=false)
▼
plugin replicates Graph subobject → clients SynchronizeRooms / LoadAllRooms
▼
Load state → nav build (content + edge tiles included)
Reference: ImplementationDocs/ForestDomainGeneration.plan.md.
Generation Pipeline¶
5-Step Process¶
GenerateDungeon(Config, Seed, bIsHQ)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: SelectTopology │
│ • Pick from Config->StandardTopologies or HQTopologies │
│ • Random selection using FRandomStream(Seed) │
│ • Call CalculateDepths() for floor assignment │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: CreateRoomsFromTopology │
│ • For each FTopologyNode: │
│ • Create FDungeonRoom with new FGuid │
│ • Copy Role + SlotTag from node │
│ • SelectRoomTemplate(Node.SlotTag, Config, Random) │
│ • Assign RoomSeed = Random.RandHelper() │
│ • Set FloorIndex from Node.Depth │
│ • Group into FDungeonFloor by depth │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: BuildRoomConnections │
│ • For each topology edge (NodeA → NodeB): │
│ • Get RoomA.RoomID, RoomB.RoomID │
│ • Add bidirectional connection │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: CalculateMapPositions │
│ • Assign FVector2D positions for UI display │
│ • X: Distribute rooms horizontally within floor │
│ • Y: Based on FloorIndex │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 5: InitializeRoomStates │
│ • StartRoom → Available │
│ • All other rooms → Locked │
│ • Set CurrentRoomID = InvalidGuid (player not yet inside) │
└─────────────────────────────────────────────────────────────────┘
Determinism Guarantee¶
Same inputs always produce identical output:
| Input | Effect |
|---|---|
Seed |
Initializes FRandomStream (derived from node identity + clear count — FWorldMapNode::GetDescentSeed()) |
Config |
Provides topology + room pools |
bIsHQ |
Selects Standard vs HQ topologies |
For multiplayer: only the server generates. Clients never regenerate — they receive the plugin's
replicated UDungeonGraph (room instances, positions, connections) and stream the same levels the
server placed. The seed matters for reproducibility (re-entering a node re-rolls it via clear count),
not for client sync.
Plugin Bridge¶
AEternalDungeonActor¶
Extends ADungeonGenerator from ProceduralDungeon plugin to use our topology.
Our System Plugin System
┌────────────────────┐ ┌────────────────────┐
│ FDungeonInstance │ │ ADungeonGenerator │
│ • FDungeonRoom │──────────────│ • URoom (spatial) │
│ • Connections │ Bridged by │ • Doors │
│ • Roles/SlotTags │ EternalDung │ • Level Streaming │
└────────────────────┘ eonActor └────────────────────┘
Key Overrides¶
| Override | Purpose |
|---|---|
ChooseFirstRoomData() |
Return Start room's UEternalRoomData |
ChooseNextRoomData() |
Return connected room based on topology |
IsValidDungeon() |
All topology rooms placed |
ContinueToAddRoom() |
Rooms remaining in queue |
ChooseDoor() |
Spawn AEternalDoor between rooms |
OnPostGeneration() |
Register URoom ↔ FGuid mappings |
Room Mapping Flow¶
OnDungeonGenerated(Topology)
│
▼
InitializeFromTopology()
│ Build placement queue (BFS from start)
▼
Plugin calls ChooseFirstRoomData() / ChooseNextRoomData()
│ We return URoomData from our pools
│ Plugin handles spatial placement
▼
OnPostGeneration()
│
▼
For each placed URoom:
│
▼
DungeonSubsystem->RegisterPluginRoom(URoom, FGuid)
│
▼
OnDungeonReady broadcast
Bidirectional Mapping¶
| Direction | Map | Use Case |
|---|---|---|
PluginRoomToRoomID |
URoom* → FGuid | Door transitions |
RoomIDToPluginRoom |
FGuid → URoom* | State queries |
Runtime State Management¶
Room State Transitions¶
TryEnterRoom(RoomID)
│
├─ CanEnterRoom(RoomID)?
│ ├─ Room.State == Available or Cleared → YES
│ └─ Otherwise → NO, return false
│
├─ Previous room exists?
│ └─ Keep as Cleared
│
├─ SetRoomState(RoomID, Current)
│
└─ OnCurrentRoomChanged broadcast
MarkRoomCleared(RoomID)
│
├─ SetRoomState(RoomID, Cleared)
│
├─ UnlockConnectedRooms(RoomID)
│ │
│ └─ For each connected room:
│ If Locked → SetRoomState(Available)
│
└─ Is terminal room (Boss/Exit)?
└─ OnDungeonCleared broadcast
Area Context¶
When entering a dungeon node, WorldMapSubsystem sets context:
| Property | Source | Used By |
|---|---|---|
CurrentAreaLevel |
Node.Depth | Enemy scaling |
CurrentEncounter |
WorldMapData resolution | Spawners |
CurrentAreaModifiers |
Node.RolledModifiers | DungeonModifierComponent |
Area modifiers are applied to each player via UDungeonModifierComponent during the spawn flow. The modifier pool is also registered with ModifierSubsystem so that GetDefinition() works for both item and dungeon modifiers.
WorldMap Integration¶
Entry Flow¶
WorldMapSubsystem::TravelToNode(DungeonNode)
│
├─ Node.GeneratesDungeon() → true
│
├─ GetEffectiveDungeonConfig(Node)
│
├─ SetAreaContext(Depth, Encounter, RolledModifiers)
│ └─ Registers modifier pool with ModifierSubsystem
│
└─ DungeonSubsystem->GenerateDungeon(Config, bIsHQ)
│
▼
OnDungeonGenerated → AEternalDungeonActor starts generation
│
▼
OnDungeonReady → Player can enter Start room
Exit Flow¶
Player clears Exit/Boss room
│
▼
MarkRoomCleared(TerminalRoomID)
│
▼
OnDungeonCleared broadcast
│
▼
WorldMapSubsystem->MarkNodeCleared(CurrentNodeID)
│
├─ UpdateUnlockedNodes() → adjacent nodes become Available
│
└─ (If HQ) LiberateDomain(DomainTag)
Fixed Level Unloading¶
When traveling to a Hub/Camp, dungeons are unloaded:
AEternalDungeonActor::OnFixedLevelEntered(NodeID, Level)
│
▼
Destroy generated dungeon
│
▼
DungeonSubsystem->ClearDungeon()
Public Contracts¶
Methods¶
| Method | Parameters | Purpose |
|---|---|---|
GenerateDungeon |
Config, bIsHQ |
Generate with random seed |
GenerateDungeonWithSeed |
Config, Seed, bIsHQ |
Generate with specific seed |
ClearDungeon |
- | Clear current dungeon |
SetAreaContext |
AreaLevel, Encounter, AreaModifiers |
Set spawner/modifier context |
ApplyAreaModifiersToPlayer |
AEternalPlayerCharacter* |
Apply current modifiers via DungeonModifierComponent |
RemoveAreaModifiersFromAllPlayers |
- | Clear modifiers from all players |
HasActiveDungeon |
- | Check if dungeon exists |
GetDungeon |
- | Get current FDungeonInstance |
GetCurrentRoom |
- | Get player's current room |
GetRoom |
RoomID |
Get room by ID |
GetAccessibleRoomIDs |
- | Get enterable room IDs |
CanEnterRoom |
RoomID |
Check if room is accessible |
TryEnterRoom |
RoomID |
Attempt room transition |
MarkRoomCleared |
RoomID |
Complete a room |
IsDungeonCleared |
- | Check if terminal reached |
RegisterPluginRoom |
URoom*, FGuid |
Map plugin room to our ID |
GetRoomIDFromPluginRoom |
URoom* |
Lookup our ID from plugin room |
GetPluginRoomFromRoomID |
FGuid |
Lookup plugin room from our ID |
OnPlayerEnteredPluginRoom |
URoom* |
Handle door transition |
GetRoomIDForLevel |
ULevel* |
Resolve RoomID from streaming level instance |
Delegates¶
| Delegate | Payload | When Fired |
|---|---|---|
OnDungeonGenerated |
FDungeonInstance& |
Topology created, rooms not streamed |
OnDungeonReady |
- | All rooms loaded, ready to play |
OnRoomStateChanged |
FGuid, ERoomState |
Any room state changes |
OnCurrentRoomChanged |
FGuid |
Player entered new room |
OnDungeonCleared |
- | Terminal room (Exit/Boss) cleared |
Source References¶
Core Types¶
FDungeonRoom- Source/ProjectEternal/Public/Dungeon/DungeonTypes.h:39FDungeonFloor- Source/ProjectEternal/Public/Dungeon/DungeonTypes.h:102FDungeonInstance- Source/ProjectEternal/Public/Dungeon/DungeonTypes.h:127ERoomRole- Source/ProjectEternal/Public/Dungeon/DungeonTypes.h:15ERoomState- Source/ProjectEternal/Public/Dungeon/DungeonTypes.h:26
Topology¶
FTopologyNode- Source/ProjectEternal/Public/Dungeon/DungeonTopology.h:13FDungeonTopology- Source/ProjectEternal/Public/Dungeon/DungeonTopology.h:49
Subsystem & Generator¶
UDungeonSubsystem- Source/ProjectEternal/Public/Dungeon/DungeonSubsystem.h:27UEternalDungeonGenerator- Source/ProjectEternal/Public/Dungeon/EternalDungeonGenerator.h:19
Plugin Bridge¶
AEternalDungeonActor- Source/ProjectEternal/Public/Dungeon/EternalDungeonActor.h:23
Configuration¶
UDomainDungeonConfig- Source/ProjectEternal/Public/Dungeon/DataAssets/DomainDungeonConfig.h:20UDungeonModifierPoolDataAsset- Source/ProjectEternal/Public/Dungeon/DataAssets/DungeonModifierPoolDataAsset.hRemnantRealmChance/RemnantMinCellsFromStart/RemnantMaxCellsFromStart- Source/ProjectEternal/Public/Dungeon/DataAssets/DomainDungeonConfig.h:112
Remnant Realm Injection¶
UEternalDungeonGenerator::TryInjectRemnantRealmRoom- Source/ProjectEternal/Private/Dungeon/EternalDungeonGenerator.cpp:243UDungeonSubsystem::ResolveRemnantRealmChance- Source/ProjectEternal/Private/Dungeon/DungeonSubsystem.cpp:97AEternalDungeonActor::SatisfiesRemnantPlacement- Source/ProjectEternal/Private/Dungeon/EternalDungeonActor.cpp:617URemnantRealmSubsystemreserved offset - Source/ProjectEternal/Private/Remnant/Subsystems/RemnantRealmSubsystem.cpp:17
Room & Environment Tiles¶
UEternalRoomData(+GetRoomDataForActor) - Source/ProjectEternal/Public/Dungeon/DataAssets/EternalRoomData.hUEternalEnvironmentTile/ETileKind/EEdgeStyle/FFillPolicy- Source/ProjectEternal/Public/Dungeon/DataAssets/EternalEnvironmentTile.hFillerTiles/EdgeTiles/FillPolicy/GetEnvironmentTiles- Source/ProjectEternal/Public/Dungeon/DataAssets/DomainDungeonConfig.h:88
Dungeon Actors¶
ARoomEncounterActor- Source/ProjectEternal/Public/Dungeon/Actors/RoomEncounterActor.hAEndPortalActor- Source/ProjectEternal/Public/Dungeon/Actors/EndPortalActor.hAEncounterGateActor- Source/ProjectEternal/Public/Dungeon/Actors/EncounterGateActor.hASubDungeonEntrance- Source/ProjectEternal/Public/Dungeon/Actors/SubDungeonEntrance.h
Area Modifiers¶
UDungeonModifierComponent- Source/ProjectEternal/Public/Dungeon/Components/DungeonModifierComponent.h
Related Systems¶
- World Map System - Triggers dungeon generation from node travel
- Spawning System - Uses encounter data for enemy spawning
- Enemy AI - AI behavior in dungeon rooms
- Replication Overview - Seed-based multiplayer sync
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-06 | Remnant Realm rooms are injected, door-connected content | TryInjectRemnantRealmRoom hangs a Room.RemnantRealm room off a 2-edge road host (host retagged to Room.Junction), rolled on its own descent-seed stream; SatisfiesRemnantPlacement rejects and retries layouts outside RemnantMin/MaxCellsFromStart. Supersedes the 2026-07-29 row below: there is no door-count exemption for Room.RemnantRealm — only UEternalEnvironmentTile is exempt |
| 2026-07-29 | Enemy density policy on DomainDungeonConfig | FEnemyDensityPolicy (EnvironmentSpawnerChance + MaxEnvironmentEnemies) gates environment-tile spawners in MarkRoomsReady; content-room spawners exempt. See Enemy Performance |
| 2026-07-29 | Room.RemnantRealm exempt from ≥1-door validation | Remnant realm rooms are space-fill tiles placed without doors; the door-count validator now skips them |
| 2026-07-03 | Content model: ERoomRole + SlotTag | ERoomType/GetRoomsByType removed; structure = ERoomRole (Start/Normal/Terminal), content = FGameplayTag slot (Room.*) resolved via tag-addressed RoomPools map (GetRoomsBySlot); per-slot encounter overrides (SlotEncounters) |
| 2026-07-03 | Landmark / POI sub-dungeons | Room.Landmark slot rooms host ASubDungeonEntrance descending into room-data-authored SubDungeonConfig via EnterSubDungeon (run-ender, optional key consumption); LandmarkMinCellsFromStart placement constraint |
| 2026-06-29 | Two-phase generation (Forest domain) | Phase B space-fill places doorless UEternalEnvironmentTile (Filler/Edge) as server-authored plugin Graph rooms around the content footprint; open vs closed zones are data (FFillPolicy + tile pools), one pipeline |
| 2026-06-29 | Environment-tile config knobs | FillerTiles/EdgeTiles/FillPolicy + GetEnvironmentTiles() on UDomainDungeonConfig; FillRadius+BorderThickness span tight corridor → open field-with-road |
| 2026-06-29 | GetRoomDataForActor | Canonical room-resolution helper on UEternalRoomData (actor → owning ARoomLevel → room data) |
| 2026-03 | Encounter gate actor | AEncounterGateActor blocks room exits until encounter clears, designer-wired to ARoomEncounterActor |
| 2026-03 | Custom shader directory | /ProjectEternal/ virtual shader path registered in FProjectEternalModule for custom material functions |
| 2026-03 | Dungeon completion actors | ARoomEncounterActor tracks encounter, AEndPortalActor handles exit interaction |
| 2026-03 | GetRoomIDForLevel | Resolve RoomID from streaming level instance for encounter/portal room binding |
| 2026-02 | Area modifier system | ModifierPool on config, DungeonModifierComponent applies GAS effects to players |
| 2026-02 | SetAreaContext expanded | Now accepts AreaModifiers, registers pool with ModifierSubsystem |
| 2025-01-17 | Initial documentation | Document dungeon system architecture |