World Map System¶
Summary: The World Map System manages The Chasm's fixed-topology navigation through
UWorldMapSubsystem, a GameInstance subsystem that handles node travel, domain progression, and level streaming. Designers author the map structure viaUWorldMapDatadata assets, while runtime state (node unlocks, domain liberation) is tracked server-authoritatively.
Table of Contents¶
- Why This Design
- Architecture Overview
- Core Concepts
- Node Types and States
- Domain System
- Configuration Resolution
- Level Streaming
- UI Layer (MVVM)
- Dungeon Integration
- Public Contracts
- Source References
- Related Systems
- Recent Changes
Why This Design¶
Designer-Authored vs Procedural¶
The World Map uses a fixed topology approach rather than procedural generation:
| Approach | World Map | Dungeons |
|---|---|---|
| Structure | Designer-authored nodes | Procedurally generated rooms |
| Connections | Fixed, bidirectional | Template-based |
| Configuration | Node override or domain default | Per-domain room pools |
Design Goals¶
- Meaningful Choices - Players choose which nodes to tackle, not which path is randomly available
- Domain Identity - Each region has distinct visual/gameplay identity through domain configuration
- Configuration Inheritance - Nodes inherit from domains unless explicitly overridden
- Server Authority - All state changes originate on server for multiplayer consistency
Architecture Overview¶
┌─────────────────────────────────────────────────────────────────────┐
│ UWorldMapData │
│ (Data Asset - Designer Authored) │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ TArray<FWorldMap- │ │ TArray<FDomain- │ │
│ │ Node> │ │ Region> │ │
│ └─────────┬─────────┘ └─────────┬─────────┘ │
└────────────┼──────────────────────┼─────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ UWorldMapSubsystem │
│ (GameInstance Subsystem - Runtime Manager) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ FWorldMapState │ │
│ │ • TMap<FName, FWorldMapNode> Nodes │ │
│ │ • TMap<FGameplayTag, FDomainRegion> Domains │ │
│ │ • FName CurrentNodeID │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ Methods: LoadWorldMap, TravelToNode, MarkNodeCleared, etc. │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
│ UDungeonSub- │ │ ULevelStreamingDynamic │ │ UWorldMap- │
│ system │ │ (Fixed Level Streaming) │ │ Controller │
│ (Dungeon Gen) │ │ │ │ (MVVM UI) │
└─────────────────┘ └─────────────────────────┘ └─────────────────┘
Component Ownership¶
| Component | Owner | Why |
|---|---|---|
UWorldMapSubsystem |
GameInstance | Persists across level transitions |
UWorldMapController |
UISubsystem | MVVM controller for map widget |
UWorldMapData |
DataAsset | Static designer-authored content |
Core Concepts¶
FWorldMapNode¶
A single location on the world map. Nodes are connected bidirectionally.
| Field | Type | Purpose |
|---|---|---|
NodeID |
FName | Unique identifier |
DisplayName |
FText | Shown to player |
NodeType |
EWorldNodeType | Determines behavior (Hub, Combat, etc.) |
Depth |
int32 | Tier on map (0 = start area) |
DomainTag |
FGameplayTag | Which domain controls this node |
ConnectedNodeIDs |
TArray\<FName> | Adjacent nodes |
MapPosition |
FVector2D | UI position (normalized 0-1) |
Tagline |
FText | Short atmospheric flavor text |
Encounter |
TSoftObjectPtr | Enemy pool override |
DungeonConfigOverride |
TSoftObjectPtr | Topology/room pool override |
ModifierPoolOverride |
TSoftObjectPtr | Area modifier pool override |
RemnantRealmChanceOverride |
float | Chance [0..1] this node's dungeon grows a Remnant Realm room; -1 (default) = inherit the domain config |
ItemLevelRangeOverride |
FIntPoint | Item level range (0,0 = auto from depth) |
FixedLevel |
TSoftObjectPtr\<UWorld> | Level to stream for non-dungeon nodes |
DepthOverride |
int32 | Gameplay-only depth override (0 = use Depth) |
Remnant Realm Chance Resolution¶
RemnantRealmChanceOverride uses a negative sentinel rather than a companion bool:
HasRemnantRealmChanceOverride() is simply >= 0.0f, so -1 means "inherit" and 0.0 is a meaningful
"explicitly never". UWorldMapData::GetEffectiveRemnantRealmChance(Node) resolves it:
GetEffectiveRemnantRealmChance(Node)
│
├─ !Node.GeneratesDungeon() → 0.0
├─ Node.HasRemnantRealmChanceOverride() → Node.RemnantRealmChanceOverride
├─ GetEffectiveDungeonConfig(Node) → Config->RemnantRealmChance
└─ otherwise → 0.0
WorldMap_Main sets Ashwood to 1.0 as a demo guarantee — that node's dungeon always grows a Remnant
Realm room. See Dungeon System for what the chance
actually places.
Unlocking is adjacency-only: clearing a node unlocks its ConnectedNodeIDs. There is no
prerequisite/AND-gating field (RULING-7, WorldSystemsScalingReview.plan.md) — vertical descent plus
HQ liberation already gate progression; add prerequisites only if a specific map design demands them.
FDomainRegion¶
A themed region containing multiple nodes.
| Field | Type | Purpose |
|---|---|---|
DomainTag |
FGameplayTag | Domain identifier (e.g., Domain.Vorath) |
DomainName |
FText | Display name |
DomainColor |
FLinearColor | UI theming color |
MinDepth / MaxDepth |
int32 | Depth range |
HQNodeID |
FName | Domain boss node |
DefaultDungeonConfig |
TSoftObjectPtr | Default config for nodes in domain |
bIsLiberated |
bool | Runtime: boss defeated |
FWorldMapState¶
Runtime state containing all nodes and domains with their current progression.
| Field | Type | Purpose |
|---|---|---|
Nodes |
TMap\<FName, FWorldMapNode> | All nodes with runtime state |
Domains |
TMap\<FGameplayTag, FDomainRegion> | All domains with runtime state |
CurrentNodeID |
FName | Player's current location |
Node Types and States¶
Node Types¶
| Type | Generates Dungeon | Description |
|---|---|---|
Hub |
No | Safe zone, services |
Camp |
No | Rest site / checkpoint |
Start |
No | Map entry point |
Event |
No | Story/dialogue node |
End |
No | Final destination |
Combat |
Yes | Standard dungeon |
Elite |
Yes | Harder dungeon |
Treasure |
Yes | Loot-focused dungeon |
DomainHQ |
Yes | Boss dungeon |
Node State Flow¶
┌──────────────────┐
│ Locked │
│ (No adjacent │
│ cleared node) │
└────────┬─────────┘
│ adjacent node cleared
▼
┌──────────────────┐
│ Available │
│ (Can travel to) │
└────────┬─────────┘
│ TravelToNode()
▼
┌──────────────────┐
│ Current │
│ (Player is here) │
└────────┬─────────┘
│ MarkNodeCleared()
▼
┌──────────────────┐
│ Cleared │
│ (Completed) │
└──────────────────┘
Helper Methods on FWorldMapNode¶
| Method | Returns | Description |
|---|---|---|
IsHubNode() |
bool | Hub, Camp, Start, or Event |
GeneratesDungeon() |
bool | Combat, Elite, Treasure, DomainHQ |
IsHQNode() |
bool | DomainHQ only |
IsSafeZone() |
bool | Hub or Camp |
HasConfigOverride() |
bool | Has explicit dungeon config |
HasModifierPoolOverride() |
bool | Has explicit modifier pool |
GetItemLevelRange() |
FIntPoint | Override or depth-based range |
GetHiddenModifierCount() |
int32 | Unrevealed modifiers |
GetHiddenThreatCount() |
int32 | Unrevealed threats |
IsFullyRevealed() |
bool | All intel revealed |
HasFixedLevel() |
bool | Has level to stream |
Domain System¶
Domain Liberation¶
Domains are "liberated" when their HQ boss is defeated:
TravelToNode(HQNode)
│
▼
Generate HQ Dungeon (with boss)
│
▼
Player defeats boss
│
▼
MarkNodeCleared(HQNode)
│
▼
LiberateDomain(DomainTag)
│
▼
OnDomainLiberated broadcast
Domain Queries¶
| Method | Purpose |
|---|---|
IsDomainLiberated(Tag) |
Check if domain boss defeated |
GetNodesInDomain(Tag) |
Get all nodes in a domain |
GetDomainColorForNode(Node) |
Get theming color |
Configuration Resolution¶
Dungeon-generating nodes inherit configuration from their domain unless overridden:
GetEffectiveDungeonConfig(Node)
│
├─ Node.GeneratesDungeon() == false?
│ └─ Return nullptr
│
├─ Node.DungeonConfigOverride set?
│ └─ Return Node.DungeonConfigOverride
│
└─ Domain.DefaultDungeonConfig set?
└─ Return Domain.DefaultDungeonConfig
└─ Otherwise: Return nullptr
Configuration Source Tracking¶
For debugging/auditing, UWorldMapData provides:
| Method | Returns | Purpose |
|---|---|---|
GetDungeonConfigSource(Node) |
EDungeonConfigSource | None, DomainDefault, NodeOverride, Missing |
GetEncounterSource(Node) |
EEncounterSource | None, DomainDefault, NodeDirect, Missing |
Level Streaming¶
Non-dungeon nodes (Hub, Camp, Event, Start, End) stream a fixed level:
TravelToNode(NodeID)
│
├─ Node.GeneratesDungeon()?
│ └─ Trigger DungeonSubsystem
│
└─ Node.HasFixedLevel()?
│
▼
StreamInFixedLevel(NodeID, Level)
│
├─ Unload previous level (if any)
│
├─ ULevelStreamingDynamic::LoadLevelInstance()
│
└─ OnLevelStreamingComplete()
│
▼
OnFixedLevelStreamingComplete broadcast
Streaming State¶
| Property | Type | Purpose |
|---|---|---|
CurrentStreamedLevel |
ULevelStreamingDynamic* | Active streamed level |
CurrentStreamedNodeID |
FName | Node that owns streamed level |
bIsStreamingInProgress |
bool | Streaming operation active |
Streaming Events¶
| Delegate | When Fired |
|---|---|
OnFixedLevelEntered |
Starting to stream a fixed level |
OnFixedLevelStreamingComplete |
Level fully loaded and visible |
OnFixedLevelUnloaded |
Previous level unloaded |
UI Layer (MVVM)¶
Controller → ViewModel → Widget¶
┌──────────────────────────────────────────────────────────────────┐
│ UWorldMapController │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ • BindToWorldMapSubsystem() │ │
│ │ • SelectNode(NodeID) │ │
│ │ • ConfirmTravel() │ │
│ │ • RefreshAllNodes() │ │
│ └─────────────────────────────────┬──────────────────────────┘ │
└────────────────────────────────────┼─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ UWorldMapViewModel │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ • SelectedNodeID │ │
│ │ • CanTravelToSelected │ │
│ │ • NodeUIDataArray │ │
│ │ • ConnectionDataArray │ │
│ │ • NodeInfoPanelData (modifiers, threats, item level) │ │
│ │ • bNodeInfoPanelVisible │ │
│ └─────────────────────────────────┬──────────────────────────┘ │
└────────────────────────────────────┼─────────────────────────────┘
│ FieldNotify bindings
▼
┌──────────────────────────────────────────────────────────────────┐
│ UMG Widgets │
│ ┌────────────────────────────┐ ┌──────────────────────────┐ │
│ │ UWorldMapWidget │ │ UNodeInfoPanelWidget │ │
│ │ (Nodes, connections, map) │ │ (Modifiers, threats, │ │
│ │ │ │ item level, action btn) │ │
│ └────────────────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Controller Event Handlers¶
| Handler | Reacts To |
|---|---|
OnWorldMapLoaded |
Map data loaded |
OnNodeStateChanged |
Node state transition |
OnCurrentNodeChanged |
Player moved to new node |
OnDomainLiberated |
Domain boss defeated |
Layout Contract¶
Node layout geometry is not stored on UWorldMapController. It lives in a single shared header,
WorldMapLayoutContract.h: a WorldMapLayout namespace of inline constexpr constants plus a stateless
FWorldMapLayoutContract transform struct. Both the runtime widget (UWorldMapController) and the editor
preview panel (SWorldMapPreviewPanel) compute positions through it, so the in-game map and the editor
preview cannot drift.
Position Formula¶
- Y (vertical) is purely depth-derived:
Y = TopMargin + Depth * DepthSpacing.MapPosition.Yis unused — depth owns the vertical axis. - X (horizontal): if
Node.bHasMapPosition, replay the authoredMapPosition.XviaFWorldMapLayoutContract::MapPositionToCanvas; otherwise useFallbackX(IndexInDepth, NodesAtDepth)(a centered per-band spread). Then addOrganicOffsetX(NodeID)(deterministic;0while jitter is disabled).
Runtime implementation: UWorldMapController::CalculateNodeCanvasPosition (WorldMapController.cpp).
WorldMapLayout Constants¶
| Constant | Value | Purpose |
|---|---|---|
CanvasWidth |
1200.0f | Fixed design-space canvas width (px); each surface scales it to fit |
TopMargin |
80.0f | Px from canvas top to the Depth-0 row center |
DepthSpacing |
150.0f | Px between depth rows (equals the editor graph lane height) |
HorizontalMargin |
0.10f | Fraction of CanvasWidth reserved as empty side margin |
NodeSpacingFraction |
0.34f | Normalized spacing between adjacent nodes in a depth band |
OrganicJitterX |
0.0f | Max deterministic per-node horizontal jitter (0 = off; placement is hand-authored) |
NodeSize |
(200, 52) | Node plaque footprint (px, design space) — preview draws rects at this size (WYSIWYG) |
FWorldMapLayoutContract Transforms¶
| Method | Purpose |
|---|---|
MapPositionToCanvas(MapPosition, Depth) |
Authored normalized position + depth → canvas pixel center |
CanvasToMapPosition(CanvasPos, Depth) |
Inverse: canvas pixel + depth lane → normalized MapPosition (Y = 0) |
CanvasYToDepth(CanvasY) |
Canvas Y → nearest depth lane (the drag snap rule; shared with the graph) |
CenteredSpreadFraction(Index, Count) |
Normalized X for a node's rank within its depth band (auto-centered) |
FallbackX(Index, Count) |
Canvas px center X for a node's band rank (used when bHasMapPosition is false) |
OrganicOffsetX(NodeID) |
Deterministic per-node offset seeded by NodeID; returns 0 while jitter is off |
Authoring (Map Editor)¶
The map is authored in the single-canvas map editor (Option B, merged 2026-06-30), a WYSIWYG canvas
(SWorldMapPreviewPanel) that replaced the old dual graph/preview workflow: lane-based depth layout,
connection draw, node create/delete, and click-select into the Details panel. Because the canvas shares
FWorldMapLayoutContract with runtime, what a designer drags is exactly what ships. Dragging a node writes
MapPosition (via CanvasToMapPosition) and sets bHasMapPosition = true; a vertical drag snaps to a depth
lane via CanvasYToDepth. The legacy node-graph tab (WorldMapGraph) is demoted to a debug fallback.
Node Intel System¶
Dungeon-generating nodes have area modifiers and threat information that is progressively revealed to the player.
Intel Population¶
When a node transitions to Available, PopulateNodeIntel() rolls modifiers and extracts threats:
PopulateNodeIntel(NodeID)
│
├─ Get modifier pool (node override → domain config)
│ └─ Register pool with ModifierSubsystem (unified lookup)
│
├─ Roll modifiers deterministically
│ └─ Seed = GetTypeHash(NodeID) → consistent across restarts
│
├─ Extract threats from encounter
│ └─ Encounter->GetUniqueEnemyTypes()
│
└─ UpdateNodeRevelation() → set what's visible
Revelation Rules¶
| Node State | Modifiers Revealed | Threats Revealed |
|---|---|---|
| Never visited | 0 | 0 |
| Visited (not cleared) | ~half (min 1) | ~half (min 1) |
| Cleared once | All | All |
| Domain HQ | Increases with attempts | Increases with attempts |
Runtime Intel Fields on FWorldMapNode¶
| Field | Type | Purpose |
|---|---|---|
RolledModifiers |
TArray\<FRolledModifier> | Area modifiers for current descent |
RevealedModifierCount |
int32 | How many modifiers are visible |
RevealedThreats |
TArray\<FRevealedThreat> | Enemy types revealed |
TotalThreatCount |
int32 | Total threats (for showing "???") |
Dungeon Integration¶
Accessibility Gate and Dev Overrides¶
TravelToNode only accepts nodes the unlock flow has reached (CanTravelToNode / GetAccessibleNodeIDs). Two
escape hatches bypass it, both by unlocking rather than by skipping the check:
- Editor test override: an
AWorldMapTestActorplaced in the level points the game mode at a target node (GetResolvedTargetNodeID). That node may be disconnected from the start, soAEternalGameModecallsMarkNodeClearedon it before travelling — otherwise the accessibility gate rejected the override and the player landed at the start node (Private/GameMode/EternalGameMode.cpp, editor-only path). DebugForceTravelToNode(non-shipping) for benchmarks and dev travel — see Public Contracts below for the persisted-state warning.
When traveling to a dungeon-generating node:
TravelToNode(NodeID)
│
▼
Node.GeneratesDungeon() == true
│
▼
GetEffectiveDungeonConfig(Node)
│
▼
DungeonSubsystem->SetAreaContext(Depth, Encounter, RolledModifiers)
│
▼
DungeonSubsystem->GenerateDungeon(Config, bIsHQDungeon)
│ (also registers modifier pool with ModifierSubsystem)
│
▼
OnDungeonGenerated → AEternalDungeonActor begins plugin generation
Exit Flow¶
Player reaches Exit/Boss room and clears it
│
▼
DungeonSubsystem->OnDungeonCleared
│
▼
WorldMapSubsystem->MarkNodeCleared(CurrentNodeID)
│
▼
UpdateUnlockedNodes() → Connected nodes become Available
│
▼
(If HQ node) LiberateDomain(DomainTag)
Public Contracts¶
Methods¶
| Method | Parameters | Purpose |
|---|---|---|
LoadWorldMap |
UWorldMapData* |
Initialize from data asset |
ClearWorldMap |
- | Unload current map |
GetCurrentNode |
- | Get player's current node |
GetNode |
FName NodeID |
Get node by ID |
GetAccessibleNodeIDs |
- | Get all travelable node IDs |
CanTravelToNode |
FName NodeID |
Check if travel is valid |
TravelToNode |
FName NodeID |
Move player to node |
MarkNodeCleared |
FName NodeID |
Mark node as completed |
IsDomainLiberated |
FGameplayTag |
Check domain liberation |
LiberateDomain |
FGameplayTag |
Mark domain boss defeated |
UnloadCurrentFixedLevel |
- | Manually unload streamed level |
DebugForceTravelToNode |
FName NodeID |
!UE_BUILD_SHIPPING. Unlock the node if needed, then travel — bypasses progression gating. The unlock is real state: it persists into the save and replicates. Throwaway saves only (the perf harness wipes SaveGames; see Enemy Performance); for a lock-safe spawn on a live save use the preset landing-node override |
Delegates¶
| Delegate | Payload | When Fired |
|---|---|---|
OnWorldMapLoaded |
UWorldMapData* |
Map data loaded |
OnNodeStateChanged |
FName, EWorldNodeState |
Any node state changes |
OnCurrentNodeChanged |
FName |
Player moved to new node |
OnDomainLiberated |
FGameplayTag |
Domain boss defeated |
OnFixedLevelEntered |
FName, TSoftObjectPtr<UWorld> |
Starting level stream |
OnFixedLevelStreamingComplete |
FName |
Level fully loaded |
OnFixedLevelUnloaded |
FName |
Level unloaded |
Source References¶
Core Types¶
FWorldMapNode- Source/ProjectEternal/Public/WorldMap/WorldMapTypes.h:56FDomainRegion- Source/ProjectEternal/Public/WorldMap/WorldMapTypes.h:170FWorldMapState- Source/ProjectEternal/Public/WorldMap/WorldMapTypes.h:217EWorldNodeType- Source/ProjectEternal/Public/WorldMap/WorldMapTypes.h:14EWorldNodeState- Source/ProjectEternal/Public/WorldMap/WorldMapTypes.h:39
Subsystem¶
UWorldMapSubsystem- Source/ProjectEternal/Public/WorldMap/WorldMapSubsystem.h:28
Data Asset¶
UWorldMapData- Source/ProjectEternal/Public/WorldMap/WorldMapData.h:48EDungeonConfigSource- Source/ProjectEternal/Public/WorldMap/WorldMapData.h:15EEncounterSource- Source/ProjectEternal/Public/WorldMap/WorldMapData.h:28
UI¶
UWorldMapController- Source/ProjectEternal/Public/UI/Controllers/WorldMapController.hUWorldMapViewModel- Source/ProjectEternal/Public/UI/ViewModels/WorldMap/WorldMapViewModel.hUNodeInfoPanelWidget- Source/ProjectEternal/Public/UI/Widgets/WorldMap/NodeInfoPanelWidget.hFNodeInfoPanelData- Source/ProjectEternal/Public/UI/ViewModels/WorldMap/WorldMapViewModel.h
Layout Contract¶
FWorldMapLayoutContract/WorldMapLayout- Source/ProjectEternal/Public/WorldMap/WorldMapLayoutContract.h- Contract implementation - Source/ProjectEternal/Private/WorldMap/WorldMapLayoutContract.cpp
SWorldMapPreviewPanel(editor canvas) - Source/ProjectEternalEditor/Public/MapEditor/Slate/WorldMap/SWorldMapPreviewPanel.h
Display Types¶
FRevealedThreat- Source/ProjectEternal/Public/AI/AIDisplayTypes.h
Related Systems¶
- Dungeon System - Procedural dungeon generation triggered by dungeon nodes
- Spawning System - Encounter data used by dungeon spawners
- Quest System - Quest objectives may reference world map nodes
- UI Architecture - MVVM pattern for world map widget
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-20 | DebugForceTravelToNode + test-actor override marks its node cleared |
Dev override to a disconnected node was rejected by the travel accessibility gate; benchmark harness needs forced travel (real persisted unlock — throwaway saves only) |
| 2026-08-06 | Per-node Remnant Realm chance override | FWorldMapNode::RemnantRealmChanceOverride (-1 = inherit) resolved by UWorldMapData::GetEffectiveRemnantRealmChance in node > domain > none order; WorldMap_Main pins Ashwood to 1.0 as a demo guarantee |
| 2026-06-30 | Layout contract | Node positions computed via shared FWorldMapLayoutContract / WorldMapLayout constants; runtime (UWorldMapController) and editor preview (SWorldMapPreviewPanel) share it so they cannot drift |
| 2026-06-30 | Single-canvas map editor | WYSIWYG canvas replaces the dual graph/preview workflow; the four node override fields (Encounter/DungeonConfigOverride/ModifierPoolOverride/ItemLevelRangeOverride) now round-trip persist through the Details panel |
| 2026-06-30 | Graph tab demoted | Legacy WorldMapGraph node-graph editor kept only as a debug fallback |
| 2026-02 | Node intel system | Area modifiers and threat revelation on dungeon nodes |
| 2026-02 | Node info panel (MVVM) | UNodeInfoPanelWidget with modifier/threat display via FNodeInfoPanelData |
| 2026-02 | Unified modifier lookup | Dungeon pools registered with ModifierSubsystem for shared GetDefinition() |
| 2025-01-17 | Initial documentation | Document world map system architecture |