UI & Online · Updated Jun 19, 2026
UI System — CommonUI + Menus (Design Doc)
UI System — CommonUI + Menus (Design Doc)
Reference doc for the upcoming menu / settings / pause UI work. Captures the current state of the CommonUI scaffolding, the base classes still to author, the controller-input pipeline (the part most likely to bite us), and a long list of UE 5.7 footguns to watch for.
When implementation lands, sections of this doc move to Docs/UISystem.md "How it works" and the build-order checklist gets folded into Docs/Progress.md.
#1. Goal
A full menu / HUD UI system built on CommonUI that:
- Works identically on mouse+keyboard and gamepad (Xbox / DS4 / DS5) — **no UX regressions on
controller**. Directional D-pad / left-stick navigation between buttons, A/X to confirm, B/O to back out, LB/RB to switch tabs, Start to pause, etc.
- Has a clean layer model — HUD always-on, in-game stacks (objective popups), menu stack
(pause / settings), modal stack (confirmations) — already partly in place via USLPrimaryGameLayout.
- Provides base classes per widget category (screen, button, list entry, modal, tab list, settings
row) so each new BP only authors layout + visuals, never plumbing.
- Pauses / unpauses input correctly: gameplay input blocked while in menus, UI input blocked while
in game, focus restored to the right place on close.
- Future-proofs for: split-screen, settings UI, controller remapping, accessibility.
Out of scope for this pass: localization, save/load of settings, MVVM (ModelViewViewModel). Those layer on top of the foundation.
#2. Current State (as of 2026-05-22)
#What's in place
| Layer | File | Status |
|---|---|---|
| Layout root | Public/UI/SLPrimaryGameLayout.h/.cpp | ✅ four layers (HUD Overlay + Game/Menu/Modal stacks) wired via meta=(BindWidget). PushWidgetToLayer + GetForPlayer helpers exist. |
| HUD bootstrap | Public/UI/SLPlayerHUD.h/.cpp | ✅ creates the layout + HUD widget in BeginPlay; exposes PushToGameStack/PushToMenuStack/PushToModalStack |
| HUD widget | Public/UI/SLHUDWidget.h/.cpp | ✅ persistent overlay — reticle, ammo, health, damage, notifications |
| Activatable base | Public/UI/SLCommonActivatableWidget.h/.cpp | ⚠️ exists but minimal — has AutoFocusWidget + bAutoFocusOnActivate, no input-method-change handling, no GetDesiredFocusTarget override |
| Viewport client | Public/UI/SLGameViewportClient.h/.cpp | ✅ inherits UCommonGameViewportClient; set in DefaultEngine.ini as GameViewportClientClassName |
| Layer enum | Public/Types/ESLUILayer.h | ✅ HUD / Game / Menu / Modal |
| Build deps | SystemLinkCore.Build.cs | ✅ CommonUI, CommonInput, EnhancedInput, UMG, Slate, SlateCore linked |
| Plugin | SystemLink.uproject | ✅ CommonUI enabled |
| Game default | Config/DefaultGame.ini | ⚠️ Only CommonButtonAcceptKeyHandling=TriggerClick set. No CommonInputSettings.InputData yet — that's required for action bar / input icons. |
#What's missing
- No UI input actions at all (
Content/SystemLink/Inputs/Actions/) — gameplay-only set: PrimaryFire, Move, Look, Jump, Crouch, Interact, DropWeapon, CycleWeapon, ToggleViewMode. Need at minimum:IA_UI_Confirm,IA_UI_Back,IA_UI_Navigate(2D),IA_UI_NextTab,IA_UI_PrevTab,IA_UI_Pause. Note: CommonUI's built-inFBindUIActionArgsuses its ownUCommonInputActionDataBasedata tables, NOT raw EnhancedInputUInputActions — see §4.2.
- No
UCommonInputActionDataBasedata tables orUCommonUIInputDatadata asset configured. Without these, theCommonActionWidgetbutton-prompt icons inCommonBoundActionBarshow as[NONE].
- No controller data assets (
UCommonInputBaseControllerDatasubclasses) — these map an input type (Xbox / PS5 / Switch / KBM) to a sprite atlas for prompts.
- No button base class (
USLButtonBase : UCommonButtonBase). Each menu BP currently uses rawUButton, which does not work with controller focus navigation.
- No pop / back logic in
USLPrimaryGameLayout— onlyPushWidgetToLayer. CommonUI handles back viaCancelAction, but theUSLPrimaryGameLayoutshould expose a wrapper for explicit BP pops.
- No input mode switching when a menu opens —
ASLPlayerControlleris permanently inGameOnlymode. Opening a menu doesn't change focus / show cursor / freeze gameplay input.
- No pause integration —
UGameplayStatics::SetGamePausednot called anywhere.
- No tab list base for tabbed settings screens.
- No list entry base for option rows (label + control).
- No modal/confirm widget for "Quit to main menu — are you sure?" flow.
USLCommonActivatableWidgetdoesn't subscribe toUCommonInputSubsystem::OnInputMethodChangedNative— gamepad ↔ KBM transitions don't trigger focus restore (the older reference project atC:\3D-DEV\HaloProject\SystemLinkdoes this and it's worth lifting wholesale).
#What can be borrowed from the old project
C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\ has working classes we can adapt rather than write from scratch:
| Old class | What to take | Notes |
|---|---|---|
SystemLinkActivatableWidget | The HandleInputMethodChanged + TryRestoreFocusIfNeeded + FocusDefaultTargetIfPossible pattern | Subscribes to OnInputMethodChangedNative, restores focus on gamepad, releases on KBM. Has a nice IsAnyTextEntryActive() guard to not steal focus while typing. Inline as enhancements to USLCommonActivatableWidget. |
SystemLinkButtonBase | Text styling + description text + uppercase toggle | Straight subclass of UCommonButtonBase. |
SystemLinkConfirmModal | Ok / YesNo / OkCancel modal pattern with UCommonBoundActionBar | The action bar lights up controller prompts at the bottom of the modal. |
SystemLinkTabListWidgetBase | Tab list with input switching | Inherits UCommonTabListWidgetBase. |
Content/Blueprints/UI/Input/Actions/* + CUI_SystemLinkInputActionDataTable | The CommonUI input data table schema | Don't copy assets across projects (UE 5.4 → 5.7 redirector risk) — re-author in 5.7 with the same names. |
⚠️ The old project is on an earlier UE version and uses bEnableEnhancedInputSupport=False — we want this True so CommonUI uses Enhanced Input under the hood. See §4.2.
#3. Architecture
#Layer model (already in place)
ASLPlayerHUD
└── USLPrimaryGameLayout (root widget)
├── HUDLayer UOverlay — persistent, no stacking
├── GameStack UCommonActivatableWidgetStack — in-game popups
├── MenuStack UCommonActivatableWidgetStack — pause, settings, inventory
└── ModalStack UCommonActivatableWidgetStack — confirm dialogs, top of z-order
A widget added to a stack via PushWidget is activated; when it deactivates (or is removed), the stack auto-activates the next widget down. Each stack manages its own focus / input.
#Widget class hierarchy (target end state)
UCommonActivatableWidget
└── USLCommonActivatableWidget <-- exists, needs enhancements (§4.1)
├── USLScreenWidget <-- new: full-screen menus (Pause, Settings, MainMenu)
├── USLModalWidget <-- new: confirm dialogs with action bar
├── USLTabbedScreenWidget <-- new: ScreenWidget + UCommonTabListWidgetBase
└── USLGameOverlayWidget <-- new: GameStack popups (objective hints, pickup prompts that need to pause input)
UCommonButtonBase
└── USLButtonBase <-- new: project-wide button base
UCommonTabListWidgetBase
└── USLTabListWidget <-- new: ties tab buttons to UCommonActivatableWidgetSwitcher
UUserWidget (or UCommonUserWidget — see §6.4)
├── USLListEntryWidget <-- new: row in a settings list (Label + Control + Description)
├── USLSettingsRow_Toggle <-- subclass for boolean settings
├── USLSettingsRow_Slider <-- subclass for numeric settings
├── USLSettingsRow_Dropdown <-- subclass for enum settings
└── USLSettingsRow_KeyBind <-- subclass for input remapping
#Push / pop flow (target)
Player presses Pause (gamepad: Start)
→ ASLPlayerController binds IA_UI_Pause → CallFunction OpenPauseMenu
→ ASLPlayerHUD::PushToMenuStack(WBP_SL_PauseMenu)
└── PrimaryGameLayout->PushWidgetToLayer(Menu, PauseMenuClass)
└── MenuStack adds widget, calls NativeOnActivated
├── SetInputMode_GameAndUI + SetShowMouseCursor(true) on PC
├── UGameplayStatics::SetGamePaused(true)
├── AutoFocusWidget receives user focus (controller-friendly)
└── Subscribes to OnInputMethodChangedNative
→ Player presses B (gamepad) or Esc (KBM)
→ CommonUI built-in CancelAction fires
→ Top widget on stack deactivates + removes itself
→ NativeOnDeactivated:
├── If MenuStack now empty: SetInputMode_GameOnly + cursor hide + unpause
├── Else: focus restored to next-down widget's AutoFocus
└── Unsubscribe input change handler
#4. Required Foundation Work
#4.1 Enhance USLCommonActivatableWidget
Currently only has AutoFocusWidget + bAutoFocusOnActivate. Bring it up to parity with the reference project plus a couple of cleanups:
// New API on USLCommonActivatableWidget
protected:
// CommonUI back-action — set in BP defaults; default-bind to CancelAction.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bSupportsCancelAction = true;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bAutoRestoreFocusOnGamepad = true;
// Pause game while this widget is active (use for pause menu, not for HUD popups).
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bPauseGameWhileActive = false;
// Input mode to apply on activation. Restored on deactivation if stack is empty.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
ESLUIInputMode InputModeOnActivate = ESLUIInputMode::GameAndUI;
virtual UWidget* NativeGetDesiredFocusTarget() const override; // returns AutoFocusWidget
virtual TOptional<FUIInputConfig> GetDesiredInputConfig() const override; // standard CommonUI input config hook
// Bind/unbind OnInputMethodChangedNative; restore focus on gamepad transition.
virtual void NativeOnActivated() override;
virtual void NativeOnDeactivated() override;
private:
UFUNCTION()
void HandleInputMethodChanged(ECommonInputType NewType);
void TryRestoreFocusIfNeeded() const;
Key: the GetDesiredInputConfig() override is how CommonUI knows to flip input mode on activation. It returns an FUIInputConfig with InputMode, MouseCaptureMode, bHideCursorDuringViewportCapture. CommonUI applies it automatically — don't call SetInputMode_* from this class directly.
Also required — deferred re-focus after transitions (fixes footgun 6.28b): activation-time focus fails if the widget is mid-fade. So USLCommonActivatableWidget must re-assert focus once the screen is settled, not only in NativeOnActivated. Add a ReassertFocusIfNeeded() that runs deferred (next tick via SetTimerForNextTick, or RequestRefreshFocus() on CommonUI 5.x) and, if no descendant holds focus and the current input is gamepad, calls SetUserFocus(NativeGetDesiredFocusTarget()). Call it from NativeOnActivated (deferred) and reuse it from HandleInputMethodChanged. Pair with the authoring rule: fades animate Render Opacity, not Visibility. This is what makes fade/tab/modal transitions reliable on a controller across every screen.
#4.2 EnhancedInput + CommonUI integration (the controller-input pipeline)
This is the part most people get wrong, so spell it out:
CommonInputSettings (Project Settings → Engine → Common Input Settings) — required entries:
; Config/DefaultGame.ini
[/Script/CommonInput.CommonInputSettings]
InputData=/Game/SystemLink/UI/Input/DA_SL_CommonInputData.DA_SL_CommonInputData_C
bEnableEnhancedInputSupport=True
DefaultInputType=MouseAndKeyboard
DefaultGamepadName=Generic
+PlatformInputs=(PlatformName="Windows", DefaultInputType=MouseAndKeyboard, ...)
InputDatapoints to a Blueprint asset deriving fromUCommonUIInputData. That class
exposes two FDataTableRowHandle slots: DefaultClickAction and DefaultBackAction. Both should point to rows in a UCommonInputActionDataBase data table (next bullet).
bEnableEnhancedInputSupport=Trueis critical — it tells CommonUI to route through
UEnhancedInputLocalPlayerSubsystem instead of legacy input. Without it, FBindUIActionArgs built from UInputAction will be ignored.
Input data tables — two assets to author:
DT_SL_InputActions—UDataTableof row typeCommonInputActionDataBase. One row per- DisplayName ("Accept", "Back", "Pause") — shown in action bar
- KeyboardInputTypeInfo / GamepadInputTypeInfo / TouchInputTypeInfo — per-platform key + icon override
UI action (Confirm, Back, Navigate, NextTab, PrevTab, Pause, Inspect, Reset). Each row carries:
DA_SL_CommonInputData—UCommonUIInputDataBlueprint asset. Set:- DefaultClickAction → row handle pointing at
DT_SL_InputActions::Confirm - DefaultBackAction → row handle pointing at
DT_SL_InputActions::Back - EnhancedInputClickAction / EnhancedInputBackAction → the actual
UInputAction(IA_UI_Confirm/IA_UI_Back)
Controller data assets — per platform:
For each input type (Xbox, PS5, Generic Gamepad, KBM), author a UCommonInputBaseControllerData Blueprint asset that maps FKey → sprite from the prompt icon atlas. Register them under:
[/Script/CommonInput.CommonInputSettings]
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_Xbox.CD_SL_Xbox_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_PS5.CD_SL_PS5_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_Generic.CD_SL_Generic_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_KBM.CD_SL_KBM_C
Enhanced Input — UI mapping context:
Author IMC_SL_UI separate from the gameplay IMC_Default. Map:
| Action | KBM key | Xbox | PS |
|---|---|---|---|
IA_UI_Confirm | Enter / Space | A | Cross |
IA_UI_Back | Esc | B | Circle |
IA_UI_Navigate (Vector2D) | WASD / Arrows | LeftStick + DPad | LeftStick + DPad |
IA_UI_NextTab | E | RB | R1 |
IA_UI_PrevTab | Q | LB | L1 |
IA_UI_Pause | Esc / P | Start | Options |
IMC_SL_UI gets pushed onto the UEnhancedInputLocalPlayerSubsystem only while a menu is open (add in USLCommonActivatableWidget::NativeOnActivated, remove in NativeOnDeactivated). Otherwise UI keys leak into gameplay (Esc would fire while playing).
#4.3 New base classes to author
Put all of these in Plugins/SystemLinkCore/Source/SystemLinkCore/Public/UI/ per the existing folder structure. Each one needs a doc comment matching the project pattern.
✅ BUILT & build-verified (2026-06-19, branch grenade-initial-imp). The sketches below are the
original plan; the shipped classes match them in spirit with these deltas worth knowing when authoring BPs:
- USLButtonBase — label text + uppercase toggle + text-style sync; description surfaces on focus
and hover via OnDescriptionChanged(FText) BIE (controller-first, footgun 6.19). Label block is
ButtonText(BindWidgetOptional,UCommonTextBlock).
-USLScreenWidget— constructor setsInputModeOnActivate = MenuandbIsBackHandler = true. Focus
is handled by the baseUSLCommonActivatableWidget(deferred re-focus, footgun 6.28b); nobAutoActivate
override (let the stack drive activation, footgun 6.10).
-USLCommonActivatableWidget— now also carriesInputModeOnActivate(ESLUIInputMode::GameAndUI/Menu)
and implementsGetDesiredInputConfig()→FUIInputConfigwithMouseCaptureMode::NoCapture(cursor stays
free for KBM while the gamepad drives focus). See §4.1.
-USLModalWidget— shipped as a binary confirm/cancel dialog (ESLModalResult { Confirmed, Cancelled }),
not the Ok/YesNo/etc. matrix. `static PushModal(APlayerController*, TSubclassOf<USLModalWidget>, FText Title,
FText Message)` pushes to the Modal layer and returns the instance; bind the BlueprintAssignable
OnModalResulton it. Buttons areConfirmButton/CancelButton(BindWidgetOptional,USLButtonBase),
wired via the public OnClicked() native event; back/Esc/B = Cancelled; result is one-shot and the modal
self-removes. Default controller focus lands on Confirm (falls back to Cancel). A multi-button variant can
be layered later if a screen needs it.
-USLTabListWidget—DefaultTabButtonClass+RegisterTabWithLabel(TabId, Label, Content, Index=-1)
(labels the createdUSLButtonBaseviaHandleTabCreation); auto-selects the first tab. Engine drives LB/RB
cycling + the linked switcher — callSetLinkedSwitcher()thenRegisterTabWithLabel()per panel from the
owning Settings screen.
-USLListEntryWidget—UCommonUserWidget+IUserObjectListEntryrow base for virtualized lists
(server browser / scoreboard / lobby roster). Caches the item + selection (GetEntryItem(),
IsEntrySelected()) and adds C++NativeOnItemSet(UObject*)/NativeOnSelectionChanged(bool)hooks on top
of the inherited BP events (On List Item Object Set,On Item Selection Changed). Host in a
UCommonListView, never a hand-filled box (footgun 6.8).
-USLSettingsRowWidget+ rows — shared base owns label (LabelTextbind) +Description(shown on focus
by the owning screen). C++ owns the value + change broadcast; the BP wires the actual control and reflects
state via the On*Refreshed BIE hooks:
-USLToggleRow—bool;SetValue/ToggleValue,OnValueChanged(bool).
-USLSliderRow—float+MinValue/MaxValue/StepSize;SetValue/SetValueFromNormalized,
GetNormalizedValue()for the slider widget,OnValueChanged(float).
-USLDropdownRow— option list + index; rotator-friendlySelectNext/SelectPrevious(footgun 6.14),
OnSelectionChanged(int32, FText).
Still to author: USLKeyBindRow — deferred to the input-settings phase (needs the Enhanced Input
PlayerMappableKeySettings rebind pipeline, not a stub).
#USLButtonBase : UCommonButtonBase
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLButtonBase : public UCommonButtonBase
{
GENERATED_BODY()
protected:
// Optional description text shown below button (e.g. tooltip on hover/focus).
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI")
FText DescriptionText;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI")
FText ButtonText;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI",
meta=(InlineEditConditionToggle))
bool bUppercase = false;
virtual void NativePreConstruct() override;
// BP designer can override to apply ButtonText/DescriptionText to widget bindings
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category="SystemLink|UI")
void RefreshButtonText();
};
#USLScreenWidget : USLCommonActivatableWidget
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLScreenWidget : public USLCommonActivatableWidget
{
GENERATED_BODY()
public:
USLScreenWidget();
protected:
// Defaults appropriate for full-screen menus.
// Subclass CDOs can override these in BP defaults.
// Set in constructor:
// bIsBackHandler = true
// bAutoActivate = false
// bSupportsActivationFocus = true
// InputModeOnActivate = GameAndUI
};
Constructor sets:
USLScreenWidget::USLScreenWidget()
{
bIsBackHandler = true;
bAutoActivate = false;
SetIsFocusable(true);
bSupportsActivationFocus = true;
}
#USLModalWidget : USLCommonActivatableWidget
UENUM(BlueprintType)
enum class ESLModalButtons : uint8 { Ok, OkCancel, YesNo, YesNoCancel };
DECLARE_DYNAMIC_DELEGATE_OneParam(FSLOnModalResult, ESLModalResult, Result);
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLModalWidget : public USLCommonActivatableWidget
{
GENERATED_BODY()
public:
static USLModalWidget* PushModal(
const UObject* WorldContext,
TSubclassOf<USLModalWidget> ModalClass,
const FText& Title,
const FText& Body,
ESLModalButtons Buttons,
FSLOnModalResult OnResult);
protected:
// Three CommonButtonBase BindWidgets — populated in BP. The C++ side wires their OnClicked.
UPROPERTY(meta=(BindWidget)) TObjectPtr<USLButtonBase> ConfirmButton;
UPROPERTY(meta=(BindWidget)) TObjectPtr<USLButtonBase> CancelButton;
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<USLButtonBase> AlternateButton;
UPROPERTY(meta=(BindWidget)) TObjectPtr<UTextBlock> TitleText;
UPROPERTY(meta=(BindWidget)) TObjectPtr<UTextBlock> BodyText;
// Optional UCommonBoundActionBar bind — auto-fills with Confirm/Back prompts.
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<UCommonBoundActionBar> ActionBar;
};
#USLTabListWidget : UCommonTabListWidgetBase
Wraps a UCommonAnimatedSwitcher (or UCommonActivatableWidgetSwitcher) of tab content widgets, plus a horizontal box of USLButtonBase tab buttons. CommonUI handles the LB/RB bindings to cycle tabs natively — just need to expose RegisterTab(TabId, ButtonClass, ContentWidget).
#USLListEntryWidget : UUserWidget
Base row for option lists (settings page). Pairs:
- Label (FText)
- Control area (variable widget — toggle, slider, dropdown, key bind)
- Description (FText, shown on focus)
Subclasses:
USLSettingsRow_Toggle—UCheckBoxor custom CommonButton toggle
USLSettingsRow_Slider—USlider+ value label
USLSettingsRow_Dropdown—UCommonRotator(CommonUI's built-in left/right cycler — perfect for gamepad)
USLSettingsRow_KeyBind— display current binding, "Press a key…" mode for rebinding
⚠️ For lists with many entries, do NOT manually populate a UVerticalBox. Use UCommonListView (IUserObjectListEntry interface) so entries virtualize. See §6.8.
#4.4 Pop / back wiring on USLPrimaryGameLayout
Add to USLPrimaryGameLayout:
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void PopWidgetFromLayer(ESLUILayer Layer);
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void ClearLayer(ESLUILayer Layer);
UFUNCTION(BlueprintPure, Category="SystemLink|UI")
USLCommonActivatableWidget* GetActiveWidget(ESLUILayer Layer) const;
UCommonActivatableWidgetStack::RemoveWidget(Top) does the work. The stack auto-activates the next widget down. Pop helpers are wrappers around this.
#4.5 Pause input + game flow
Add a method to ASLPlayerController that menus call (or wire via GetDesiredInputConfig in USLCommonActivatableWidget — which handles the input mode side):
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void OpenPauseMenu();
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void ClosePauseMenu();
OpenPauseMenu:
- Reject if not local controller or character dead.
HUD->PushToMenuStack(PauseMenuClass)— input mode + cursor handled by activatable widget's
GetDesiredInputConfig.
UGameplayStatics::SetGamePaused(this, true)— but only on standalone / listen-server host.
In multiplayer dedicated, never pause the world; the pause menu UI shows but the game keeps running. Standard behavior — SetGamePaused is a no-op in netmode authority on dedicated.
IA_UI_Pause is bound at the PC level in SetupInputComponent and calls OpenPauseMenu. It must be in the gameplay IMC, not the UI IMC — when the menu is open, Esc/B becomes the back action (handled by CommonUI's CancelAction), and re-pressing Pause from inside a menu is unusual.
#5. Controller Input — The Important Stuff
CommonUI's controller support is excellent if you set it up correctly and a black hole of "why does my button not navigate" if you don't.
#5.1 Focus rules
- **Only widgets that derive from
SUserWidget-aware bases (CommonButtonBase, etc.) participate
in focus navigation**. Raw UButton, UTextBlock, UImage do not. If you want a clickable in a menu, it must be a USLButtonBase.
- A widget must have
bIsFocusable = trueANDVisibility != Collapsed/Hiddento receive focus.
- Direction navigation between buttons follows their on-screen layout in the slot panel —
CommonUI walks the Slate widget tree spatially. If buttons are stacked in a UVerticalBox, Up/Down works naturally. Horizontal UHorizontalBox gives Left/Right. Grids work too.
- Override
NativeGetDesiredFocusTarget()on every activatable widget. Return the widget
that should receive focus on activation. If you don't, focus falls to the first focusable child found by Slate's walk, which on a complex layout might be a tab button you didn't want.
#5.2 The OnInputMethodChangedNative dance
Player on gamepad → opens menu → focus goes to AutoFocusWidget → player clicks mouse → focus clears, mouse cursor appears → player picks up controller again → focus must be restored or the menu is unnavigable.
Lifted from the reference project (SystemLinkActivatableWidget.cpp:119-126):
void USLCommonActivatableWidget::NativeOnActivated()
{
Super::NativeOnActivated();
if (ULocalPlayer* LP = GetOwningLocalPlayer())
{
if (UCommonInputSubsystem* CIS = UCommonInputSubsystem::Get(LP))
{
CIS->OnInputMethodChangedNative.AddUObject(this, &ThisClass::HandleInputMethodChanged);
HandleInputMethodChanged(CIS->GetCurrentInputType());
}
}
}
void USLCommonActivatableWidget::HandleInputMethodChanged(ECommonInputType NewType)
{
if (bAutoRestoreFocusOnGamepad && NewType == ECommonInputType::Gamepad)
{
TryRestoreFocusIfNeeded();
}
}
TryRestoreFocusIfNeeded should:
- Skip if input type isn't gamepad
- Skip if a descendant already has focus (
HasFocusedDescendants())
- Skip if a text-entry field is focused (don't steal focus while user is typing)
- Skip if we aren't the active widget on the stack
- Otherwise:
NativeGetDesiredFocusTarget()->SetUserFocus(PC)
#5.3 Action bar
UCommonBoundActionBar is the strip at the bottom of a menu that shows "A Accept | B Back |
|---|
Y Reset" with platform-appropriate icons. It picks these up from:
- The active widget's
RegisterBindingcalls (action ID + handler)
- Plus the global Confirm / Back actions from
UCommonUIInputData
To add an action to a screen:
FBindUIActionArgs Args(SLTags::UI::Actions::Reset, false, FSimpleDelegate::CreateUObject(this, &ThisClass::HandleReset));
Args.bDisplayInActionBar = true;
Args.OverrideDisplayName = NSLOCTEXT("Menus", "Reset", "Reset to defaults");
RegisterBinding(Args);
⚠️ bDisplayInActionBar only works if a UCommonBoundActionBar is in the same widget tree.
#5.4 Cancel / back action
bIsBackHandler = true on the screen widget means it handles the global back action. When fired, NativeOnHandleBackAction() is called — default implementation pops the widget. Override if a modal needs to cancel sub-state before closing.
If you have nested screens, only the topmost stack widget should be the back handler at a time. CommonUI handles this — but if you set bIsBackHandler on a HUD-tier widget, it will eat the back press too. Keep it false on USLGameOverlayWidget subclasses.
#6. Footguns (Read All Of These)
Field-tested traps, ordered roughly by likelihood of getting bitten.
#6.1 BindWidget naming is silent on mismatch
We already know this from the AmmoStrip work. A meta=(BindWidget) UPROPERTY in C++ named MenuStack requires the BP widget instance to be named exactly MenuStack. Wrong name = null pointer at runtime, no warning unless you log it. Always add an ensureMsgf or UE_LOG(Warning) when a BindWidget property is null.
#6.2 UCommonActivatableWidget BP events already exist
BP_OnActivated and BP_OnDeactivated are pre-declared as BlueprintImplementableEvents on UCommonActivatableWidget. Do not redeclare them in subclasses — .h will compile but the BP event graph shows two duplicates and the wrong one fires. (We already have this memory note — feedback_commonui_includes.)
#6.3 Activatable widget container include path
UCommonActivatableWidgetStack lives in Widgets/CommonActivatableWidgetContainer.h, NOT CommonActivatableWidgetStack.h. The header file is named after the base container class.
#6.4 UCommonUserWidget vs UUserWidget
For widgets that need CommonUI features (input handling, focus integration), inherit UCommonUserWidget. For pure visual containers (a label + icon row), UUserWidget is fine. USLListEntryWidget could go either way — recommended UCommonUserWidget so focus works on row selection.
#6.5 bIsFocusable defaults
UCommonButtonBase is focusable by default (good). Plain UUserWidget is not focusable by default. If you have a custom row that should accept focus (e.g., a settings row that opens a sub-menu), call SetIsFocusable(true) in the constructor.
#6.6 Input mode flipping is order-sensitive
The sequence:
- Push widget to MenuStack
- Set input mode
GameAndUI
- Set show mouse cursor true
- Focus the widget
…must happen in that order. If you focus the widget before setting input mode, the focus is captured by the viewport which then revokes it when the input mode flips. CommonUI's GetDesiredInputConfig mechanism handles this correctly — use it instead of calling the input mode functions manually.
#6.7 SetInputMode_UIOnly is almost never what you want
In game UI, use FInputModeGameAndUI with LockMouseToViewportBehavior = LockOnCapture and bHideCursorDuringCapture = false. UIOnly blocks gameplay input entirely — fine for main menu screen, dangerous if invoked mid-game because the player has no way to recover if the menu deactivation fails.
#6.8 UCommonListView requires IUserObjectListEntry
If you use UCommonListView for settings rows, each row widget must:
- Implement
IUserObjectListEntry
- Override
NativeOnListItemObjectSet(UObject* InObject)to populate from data
- The row class is set on the ListView (
EntryWidgetClass), and you callSetListItems(TArray<UObject*>)
Don't use UListView directly — it's the base; CommonUI extends it with focus + input handling.
#6.9 Activatable widgets outside a stack don't activate
If you add a USLCommonActivatableWidget directly to a UOverlay (the HUD layer), it will not call NativeOnActivated because nothing tells it to. UCommonActivatableWidgetStack is what activates its children. If you need a HUD-tier popup that activates (gets focus, handles input), put it on GameStack instead.
#6.10 bAutoActivate is a trap
bAutoActivate = true on an activatable widget means it activates as soon as it's added to a widget tree, regardless of stack ownership. This bypasses the stack's lifecycle and causes exactly one nested deactivation bug. Leave it false and let the stack drive activation.
#6.11 GameViewportClient swap requires editor restart
The first time DefaultEngine.ini is changed to point at a new viewport client class, the editor must be restarted to pick it up. Already done for SystemLink — SLGameViewportClient is live — but worth noting if it ever gets changed again.
#6.12 Network-mode mismatch on SetGamePaused
UGameplayStatics::SetGamePaused no-ops on dedicated servers and replicated clients. On listen server it pauses for all clients. On standalone it pauses for the player. Decide intentionally whether the pause menu should pause time — for SystemLink (cooperative listen-server intent), pause is fine; if you ever go competitive dedicated, the pause menu must not affect game time.
#6.13 Save Game during pause
UGameplayStatics::SaveGameToSlot works fine while paused, but AsyncSaveGameToSlot runs on a task graph thread and the response delegate fires on game thread — if the game is paused with a modal showing "Saving…", make sure the modal's timer / animation uses Tickable or SetTickableWhenPaused so it actually animates.
#6.14 UCommonRotator for enum settings
For settings like "Quality: Low / Medium / High", UCommonRotator is way better than a dropdown on gamepad — left/right cycles the value with the D-pad without opening a list. Built into CommonUI, no custom widget needed. (Found WBP_Rotator_* in the old project's assets.)
#6.15 UCommonNumericTextBlock for animated counters
Score / XP / currency rolling animations — use UCommonNumericTextBlock instead of a tween on a plain UTextBlock. Handles localization formatting automatically.
#6.16 Text autosize traps
UCommonTextBlock has autosizing that scales the font to fit. On localized strings ("Begin" vs "Spiel Beginnen") the scaling can produce wildly different visuals across languages. Set a min font size in the text style or button will look bad in DE/RU.
#6.17 Focus arrows and CommonUI navigation
If the user reports "I can't navigate past button X" — check the panel widget. UCanvasPanel does not auto-route navigation; you have to set Navigation rules on each child widget. Use UVerticalBox / UHorizontalBox / UGridPanel for menus, not canvas, unless you manually wire each Navigation direction.
#6.18 Click vs Confirm
On UCommonButtonBase, the OnClicked event fires for both mouse click and gamepad confirm, but the OnDoubleClicked event is mouse-only. Don't gate critical confirm logic on double-click.
#6.19 Tooltip on focus, not hover
Standard UCommonButtonBase shows tooltips on hover, which is mouse-only. For a gamepad-friendly "description on focus" pattern, override NativeOnAddedToFocusPath() / NativeOnRemovedFromFocusPath() to push the description to a shared description panel. Don't rely on tooltips for important info.
#6.20 Multiple input devices simultaneously
A player with both an Xbox controller and a mouse connected: every mouse movement flips UCommonInputSubsystem back to KBM. If the player uses both intentionally (controller for movement, mouse for menus), the auto-detection will fight them. Provide a "Lock input type" setting that disables auto-detection — UCommonInputSubsystem::SetInputTypeFilter is the hook.
#6.21 Initial input type on PIE
In Editor PIE, UCommonInputSubsystem::GetCurrentInputType() starts as MouseAndKeyboard even if the player has a controller plugged in — until the player touches it. This means a controller-only player has no focus on their first menu open. The reference project's HandleInputMethodChanged(CIS->GetCurrentInputType()) call inside NativeOnActivated handles this — by force-calling the handler immediately, focus gets restored if they're already on gamepad.
#6.22 Don't push the same widget class twice
PushWidget<T>() creates a new instance every call. Pushing the pause menu twice gives you two stacked pause menus, both visible, both eating input. Either deduplicate at the push site (check GetActiveWidget(Layer)) or make the back action close all instances.
#6.23 GameInstanceSubsystem ordering
If you ever move stack management out of ASLPlayerHUD into a UGameInstanceSubsystem (common upgrade path), be aware that subsystems initialize before the LocalPlayer is created, so don't try to bind OnInputMethodChangedNative in Initialize(). Defer to OnLocalPlayerAdded instead.
#6.24 BlueprintImplementableEvent without BlueprintCallable
(Already in memory — feedback_self_documenting_code and the global CLAUDE.md). Every BlueprintImplementableEvent exposed for BP override must also be BlueprintCallable or it won't appear in BP search menus reliably.
#6.25 bIsModal is for blocking, not visuals
UCommonActivatableWidget::bIsModal (sometimes called bModal) blocks input from layers below. It doesn't dim them visually. The "darken the background" effect needs a separate UImage with semi-transparent black behind your modal contents. Cosmetic ≠ logical.
#6.26 Touch input as a third platform
CommonUI supports Touch as a first-class input type. If we never plan to ship to mobile, set TouchInputType to an empty controller data and avoid the cost. If we do, every menu needs gesture support designed in (not slapped on later).
#6.27 Pause menu and audio
Music should keep playing while paused. Set bIgnoreForPause on the audio component or set the mix on the master to bypass time dilation. Without this, every UI sound goes silent the moment the menu pauses the game.
#6.28b Focus is lost across a fade/transition between menus (CONFIRMED, cost real time)
Symptom: open menu B from menu A with a fade transition → B appears but the controller can't navigate it; no focus anywhere. Cause: CommonUI sets focus once at activation via NativeGetDesiredFocusTarget, but during the fade the target is in a non-focusable state — usually because the fade animates Visibility (Hidden/Collapsed) instead of opacity, or the widget isn't laid out yet — so SetUserFocus silently no-ops and is never retried. Fade finishes → visible but unfocused → dead on controller.
Fix (both, both one-time in the foundation):
- Fade with Render Opacity, not Visibility. Keep the widget
Visible/SelfHitTestInvisiblethroughout
(so it stays focusable); animate Render Opacity 0→1. Animating Visibility is the usual culprit.
- Re-assert focus after the transition in
USLCommonActivatableWidget(see §4.1) — not just at
activation. Use RequestRefreshFocus() (CommonUI 5.x) or a 1-frame deferred reassert: if no descendant has focus and the current input is gamepad, SetUserFocus(NativeGetDesiredFocusTarget()). Inherited by every screen → fades, tab switches, modal pops all work.
This is the single highest-ROI reason to enhance the activatable base before authoring any screen.
#6.28 The Widget Reflector is your friend
Tools → Debug → Widget Reflector. Hold Ctrl+Shift+W in PIE, click on any UI element, see the Slate tree, focused widget, hit-test path. 90% of "why isn't this focusable" / "why doesn't this respond" is solved here.
#7. Build Order
Recommended sequence. Each step is small, testable in isolation, and doesn't depend on later steps to work.
- Author UI input actions + IMC (
IA_UI_*,IMC_SL_UI) — empty handlers, just wire keys.
Test: enable IMC programmatically, press gamepad A, verify IA_UI_Confirm triggers in a print.
- Author CommonUI data assets —
DT_SL_InputActions,DA_SL_CommonInputData, four
CD_SL_* controller data. Register in DefaultGame.ini. Test: add a UCommonBoundActionBar to the existing HUD widget, set its InputActions to Confirm+Back, verify icons render at runtime (Xbox vs KBM swap).
- Enhance
USLCommonActivatableWidget—GetDesiredFocusTarget,GetDesiredInputConfig,
OnInputMethodChangedNative subscription, bPauseGameWhileActive. Test: small WBP_SL_TestScreen with one button, push it via PIE console, verify focus on activation + focus restore on KBM↔gamepad transitions.
- Author
USLButtonBase— portSystemLinkButtonBasefrom the old project. Reskin in
WBP_SL_Button_Default. Verify it appears in BP dropdowns and navigation between two of them in a VerticalBox works on gamepad.
- Pop / clear API on
USLPrimaryGameLayout—PopWidgetFromLayer,GetActiveWidget,
ClearLayer. Test: push two widgets, pop top, verify second activates.
- Pause menu —
WBP_SL_PauseMenu(subclass ofUSLScreenWidget). Three buttons: Resume,
Settings, Quit. Bind IA_UI_Pause at PC level → ASLPlayerController::OpenPauseMenu. Test: F-key to pause, B/Esc to close, verify gameplay input blocked while menu open.
USLModalWidget— Ok / YesNo modal with action bar. Quit-to-desktop confirm flow uses it.
USLTabListWidget— tab + content switcher for settings.
- Settings page skeleton —
WBP_SL_Settings(tabs: Video / Audio / Controls), three blank
tab content widgets.
- Settings rows —
USLListEntryWidget+USLSettingsRow_Toggle,_Slider,_Rotator,
_KeyBind. Persist to USaveGame later (separate work item).
- Main menu — only needed if we go to a level-select pattern; not blocking gameplay work.
#8. Open Questions / Decisions
Before any of the above lands, get these answered:
- Pause behavior in multiplayer — does the pause menu pause time? (If listen server, yes is
fine. If dedicated future-proofing, no — menu UI shows but game runs.)
- Settings persistence —
USaveGameslot?GameUserSettings.ini?JSON? Affects how
settings rows wire their values.
- Localization — out of scope for now, but every
FTextin the base classes should use
NSLOCTEXT macro so it's localizable later without a global find/replace.
- Input remapping — first-class feature, or save-for-later? If first-class, the runtime
UEnhancedInputUserSettings API (UE 5.3+) is the path; if save-for-later, plan a stub UI.
- Initial controller — at game start, do we assume KBM or auto-detect? Auto-detect is the
default; if the first menu the user sees is the main menu and they're on controller, focus must land correctly the first time (see §6.21).
- Modal stacking — can a modal push another modal? Yes from a UI standpoint, but design-wise
it usually indicates a flow problem. Decide policy.
#9. References
- Reference project:
C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\— 4 base classes,
modal pattern, input data table example. UE 5.4 era, port not copy.
- Current scaffolding:
Plugins/SystemLinkCore/Source/SystemLinkCore/Public/UI/— layout, HUD,
activatable base, viewport client.
- Lyra:
Lyra/Plugins/CommonGame/Source/CommonGame/Public/CommonActivatableWidget.h— gold
standard. Too large to copy whole, but UCommonGameViewportClient, UCommonLocalPlayer patterns there are worth a read.
- Epic docs: <https://dev.epicgames.com/documentation/en-us/unreal-engine/common-ui-plugin-for-unreal-engine>
(link not generated — search "Common UI Plugin" in the UE docs site).