Skip to content

UI Architecture

Summary: The UI system uses UEternalUISubsystem (GameInstanceSubsystem) as the central manager for all UI controllers. Controllers extend UBaseUIController to manage widget lifecycle and ViewModel registration. Input routing flows through UEternalInputSubsystem (LocalPlayerSubsystem) using Enhanced Input.

Table of Contents


Why This Architecture

Design Goals

  1. Persistence Across Levels: UI state must survive level transitions, requiring GameInstance-level storage
  2. Separation of Concerns: Controllers mediate between game systems and UI widgets
  3. Data-Driven Configuration: Widget classes and input mappings live in data assets, not code
  4. Multiplayer Support: UI must work correctly for each local player independently
  5. 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:

  1. Clear PendingPreset and PendingStartNodeOverride on the GameInstance.
  2. UWorldMapSubsystem::ClearWorldMap().
  3. CloseAllGameplayPanels(), then hide the game menu.
  4. Force-empty GameplaySuppressors and broadcast suppression false — closing panels pops their suppressors, but a straggler left in the set would keep gameplay input muted into the next run.
  5. Set bRouteToCharacterSelectOnMenuLoad so the menu map opens on character select rather than the main menu.
  6. 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 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


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