# Architecture How the project is put together in Unreal Engine 5: modules, lifetimes, authority, content, the C++ and Blueprint boundary, testing and the conventions that follow from all of it. Read this before writing gameplay code; nearly every wiring decision in the other specs follows from something here. The two projects this grew out of solved the same problems in Unity with a dependency-injection container, assembly definitions and a hand-rolled tag type. Unreal has native answers to each: subsystems for lifetimes, modules for dependency direction, Gameplay Tags for vocabulary, the Gameplay Ability System for attributes and abilities, and server-authoritative replication built into the actor model. This document maps the old discipline onto those rather than reintroducing a container the engine does not need. ## Modules Two runtime modules from step 1. Modules are Unreal's real dependency boundary, the equivalent of the old assembly definitions, and the direction between them is the only architectural rule that is enforced by the build. ``` Source/ ├── Core/ rules, data types, tags, the telemetry contract. Knows no AActor, no UWorld. │ ├── Crafting/ pure crafting rules and the definition asset classes │ ├── Combat/ damage maths, threat table, weapon profile │ ├── Movement/ the look model, tuning asset validation │ ├── Stats/ CarryMath and the resistance formula: the pure parts of the stat block │ ├── Tags/ native gameplay tag declarations │ ├── Telemetry/ ITelemetrySink, the event struct, the event name constants │ └── Tests/ automation tests for everything above └── / gameplay. Depends on Core. Actors, components, subsystems, abilities, UI. ├── Core/ GameMode, GameState, PlayerState, PlayerController, GameInstance, subsystems ├── Stats/ the stat block attribute set, the effect helper, effect volumes, surface materials ├── Movement/ ├── Interaction/ ├── Combat/ ├── Crafting/ ├── UI/ └── Tests/ functional and integration tests that need a world ``` `Core` depends on `Core`, `CoreUObject`, `Engine` (for `UPrimaryDataAsset` and `FGameplayTag`), `GameplayTags` and `GameplayAbilities` (for `FGameplayAttribute` in the damage maths). It does **not** depend on the gameplay module, and the gameplay module never has a type the core module needs. If the core module seems to need an actor, the thing it needs is data and belongs in a struct the actor fills in. An editor module (`Editor`) arrives when the first editor tool does, never before. A third runtime module appears when a feature is stable enough to be built on its own; splitting is cheap in Unreal (a folder and a `Build.cs`), so the default is one gameplay module with a folder per feature. **The rule between feature folders:** a feature may use another feature's data types, tags, pure rules and subsystem API. It may never reach into another feature's actors or components by class. Combat asks the crafting subsystem for a weapon profile; it does not `Cast<>` a bench. ## Lifetimes Unreal already has the three lifetimes the old projects modelled with container scopes. | Lives for | Unreal type | Examples here | | --- | --- | --- | | The whole application | `UGameInstanceSubsystem` | Telemetry, the persistence provider, user settings, the content catalogue | | One world (a map, on the server or a client) | `UWorldSubsystem` | Interaction registry, crafting service, activity runtime, encounter spawner | | One player, across pawn deaths and map travel | `APlayerState` (replicated) | Ability system component, attributes, class, the player's persistent identity | | One body | `APawn` / `ACharacter` | Movement, camera, mesh, the interaction and carry components | | A single local player's machine | `ULocalPlayerSubsystem` | Input mode, prompt glyphs, HUD state | Two consequences worth stating: - **The ability system component lives on the player state for players and on the pawn for enemies.** A player's attributes and cooldowns must survive their body dying and respawning; an enemy's die with it. This is the standard arrangement and every ability in [Combat.md](Combat.md) assumes it. - **Everything about a player that would matter outside this session is on the player state or behind the persistence provider, never on the character actor.** Class, progression, identity. A body is a thing the world lends the player for a while. A world subsystem exists on the server and on every client. Authority-only work checks `GetWorld()->GetNetMode()` or lives in the `AGameModeBase` subclass, which only exists on the server. There is no other "is this the host" branch anywhere in gameplay code. ## Authority **Server-authoritative, always.** The client sends intent (an input, a request), the server validates and mutates, replication carries the result back. The client predicts exactly two things: its own movement through the character movement component and the animation of its own abilities through the ability system. Nothing else is predicted, because everything else has a visible victim when the prediction is wrong. This is not a choice made for a four-player game and it is not a choice that scales down. It is the one posture that stays correct as the world grows, and every actor, subsystem and RPC in this project is written as if the server were a separate process on another machine, because in the intended build it is. [Networking.md](Networking.md) has the full authority table and the posture on tick rate, prediction and rollback. The rule that keeps it all playable: **no rollback of world state a player can already see.** If a design would need one, move the decision to the server and cover the round trip with animation. ## Content All authored gameplay content is a `UPrimaryDataAsset` subclass, registered with the asset manager under a primary asset type per definition class, and addressed by primary asset id or gameplay tag. String ids do not exist. ```cpp // A definition asset. Designers fill these in; code reads them. Never the other way round. UCLASS(BlueprintType) class UWeaponDefinition : public UPrimaryDataAsset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly, Category = "Identity") FGameplayTag WeaponTag; // Item.Weapon.Sword, the stable identity UPROPERTY(EditDefaultsOnly, Category = "Identity") FText DisplayName; // FText, never FString: localisable from day one UPROPERTY(EditDefaultsOnly, Category = "Visual") TSoftObjectPtr Mesh; // soft, so a definition never hard-loads its art // GetPrimaryAssetId() uses the class name as the type; the asset manager scans Content//Definitions. }; ``` Rules that follow: - **Soft references for art and other assets** (`TSoftObjectPtr`, `TSoftClassPtr`). A definition is small data and must be loadable on a headless server without dragging meshes in. - **Polymorphic inline content** (a list of effects, a spawn rule) uses `Instanced` `UObject` properties with `EditInlineNew` classes, or `FInstancedStruct` for value types. Both serialise the concrete type; both survive a rename only with a core redirect, so name such classes carefully and early. - **Tables for tuning numbers**, data assets for things with identity. A `UDataTable` of `FMovementTuningRow` is fine; an enemy is a data asset. - **Every visual defaults to an engine primitive or the engine mannequin**, so the first art pass is a content edit. ## Gameplay tags Gameplay Tags are the project's vocabulary: hierarchical, parent-matching, cheap to compare, replicated as indices, native to the ability system. They replace every enum that would otherwise leak across features and every string that would otherwise be typed twice. **Source of truth:** `Config/Tags/.ini`, one file per top-level namespace, reviewed in pull requests like code. Tags that code references are additionally declared natively so a typo fails at compile time: ```cpp // Core/Tags/NativeTags.h UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_State_Downed); UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Damage_Type_Physical); // NativeTags.cpp UE_DEFINE_GAMEPLAY_TAG_COMMENT(TAG_State_Downed, "State.Downed", "At zero health, immobile, revivable. Not dead."); ``` The taxonomy, top level only. Each spec owns the children of its namespaces. ``` Input.* Input.Move, Input.Jump, Input.Sprint, Input.Interact, Input.Drop, Input.Attack, Input.Ability.1..4 Ability.* asset tags on abilities: Ability.Melee, Ability.Dodge, Ability.Taunt, Ability.Signature Cooldown.* granted by cooldown effects, one per ability Data.* SetByCaller keys: Data.Damage, Data.Heal, Data.Impulse Damage.Type.* Physical, Magic, True Damage.Source.* Melee, Projectile, Ability, Fall, Hazard, Shove State.* what a body IS: Downed, Dead, Invulnerable, Sprinting, Dodging, Carrying, Carrying.TwoHanded Status.* an outside influence on a body: Status.Buff.Haste, Status.Debuff.Slow, .Stunned ... (Stats.md) Immune.* Immune.Status., Immune.Damage.: the counters a body carries Surface.* Mud, Ice, Hot: on physical materials, applied by the ground trace Event.* gameplay events between animation and abilities: Event.Montage.Hit, Event.Montage.WindupEnd Class.* Warrior, Ranger, Mage, Cleric, Rogue Item.* Item.Family.*, Item.Part.*, Item.PieceType.*, Item.Substance.*, Item.Enchant.*, Item.Weapon.* Piece.Layer.* Characteristic, Substance, Enchantment Substance.* Substance.Class.*, Substance.Rarity.*, Substance.State.* Theme.* synergy vocabulary shared by characteristics and enchantments Domain.* Forge, Wood, Enchant Station.* Station.State.*, Station.Type.* Activity.* Heat, Strike, Assemble Fault.* Mark.* Interact.* Interact.Reason.* (why a verb is unavailable), Interact.Verb.* GameplayCue.* hit impacts, hit stop, outlines: presentation only UI.* theme roles, see UI.md ``` ## The C++ and Blueprint boundary C++ owns every rule, every state machine and every replicated property. Blueprint owns composition, tuning and cosmetics: which mesh, which montage, which numbers, what particle plays. The contract each C++ type declares: | Surface | Meaning | | --- | --- | | `UFUNCTION(BlueprintCallable)` | a command a designer or UI may invoke; verb-noun names | | `UFUNCTION(BlueprintPure)` `Get*` / `Preview*` | side-effect-free query, safe every frame | | `UFUNCTION(BlueprintNativeEvent)` | a working C++ default a subclass may replace, for cosmetic reactions only | | `UPROPERTY(BlueprintAssignable)` delegate, `*_Cosmetic` | an Inspector-wired reaction; never load-bearing | | `UPROPERTY(EditDefaultsOnly)` | tuning data, authored not called | | `protected` / `private` C++ | authoritative internals; no Blueprint access | A Blueprint may subclass a C++ actor to set its assets and numbers and to wire cosmetic events. A Blueprint never implements a rule, never writes a replicated property and never contains a `Server` RPC. Blueprints are binary and unreviewable in a diff, which is the practical reason for the line; the principled reason is that a rule in Blueprint cannot run in a headless test. ## Testing Two kinds, and the split mirrors the module split. - **Automation tests in `Core/Tests/`** for every pure rule, using `IMPLEMENT_SIMPLE_AUTOMATION_TEST` with the `ProductFilter` flag. They run with no world, in the editor's Session Frontend or headless: ``` UnrealEditor-Cmd .uproject -ExecCmds="Automation RunTests .Core; Quit" -unattended -nopause -NullRHI -log ``` Every rejection reason has a named test, not just the happy path. A test name is `.Core...` so a filter can run one feature. - **Functional tests in maps** (`AFunctionalTest` actors in `Content/Tests/`) for anything that needs a world: the gym's step-up heights, a swing landing on a dummy, two clients seeing the same assembled item. Run through the same command with the `Project.Functional` filter. There is no continuous integration and there will not be until there is a reason for it. `Scripts/run-tests.sh` wraps the command above and is run by hand before a step is called done. ## Conventions - **Naming follows Unreal:** `A` actors, `U` objects, `F` structs, `E` enums, `I` interfaces, no project prefix on classes. Assets: `BP_`, `DA_` (data asset), `DT_`, `IA_`, `IMC_`, `GA_`, `GE_`, `GC_` (gameplay cue), `ABP_`, `AM_` (montage), `SK_`, `SM_`, `M_`, `MI_`, `WBP_`, `T_`, `L_` (map). Placeholder assets carry `_Proto` before the descriptor so they can be purged in one search. - **Identity is `FGuid`** for anything that persists or is referenced across peers: an item, a piece, a substance object, an activity session. Never an index, never a name. - **No world searches in gameplay code.** `GetAllActorsOfClass`, `FindComponentByClass` on arbitrary actors, and static singletons holding gameplay state are all banned. A subsystem is the registry; actors register with it in `BeginPlay` and unregister in `EndPlay`. - **`TObjectPtr` for `UPROPERTY` object references**, raw pointers only for function-local use. - **`FText` for anything a player reads**, from a string table. `FString` for identifiers and logs. - **Private members `Name`, not `_name`**: Unreal's own style, and the engine code the project reads uses it. - **One header, one class.** A struct shared by several may have its own header. - **Comments say why.** What the code does is visible; why it does it that way is the part that gets lost. ## Gotchas Real ones, from the engine, that cost a day each if met in the wild rather than here. - **Replication needs a registered property.** A `UPROPERTY(Replicated)` does nothing until it is listed in `GetLifetimeReplicatedProps`. There is no warning. - **Server RPCs need an owning connection.** `UFUNCTION(Server)` on an actor the client does not own silently drops. Components on the player's pawn or player state are owned; a bench is not, so interaction RPCs go through the player's own interaction component, never through the prop. - **The ability system component must be initialised on both sides.** `InitAbilityActorInfo` in `PossessedBy` on the server and in `OnRep_PlayerState` on the client, and `UAbilitySystemGlobals::Get().InitGlobalData()` once at startup or target data will not serialise. - **`BeginPlay` order between actors is not defined.** A component that needs another actor reads it on first use, not in `BeginPlay`. - **Blueprint child of a C++ class: `Super::` calls are on the Blueprint author.** A cosmetic override that forgets to call the parent event silently drops the C++ default. - **`CustomTimeDilation` on the server changes the simulation.** Hit stop is a client-side gameplay cue, never a server-side dilation. - **`FGameplayTag` matching is hierarchical by default.** `HasTag(State)` is true for `State.Downed`. Use `HasTagExact` when the parent must not match. - **Dedicated server packages need the engine from source.** The launcher build compiles no server target. Play In Editor's "Run Dedicated Server" works on the launcher build and is the everyday test; the source build arrives when the first packaged server does. ## What was built, and where it differs Step 1, 2026-09-16. - **Name and engine:** `Salty`, UE 5.8 launcher build (D-39, D-40). Modules `SaltyCore` and `Salty` as specified; `SaltyServer.Target.cs` exists but the launcher build cannot compile it, so the dedicated server is proved through Play In Editor (OD-04). - **The project started from the Third Person template, not empty.** Its plain character, game mode and controller survive as `ATemplateCharacter`, `ATemplateGameMode`, `ATemplatePlayerController` (class redirects in `DefaultEngine.ini`) and are the placeholder body until step 3 replaces them. The template's three variants stay as reference only (D-41); they do not follow these conventions and nothing is built on them. - **`USaltyAssetManager` arrived in step 1**, earlier than the spec implies, because it is the one place `UAbilitySystemGlobals::InitGlobalData` belongs and the cost was a file. No `PrimaryAssetTypesToScan` entries yet; each definition class adds its own. - **Tags:** one `Config/Tags/.ini` per top-level namespace (23 files), each holding only its root tag. `NativeTags.h/.cpp` exist and are empty until step 2 declares the first native tag. - **The gym is authored by `Scripts/Authoring/create_gym.py`**, not by hand: a floor, daylight and two player starts. Step 3 extends the same script with the sections table. - `.gitattributes` routes binaries through LFS; the four asset packs are in the working tree but no pack is referenced by code or a definition yet.