UI Architecture¶
Summary: The UI system uses
UEternalUISubsystem(GameInstanceSubsystem) as the central manager for all UI controllers. Controllers extendUBaseUIControllerto manage widget lifecycle and ViewModel registration. Input routing flows throughUEternalInputSubsystem(LocalPlayerSubsystem) using Enhanced Input.
Table of Contents¶
- Why This Architecture
- System Overview
- Core Subsystems
- Controller Pattern
- Dialogue Runtime
- Input Routing
- Configuration
- Initialization Flow
- API Reference
- Source References
- Related Systems
- Recent Changes
Why This Architecture¶
Design Goals¶
- Persistence Across Levels: UI state must survive level transitions, requiring GameInstance-level storage
- Separation of Concerns: Controllers mediate between game systems and UI widgets
- Data-Driven Configuration: Widget classes and input mappings live in data assets, not code
- Multiplayer Support: UI must work correctly for each local player independently
- Input Consistency: All input flows through centralized subsystems for unified handling
Key Tradeoffs¶
| Decision | Benefit | Cost |
|---|---|---|
| GameInstance Subsystem | UI survives level loads | Controllers live forever, must handle cleanup |
| Controller per UI | Clear ownership, focused logic | Many controllers created at startup |
| Data Asset Config | Change widgets without recompiling | Extra indirection, harder debugging |
| LocalPlayer Input | Correct multiplayer input routing | Two subsystems to coordinate |
System Overview¶
+------------------------------------------------------------------+
| UGameInstance |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| UEternalUISubsystem |
| (GameInstanceSubsystem) |
+------------------------------------------------------------------+
| |
v v
+-------------------+ +------------------------+
| UUIConfig | | Registered Controllers|
| (Data Asset) | | (one per UI feature) |
+-------------------+ | |
| HUDWidgetClass | | - HUDController |
| InventoryClass | | - InventoryController |
| TooltipClasses | | - CharacterController |
| DropCatcherClass | | - (see full list) |
+-------------------+ +------------------------+
|
v
+-------------------+
| Widget Instances |
| (Created on demand)|
+-------------------+
+------------------------------------------------------------------+
| ULocalPlayer |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| UEternalInputSubsystem |
| (LocalPlayerSubsystem) |
+------------------------------------------------------------------+
| |
v v
+-------------------+ +------------------------+
| UInputConfig | | Input Handlers |
| (Data Asset) | | |
+-------------------+ | UI Toggles: |
| ToggleInventory | | I -> Inventory |
| ToggleCharacter | | C -> Character |
| MouseActions | | K -> Compendium |
| AbilityActions | | |
+-------------------+ | Mouse Events: |
| LMB -> Broadcast |
| RMB -> Broadcast |
+------------------------+
Core Subsystems¶
UEternalUISubsystem¶
Purpose: Central manager for all UI controllers and shared UI state.
Why GameInstance? UI must persist across level transitions. Player inventory panels, tooltips, and HUD elements shouldn't reset when entering a dungeon.
| Responsibility | How |
|---|---|
| Controller Lifecycle | Creates all controllers on Initialize, destroys on Deinitialize |
| Controller Access | Type-safe getters for each controller type |
| Drop Catcher | Manages full-screen widget for item drops to world |
| Safe Zone Updates | Broadcasts to registered controllers on viewport changes |
UEternalInputSubsystem¶
Purpose: Centralized input routing for UI and gameplay.
Why LocalPlayer? Each local player (split-screen, PIE) needs independent input handling. Mouse events broadcast as delegates so widgets can subscribe without coupling.
| Responsibility | How |
|---|---|
| UI Toggles | Binds keys (I, C, Tab, etc.) to controller show/hide |
| Mouse Broadcasts | OnLeftMouseClick, OnRightMouseClick delegates |
| Input State | Tracks held inputs via GameplayTags |
| Action Binding | Connects Enhanced Input actions to handlers |
Controller Pattern¶
UBaseUIController¶
Controllers serve as the bridge between game systems and UI widgets. They own ViewModels and manage widget lifecycle.
+------------------+
| Game Systems | (Combat, Inventory, Equipment, Abilities)
+------------------+
|
| Events / Callbacks
v
+------------------+
| Controller | (UBaseUIController subclass)
+------------------+
|
| Creates & Updates
v
+------------------+
| ViewModel | (Data container)
+------------------+
|
| Binds to
v
+------------------+
| Widget | (UUserWidget subclass)
+------------------+
Controller Responsibilities¶
| Responsibility | Description |
|---|---|
| Widget Creation | Uses DefaultWidgetClass from UIConfig |
| Widget Lifecycle | Show/Hide/Remove widgets by name |
| ViewModel Creation | NewObject<>, Initialize(), RegisterViewModel() |
| Game Binding | Subscribe to component events, update ViewModels |
| Player Ownership | Track owning player for widget context |
Active Controllers¶
This table mirrors the contents of
Source/ProjectEternal/Public/UI/Controllers/— regenerate it from that directory when controllers are added or removed. Do not hardcode a controller count elsewhere in this doc.
| Controller | Purpose |
|---|---|
| UAreaTitleController | Area name display on zone entry |
| UAttributeHUDController | Player attribute display (Health, Stamina, Resonance); extracted from HUDController |
| UAutomapController | Slate-painted parchment floor plan — full-screen overlay plus corner minimap, actor markers, quest pins, party arrows, camera-aligned view with pitch foreshortening. See Automap (Generation-Native v2) |
| UCharacterController | Character stats and equipment |
| UCharacterSelectController | "Camp at the Rim" character-select screen; preset entry cards, routes Confirm to StartGamePreset |
| UCompendiumController | Game encyclopedia |
| UCraftingController | Crafting interface |
| UDialogueController | Dialogue playback loop — see "Dialogue Runtime" below |
| UDungeonEdictController | Dungeon modifier HUD (collapsed sigils / expanded details) |
| UEventBannerController | Large centered banner for major events (awakening, boss kills); queues concurrent requests |
| UGameMenuController | In-game menu (ESC / Menu button); Change Preset returns to character select; inline confirm for unsaved run progress |
| UGlyphController | Glyph socketing |
| UGrantedPowersController | HUD granted-powers tray and character-sheet power lists; pushes immutable power snapshots |
| UHUDController | Main gameplay HUD (health, stamina, skill bar) |
| UInteractionController | World interaction prompts |
| UInventoryController | Inventory management and essence display |
| UItemTooltipController | Item hover tooltips |
| UMainMenuController | Main menu widget and navigation actions (e.g. Start Game) |
| UModalController | Reusable Modal widget (W_Modal); single- and multi-page modal sequences resolved from authored content by id — see "Modal Content" below |
| UNotificationController | Notification overlay (ZOrder 100, always above other UI) |
| URemnantDesireTrackerController | Feeds Sealed Remnant items with near-complete Desires into the objective tracker panel; "nearing awakening" toast |
| UScreenEffectsController | Owner-side low-health/death screen FX; folds ASC health deltas through intensity model and accessibility CVars |
| USkillTooltipController | Ability hover tooltips |
| USkillbarController | Skill slot display and ability tracking; extracted from HUDController |
| UStatusController | World-space status widgets (damage numbers, health bars); extracted from HUDController |
| UStatusEffectController | Status effect panel display; extracted from HUDController |
| UStatusEffectTooltipController | Buff/debuff tooltips |
| UTransitionController | Screen transitions |
| UVendorController | Vendor trade interface |
| UWorldMapController | World map navigation |
Note: some entries (AttributeHUD, Skillbar, Status, StatusEffect, GrantedPowers, RemnantDesireTracker, ScreenEffects) are plain UObject sub-controllers owned by other controllers rather than UBaseUIController subclasses.
Sub-Controller Rebind Contract¶
UHUDController outlives the run, so SetupSubControllers() calls ShutdownSubControllers() first, every time.
Without that, a second world in the same GameInstance stacks the previous run's delegate bindings and shared-ViewModel
entries (Remnant desire rows keyed by dead item GUIDs) on top of the new run's.
The same discipline applies to any sub-controller that binds to a PlayerState-scoped source.
UStatusEffectController::Shutdown removes its OnActiveGameplayEffectAddedDelegateToSelf /
OnAnyGameplayEffectRemovedDelegate handles because the ASC lives on the persistent PlayerState — it survives the
pawn, so every respawn or travel would otherwise leak a listener pointing into a dead panel ViewModel.
Respawn Rebinding¶
AEternalPlayer::OnUnPossess tears the pawn-scoped UI down (hide HUD, unbind input, clear the audio listener), so
every repossession has to re-drive it. AEternalPlayer::AcknowledgePossession runs on the local side of every
possession — listen host and client alike — and calls RebindPawnBoundUI(), which:
| Step | Why |
|---|---|
| Rebind input first, independent of pawn type | Even a non-combat pawn (spectator, scripted) needs movement and UI toggles; gated on the subsystem's bound state so a pure client (which never unbinds) doesn't double-register handlers |
| Bail out for pawns with no combat component | Nothing character-scoped to rebind |
| Bounded next-tick retry until GAS links resolve | The pawn's ASC and AttributeSet resolve a few ticks after possession; binding early builds the skillbar and HUD sub-controllers against a null AttributeSet. The retry handle coalesces rapid possession chains |
Set the skill-tooltip combat-component source before ShowHUD() |
Skillbar reconstruction builds its tooltips immediately, and a stale (dead-pawn) combat component has no AttributeSet |
PlayerState-scoped controllers (inventory, equipment, quests) are untouched by the swap.
Panel Slot Exclusivity¶
Gameplay panels declare a screen region via EMenuPanelSlot (Left / Right / Full) and the subsystem enforces
mutual exclusion in TogglePanel so panels never stack on each other:
| Rule | Effect |
|---|---|
| At most one panel per side | Opening a second Left panel closes the first |
Full conflicts with everything |
A full-screen panel closes all others, and cannot open beside one |
Inventory is the sole Right tenant |
Item-adjacent panels (character, crafting, vendor) sit Left so they can stay open beside the inventory |
GetPanelSlot(EMenuPanel) is the single mapping; PanelsConflict(A, B) is the predicate.
Change Preset / Return to Preset Select¶
UEternalUISubsystem::ReturnToPresetSelect() leaves a run cleanly. Everything else dies with the non-seamless
travel, so it only has to clear GameInstance-lifetime state:
- Clear
PendingPresetandPendingStartNodeOverrideon the GameInstance. UWorldMapSubsystem::ClearWorldMap().CloseAllGameplayPanels(), then hide the game menu.- Force-empty
GameplaySuppressorsand broadcast suppressionfalse— closing panels pops their suppressors, but a straggler left in the set would keep gameplay input muted into the next run. - Set
bRouteToCharacterSelectOnMenuLoadso the menu map opens on character select rather than the main menu. OpenLevel(UIConfig->MainMenuMap).
Bails out with an error if MainMenuMap is unset on the UIConfig.
Dialogue Runtime¶
The DlgSystem playback loop is C++-owned (migrated from the BP_PC "Dialogue" ubergraph page, 2026-07).
Why controller-owned: any actor that can talk only needs a Dialogue asset assigned — no per-actor
Blueprint graphs, and headless narrative authoring exercises the shipped code path.
Talking actor (local interaction path only — never server-side)
AEternalNPC · AQuestTriggerActor · ALoreFragmentActor (native auto-start)
Cinematics / widget buttons (UDialogueStatics, one node)
│
▼
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ UDialogueController │ ShowNode│ W_Dialogue (BP visuals) │
│ • UDlgContext lifecycle ├────────▶│ • renders line + options │
│ • HUD hide + input suppress │StopVoice│ • plays voice-over (interim)│
│ • OnDialogueStarted/Ended │ │ • buttons → DialogueStatics │
└──────────────────────────────┘ └─────────────────────────────┘
| Entry Point | Purpose |
|---|---|
UDialogueController::StartDialogue/SelectOption/Advance/TryEndDialogue |
The loop (C++ callers) |
UDialogueStatics::StartDialogue/SelectDialogueOption/AdvanceDialogue/EndDialogue |
Single-node Blueprint entry points |
Interim widget model: W_Dialogue still reads the UDlgContext handed to it via
UDialogueWidget::ShowNode and owns voice-over playback. UDialogueViewModel is populated every step so
a later Designer pass can bind the widget MVVM-style and retire the Context path
(see ImplementationDocs/DialogueRuntimeMigration.plan.md §Deferred follow-ups).
Input Routing¶
UI Toggle Flow¶
Player presses 'I'
|
v
EnhancedInputComponent
|
v
UEternalInputSubsystem::HandleToggleInventory()
|
v
UEternalUISubsystem::GetInventoryController()
|
v
UInventoryController::ToggleInventory()
|
v
ShowWidget() / HideWidget()
Mouse Event Flow¶
Player clicks LMB
|
v
EnhancedInputComponent
|
v
UEternalInputSubsystem::HandleLeftMouseClick()
|
v
OnLeftMouseClick.Broadcast()
|
v
[Subscribed Widgets]
- ItemWidget::OnLeftClick()
- CraftingWidget::OnLeftClick()
- etc.
Configuration¶
UUIConfig (Data Asset)¶
Widget class references configured in editor, loaded at runtime. Categories below mirror the Category on each
UPROPERTY in UIConfig.h — regenerate this table from that header when properties are added or removed.
| Category | Properties |
|---|---|
| HUD | HUDWidgetClass, AreaTitleWidgetClass, EventBannerWidgetClass, DungeonEdictBarWidgetClass |
| Inventory / Character | InventoryWidgetClass, CharacterWidgetClass |
| Systems | CompendiumWidgetClass, CraftingWidgetClass, DialogueWidgetClass, GlyphWallWidgetClass |
| Automap | AutomapOverlayWidgetClass |
| World Map / Transition | WorldMapWidgetClass, TransitionWidgetClass |
| Vendor | VendorWidgetClass |
| Menus | MainMenuWidgetClass, CharacterSelectWidgetClass, GameMenuWidgetClass |
| Notifications | NotificationOverlayWidgetClass, NotificationWidgetClass |
| Tooltips | ItemTooltipWidgetClass, SkillTooltipWidgetClass, StatusEffectTooltipWidgetClass |
| Utility | DropCatcherWidgetClass, ModalWidgetClass |
| Items | ItemWidgetClass |
Non-widget data references on the same asset:
| Category | Property | Purpose |
|---|---|---|
| Data | AutomapStyle | Automap canvas palettes, strokes, sigils |
| Data | ScreenEffectsConfig | Low-health vignette / big-hit pulse tuning |
| Data | ModalContent | Authored modal text addressed by id (see below) |
| Data | DefaultGameMap, MainMenuMap | Level targets for Start Game and Change Preset |
| Data | MenuWorldMapData, EraDisplayData | Character-select Domain panel and era badges |
Modal Content¶
Modal text is data, not code. UModalContentDataAsset holds a TMap<FName, FModalSequence>; an
FModalSequence is { Pages, bShowOncePerSession } and each FModalPage carries Title, BodyText (rich-text
markup allowed), ConfirmText and CancelText — empty button text hides that button, and on any page but the last a
button advances instead of closing. UModalController resolves a sequence by FName key from
UIConfig->ModalContent.
| Consequence | Detail |
|---|---|
| Adding a modal is content-only | Author a new key + pages; code only needs the id |
| Once-per-session is data | bShowOncePerSession per sequence, tracked at runtime as a TSet<FName> of shown ids. Not saved, so it replays each launch |
| Runtime-text modals bypass it | The death screen and anything whose text is only known at runtime still call UModalController::ShowModal directly and are not listed in the asset |
Startup-modal gate: the disclaimer/intro modals are gated by the CVar Eternal.UI.ShowStartupModals
(default !WITH_EDITOR), replacing the old #if !WITH_EDITOR compile guards — so the packaged modal path can be
exercised from PIE by flipping the CVar.
UInputConfig (Data Asset)¶
Input action mappings for all UI and gameplay inputs.
| Category | Actions |
|---|---|
| UI | ToggleInventory, ToggleCharacter, ToggleCompendium, ToggleSettings |
| Mouse | LeftMouseClick, RightMouseClick |
| Abilities | AbilityInputActions (tagged array for dynamic binding) |
| Movement | MoveForward, MoveRight, Sprint, Jump |
Initialization Flow¶
Game Instance Created
|
v
UEternalUISubsystem::Initialize()
|
+-- LoadUIConfig()
| Load UUIConfig from UEternalUISettings path
|
+-- InitializeUISystems()
Skip on dedicated server
|
+-- Create all controllers (see Active Controllers table)
| NewObject<UHUDController>()
| NewObject<UInventoryController>()
| ...
|
+-- Create DropCatcherWidget
Full-screen item drop target
|
v
Player Joins / Pawn Spawned
|
v
AEternalGameHUD::BeginPlay()
|
+-- Get UEternalUISubsystem
+-- OnHUDReady() [BlueprintImplementableEvent]
|
v
UEternalInputSubsystem::Initialize()
|
+-- LoadInputConfig()
+-- BindInputActions(PlayerController)
Combat -> PlayerController
UI Toggles -> InputSubsystem
Movement -> PlayerController
API Reference¶
UEternalUISubsystem¶
| Method | Return | Description |
|---|---|---|
| Get*Controller() | UController | Type-safe getter for each controller |
| RegisterController(Controller) | void | Register for safe zone updates |
| ShowDropCatcher() | void | Display item drop target |
| HideDropCatcher() | void | Hide item drop target |
| GetUIConfig() | UUIConfig* | Access widget class configuration |
UBaseUIController¶
| Method | Return | Description |
|---|---|---|
| Initialize() | void | Set up controller, call OnInitialize() |
| Shutdown() | void | Clean up, call OnShutdown() |
| CreateWidget(Class, Name, Owner) | UUserWidget* | Create and register widget |
| GetWidget(Name) | UUserWidget* | Retrieve widget by name |
| ShowWidget(Name, ZOrder) | void | Add widget to viewport |
| HideWidget(Name) | void | Remove widget from viewport |
| RegisterViewModel(VM, Name) | void | Register with MVVM subsystem |
| SetOwningPlayer(PC) | void | Set player context |
UEternalInputSubsystem¶
| Method | Return | Description |
|---|---|---|
| BindInputActions(PC) | void | Bind all input to player controller |
| UnbindInputActions() | void | Remove all bindings |
| GetInputConfig() | UInputConfig* | Access input configuration |
| Delegate | Signature | Description |
|---|---|---|
| OnLeftMouseClick | FOnLeftMouseClick | Broadcast on LMB press |
| OnRightMouseClick | FOnRightMouseClick | Broadcast on RMB press |
Source References¶
| Class | File | Line |
|---|---|---|
| UEternalUISubsystem | Source/ProjectEternal/Public/UI/EternalUISubsystem.h | 1 |
| UBaseUIController | Source/ProjectEternal/Public/UI/BaseUIController.h | 1 |
| UEternalInputSubsystem | Source/ProjectEternal/Public/Input/EternalInputSubsystem.h | 1 |
| UUIConfig | Source/ProjectEternal/Public/UI/UIConfig.h | 1 |
| UInputConfig | Source/ProjectEternal/Public/Input/InputConfig.h | 1 |
| UEternalUISettings | Source/ProjectEternal/Public/UI/EternalUISettings.h | 1 |
Related Systems¶
- MVVM Framework - ViewModel binding patterns
- CommonUI Integration - Input handling and widget base classes
- Widget Library - Concrete widget implementations
- Item System - Inventory UI integration
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-06 | Modal content moved into UModalContentDataAsset |
FModalPage/FModalSequence left ModalController.h; sequences resolve by id from UIConfig->ModalContent, once-per-session bools became a data-driven id set. Adding a modal is content-only; startup modals now gated by Eternal.UI.ShowStartupModals instead of #if !WITH_EDITOR |
| 2026-08-06 | Panel slot exclusivity documented | EMenuPanelSlot (Left/Right/Full) enforced in TogglePanel: one panel per side, Full conflicts with everything, Inventory is the sole Right tenant |
| 2026-08-06 | Respawn / second-run rebinding contract documented | AcknowledgePossession re-drives pawn-scoped UI+input with a bounded next-tick retry until GAS resolves; HUDController::SetupSubControllers shuts down the previous run's sub-controllers; StatusEffectController::Shutdown unbinds its PlayerState-owned ASC delegates |
| 2026-08-06 | Preset menu-availability filtering | Character-select and main-menu listings filter presets on EPresetAvailability; Eternal.PlayPreset deliberately ignores it |
| 2026-08-06 | Automap v2 UI | UAutomapController entry expanded: Slate parchment floor plan, overlay + minimap, markers/pins/party arrows, camera-aligned pitch foreshortening |
| 2026-08-06 | UIConfig property table + controller table regenerated | Table had listed 11 of 26 properties; UGrantedPowersController added, non-existent UPoiseIndicatorController removed |
| 2026-07-29 | UGameMenuController added | In-game menu with Change Preset return-to-select flow; GameMenuWidgetClass on UIConfig; ESC wired through HandleToggleSettings |
| 2026-07-13 | Dialogue playback loop migrated BP→C++ (UDialogueController owns the UDlgContext; native auto-start on NPC/QuestTrigger/LoreFragment; UDialogueStatics BP entry points) |
Talking actors need zero graphs; see Dialogue Runtime section |
| 2026-07-03 | Active Controllers table regenerated from Public/UI/Controllers/ (29 headers); hardcoded controller counts removed |
Table is now the single source; regenerate from the directory on change |
| 2026-02 | Added UDungeonEdictController | Dungeon modifier HUD with collapsed/expanded states |
| 2026-02 | Extracted UNotificationController | Standalone overlay at ZOrder 100, no longer inside PlayerHUDWidget |
| 2026-02 | Controller count 11 → 18 | Added Interaction, WorldMap, AreaTitle, Transition, Vendor controllers |
| 2024-12 | SkillTooltipController uses dependency injection via SetCombatComponent() | Controllers no longer discover CombatComponent from pawn; caller provides it |
| 2024-12 | Ability display types moved to AbilityDisplayTypes.h | Core ability structs (ESkillType, FAbilityDamageDisplayInfo, etc.) now in Abilities module |