Skip to content

UE5 Starting Stack

May 12, 2026

A breakdown of a lightweight tech stack based on Lyra's best practices. How to integrate the Gameplay Ability System, Enhanced Input, and Common UI via C++ while eliminating boilerplate.

Building a scalable input and UI architecture in Unreal Engine 5 is a complex task. In the official LyraStarterGame example, Epic Games introduced a brilliant multi-layered approach using the Common UI and Enhanced Input plugins. However, the original implementation is heavily bloated due to multiplayer requirements and the complex UIExtension plugin.

This article demonstrates how to extract the best AAA practices from Lyra—specifically the Gameplay Ability System (GAS), Common UI, and Enhanced Input—and adapt them for simpler tasks using clean, transparent C++ code.

1. Decoupling Input with Gameplay Tags (GAS + Enhanced Input)

The traditional approach to input involves hardcoding actions to specific functions via enums or strings. In this stack, we abandon that completely in favor of routing input entirely through Gameplay Tags.

Every user action (InputAction) maps directly to a specific FGameplayTag. The Ability System Component simply listens for these tags. This provides immense flexibility: if a character enters a vehicle, we simply swap the active Input Mapping Context (IMC) in Enhanced Input. This generates different tags, seamlessly routing input to new abilities without needing any restrictive conditional checks in the player controller code.

2. Automating Focus with UInputRouterActivatableWidget

The Common UI Action Router handles input mode routing brilliantly. However, the base classes require manual setup for every screen. To let the UI automatically decide when it needs mouse capture and when to return control to the game, I use a custom base class UInputRouterActivatableWidget inheriting from UCommonActivatableWidget.

We expose a simple designer-facing enum, EInputRouterInputMode, to Blueprints, allowing designers to easily select the desired input mode for any window:

#pragma once

#include "CoreMinimal.h"
#include "CommonActivatableWidget.h"
#include "InputRouterActivatableWidget.generated.h"

UENUM(BlueprintType)
enum class EInputRouterInputMode : uint8
{
    Default,
    GameAndMenu,
    Game,
    Menu
};

UCLASS()
class PROJECTHORRORPHOTO_API UInputRouterActivatableWidget : public UCommonActivatableWidget
{
    GENERATED_BODY()

protected:
    /** The desired input mode to use while this UI is activated, for example do you want key presses to still reach the game/player controller? */
    UPROPERTY(EditDefaultsOnly, Category = Input)
    EInputRouterInputMode InputConfig = EInputRouterInputMode::Default;

    virtual TOptional<FUIInputConfig> GetDesiredInputConfig() const override;
};

The routing magic happens in the overridden GetDesiredInputConfig() method. When the widget activates (e.g., opening a menu), Common UI automatically requests this configuration and intercepts the input focus:

#include "InputRouterActivatableWidget.h"

TOptional<FUIInputConfig> UInputRouterActivatableWidget::GetDesiredInputConfig() const
{
    switch (InputConfig)
    {
        case EInputRouterInputMode::GameAndMenu:
            return FUIInputConfig(ECommonInputMode::All, GameMouseCaptureMode);
        case EInputRouterInputMode::Game:
            return FUIInputConfig(ECommonInputMode::Game, GameMouseCaptureMode);
        case EInputRouterInputMode::Menu:
            return FUIInputConfig(ECommonInputMode::Menu, EMouseCaptureMode::NoCapture);
        case EInputRouterInputMode::Default:
        default:
            return TOptional<FUIInputConfig>();
    }
}

3. Custom Layer Subsystem: Goodbye Bloated UIExtension

Lyra divides screens into prioritized layers like UI.Layer.Game, UI.Layer.Menu, and UI.Layer.Modal. But it manages this using the highly complex UIExtension plugin. To maintain the benefits of strict layer isolation without the headache, I built a custom UCommonLayersSubsystem inheriting from UGameInstanceSubsystem.

The subsystem uses a straightforward dictionary map linking layer tags to their respective UCommonActivatableWidgetStack:

UCLASS()
class PROJECTHORRORPHOTO_API UCommonLayersSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, Category = "UI")
    virtual void RegisterLayer(UPARAM(meta = (Categories = "UI.Layer")) FGameplayTag Tag, UCommonActivatableWidgetStack* Layer, ESlateVisibility Visibility = ESlateVisibility::Visible);

    UCommonActivatableWidget* PushWidgetToLayer(FGameplayTag Tag, TSubclassOf<UCommonActivatableWidget> WidgetClass);

protected:
    UPROPERTY()
    TMap<FGameplayTag, UCommonActivatableWidgetStack*> Layers;
    
    UPROPERTY()
    TMap<FGameplayTag, ESlateVisibility> LayerVisibility;
};

Adding a new screen becomes a clean, direct C++ function call: find the target layer by its tag and push the widget class to it.

UCommonActivatableWidget* UCommonLayersSubsystem::PushWidgetToLayer(FGameplayTag Tag, TSubclassOf<UCommonActivatableWidget> WidgetClass)
{
    auto Layer = Layers.Find(Tag);
    if (Layer)
    {
        auto Widget = Layers[Tag]->AddWidget(WidgetClass);
        ESlateVisibility Visibility = LayerVisibility[Tag];
        Layers[Tag]->SetVisibility(Visibility);
        return Widget;
    }
    return nullptr;
}

When you open a Main Menu, it can explicitly hide widgets on lower layers, ensuring context isolation and blocking player game input.

4. Dynamic Layers via ExtensionWidget

For elements that aren’t fullscreen windows but rather dynamic HUD injections (buff icons, hit markers, floating notifications), I created an UExtensionWidget class inheriting from UDynamicEntryBoxBase.

Its core advantage is self-registration. Inside its RebuildWidget() method, the widget communicates directly with the layer subsystem and registers itself under a specified tag:

TSharedRef<SWidget> UExtensionWidget::RebuildWidget()
{
    if (!IsDesignTime() && ExtensionLayerTag.IsValid())
    {
        UGameInstance* GameInstance = GetGameInstance();
        if (GameInstance)
        {
            UCommonLayersSubsystem* LayersSubsystem = GameInstance->GetSubsystem<UCommonLayersSubsystem>();
            if (LayersSubsystem)
            {
                LayersSubsystem->RegisterExtensionLayer(ExtensionLayerTag, this);
            }
        }
    }
    return Super::RebuildWidget();
}

Now, from anywhere in the code, we can call PushWidgetToExtensionLayer, pass the appropriate tag, and provide a widget class to dynamically add the instance to the screen.

Summary

This architecture takes the absolute best tools from Epic Games’ standard AAA development. You gain powerful controller routing from Common UI and the deep flexibility of GAS Gameplay Tags. Simultaneously, the custom UCommonLayersSubsystem gives you full C++ control over layer initialization and routing, completely removing the need to hunt for bugs across dozens of interconnected data assets like you would in a full-scale Lyra project.

Comments

Server JSON storage

Other users will see it btw