Files
Rainer LeitandClaude Fable 5.1 4f2c55cd2a Step 1: the Salty project, two modules, tests and the gym
Salty.uproject (UE 5.8) from the Third Person template with class redirects, the SaltyCore and Salty modules,
the three build targets, USaltyAssetManager calling InitGlobalData, Config/Tags with the 23 root namespaces,
Scripts/run-tests.sh, build.sh and Authoring/create_gym.py, L_Gym, Git LFS attributes and the placeholder test.
Template variants kept as reference (D-41). The four Fab packs stay out of the repository for now (~10 GB).
The editor serves the engine MCP plugin on 127.0.0.1:8000 through DefaultEditorPerProjectUserSettings.ini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:10:43 +03:00

17 KiB

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/
├── <Project>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
└── <Project>/              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

<Project>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 (<Project>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 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 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.

// 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<UStaticMesh> Mesh;              // soft, so a definition never hard-loads its art

    // GetPrimaryAssetId() uses the class name as the type; the asset manager scans Content/<Feature>/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/<Feature>.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:

// <Project>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.<Name>, Immune.Damage.<Type>: 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 <Project>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 <Project>.uproject -ExecCmds="Automation RunTests <Project>.Core; Quit" -unattended -nopause -NullRHI -log
    

    Every rejection reason has a named test, not just the happy path. A test name is <Project>.Core.<Feature>.<Rule>.<Case> 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<T> 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/<Namespace>.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.