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

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.

#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 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 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);

⚠️ 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:

  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.


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