UI & Online · Updated 2552.08.07.08.17

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 pipel

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)

Update 2026-07-06 (branch menu-foundation): the §4.3 base-class kit is built, and the following items in
"What's missing" below are now DONE: pop/back API on USLPrimaryGameLayout (PopWidgetFromLayer/ClearLayer/
GetActiveWidget), input-mode switching (GetDesiredInputConfig on the activatable), input-method-change focus
restore, and pause integration (bPauseGameWhileActive on the activatable + OpenPauseMenu/ClosePauseMenu/
TogglePauseMenu on ASLPlayerController). bEnableEnhancedInputSupport=True is set in DefaultGame.ini.
Still missing (Phase 0b): the UI input actions + IMC_SL_UI, CommonUI data tables/UCommonUIInputData, and
the CD_SL_* controller glyph data.

#What's in place

LayerFileStatus
Layout rootPublic/UI/SLPrimaryGameLayout.h/.cpp✅ four layers (HUD Overlay + Game/Menu/Modal stacks) wired via meta=(BindWidget). PushWidgetToLayer + GetForPlayer helpers exist.
HUD bootstrapPublic/UI/SLPlayerHUD.h/.cpp✅ creates the layout + HUD widget in BeginPlay; exposes PushToGameStack/PushToMenuStack/PushToModalStack
HUD widgetPublic/UI/SLHUDWidget.h/.cpp✅ persistent overlay — reticle, ammo, health, damage, notifications
Activatable basePublic/UI/SLCommonActivatableWidget.h/.cpp⚠️ exists but minimal — has AutoFocusWidget + bAutoFocusOnActivate, no input-method-change handling, no GetDesiredFocusTarget override
Viewport clientPublic/UI/SLGameViewportClient.h/.cpp✅ inherits UCommonGameViewportClient; set in DefaultEngine.ini as GameViewportClientClassName
Layer enumPublic/Types/ESLUILayer.h✅ HUD / Game / Menu / Modal
Build depsSystemLinkCore.Build.csCommonUI, CommonInput, EnhancedInput, UMG, Slate, SlateCore linked
PluginSystemLink.uprojectCommonUI enabled
Game defaultConfig/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-in FBindUIActionArgs uses its own UCommonInputActionDataBase data tables, NOT raw EnhancedInput UInputActions — see §4.2.
  • No UCommonInputActionDataBase data tables or UCommonUIInputData data asset configured. Without these, the CommonActionWidget button-prompt icons in CommonBoundActionBar show as [NONE].
  • No controller data assets (UCommonInputBaseControllerData subclasses) — 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 raw UButton, which does not work with controller focus navigation.
  • No pop / back logic in USLPrimaryGameLayout — only PushWidgetToLayer. CommonUI handles back via CancelAction, but the USLPrimaryGameLayout should expose a wrapper for explicit BP pops.
  • No input mode switching when a menu opens — ASLPlayerController is permanently in GameOnly mode. Opening a menu doesn't change focus / show cursor / freeze gameplay input.
  • No pause integrationUGameplayStatics::SetGamePaused not 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.
  • USLCommonActivatableWidget doesn't subscribe to UCommonInputSubsystem::OnInputMethodChangedNative — gamepad ↔ KBM transitions don't trigger focus restore (the older reference project at C:\3D-DEV\HaloProject\SystemLink does 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 classWhat to takeNotes
SystemLinkActivatableWidgetThe HandleInputMethodChanged + TryRestoreFocusIfNeeded + FocusDefaultTargetIfPossible patternSubscribes 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.
SystemLinkButtonBaseText styling + description text + uppercase toggleStraight subclass of UCommonButtonBase.
SystemLinkConfirmModalOk / YesNo / OkCancel modal pattern with UCommonBoundActionBarThe action bar lights up controller prompts at the bottom of the modal.
SystemLinkTabListWidgetBaseTab list with input switchingInherits UCommonTabListWidgetBase.
Content/Blueprints/UI/Input/Actions/* + CUI_SystemLinkInputActionDataTableThe CommonUI input data table schemaDon'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=Falsewe 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.

#HUD children: container-and-class vs designer-placed (decided 2026-08-06)

USLHUDWidget hosts its children two different ways. This looks arbitrary but is not, and the split should be preserved rather than unified:

**Use a bound container + a TSubclassOf property, created at runtime, when the widget's CLASS varies
at runtime or the widget is TRANSIENT.**
**Use a designer-placed BindWidget / BindWidgetOptional when there is exactly one, it is always
present, and it is never swapped.**
ChildPatternWhy
ReticleReticleContainer + per-weapon classClass varies per weapon; instances are cached and reused
Notifications / pickup promptNotificationContainer + classSeveral classes, and transient by nature
Sidearm ammoSidearmAmmoContainer + WeaponData->AmmoWidgetClassClass comes from the equipped sidearm's data asset
Health widgetHealthContainer + HealthWidgetClassDoes not need it — see note below
Damage overlayDamageOverlayContainer + classDoes not need it — see note below
AmmoStripdesigner, BindWidgetOne class, always present
GrenadeIndicatordesigner, BindWidgetOptionalOne class, optional per HUD

Health and damage overlay are the two that do not strictly need the indirection — one class each, always present, never swapped. They were deliberately left as-is (2026-08-06): converting them would buy designer-visible layout and a compile-time bind error instead of an unset TSubclassOf failing silently, but it would cost a HUD Blueprint subclass the ability to point at a different health widget without rebuilding the layout. That configurability matters for MenuKit as a Fab product, where a licensee swapping in their own widget is a feature, and WB_DamageHudOld shows the swap has been used before. Not worth refactoring working code either direction — but new children should follow the rule above rather than copying whichever neighbour they happen to sit next to.

BindWidgetOptional binds null on a name mismatch with no warning (§6.1). Plain BindWidget fails the Blueprint compile instead, so prefer it whenever the child is genuinely mandatory.

#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, ...)

  • InputData points to a Blueprint asset deriving from UCommonUIInputData. That class
  • exposes two FDataTableRowHandle slots: DefaultClickAction and DefaultBackAction. Both should point to rows in a UCommonInputActionDataBase data table (next bullet).

  • bEnableEnhancedInputSupport=True is 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:

  1. DT_SL_InputActionsUDataTable of row type CommonInputActionDataBase. One row per
  2. UI action (Confirm, Back, Navigate, NextTab, PrevTab, Pause, Inspect, Reset). Each row carries:

    • DisplayName ("Accept", "Back", "Pause") — shown in action bar
    • KeyboardInputTypeInfo / GamepadInputTypeInfo / TouchInputTypeInfo — per-platform key + icon override
  1. DA_SL_CommonInputDataUCommonUIInputData Blueprint 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:

ActionKBM keyXboxPS
IA_UI_ConfirmEnter / SpaceACross
IA_UI_BackEscBCircle
IA_UI_Navigate (Vector2D)WASD / ArrowsLeftStick + DPadLeftStick + DPad
IA_UI_NextTabERBR1
IA_UI_PrevTabQLBL1
IA_UI_PauseEsc / PStartOptions

IMC_SL_UI must be applied while a menu is open — and MUST NOT be applied outside one. Both halves bite, in opposite directions, and each one cost a session (2026-07-15):

  • Applied only while a menu owns input — because CommonUI resolves every glyph through
  • QueryKeysMappedToAction, which reports only contexts applied right now. No live context → no key → NoBrush → the action widget collapses itself. See §6.32.

  • Never applied during play — the UI actions share keys with gameplay (Confirm=A/Space vs Jump=A/Space;
  • NextTab=E/RB vs Interact=E/RB; PrevTab=Q/LB vs SidearmMode/Grenade) and UInputAction defaults to bConsumeInput=True. Applied at a higher priority during play, the UI actions silently eat those gameplay actions — jump/interact/grenade just stop working, with nothing in the log. See §6.33.

Where it's wired (as built): ASLPlayerController::UIInputMappingContext, added/removed inside Push/PopGameplayInputSuppression — the ref-counted "a menu owns input" signal that already swaps the gameplay contexts out. Gameplay contexts off ⇄ UI context on, one place, every close path, ref-counted for stacked menus. It is deliberately not a member of InputMappingContexts: that array is the set suppression removes, and this context takes the opposite path.

Known limit: glyphs can only resolve while a menu is open. That's fine for menus, but a HUD prompt
outside a menu ("Press [A] to pick up") will render blank. When that's needed, don't just leave the context
applied — set bConsumeInput=False on the IA_SL_UI_* actions first, or the collisions above come back.

#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 sets InputModeOnActivate = Menu and bIsBackHandler = true. Focus
is handled by the base USLCommonActivatableWidget (deferred re-focus, footgun 6.28b); no bAutoActivate
override (let the stack drive activation, footgun 6.10).
- USLCommonActivatableWidget — now also carries InputModeOnActivate (ESLUIInputMode::GameAndUI/Menu)
and implements GetDesiredInputConfig()FUIInputConfig with MouseCaptureMode::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
OnModalResult on it. Buttons are ConfirmButton/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.
- USLTabListWidgetDefaultTabButtonClass + RegisterTabWithLabel(TabId, Label, Content, Index=-1)
(labels the created USLButtonBase via HandleTabCreation); auto-selects the first tab. Engine drives LB/RB
cycling + the linked switcher — call SetLinkedSwitcher() then RegisterTabWithLabel() per panel from the
owning Settings screen.
- USLListEntryWidgetUCommonUserWidget + IUserObjectListEntry row 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 (LabelText bind) + 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:
- USLToggleRowbool; SetValue/ToggleValue, OnValueChanged(bool).
- USLSliderRowfloat + MinValue/MaxValue/StepSize; SetValue/SetValueFromNormalized,
GetNormalizedValue() for the slider widget, OnValueChanged(float).
- USLDropdownRow — option list + index; rotator-friendly SelectNext/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_ToggleUCheckBox or custom CommonButton toggle
  • USLSettingsRow_SliderUSlider + value label
  • USLSettingsRow_DropdownUCommonRotator (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:

  1. Reject if not local controller or character dead.
  1. HUD->PushToMenuStack(PauseMenuClass) — input mode + cursor handled by activatable widget's
  2. GetDesiredInputConfig.

  1. UGameplayStatics::SetGamePaused(this, true) — but only on standalone / listen-server host.
  2. 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 = true AND Visibility != Collapsed/Hidden to 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:

  1. Skip if input type isn't gamepad
  1. Skip if a descendant already has focus (HasFocusedDescendants())
  1. Skip if a text-entry field is focused (don't steal focus while user is typing)
  1. Skip if we aren't the active widget on the stack
  1. Otherwise: NativeGetDesiredFocusTarget()->SetUserFocus(PC)

#5.3 Action bar

UCommonBoundActionBar is the strip at the bottom of a menu that shows "A AcceptB Back

Y Reset" with platform-appropriate icons. It picks these up from:

  • The active widget's RegisterBinding calls (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);

⚠️ Corrected 2026-07-15 — an earlier version of this section claimed the bar must be in the same widget tree. It doesn't. UCommonBoundActionBar::UpdateDisplay() gathers bindings from the local player's UCommonUIActionRouterBase subsystem, not from its own tree, and shows whatever the active input path has registered. So one bar in a shared layout serves every screen — you don't need one per screen (and two visible bars would both show the same actions).

What the bar actually requires:

RequirementWhereBites if missing
ActionButtonClass set on the barbar instanceCompile error (self-enforcing — the one safe link)
Entry widget has InputActionWidget and Text_ActionNameWBP_SL_BoundActionButtonSee §6.35 — blank button, no text either
bDisplayInActionBar = true on the bindingFBindUIActionArgsAction works, never displays
bIsBackActionDisplayedInActionBar = true for the back actionactivatable widget — defaults falseB/Esc works but never appears (§6.35)
A display name (OverrideDisplayName → else InputAction->ActionDescription)binding / the UInputActionCorrect glyph, blank label
The UI IMC appliedASLPlayerController (§4.2)ActionValidForInputType fails → row filtered out entirely

USLScreenWidget sets bIsBackHandler and bIsBackActionDisplayedInActionBar in its constructor, so every SL screen advertises its back action without per-screen setup.

#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.

#5.5 CommonUI override reference — every function you touch, per class

The single most confusing thing about CommonUI is knowing which function to override to change a behavior, because the behavior is spread across a class hierarchy and half of it is Native (C++) with a parallel BP_ (Blueprint) event. This is the map. "Where" = C++ Native… override or BP graph event.

#UCommonActivatableWidgetUSLCommonActivatableWidget / USLScreenWidget (every screen, modal, overlay)

FunctionWhereOverride it to…Footgun
GetDesiredFocusTarget() / BP_GetDesiredFocusTargetBP "Get Desired Focus Target" graph override (screens) or C++ NativeGetDesiredFocusTargetReturn the control focus lands on when the screen opens. Mandatory on every screen.Returning null = un-navigable on a controller (§6.29). BP screens must use the graph override — AutoFocusWidget is C++-only (not BP-exposed) because the CDO picker reverts + crashes. The compile guard enforces this.
GetDesiredInputConfig()C++ GetDesiredInputConfigSet input routing (ECommonInputMode::Game/Menu/All), mouse capture, cursor. This is the CommonUI way to switch input mode — never call SetInputMode_* yourself.Our base already implements it off InputModeOnActivate. Get it wrong and gameplay leaks under the menu, or the game stays dead after close (§6.30).
NativeOnActivated() / BP_OnActivatedC++ NativeOnActivated (call Super) or BP "On Activated"Run per-open logic (pause, bind input-method-changed, focus).Don't redeclare BP_OnActivated in a C++ subclass (§6.2). Always call Super::NativeOnActivated().
NativeOnDeactivated() / BP_OnDeactivatedsamePer-close cleanup (unpause, unbind, restore game input).Must balance whatever NativeOnActivated pushed (ref-counted suppression, delegates).
NativeOnHandleBackAction()C++Intercept B/Circle/Esc before the default pop (e.g. cancel an edit first, or confirm-on-quit). Return true if handled.Only fires if bIsBackHandler = true. Leave that false on HUD-tier overlays or they eat the back press meant for the menu (§5.4).
bIsBackHandler (prop)BP Class Defaults / ctorMark the screen as the back handler.USLScreenWidget sets it true; overlays must stay false.
bIsModal (prop)BP Class DefaultsBlock input to layers beneath.Blocking ≠ visual dim — add your own dark UImage (§6.25).
bAutoActivate (prop)Leave false. Let the stack drive activation.True bypasses the stack lifecycle (§6.10).

#UCommonButtonBaseUSLButtonBase (every clickable)

FunctionWhereOverride it to…Footgun
OnClicked() (BlueprintAssignable)BP graph "On Clicked" or C++ NativeOnClickedReact to click and gamepad-confirm (same event).OnDoubleClicked is mouse-only — never gate confirm logic on it (§6.18).
NativeOnCurrentTextStyleChanged()C++Re-apply the label's text style when the button's style changes.Our base syncs ButtonText here.
NativeOnAddedToFocusPath() / NativeOnRemovedFromFocusPath()C++Surface description/tooltip on focus (controller-reachable), not just hover.Hover is mouse-only — don't put important info in a hover tooltip (§6.19).
NativeOnHovered() / NativeOnUnhovered()C++Mouse-only hover cosmetics.Pair with the focus-path pair above so controller users get parity.
NativeOnSelected() / NativeOnDeselected()C++Toggle/tab-button selected state (radio behavior).Only meaningful when GetButtonSelectability allows selection.
Style (prop)BP Class DefaultsGive the button a CommonButtonStyle. Mandatory.No style = invisible, label-less widget + a CommonUI warning. Menus place WBP_SL_Button, never raw USLButtonBase (Docs/ButtonWidget.md).

#List rows — IUserObjectListEntryUSLListEntryWidget

FunctionWhereOverride it to…Footgun
**NativeOnListItemObjectSet(UObject*) / "On List Item Object Set"**C++ or BPPopulate the row from its data object.The row is recycled/virtualized — repopulate fully every call, don't assume first-time state.
NativeOnItemSelectionChanged(bool) / "On Item Selection Changed"C++ or BPReact to the row being selected.
NativeOnEntryReleased() / "On Entry Released"C++ or BPRelease cached refs when the row is recycled out.Host in UCommonListView, never a hand-filled box (§6.8).

#Tabs — UCommonTabListWidgetBaseUSLTabListWidget

FunctionWhereOverride it to…Footgun
HandleTabCreation_Implementation(FName, UCommonButtonBase*)C++Style/label each tab button as it's created.
SetLinkedSwitcher() + RegisterTab…()C++ call orderLink the switcher first, then register tabs.Engine drives LB/RB cycling once linked — don't hand-wire tab input.

#Action bar — UCommonBoundActionBar + UCommonBoundActionButton

PieceRequirementFootgun
Action Button widget (WBP_SL_BoundActionButton)Must contain a CommonTextBlock named exactly Text_ActionName (engine BindWidget).Wrong name → won't compile-bind the prompt.
Screen RegisterBinding(FBindUIActionArgs) with bDisplayInActionBar=trueAdds A/B/X/Y prompts.Only shows if a UCommonBoundActionBar is in the same widget tree (§5.3).

#Input subsystem — UCommonInputSubsystem (bind, don't subclass)

MemberUseFootgun
OnInputMethodChangedNativeBind in NativeOnActivated to restore focus on gamepad after mouse use (§5.2).On PIE the initial type is KBM until touched — force-call the handler once on activate (§6.21).
GetCurrentInputType()Gate "force focus" to gamepad only.Forcing focus on KBM fights the user.
SetInputTypeFilter()A "lock input type" accessibility setting.Mouse movement flips type to KBM constantly (§6.20).
The one-screen checklist: every new screen needs exactly two things authored, everything else is inherited —
(1) a Get Desired Focus Target graph override returning its first control, and (2) buttons in a
box, not a canvas (§6.17). If both are done and it still won't navigate, open the Widget Reflector (§6.28).

#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:

  1. Push widget to MenuStack
  1. Set input mode GameAndUI
  1. Set show mouse cursor true
  1. 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:

  1. Implement IUserObjectListEntry
  1. Override NativeOnListItemObjectSet(UObject* InObject) to populate from data
  1. The row class is set on the ListView (EntryWidgetClass), and you call SetListItems(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):

  1. Fade with Render Opacity, not Visibility. Keep the widget Visible/SelfHitTestInvisible throughout
  2. (so it stays focusable); animate Render Opacity 0→1. Animating Visibility is the usual culprit.

  1. Re-assert focus after the transition in USLCommonActivatableWidget (see §4.1) — not just at
  2. 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.

#6.29 The AutoFocusWidget CDO picker silently reverts to blank (CONFIRMED, cost a session)

Symptom: you set Auto Focus Widget = Btn_Resume in a screen's Class Defaults, compile — and the field is blank again next time you look; on a controller the wrong button (or nothing) is focused. The buttons are variables; it's not that.

Cause: a UWidget* UPROPERTY on the CDO references a widget that lives in the WidgetTree. The UMG compiler regenerates the tree on compile and can't reliably re-bind that CDO→tree reference, so it clears to null. (Repeatedly poking that details-panel object picker also tends to crash the UMG editor — PropertyPathUMGEditor assertion in UObjectHash.)

Fix — use the graph override: in the widget Graph, override Get Desired Focus Target (BP_GetDesiredFocusTarget) and return the button variable (e.g. Btn_Resume). (As of 2026-07-09 AutoFocusWidget is no longer BP-exposed — the picker is gone entirely, so the graph override is the only BP path. AutoFocusWidget survives as a C++-only convenience.) Leave Auto Focus Widget blank. USLCommonActivatableWidget::NativeGetDesiredFocusTarget() returns AutoFocusWidget if set, else falls through to the BP override — so a blank field + this override focuses correctly, persists, and never touches the crashy picker. This is CommonUI's intended pattern; the AutoFocusWidget field stays only as a convenience for the rare case it holds. → 2026-07-09 pause-menu debug.

Enforced at compile time. USLScreenWidget::ValidateCompiledWidgetTree emits a **Widget BP compile
error** if a screen has neither AutoFocusWidget set nor a Get Desired Focus Target graph override — so
you can't ship an un-navigable screen by accident. New screens will show that error until the override is
added (that's the nudge). Downgrade CompileLog.ErrorWarning in SLScreenWidget.cpp if it's ever too
strict (e.g. a C++ screen subclass that overrides NativeGetDesiredFocusTarget instead).

#6.30 Closing a menu doesn't hand input back to the game (CONFIRMED)

Symptom: the pause menu closes fine, but the game is dead afterward — no mouse-look, no keyboard, clicking the viewport doesn't help. Cause: CommonUI applies a Menu input config while a screen is active but only reverts to another activatable widget's config. Our gameplay/HUD layer isn't an activatable widget, so when the last menu closes there's nothing to revert to and input routing/viewport focus is left in menu land. Fix: explicitly restore FInputModeGameOnly + SetAllUserFocusToGameViewport() when the last menu closes — hooked to the input-suppression 1→0 pop (ASLPlayerController::RestoreGameInputMode), so it fires for every close path (Resume click, B/Circle, Esc). → 2026-07-09.

#6.31 Don't use bShouldSelectUponReceivingFocus for focus highlighting — selection is sticky (CONFIRMED, cost real time)

Symptom: you enable bShouldSelectUponReceivingFocus so a focused button shows the Selected style, and now every button you navigate past stays selected — the whole menu lights up.

Cause: the flag selects on focus-in (UCommonButtonBase::HandleFocusReceivedSetIsSelected(true)) but CommonUI has no deselect on focus-out — selection is designed to persist (it's for toggles/tabs).

What does NOT work: trying to "complete the pair" by deselecting in NativeOnRemovedFromFocusPath / NativeOnFocusLost on the widget. CommonButton drives selection off its internal Slate button's focus delegates (HandleFocusReceived/HandleFocusLost), not the UUserWidget focus path — so those overrides don't reliably fire, and the buttons stay stuck selected. (We tried this; it didn't work.)

The fix — don't use Selection for focus highlighting at all. CommonUI already shows the Hovered brush on focus, natively and non-stickily (reverts to Normal when focus leaves). Style the Hovered brush as your focus look and leave bSelectable/bShouldSelectUponReceivingFocus off. In our style the Hovered brush was already the intended white-outline look, so reverting the selection flags gave the identical visual, non-sticky, zero code. → 2026-07-09. (Reserve real Selection + UCommonButtonGroupBase single-selection for genuine radio/tab state, not for "which button is focused.")

#6.32 A UCommonActionWidget shows its glyph in the designer and nothing at runtime (CONFIRMED, cost a session)

Symptom: WBP_SL_ButtonTile shows a crisp "A" in the UMG designer. In PIE the label renders and the glyph is simply absent — no warning, no log line, no empty box. Nothing looks broken; the icon just isn't there.

Cause 1 — the designer is lying to you. UCommonActionWidget::GetIcon() has a WITH_EDITORONLY_DATA branch that bypasses the entire runtime pipeline and renders DesignTimeKey. A glyph in the designer only proves DesignTimeKey is set to a gamepad key. It carries zero information about runtime. Do not use the designer to validate prompt icons — ever. Nothing about the real path is exercised until PIE.

Cause 2 — the runtime path bails silently by design. UpdateActionWidget() calls SetVisibility(ESlateVisibility::Collapsed) whenever it can't resolve a brush (Icon.DrawAs == NoDrawType), which is the correct behaviour (no glyph beats a broken glyph) but means every failure is invisible. It collapses if any link in this chain is missing:

  1. the UCommonActionWidget is named exactly InputActionWidget (BindWidgetOptional on
  2. UCommonButtonBase — a mismatch binds null with no warning, §6.1);

  1. an action actually got pushed into it (see §6.33 — the fallback is hover-gated);
  1. QueryKeysMappedToAction returns a key → the IMC must be applied right now (§4.2);
  1. the key maps to a brush in a registered CD_SL_* whose GamepadName matches DefaultGamepadName.

Debug order (cheapest first): confirm the widget's name; then whether the IMC is applied (get_referencers on the IMC — ours was orphaned, referenced by nothing, which is what a never-applied context looks like from the outside); then dump QueryKeysMappedToAction live in PIE. Don't theorise about brushes until you've proven a key comes back. → 2026-07-15.

#6.33 The default-click glyph fallback is gated on mouse hover, so it never fires on a gamepad (CONFIRMED)

Symptom: the glyph appears when you mouse-hover a button, but navigating to it with a gamepad shows nothing — even though the button is clearly focused and DA_SL_CommonInputData.EnhancedInputClickAction is set.

Cause: UCommonButtonBase::UpdateInputActionWidget() only reaches the default-click-action fallback via bShouldUseFallbackDefaultInputAction && bButtonEnabled && IsHovered(). IsHovered() is the internal Slate button's mouse hover. NativeOnFocusReceived never sets it, and UCommonButtonBase has no focus-path handlers at all — focus ≠ hover in CommonUI. With no TriggeringInputAction / TriggeringEnhancedInputAction set, a gamepad-focused button takes the else branch, pushes an empty action, and collapses.

The tempting fix that is wrong: setting TriggeringEnhancedInputAction = IA_SL_UI_Confirm on the button. It resolves the glyph unconditionally, but it also registers a UI action binding for A (BindTriggeringInputActionToClick). FActionRouterBindingCollection::ProcessNormalInput matches bindings by registration order, gated on IsWidgetReachableForInput — which is reachability, not focus. Give all three tiles the same action and A always clicks the first one regardless of what's focused.

The fix: drive the action widget from focus on USLButtonBase (set the enhanced input action on focus received, clear it on focus lost). Glyph follows focus; no competing click binding. Per-button TriggeringInputAction is for buttons that own a distinct action (hold-to-confirm), not for "A confirms the focused thing" — that's what focus + the default click action already do. → 2026-07-15.

#6.34 "On Focus Lost" is the WRONG node on a CommonButton — its partner is "On Unfocused" (CONFIRMED, cost real time)

Symptom: you drive a focus visual (border DMI, highlight) from On Focused → set, On Focus Lost → clear. Focus-in works; focus-out never fires, so every button you navigate past stays lit. Looks identical to sticky selection (§6.31) but selection is off and the style brushes are innocent — a real red herring.

Cause: two different classes expose confusingly-named focus events, and the BP palette shows both:

Node labelActual sourceFires on a CommonButton?
On FocusedUCommonButtonBase::BP_OnFocusReceived
On UnfocusedUCommonButtonBase::BP_OnFocusLost✅ ← the real partner
On Focus ReceivedUUserWidget::OnFocusReceived (returns FEventReply)
On Focus LostUUserWidget::OnFocusLost (has an In Focus Event pin)never fires

UCommonButtonBase::NativeOnFocusReceived immediately forwards user focus to its internal SCommonButton (SetUserFocus(RootButton->GetCommonButton())), so the UUserWidget never holds focus and therefore never loses it. CommonButton's own events are bound to the internal button's delegates (RootButton->OnReceivedFocus/OnLostFocusHandleFocusReceived/HandleFocusLost, CommonButtonBase.cpp:503). On Focused (CommonButton) paired with On Focus Lost (UUserWidget) is a mismatched pair from two classes — set fires, clear doesn't.

Tell them apart in the graph: the UUserWidget one has an In Focus Event pin. CommonButton's has none.

Fix: pair On Focused with On Unfocused. Never mix the two families.

Related asymmetry (why the description events DO work): focus-path events (NativeOnAddedToFocusPath / NativeOnRemovedFromFocusPath, used by USLButtonBase for descriptions) fire correctly, because the path includes ancestors — the widget is in the focus path even though the inner Slate button holds the focus. Direct focus events are the ones that don't. Same root cause as §6.31's failed deselect hook. → 2026-07-15.

#6.35 The bound action bar: back doesn't display, and a missing glyph widget blanks the LABEL (CONFIRMED)

Three separate traps, each producing "the command bar doesn't work":

(a) bIsBackActionDisplayedInActionBar defaults to false. bIsBackHandler = true makes B/Esc function; advertising that action to the bar is a separate opt-in (CommonActivatableWidget.h:193). Back works but never appears — which reads as a broken action bar rather than an unset flag. USLScreenWidget's constructor sets both, so don't re-diagnose this per screen.

(b) No InputActionWidget ⇒ blank button, INCLUDING the text. The whole of UCommonBoundActionButton::UpdateInputActionWidget() is wrapped in if (InputActionWidget) — and Text_ActionName->SetText(...) lives inside that check:


if (InputActionWidget) //optional bound widget

{

    InputActionWidget->SetInputActionBinding(BindingHandle);

    FText ActionDisplayName = BindingHandle.GetDisplayName();

    ...

    if (Text_ActionName) { Text_ActionName->SetText(ActionDisplayName); }

    OnUpdateInputAction();

}

So the entry widget needs both InputActionWidget (a UCommonActionWidget) and Text_ActionName (a UCommonTextBlock), named exactly — both are BindWidgetOptional, so a name typo is silent (§6.1). Authoring only Text_ActionName (as an earlier CurrentFocus note suggested) yields a button that renders nothing, and the empty label sends you hunting the text when the glyph widget is what's missing.

(c) Blank label with a correct glyph = no display name. Precedence is OverrideDisplayName → else InputAction->ActionDescription (UIActionRouterTypes.cpp:124/169). On the enhanced-input path with both empty, the glyph resolves and the text is empty. Set ActionDescription on the IA_SL_UI_* asset (data-driven, serves every binding) or OverrideBackActionDisplayName per screen.

Not a requirement: the bar does not need to be in the same widget tree — it reads the local player's action router (§5.3). → 2026-07-15.


#7. Build Order

Recommended sequence. Each step is small, testable in isolation, and doesn't depend on later steps to work.

  1. Author UI input actions + IMC (IA_UI_*, IMC_SL_UI) — empty handlers, just wire keys.
  2. Test: enable IMC programmatically, press gamepad A, verify IA_UI_Confirm triggers in a print.

  1. Author CommonUI data assetsDT_SL_InputActions, DA_SL_CommonInputData, four
  2. 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).

  1. Enhance USLCommonActivatableWidgetGetDesiredFocusTarget, GetDesiredInputConfig,
  2. 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.

  1. Author USLButtonBase — port SystemLinkButtonBase from the old project. Reskin in
  2. WBP_SL_Button_Default. Verify it appears in BP dropdowns and navigation between two of them in a VerticalBox works on gamepad.

  1. Pop / clear API on USLPrimaryGameLayoutPopWidgetFromLayer, GetActiveWidget,
  2. ClearLayer. Test: push two widgets, pop top, verify second activates.

  1. Pause menuWBP_SL_PauseMenu (subclass of USLScreenWidget). Three buttons: Resume,
  2. 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.

  1. USLModalWidget — Ok / YesNo modal with action bar. Quit-to-desktop confirm flow uses it.
  1. USLTabListWidget — tab + content switcher for settings.
  1. Settings page skeletonWBP_SL_Settings (tabs: Video / Audio / Controls), three blank
  2. tab content widgets.

  1. Settings rowsUSLListEntryWidget + USLSettingsRow_Toggle, _Slider, _Rotator,
  2. _KeyBind. Persist to USaveGame later (separate work item).

  1. 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:

  1. Pause behavior in multiplayer — does the pause menu pause time? (If listen server, yes is
  2. fine. If dedicated future-proofing, no — menu UI shows but game runs.)

  1. Settings persistenceUSaveGame slot? GameUserSettings.ini? JSON? Affects how
  2. settings rows wire their values.

  1. Localization — out of scope for now, but every FText in the base classes should use
  2. NSLOCTEXT macro so it's localizable later without a global find/replace.

  1. Input remapping — first-class feature, or save-for-later? If first-class, the runtime
  2. UEnhancedInputUserSettings API (UE 5.3+) is the path; if save-for-later, plan a stub UI.

  1. Initial controller — at game start, do we assume KBM or auto-detect? Auto-detect is the
  2. 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).

  1. Modal stacking — can a modal push another modal? Yes from a UI standpoint, but design-wise
  2. 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).