Files
UnrealPrototyping/Docs/Spec/Architecture.md
T
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

Rewrites the design and engineering docs of the two earlier Unity projects
(Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that
builds and proves three things in order: a movement controller, fighting and
crafting. Neither earlier core loop is carried; their ideas are catalogued as
features with what each needs.

- Docs/Design.md: the human summary (pillars, fixed decisions, glossary)
- Docs/Steps.md: a ladder of fifteen proofs, the first six in full
- Docs/Ideas.md: salvaged ideas from both projects, none scheduled
- Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals
- Docs/Spec/: notation, Architecture, Movement, Interaction, Combat,
  Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons
- CLAUDE.md and README.md for the repository


* Add the stat block: one attribute set on every body, every influence an effect

Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and
every outside influence on it is a gameplay effect: base values, buffs,
debuffs, ground surfaces, carried weight, gear, being downed. Counters are
tags and attributes on the receiver: immunity blocks by tag, resistance
scales by attribute, cleanse removes by tag. Same status from several
sources: strongest wins; different statuses multiply.

- Docs/Spec/Stats.md: the block, effects and their five sources (abilities,
  areas, surfaces, items, the world), stacking, counters, readers, tests
- Movement: the movement component's tagged speed-multiplier map is removed;
  speed is tuning times the MoveSpeed attribute; the ground surface trace
  turns mud into an effect; the gym gets mud and ice patches
- Combat: the attribute set moves to Stats; enemies and kits carry
  FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags;
  taunt and mark are statuses
- Interaction: carried weight is GE_Encumbered against CarryCapacity
- Crafting: class bonuses are the WorkSpeed and WorkQuality attributes;
  enchantments may grant a holder effect
- Steps: step 5 builds the block with mud, a volume and an immunity
- Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
2026-09-15 17:54:00 +03:00

249 lines
15 KiB
Markdown

# 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](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<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:
```cpp
// <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.