UE5 Starting Stack
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 an input and UI architecture in Unreal Engine 5 gets complicated quickly. The official LyraStarterGame example uses a strong multi-layered approach built on Common UI and Enhanced Input, but its multiplayer requirements and UIExtension plugin add a lot of machinery.
This article extracts the useful parts of Lyra—Gameplay Ability System (GAS), Common UI, and Enhanced Input—and adapts them to smaller projects with direct 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 to an FGameplayTag, and the Ability System Component listens for those tags. When a character enters a vehicle, we swap the active Input Mapping Context (IMC) in Enhanced Input. The new context emits different tags, routing input to vehicle abilities without conditional checks in the player controller.
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 happens in the overridden GetDesiredInputConfig() method. When the widget activates, such as when opening a menu, Common UI requests this configuration and takes control of 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 screen becomes a direct C++ call: find the target layer by 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 stack keeps Common UI’s input routing and GAS Gameplay Tags while replacing Lyra’s UIExtension machinery with a small UCommonLayersSubsystem. You control layer initialization and routing in C++, without tracing bugs through dozens of connected data assets.
Comments
Server JSON storage