If you have worked in Unreal Engine C++ for a while, you know the failure mode: the Character, PlayerController and GameMode slowly become giant classes that know about everything. Input, UI, networking, abilities, scoring and cosmetics all end up in the same few files, and every change risks breaking something unrelated.
Epic built the Lyra Starter Game partly to show a different way. It is a complete multiplayer shooter sample, shipped with Unreal Engine 5, and its source is probably the best public example of how Epic itself expects large Unreal projects to be structured.
This post walks through the architecture of Lyra's C++ code: which object-oriented ideas it actually uses, which ones it deliberately avoids, and how you can check claims about coupling with a tool such as CppDepend instead of taking them on faith. I will also be careful to separate what Lyra really does from what is often said about it.
1. What Lyra is, structurally
Lyra is organised in layers:
- The main game module (LyraGame) contains the core C++ framework: characters, player state, game mode, ability system integration, teams, equipment, inventory, camera, settings and so on.
- Reusable plugins hold generic functionality that is not specific to Lyra: Common UI, the Game Feature and Modular Gameplay systems, a gameplay message router, UI extension points, game settings and loading-screen support.
- Game Feature plugins (for example the shooter core content and its maps) hold gameplay that can be added or removed at runtime, mostly as data plus a little code.
- Content (Blueprints, data assets, widgets, animation) sits on top and configures the C++ layer.
The important point is that most of Lyra's architecture lives in how these pieces are connected, not in a single clever class. Keep that in mind, because the rest of this post is about connections.
Taking a brief look in the CppDepend code city, Lyra is clean with a very few complex methods:

As visualized above through CppDepend's 3D Code City metric view (where building height represents method complexity and color painting reflects maintainability), Unreal Engine 5's Lyra Starter Game exhibits an exceptionally clean architectural layout. The vast majority of districts remain flat, green, and well-structured, showing that Epic Games' core engineering team maintained strict discipline. Only a handful of localized structures rise into warmer, taller blocks, highlighting the specific subsystems where complex logic is safely isolated.
2. Principles and where to find them
Here is a map you can use while reading the source. Each row names an OOP or design principle, the Lyra mechanism that implements it, and a class to open first.
| Principle | Lyra mechanism | Start reading |
|---|---|---|
| Separation of concerns | Controller, pawn and player state each own a narrow slice of responsibility | ALyraPlayerController, ALyraPlayerState, ALyraCharacter |
| Composition over inheritance | Behaviour added through components attached to actors | ULyraHealthComponent, ULyraPawnExtensionComponent, ULyraHeroComponent |
| Dependency inversion | Systems talk to interfaces, not concrete actor types | ILyraTeamAgentInterface, IAbilitySystemInterface, ILyraAbilitySourceInterface |
| Data-driven design | Rules and configuration live in data assets | ULyraExperienceDefinition, ULyraPawnData, ULyraAbilitySet, ULyraInputConfig |
| Loose coupling by messaging | Publish and subscribe on gameplay tag channels | the GameplayMessageRouter plugin |
| Controlled object creation | Definitions describe what to spawn, managers create it | ULyraEquipmentDefinition, ULyraEquipmentManagerComponent |
| Managed singletons | Subsystems with an owned lifetime | ULyraTeamSubsystem, game instance and world subsystems |
3. Modularity: modules and Game Features, not namespaces
A common claim, including in some write-ups about Lyra, is that it breaks up the monolith with namespaces by feature. That is not really how Unreal projects are organised, and Lyra is no exception.

Unreal's convention is to use type prefixes (U for UObject-derived classes, A for actors, F for structs, E for enums, I for interfaces) and module boundaries, not feature namespaces. There is also a practical reason: the Unreal Header Tool, which generates reflection code, expects reflected types (UCLASS, USTRUCT, UENUM) to be declared at global scope rather than inside a namespace. So the largest part of Lyra's class set is simply not namespaced.
Namespaces still appear in Lyra in two small but useful ways:
- Named namespaces for constants and helpers, for example grouping gameplay tag declarations so they read as
LyraGameplayTags::SomeTaginstead of being loose globals. - Anonymous namespaces in .cpp files, which keep file-local helpers and constants from leaking into the rest of the build. This is the C++ replacement for file-level static and a good habit in any codebase.
// In a .cpp file: private to this translation unit
namespace
{
constexpr float SomeLocalTuningValue = 0.25f;
bool IsValidThing(const FThing& Thing)
{
return Thing.Id != INDEX_NONE;
}
}
The real modular structure in Lyra is built from three layers of separation:
- Unreal modules (separate build units with explicit dependency lists in their .Build.cs files). This is the compile-time boundary.
- Plugins with their own modules, so reusable systems do not depend on game-specific code.
- Game Feature plugins, which can be activated and deactivated at runtime and which depend on the core, never the other way around.
If you want a dependency rule that actually protects you, start with this one: core modules must never reference Game Feature plugins. That is a stronger guarantee than any namespace convention.
4. The Experience system: Lyra's composition root
If you read only one part of Lyra's architecture, read this one.
An Experience is a data asset (ULyraExperienceDefinition) that describes a game mode configuration: which Game Feature plugins to enable, which pawn data to use, and which action sets to run. When a match starts, the game mode chooses an experience from several possible sources (URL options, world settings, user selection, developer settings), and a component on the game state (ULyraExperienceManagerComponent) loads it asynchronously.
The sequence, simplified, is:
- The game mode determines which experience should be used.
- The experience manager loads the experience asset and any assets it references.
- Required Game Feature plugins are loaded and activated.
- Each Game Feature Action in the experience runs. Typical actions add components to actors, grant abilities, add input bindings, or add widgets to the UI.
- The manager announces that the experience is loaded, and interested systems (called in priority order) finish their own setup.
In design-pattern terms, the Experience is a composition root: the one place where the application is assembled from its parts. Individual classes do not decide what they are connected to. They declare what they need, and the experience wires them together. This is the main reason gameplay modes like the shooter and the top-down sample can share the same core without the core knowing either exists.
5. Interfaces: contracts that hide concrete types
Unreal interfaces come in pairs: a U class for reflection and an I class that holds the actual contract. Lyra uses them where several unrelated types must be treated the same way by some other system.
// Simplified shape of a Lyra-style interface
UINTERFACE(meta = (CannotImplementInBlueprint))
class ULyraTeamAgentInterface : public UGenericTeamAgentInterface
{
GENERATED_BODY()
};
class ILyraTeamAgentInterface : public IGenericTeamAgentInterface
{
GENERATED_BODY()
public:
// Anything that has a team exposes the same contract,
// whether it is a player state, a bot or a placed object.
};
The benefit is that a scoring rule, a damage check or a UI element can ask does this thing belong to a team? without knowing whether it is looking at a human player, an AI-controlled pawn or a world object. Lyra also routes most team queries through a world subsystem (ULyraTeamSubsystem), so game code asks the subsystem questions such as whether one object can damage another, instead of casting around.
Other interface examples worth reading:
- IAbilitySystemInterface (from the engine's Gameplay Abilities plugin) lets any actor expose its ability system component.
- ILyraAbilitySourceInterface lets weapons and similar sources describe things like distance falloff for damage without the damage code knowing about specific weapons.
- Interaction interfaces let any object declare itself interactable and describe the interaction options.
- IGameFrameworkInitStateInterface lets components participate in a shared initialization state machine (more on this below).
Two honest caveats. First, Unreal interface casts go through reflection and are not free, so avoid them in per-frame hot loops over thousands of objects. Second, interfaces work best at a handful of well-chosen seams. An interface for everything gives you indirection without benefit.
6. Controller, pawn and player state: who owns what
It is often stated that Lyra moves input handling into a rewritten player controller. The reality is more interesting, and it is a better lesson.
| Class | Main responsibility in Lyra |
|---|---|
ALyraPlayerController | Player-facing glue: camera assist, team agent behaviour, settings hooks, and routing to the player state. Deliberately thin. |
ALyraPlayerState | Persistent per-player data: team identity, selected pawn data, and for human players the ability system component that holds abilities and attributes. |
ALyraCharacter | The physical body and its movement. Behaviour is added through components. |
ULyraHeroComponent | Player-specific pawn behaviour: sets up Enhanced Input mapping contexts, binds input to abilities, and manages the camera mode. |
ULyraPawnExtensionComponent | Coordinates initialization between the pawn, the player state and the ability system. |
ULyraHealthComponent | Health, damage and death state, driven by ability system attributes. |
A few details matter here:
- Input binding is not in the controller.
ULyraHeroComponentinitializes player input through the local player's Enhanced Input subsystem and usesULyraInputComponentto bind actions to gameplay tags defined in aULyraInputConfigdata asset. Changing which button triggers which ability is a data change. - The ability system lives on the player state for players, so abilities, cooldowns and attributes survive pawn death and respawn. AI-controlled characters use a variant that owns its own ability system.
- Teams and identity are data on the player state, not code in the controller.
The result is that no single class is the brain. The controller is a connector, the player state is the durable data, and the pawn is a host for components.
7. Composition over inheritance: components and init states
Unreal's actor-component model encourages composition by design, and Lyra leans on it. Instead of subclassing ACharacter into a long chain (ASoldier, AHeavySoldier, ASniperSoldier), it keeps the character class small and attaches components that provide capabilities.
The interesting engineering problem is initialization order. A pawn, its player state, the ability system and the input system all become available at different times, especially in multiplayer where replication timing varies. Lyra solves this with the framework's init state chain, where components register for named states and advance together:
- Spawned
- Data available
- Data initialized
- Gameplay ready
ULyraPawnExtensionComponent acts as the coordinator. Each other component declares which state it can reach and what it depends on, and the framework advances them in order. This replaces a classic source of bugs (BeginPlay ran before the player state was replicated) with an explicit, testable state machine.
Two more places where composition shows up:
- Equipment. An equipment definition lists what to spawn and which abilities to grant, and an equipment manager component equips it. A pistol and a rifle are mostly different data, not different class trees.
- Inventory items. Items are built from fragments, small reusable pieces of data or behaviour attached to an item definition, rather than one class per item type.
To be fair to inheritance: Lyra still uses it, but shallowly. For example, the ability classes have a base class with a few specialised subclasses, and weapon instances derive from a general equipment instance. The pattern is shallow, purposeful hierarchies for contracts, composition for capabilities.
8. Data-driven design
Lyra consistently moves configuration out of C++ and into primary data assets loaded through Unreal's asset manager:
- Experience definitions decide what a game mode contains.
- Pawn data says which pawn class, ability sets, input config and camera mode a player gets.
- Ability sets bundle abilities, effects and attribute sets to grant together.
- Input configs map input actions to gameplay tags.
- Item and equipment definitions describe inventory and weapons.
This gives three practical benefits. Designers can change behaviour without recompiling. Assets can be loaded asynchronously and only when needed. And the C++ classes stay focused on logic, because they no longer carry dozens of tuning fields that belong to content.
The trade-off is indirection: to find out why something behaves a certain way, you often have to look in an asset, not a source file. Good naming and editor tooling matter more in a data-driven codebase.
9. Decoupled communication: messages, tags and extension points
Direct references between systems are the main source of coupling, so Lyra replaces many of them with indirection.
- Gameplay message router. A game instance subsystem lets any system broadcast a message on a gameplay tag channel, and any other system listen on that channel. The health component can announce an elimination without knowing that a scoreboard, a kill feed and an audio system all care.
- UI extension points. UI code registers extension points by tag, and other code (often a Game Feature) contributes widgets to them. The HUD does not need to know in advance which features exist.
- Gameplay tags as a shared vocabulary. Abilities, input, teams and messages all use hierarchical tags, which gives loose coupling with a searchable naming scheme.
- Subsystems. Game instance, world and local player subsystems give you singletons with an owned lifetime and automatic creation and teardown.
// Conceptual publish and subscribe usage
UGameplayMessageSubsystem& Router = UGameplayMessageSubsystem::Get(this);
// Broadcaster: has no idea who is listening
Router.BroadcastMessage(EliminationChannelTag, MessagePayload);
// Listener: has no idea who is broadcasting
Router.RegisterListener(EliminationChannelTag, this, &UMyWidget::OnElimination);
A clarification on terminology: the draft idea that Lyra uses polymorphic singletons is a stretch. Subsystems are lifetime-managed singletons. The polymorphism comes from the interfaces and data they work with, not from the subsystem itself.
The costs are real as well. Tag-based messaging is harder to trace in a debugger than a direct call, tags are effectively stringly-typed identifiers, and ordering between listeners can be subtle. Use it for events that genuinely have many unrelated consumers, not for every function call.
10. Object creation: definitions plus managers
Unscattered object creation is a real architectural concern, but Lyra does not use classic Gang-of-Four factory classes much. What it does is closer to data-driven factories:
// Conceptual: callers supply a definition, the manager does the creating
ULyraEquipmentInstance* ULyraEquipmentManagerComponent::EquipItem(
TSubclassOf<ULyraEquipmentDefinition> EquipmentDefinition)
{
// read the definition, create the instance, spawn the actors,
// grant the abilities, remember it for later removal
}
The calling code never calls NewObject for equipment, never spawns the weapon actors itself, and never grants the abilities. It hands over a definition and gets back an instance. The creation details are centralised, replicated correctly and cleaned up in one place.
On the asynchronous side, Lyra provides async action nodes such as waiting for the experience to be ready, so Blueprint and C++ callers can say run this when the experience has loaded instead of polling or guessing timing.
11. What Lyra Is Not: Beyond Zero-Cost Abstractions — Unreal's Pragmatic C++ Architecture
While modern C++ often leans heavily into template metaprogramming to achieve zero-cost abstractions, Epic Games' Lyra Starter Game chooses a fundamentally different design philosophy—and that contrast is worth highlighting plainly:
- Unreal's reflection system and UObject base class add per-object overhead and a garbage collector.
- Unreal uses its own containers and smart pointers (TArray, TMap, TSharedPtr, TObjectPtr, TSoftObjectPtr) rather than the standard library.
- Exceptions and standard RTTI are off. Unreal has its own reflection and cast system instead.
- Virtual dispatch, interface casts and Blueprint calls are used freely where they improve structure.
- Templates are used where they help (TSubclassOf, weak and soft pointer types, container types), but gameplay architecture is built mainly from classes, components and data, not template metaprogramming.
The optimisation target is different. An MCU firmware project optimises bytes and cycles. A game framework optimises iteration speed, team scalability and content flexibility, and spends its performance budget where it matters (rendering, animation, physics, networking) rather than on shaving a virtual call from gameplay glue.
The common lesson is the same, though: know which abstractions cost something, and pay for them deliberately.
12. Measuring it with CppDepend
Static analysis can turn architectural intentions into checks that fail a build. CppDepend (and tools like it) can load a Visual Studio solution, compute metrics and let you write rules in its LINQ-based query language, CQLinq. A few cautions before you do:
- Filter out engine code and generated files. Unreal's generated headers and engine dependencies inflate coupling numbers. Analyse your own modules and treat engine types as external.
- Measure at module level first. The question does the core depend on features? matters more than any individual class number.
- Do not quote numbers you have not measured. Claims that Lyra has extremely low coupling or a specific abstractness score are only meaningful if you ran the analysis on a known engine version and can reproduce it.
- Abstractness is a poor fit for some Unreal code. Interfaces are only part of the story when so much of the design is data assets and components.
Useful rules to automate for an Unreal project:
- Layer rule: core modules and plugins must not depend on game-specific modules or Game Feature plugins.
- Circular dependency rule: no cycles between modules, and ideally none between major class groups.
- Size rule: flag classes above a threshold of methods, fields or lines of code, which is the cheapest way to detect a growing god class.
- Coupling rule: flag classes that reference too many other types, particularly controllers, game modes and characters.
- Visibility rule: flag public fields that should be private or properties exposed to Blueprint without need.
- Interface rule: flag casts to a concrete gameplay class in code that should be using an interface or a subsystem.
- Complexity rule: flag methods with high cyclomatic complexity or deep nesting.
Run them in CI and treat new violations as build failures, while allowing a baseline of existing ones to be paid down over time. That is how a metric becomes an architectural guardrail.
13. Lessons you can apply to your own project
- Keep the controller thin. Let it connect things, not own them.
- Put durable per-player data on the player state, and let pawns come and go.
- Prefer components to subclasses for capabilities, and keep inheritance shallow.
- Make the initialization order explicit. An init state chain beats hoping that BeginPlay runs in the right order.
- Move tuning and configuration into data assets, and load them asynchronously.
- Use interfaces at a few real seams, not everywhere.
- Use messaging for one-to-many events and direct calls for everything else.
- Assemble the game in one place (an experience, a bootstrap, a composition root) rather than letting classes find each other.
- Enforce module layering with rules, not conventions in a wiki.
- Measure, do not assume. Run the analysis on your own code and look at the numbers over time.
Conclusion
Lyra is valuable not because it uses a long list of textbook patterns, but because it shows how a handful of ideas, thin coordinators, composition, data-driven configuration, interface seams and explicit assembly, keep a large multiplayer codebase changeable. It is a pragmatic Unreal answer to the god-class problem, not a pure OOP exercise, and reading it with that framing teaches more than treating it as a checklist.
Pair that architecture with automated checks for layering, cycles and size, and you get what most teams lack: a design that the build itself defends.
