* 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
249 lines
15 KiB
Markdown
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.
|