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
This commit is contained in:
2026-09-15 17:54:00 +03:00
committed by GitHub
parent abac63da16
commit 0e61a77346
18 changed files with 4026 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
# 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.
+459
View File
@@ -0,0 +1,459 @@
# Combat
Owns attributes, the one damage funnel, health and the downed state, melee hit detection and feel, enemies, the
ability system, class kits, and the rules that keep gear and mods from dissolving the classes. It does **not** own
how a body moves ([Movement.md](Movement.md), whose root-motion hooks the movement abilities use), how a player
touches a downed teammate ([Interaction.md](Interaction.md)), or where a weapon's numbers come from when the weapon
was crafted ([Crafting.md](Crafting.md), which produces the `FWeaponProfile` defined here).
Read [Architecture.md](Architecture.md) first. Steps 7 to 11 in [`../Steps.md`](../Steps.md).
## Decisions
```
[DECIDED] The Gameplay Ability System. Attributes, abilities, effects, cooldowns, tags, cues.
The earlier project hand-built a damage resolver, a health service, a cooldown tracker, an ability runner and a
client-to-host relay, then spent a step making them replicate. GAS is those five things, server-authoritative
with client prediction, already replicated, and the engine's own combat samples are written on it. The cost is
its learning curve, which is paid once, and its opinions, which happen to be this project's: one attribute set,
effects as data, abilities as classes, everything addressed by tag.
```
```
[DECIDED] One damage funnel. Every point of damage in the game passes through one execution calculation.
Melee, projectiles, abilities, falling, hazards, a shove into a pit. One place, for three reasons: modifiers from
gear and upgrades apply in one place, telemetry is emitted in one place, and the no-friendly-fire rule is enforced
in one place rather than remembered at twenty call sites. No ability, weapon or hazard writes Health directly.
```
```
[DECIDED] Down before death. Zero health is downed: immobile, revivable, bleeding out. Death is what happens when
nobody comes. Any player can revive; some kits do it faster. When no player is standing, everyone downed dies at
once, because nobody can revive anybody.
```
```
[DECIDED] The ability system component is on the player state for players and on the pawn for enemies.
A player's cooldowns, attributes and class survive their body; an enemy's die with it.
```
```
[SALVAGED] Kits define ability access. Gear defines stat and utility access, and crosses class lines. Mods bend
numbers and change how an ability you have behaves, and never grant another class's signature ability. Without that
line, free-form gear dissolves five classes into five slightly different damage dealers within one content patch.
Gear and mods themselves are not built in these steps; the flag that guards the line is, because it is free now.
```
## Layout
```
Source/<Project>Core/Combat/
├── WeaponProfile.h // FWeaponProfile: the one shape both authored and crafted weapons produce
├── DamageMath.h / .cpp // pure: ComputeDamage(context) -> FDamageResult
└── ThreatTable.h / .cpp // pure: who an enemy wants to hit
Source/<Project>/Combat/ // the stat block and the ability system component live in Stats/, see Stats.md
├── DamageExecution.h // UGameplayEffectExecutionCalculation: THE funnel
├── CombatantComponent.h // per body: team, downed/dead state machine, revive target, hit reaction
├── WeaponDefinition.h // UPrimaryDataAsset for authored weapons
├── WeaponActor.h // the held weapon: mesh on the hand socket, its profile, its trace sockets
├── Abilities/
│ ├── KitAbility.h // UGameplayAbility base: InputTag, bSignature, activation policy
│ ├── GA_MeleeAttack.h // montage, hit window, server sweep, apply damage
│ ├── GA_Dodge.h GA_Blink.h GA_ShoulderCharge.h GA_Taunt.h GA_Mark.h GA_Heal.h GA_Downed.h GA_Revive.h
│ └── GA_Fireball.h GA_Volley.h // step 11, projectiles
├── ClassKitDefinition.h // UPrimaryDataAsset: abilities in slot order, starting weapon, base attributes
├── Enemies/
│ ├── EnemyDefinition.h // UPrimaryDataAsset: stats, montages, drops later
│ ├── EnemyCharacter.h // ABaseCharacter with its own ASC
│ ├── EnemyController.h // AAIController + StateTree + perception + the threat table
│ └── EncounterSubsystem.h // UWorldSubsystem, server: spawns and counts, party-size scaling
├── ProjectileActor.h // step 11
└── Cues/ // GameplayCue notifies: impact, hit stop, camera kick, outline flash
Content/Combat/
├── Definitions/DA_Weapon_Sword, DA_Weapon_Mace, DA_Weapon_Dagger, DA_Weapon_Staff, DA_Weapon_Bow
├── Definitions/DA_Kit_Warrior, DA_Kit_Ranger, DA_Kit_Mage, DA_Kit_Cleric, DA_Kit_Rogue
├── Definitions/DA_Enemy_Goblin
├── Effects/GE_Damage, GE_Heal, GE_Cooldown_*, GE_BleedOut, GE_Invulnerable, GE_FallDamage
├── Abilities/GA_* // Blueprint children setting montages and cues only
├── Animations/AM_Melee_*, AM_Hit_* // montages with the hit-window notify states
└── Cues/GC_*
```
## Types at a glance
| Type | Module | Lifetime | Notes |
| --- | --- | --- | --- |
| `FWeaponProfile` | Core | value | Authored or crafted, same struct |
| `DamageMath::ComputeDamage` | Core | pure | The maths the execution calls |
| `FThreatTable` | Core | value | Owned by an enemy controller |
| `UStatBlockAttributeSet` | Gameplay (Stats) | per ASC | The stat block, see [Stats.md](Stats.md) |
| `UDamageExecution` | Gameplay | asset-referenced | Referenced by `GE_Damage` |
| `UCombatantComponent` | Gameplay | per body | Team, downed state, revive target, hit reaction |
| `UWeaponDefinition`, `UClassKitDefinition`, `UEnemyDefinition` | Gameplay | asset | Content |
| `AWeaponActor` | Gameplay | per equipped weapon | Attached to the hand socket |
| `UKitAbility` and children | Gameplay | granted per ASC | Abilities |
| `AEnemyCharacter`, `AEnemyController` | Gameplay | per enemy | Server-driven |
| `UEncounterSubsystem` | Gameplay | world, server | Spawning and the wipe |
## Attributes
The numbers combat reads and writes are on the one stat block every body carries, `UStatBlockAttributeSet`,
specified in [Stats.md](Stats.md): `Health` and `MaxHealth`, `AttackPower` and `AttackSpeed`, `Armour`,
`MagicResist` and `KnockbackResist`, and the two meta attributes the funnel writes. Combat owns what they mean;
it does not own the set, and it adds nothing to it without a row in that document's table.
```cpp
// UStatBlockAttributeSet, the parts combat uses
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingDamage) // META: written by the execution, consumed below, never replicated
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingHeal) // META, same
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
// IncomingDamage: Health = clamp(Health - Incoming, 0, Max); if it reached zero and was not zero, raise
// OnOutOfHealth(instigator, causer, context). Emit player_damaged / enemy_damaged. Clear the meta attribute.
// IncomingHeal: overheal past MaxHealth is returned to the caller as a value, because a heal that overflows
// is a launch (the salvaged joke, see Abilities). Health clamps; the surplus goes out through OnOverheal.
```
Base values are a `FStatBlockDefaults` on the kit or the enemy definition, applied through the one init effect
on grant or spawn; nothing sets an attribute directly. A body's speed does not live in combat at all: it is
`MoveSpeed` on the block, and a slow from a frost bolt changes it the same way mud does.
## The damage funnel
```cpp
// Source/<Project>Core/Combat/DamageMath.h // pure
struct FDamageContext
{
float BaseAmount; // from the weapon profile or the ability's SetByCaller Data.Damage
FGameplayTag DamageType; // Damage.Type.Physical | Magic | True
FGameplayTag Source; // Damage.Source.Melee | Projectile | Ability | Fall | Hazard | Shove
float SourceAttackPower = 1.f; // captured attribute
float TargetArmour = 0.f; // captured attribute, physical
float TargetMagicResist = 0.f; // captured attribute, magic
bool bSourceIsPlayer = false, bTargetIsPlayer = false;
bool bTargetMarked = false; // Status.Debuff.Marked: everyone hits harder
bool bTargetInvulnerable = false; // dodge i-frames, a shield
FGameplayTagContainer TargetImmunities; // Immune.Damage.<Type> tags on the target: matching damage is zero
FGameplayTagContainer GrantedTags; // from an enchanted weapon: Item.Enchant.Fire.Penetration ...
};
struct FDamageResult { float FinalAmount = 0.f; bool bDiscardedFriendlyFire = false; bool bCritical = false; };
namespace DamageMath
{
// pure. Tested for: the friendly-fire gate; True damage ignores armour and resist; a mark multiplies;
// invulnerable is zero; an Immune.Damage.<Type> tag zeroes that type; fall damage is never friendly fire
// even when a player caused the fall.
FDamageResult ComputeDamage(const FDamageContext& Ctx, const struct FDamageTuning& Tuning);
}
```
```cpp
/**
* THE funnel. Referenced by GE_Damage and by nothing else; every damaging effect in the project is GE_Damage with
* SetByCaller magnitudes, or a subclass of it. Runs on the server only, as executions do.
*/
UCLASS()
class UDamageExecution : public UGameplayEffectExecutionCalculation
{
GENERATED_BODY()
public:
UDamageExecution(); // captures AttackPower from the source; Armour, MagicResist and the State, Status and Immune tags from the target
virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& Params,
FGameplayEffectCustomExecutionOutput& Out) const override;
// 1. Build FDamageContext from the spec: Data.Damage, the type and source tags on the spec, the captured
// attributes, team membership of instigator and target (IGenericTeamAgentInterface), target state tags.
// 2. THE FRIENDLY FIRE GATE. Both players: FinalAmount is zero. The effect still applies, so the impulse
// and the cue survive. Player-on-player is impulse and ragdoll, never health loss. This is the
// consequence-free griefing rule from the earlier project, enforced here so no ability can violate it.
// 3. DamageMath::ComputeDamage. Modifiers from gear read the source's granted tags and attributes; nothing
// is pushed into this class by another system, it reads.
// 4. AddOutputModifier(IncomingDamage, Additive, FinalAmount).
// 5. The attribute set's PostGameplayEffectExecute emits telemetry and raises OnOutOfHealth. This class
// emits nothing itself: one emitting site per event.
};
```
Fall damage is a `GE_FallDamage` applied by the character's `Landed` through the same funnel with
`Damage.Source.Fall` and no instigator; hazards are the same with `Hazard`. A shove is `GE_Damage` with zero base
amount and an `Impulse` magnitude: the gate discards nothing because there is nothing, and the impulse cue lands.
## Health, down, revive, wipe
```cpp
/** Per body. Turns attribute events into the downed/dead state and exposes the revive prop. */
UCLASS()
class UCombatantComponent : public UActorComponent, public IInteractable
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGenericTeamId Team; // players 1, enemies 2; the attitude solver says 1 vs 2 is hostile
FGuid CombatantId; // stable runtime identity for the threat table and telemetry
// Server, on OnOutOfHealth:
// Enemies: grant State.Dead, play the death montage through a cue, disable collision, the encounter counts it.
// Players: ASC->TryActivateAbilityByClass(GA_Downed). GA_Downed grants State.Downed, applies GE_BleedOut
// (30 s duration, on expiry -> Die), applies GE_Downed (MoveSpeed overridden to zero), forces a drop through the carry
// component, drops the camera, and enables this component's revive collider on the Interactable channel.
// Then: if no player in the world is standing, every downed player dies now (a solo down is an instant wipe;
// bleed-out only matters with a party) and the encounter subsystem's wipe fires.
// IInteractable, the revive prop:
// GetPrompt: Interact.Verb.Revive, enabled when the target has State.Downed and the asker does not.
// Interact (server): activate GA_Revive on the reviver, which holds for ReviveSeconds (faster for a Cleric),
// then removes GE_BleedOut and restores a fraction of MaxHealth. Emit player_revived with down_duration_s.
// Hit reaction: OnDamaged raises HitReaction_Cosmetic(direction); the animation blueprint bends the spine with
// a damped spring in the direction of the hit, on top of whatever plays, and never moves the hands.
};
```
A downed player is the first prop in the game, and reviving gets the server-side range and permission re-check
for free from [Interaction.md](Interaction.md). It is the reason interaction is built before combat.
## Weapons and the melee swing
```cpp
// Source/<Project>Core/Combat/WeaponProfile.h
/**
* The one shape a weapon has at the moment of the swing. An authored UWeaponDefinition produces it directly; a
* crafted item produces it through CraftingRules::MakeWeaponProfile. The melee ability, the action bar and the
* damage funnel read this and nothing else, which is the seam that lets a crafted sword be a sword.
*/
struct FWeaponProfile
{
FGameplayTag WeaponTag; // Item.Weapon.Sword; a crafted item carries its family tag here
float Damage = 12.f;
float ReachCm = 180.f;
float ArcHalfWidthCm = 60.f, ArcHalfHeightCm = 50.f;
float WindupSeconds = 0.12f; // press to hit check: the tell
float SwingSeconds = 0.55f; // press to next press
float HitStopSeconds = 0.06f; // cosmetic, client side
FGameplayTag DamageType; // Damage.Type.Physical by default
FGameplayTagContainer GrantedTags; // an enchanted blade grants Item.Enchant.* here
bool bTwoHanded = false; // a greatsword occupies both hands, see Interaction.md
FSoftObjectPath Montage; // AM_Melee_*; a crafted weapon uses its family's montage
};
```
```cpp
UCLASS()
class UGA_MeleeAttack : public UKitAbility
{
GENERATED_BODY()
// Net execution policy: LocalPredicted. The montage plays immediately on the owner; the server plays it too and
// does the hit check itself. Activation: on Input.Attack, blocked by State.Downed, State.Carrying.TwoHanded,
// State.Dodging, and by its own cooldown (SwingSeconds, a GE_Cooldown_Melee with SetByCaller duration).
//
// ActivateAbility:
// Profile = Owner->GetWeaponActor()->GetProfile(); timings divided by the body's AttackSpeed attribute
// PlayMontageAndWait(Profile.Montage, rate = AttackSpeed) // predicted
// WaitGameplayEvent(Event.Montage.Hit) // fired by ANS_HitWindow on the montage, at WindupSeconds
// on event, SERVER ONLY (HasAuthority):
// sweep a box (ArcHalfWidth x ArcHalfHeight x Reach) from the eye along the body's aim on the Enemy channel
// for each hit, once per swing: Linecast eye -> hit point on the world channel; a wall blocks it
// for each surviving hit: MakeOutgoingSpec(GE_Damage), SetByCaller(Data.Damage, Profile.Damage),
// add DamageType and Damage.Source.Melee to the spec, ApplyToTarget
// execute cue GameplayCue.Melee.Impact at the hit point on every peer
// EndAbility on montage end.
//
// Why a single box after the wind-up rather than a per-frame sweep along the blade: it is what the earlier
// project shipped, it hits the whole pack, and it reads. A blade-socket sweep is a change to this one ability
// if the feel pass wants it; nothing else would move.
};
```
Feel, all cosmetic and all client-side through gameplay cues: **hit stop** (a brief `CustomTimeDilation` dip on the
local player and the struck enemy, never on the server), a **camera kick** through a camera shake on the owner's
controller, and the **procedural hit reaction** on the struck body. Every duration passes through the motion
accessibility scale, so reduced motion turns them down.
The wind-up is what makes the animation and the rules agree: the player's hit check is delayed past the press by
`WindupSeconds`, and the goblin's damage lands after its own wind-up, missing if the target has stepped out of
reach. The tell is real; reading it and stepping back works.
**Combat feel is the honest unknown of these steps.** No document settles whether a swing lands well. Step 7 is not
done until one training dummy and then one goblin are satisfying to hit, and the numbers that made it so are
committed.
## Enemies
```cpp
UCLASS(BlueprintType)
class UEnemyDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag EnemyTag; // Enemy.Goblin
UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // 30 health, MoveSpeed 1: the same block a player has (Stats.md)
UPROPERTY(EditDefaultsOnly) TObjectPtr<UMovementTuning> Tuning; // the goblin's 320 cm/s walk; the block multiplies it like anyone's
UPROPERTY(EditDefaultsOnly) FWeaponProfile Attack; // 8 damage, 1.2 s, 0.35 s wind-up: the goblin's claws
UPROPERTY(EditDefaultsOnly) float AggroRangeCm = 1000.f;
UPROPERTY(EditDefaultsOnly) float LeashRangeCm = 2500.f;
UPROPERTY(EditDefaultsOnly) float FleeSecondsOnAllyDeath = 2.5f; // reads as cowardice; funnier than fighting to the death
UPROPERTY(EditDefaultsOnly) int32 ThreatCost = 1; // what it costs an encounter budget
UPROPERTY(EditDefaultsOnly) bool bRequiresRole = false; // ineligible below three players, see scaling
UPROPERTY(EditDefaultsOnly) TSoftClassPtr<AEnemyCharacter> Body;
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStateTree> Brain;
};
```
```cpp
// Source/<Project>Core/Combat/ThreatTable.h // pure
struct FThreatTable
{
void AddThreat(FGuid Combatant, float Amount); // damage dealt to me, healing done near me
void ForceTarget(FGuid Combatant, float Seconds); // a taunt
void Decay(float DeltaSeconds);
TOptional<FGuid> GetTarget() const; // forced target while it lasts, else highest threat
void Remove(FGuid Combatant);
};
```
The controller is an `AAIController` running a StateTree from the definition, with a perception component for
sight and the threat table for choice. States: Idle, Chase, Attack (wind-up, hit through the same melee ability
class the player uses, on the enemy's own ASC), Flee (on an ally's death, for `FleeSecondsOnAllyDeath`), Leash
(back to the spawn point past `LeashRangeCm`). Threat accrues from damage dealt and from healing done, which is why
a healer needs a tank. The threat table is what makes a taunt mean something.
**Party-size scaling is a reserved rule, not a built one.** The encounter subsystem reads the party size from the
game state and scales the enemy budget sublinearly: 1 player 1.0, 2 players 1.75, 3 players 2.4, 4 players 3.0.
Linear scaling is the obvious choice and it is wrong: four coordinated players are worth far more than four times
one. Enemies with `bRequiresRole` are ineligible below three players, which is what "playable alone" means
concretely: a constraint on which enemies may appear, not a damage multiplier. Only the goblin exists, so the
subsystem multiplies a count and filters a list; the table is written down so the first second enemy inherits it.
## Abilities and kits
```cpp
UCLASS(Abstract)
class UKitAbility : public UGameplayAbility
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "Kit") FGameplayTag InputTag; // Input.Ability.1..4, or Input.Attack, Input.Dodge
UPROPERTY(EditDefaultsOnly, Category = "Kit") bool bSignature = false; // THE flag a mod may never cross
UPROPERTY(EditDefaultsOnly, Category = "Kit") FText DisplayName; // the action bar's label
// Cooldown and cost are the engine's: a GE_Cooldown_<Ability> with a Cooldown.<Ability> granted tag,
// and an optional stamina cost effect. CommitAbility applies both. The HUD reads the cooldown tag's remaining time.
};
USTRUCT()
struct FKitAbilitySlot
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly) TSubclassOf<UKitAbility> Ability;
UPROPERTY(EditDefaultsOnly) FGameplayTag InputTag; // which slot; the ability's own InputTag is the default
};
UCLASS(BlueprintType)
class UClassKitDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Class.Warrior
UPROPERTY(EditDefaultsOnly) FText DisplayName;
UPROPERTY(EditDefaultsOnly) TArray<FKitAbilitySlot> Abilities; // slot order; at most four on the bar
UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // applied through the one init effect (Stats.md)
UPROPERTY(EditDefaultsOnly) TObjectPtr<UWeaponDefinition> StartingWeapon;
UPROPERTY(EditDefaultsOnly) float ReviveSeconds = 4.f; // the Cleric's is 2
// IsDataValid: at most one bSignature ability, at most four slots, distinct input tags.
};
```
`UExtendedAbilitySystemComponent` routes input by tag: on `Input.Ability.2` pressed it activates every granted
ability whose asset tags contain that input tag. The player character forwards its `IA_Ability*` and `IA_Attack`
presses there and decides nothing. Granting a kit is `GrantKit(const UClassKitDefinition&)` on the player state's
component: `ApplyDefaults(BaseStats)`, give each ability with its input tag, equip the starting weapon. Changing
class regrants.
The kits, sketched. Each is a role, a signature no mod may grant elsewhere, and a silly escalation that later
content pushes on. Two abilities each for the steps; slots three and four are content.
| Kit | Role | Weapon | Signature | Second ability | Escalation later |
| --- | --- | --- | --- | --- | --- |
| Warrior | Hold attention, absorb, open a path | Sword, or shield and mace | **Taunt**: force nearby enemies onto you | Shoulder charge: damage to enemies in a cone, impulse to teammates | Charge launches teammates across rooms |
| Ranger | Sustained ranged damage, mobility | Bow (step 11); dagger until then | **Mark**: a marked target takes more from everyone | Volley | Arrows that carry things |
| Mage | Burst and area, crowd control | Staff | **Blink**: short teleport along the aim | Fireball (step 11) | Blink takes whoever is touching you |
| Cleric | Sustain, revives, buffs | Mace | **Mass heal** | Mend: targeted heal; **overheal launches the target** | The heal launches people over walls |
| Rogue | Burst, mobility | Dagger | **Backstab**: aimed, high damage, short cooldown | Shadowstep: a self blink along the aim | |
Effects an ability may apply, each through the engine's own mechanism and never by writing a value: **Damage**
(`GE_Damage` through the funnel), **Heal** (`GE_Heal` into `IncomingHeal`), **Impulse** (`LaunchCharacter` on the
target through a client RPC to its owner, since movement is owner-predicted, scaled by the target's
`KnockbackResist`), **Taunt** (`Status.Debuff.Taunted` through `ApplyStatus`, which the enemy controller turns
into `ForceTarget`; a boss's `Immune.Status.Taunted` blocks it like any status), **Mark** (`Status.Debuff.Marked`
for a duration, read by the funnel), **Slow**, **Haste** and every other buff or debuff (`ApplyStatus`, see
[Stats.md](Stats.md)), **Blink** and **Dodge** (root-motion sources through the movement component, with
`State.Invulnerable` for the dodge's i-frames).
**Parity target:** every kit completes the same gym fight solo in roughly the same time. Not equal damage, equal
viability. `enemy_killed.killer_class` against `time_to_kill_s` is the measurement.
**The recoverability contract**, salvaged whole: any verb that lets one player inconvenience another (a charge
into a carrier, a heal off a ledge, a blink with a passenger) must have a recovery available to the victim that
takes **less time than the verb took to perform**, and none may down, kill or permanently deprive. Damage is gated
in the funnel; things are never destroyed. Every such verb emits `grief_action`, because a rule nobody can measure
is a rule nobody can defend.
## Projectiles (step 11)
`AProjectileActor` with a `UProjectileMovementComponent`, spawned on the server by `GA_Volley` and `GA_Fireball`
and replicated; the firing client spawns a local cosmetic copy on activation so the shot leaves the hand without a
round trip (predict the animation, not the ownership), and the cosmetic copy hides itself when the replicated one
arrives. On hit the server applies `GE_Damage` with `Damage.Source.Projectile`. Travel time and drop are real, so
aim matters. The bow's `FWeaponProfile` has reach zero and a montage that fires `Event.Montage.Release` instead of
`Hit`.
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Attributes | Server | Replicated attribute set on the ASC (Mixed mode for players, Minimal for enemies) |
| Ability activation | Client requests, server decides | GAS prediction keys; LocalPredicted for the player's abilities, ServerOnly for GA_Downed |
| Damage | Server | Executions run on the server only |
| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim, which is fine in cooperative play |
| Cooldowns | Server, client predicts | Cooldown effects with prediction; the bar shows the prediction and takes the correction |
| Enemy brains | Server | StateTree ticks on the server; bodies replicate movement and montages |
| Impulses on players | Server decides, owner applies | Client RPC to the owner's body, because movement is owner-predicted |
| Statuses on bodies | Server, own-ability ones predicted | `ApplyStatus`, see [Stats.md](Stats.md) |
| Cues | Everywhere | Gameplay cues, unreliable multicast; presentation only |
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `enemy_spawned` | The encounter spawns | `enemy`, `spawn_point`, `party_size` |
| `enemy_killed` | Health reaches zero | `enemy`, `killer_class`, `weapon`, `ability`, `time_to_kill_s` |
| `ability_used` | Activation commits | `ability`, `class`, `target_count` |
| `player_damaged` | Damage lands on a player | `source`, `attacker`, `amount`, `health_after` |
| `player_downed` | Health reaches zero | `source`, `party_alive`, `party_size` |
| `player_revived` | Revive completes | `by_class`, `down_duration_s` |
| `player_died` | Bleed-out or wipe | `down_duration_s`, `cause` |
| `party_wiped` | Nobody standing | `enemies_alive`, `duration_s` |
| `grief_action` | A recoverable verb on a teammate | `verb`, `actor`, `target` |
## Tests
- Automation: `DamageMath` for every branch above; `FThreatTable` for forced target expiry, decay and removal;
`UClassKitDefinition::IsDataValid` for two signatures and five slots.
- Functional: `FT_Combat_Funnel` applies `GE_Damage` from a player to a player and asserts health is unchanged and
the cue fired; `FT_Combat_Down` drives a player to zero, asserts `State.Downed`, revives through the interaction
path and asserts health; `FT_Combat_Swing` swings at a dummy behind a wall and asserts no damage.
## Open questions
- **Q1. Does a shove into a pit count as friendly fire?** The gate discards player-to-player damage, but a shove
that causes a fall produces fall damage from the world. Recoverable if the victim is downed rather than killed
and can be revived. Decide when the first pit exists; the funnel already distinguishes the cases.
- **Q2. Resource model.** Cooldowns only for these steps. Stamina is an attribute in the set so that adding a cost
is a cost effect, not a refactor.
- **Q3. Hit registration latency.** Server-authoritative with the owner's aim is the plan. At what latency does a
swing feel wrong? Test at 100 ms and 150 ms in step 7 and write the number down.
- **Q4. A blade sweep instead of a box.** If the box reads as imprecise once real animations arrive, sweep the
weapon actor's tip and base sockets per frame during the hit window. Contained to `GA_MeleeAttack`.
- **Q5. Which kits exist at all.** Five are named because two source projects between them named these five and
their signatures survive contact with the guardrail. Steps 9 and 10 build two; the rest are content.
- **Q6. Cross-class weapon animation.** A Warrior carrying a bow uses the bow's montage, which is authored per
weapon family, not per class. That is the cheap answer and it is the plan; revisit when animation gets real.
+717
View File
@@ -0,0 +1,717 @@
# Crafting
Owns what an item is made of and how it comes to exist: families, parts, pieces and their three trait layers; the
substances pieces are shaped from and the pipeline that refines them; the pure rules for legality, naming and
quality; stations and the one activity runtime their hands-on work runs on; enchanting; and the seam through which
a crafted weapon becomes a weapon you fight with. It does **not** own how a piece is picked up or inserted
([Interaction.md](Interaction.md)), what happens when the weapon lands ([Combat.md](Combat.md)), or any economy,
customer or shop: none of those exist here, and the ideas are parked in [`../Ideas.md`](../Ideas.md).
Read [Architecture.md](Architecture.md) first. Steps 12 to 15 in [`../Steps.md`](../Steps.md). This is the
largest spec because the earlier smithing project had already worked its crafting model through several review
passes; what follows is that model, restated for Unreal and cut to what a fighting prototype needs.
## Decisions
```
[DECIDED] Three words, three levels. A FAMILY declares PARTS. A PIECE is a crafted object filling one part. A
piece carries three TRAIT layers: a characteristic (its shape), a substance (what it is made of) and zero or more
enchantments (its ornament). An item is a family's parts, filled.
Blade: mithril, blunt · Guard: mithril, pointy · Handle: dragonhide, of embers · Pommel: adamantine
-> "Blunt Mithril Sword of Embers". The player made that; they did not pick it from a list.
```
```
[DECIDED] The design word "material" is "substance" in this project's code and docs. UMaterial is the engine's
render material and the collision would be permanent. Substance is the word the earlier design used in its own
subtitle, so nothing of meaning is lost.
```
```
[DECIDED] No recipes, anywhere. A family lists slots and filters. Any slot-complete, filter-satisfying set of
pieces is a legal item. If a combination needs a table to be legal, the filters are wrong.
```
```
[DECIDED] Substances are objects, not stacks. Twelve iron ingots are twelve actors, each with its own identity
and quality. Quality variance is then free, provenance is free, and "the sword made from that dragon's bone" is a
queryable fact. Affordable only because storage is physical and caps the count: any future feature that lets
substance accumulate without occupying space breaks the assumption and is refused on that ground.
```
```
[DECIDED] Discovery unlocks, choice applies. You discover characteristics through play; you choose them at the
station. Strike quality decides how well a shape came out, never which shape you got, so a crafted item is always a
deliberate act and a request for "a serrated blade" is always fillable.
```
```
[DECIDED] One activity runtime, three guarantees. Every hands-on station task runs on one runtime that owns
participation, authority, progress, pause, quality accumulation and telemetry; a minigame implements only its own
rules. The guarantees, binding forever: completion is never gated on skill; skill modulates a quality band and
nothing else; a second pair of hands is always additive and never required.
```
```
[DECIDED] The assembly bench is domain-neutral. Every pipeline converges there; gating it would make one player a
bottleneck. Domains gate the WORK (a station refuses a piece of the wrong domain) and never the WORKER (anyone may
use any station). Class changes speed and quality only.
```
```
[DECIDED] Durability is a part property summed into an item total, and the item is lost at zero. Nothing in the
shop wears out from crafting; wear is something the world does to gear later.
```
## Vocabulary and tags
| Term | Meaning | Tag namespace |
| --- | --- | --- |
| Family | A kind of item and the schema of its parts, as a tree | `Item.Family.Sword`, `.Axe`, `.Bow`, `.Shield` |
| Part | A position in an item | `Item.Part.Blade`, `.Guard`, `.Handle`, `.Pommel` |
| Piece type | What a piece is, before its traits | `Item.PieceType.Blade`, `.Guard`, `.Handle`, `.Pommel` |
| Piece | A crafted object filling a part; has an `FGuid` | runtime, `FPieceInstance` |
| Trait layer | Characteristic, substance, enchantment | `Piece.Layer.*` |
| Characteristic | Shape: serrated, blunt, curved, heavy | `Item.Characteristic.*` |
| Substance | What a piece is made of | `Item.Substance.Iron`, `.Oak`, `.Mithril` ... |
| Enchantment | Ornament: of embers, of frost | `Item.Enchant.Fire.Penetration` ... |
| Substance object | One physical ingot, log, hide; has an `FGuid` | runtime, `FSubstanceInstance` |
| Domain | Which station family works a thing | `Domain.Forge`, `.Wood`, `.Enchant` |
| Theme | Synergy vocabulary shared by characteristics and enchantments | `Theme.Aggressive`, `.Defensive`, `.Fire` ... |
| Coherence | An item's quality score: piece values plus synergy | runtime |
Other namespaces this spec owns: `Substance.Class.[Metal|Wood|Hide|Bone|Arcane|Fuel]`,
`Substance.Rarity.[Common|Uncommon|Rare|Unique]`, `Substance.State.[Raw|Processed]`,
`Station.Type.*`, `Station.State.[Idle|Occupied|Working|Blocked|Paused]`, `Activity.[Heat|Strike|Assemble]`,
`Activity.State.*`, `Fault.*`, `Mark.*`, and `Craft.Reason.*` for every rejection.
## Layout
```
Source/<Project>Core/Crafting/
├── CraftingDefinitions.h // the definition asset classes (data only, no world)
├── CraftingTypes.h // FPieceInstance, FAssembledItem, FSubstanceInstance, FProvenance, verdict structs
├── CraftingRules.h / .cpp // pure: CanFillSlot, ValidateAssembly, ResolveAttachChains, ComposeName, ScoreCoherence,
│ // PieceValue, PartDurability, MakeWeaponProfile
└── ActivityTypes.h // FActivityResult, FActivitySession, the strike and heat rule structs (pure parts)
Source/<Project>/Crafting/
├── CraftingSubsystem.h // UWorldSubsystem, server: the Server* flows, the item registry, discovery
├── SubstanceActor.h // a carryable physical substance object
├── PieceActor.h // a carryable piece
├── AssembledItemActor.h // a carryable assembled item; builds its visual from sockets; equippable if a weapon
├── Stations/
│ ├── StationDefinition.h // UPrimaryDataAsset
│ ├── StationActor.h // IInteractable receptacle, buffers, operators, state machine
│ ├── AssemblyBench.h // staging, preview, commit
│ ├── AnvilStation.h // hosts the strike activity
│ └── ForgeStation.h // hosts the heat activity, the bed
└── Activities/
├── ActivityRuntime.h // UWorldSubsystem: sessions, participants, pause, the one result funnel
├── ActivityDefinition.h // UPrimaryDataAsset per activity
├── StrikeActivity.h // the growing zone
└── HeatActivity.h // the coal bed grid, two LODs
Content/Crafting/
├── Definitions/Families/DA_Family_Sword
├── Definitions/PieceTypes/DA_PieceType_Blade, _Guard, _Handle, _Pommel
├── Definitions/Characteristics/DA_Char_Straight, _Serrated, _Blunt, _Curved
├── Definitions/Substances/DA_Sub_IronOre, _IronIngot, _OakLog, _OakPlank, _Coal
├── Definitions/Enchantments/DA_Ench_Embers, _Frost
├── Definitions/Stations/DA_Station_AssemblyBench, _Anvil, _Forge
├── Definitions/Activities/DA_Activity_Strike, _Heat
├── DA_CraftingConfig // the weights and thresholds every pure rule reads
└── Meshes/SM_Proto_Blade ... // primitives with named sockets, tinted by substance
```
## Data
```cpp
// Source/<Project>Core/Crafting/CraftingDefinitions.h
UCLASS(BlueprintType)
class UPieceTypeDefinition : public UPrimaryDataAsset // "Blade", "Guard", "Handle", "Pommel"
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag PieceTypeTag; // Item.PieceType.Blade
UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Blade: which part it can fill
UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge | Domain.Wood: who shapes it
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer FitsFamilies; // Item.Family.Sword, .Dagger ...
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Sword": root candidate when this part dominates
UPROPERTY(EditDefaultsOnly) int32 PartWeight = 1; // dominance for naming and scoring: blade 4, guard 2, handle 2, pommel 1
UPROPERTY(EditDefaultsOnly) int32 BaseValue = 10;
UPROPERTY(EditDefaultsOnly) int32 SubstanceObjects = 1; // objects consumed to shape one: blade 3, guard 1, handle 2, pommel 1
UPROPERTY(EditDefaultsOnly) int32 BaseDurability = 100;
UPROPERTY(EditDefaultsOnly) int32 AddonSockets = 1; // enchantment capacity
UPROPERTY(EditDefaultsOnly) int32 HotSpots = 4; // for the strike activity, 3..6
UPROPERTY(EditDefaultsOnly) FVector2D ScaleRange = FVector2D(0.7f, 1.6f); // absurd proportions are legal, bounded
UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // what this part adds to a weapon profile, see the seam
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStaticMesh> Mesh; // carries the named sockets
};
UCLASS(BlueprintType)
class UCharacteristicDefinition : public UPrimaryDataAsset // "Serrated", "Blunt", "Curved", "Heavy"
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag CharacteristicTag;
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToParts; // a blade can be serrated; a pommel cannot
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // synergy input
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Serrated", the prefix
UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0;
UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // serrated: +damage, -durability, say
UPROPERTY(EditDefaultsOnly) bool bKnownFromStart = false; // else discovered
};
UCLASS(BlueprintType)
class USubstanceDefinition : public UPrimaryDataAsset // the TYPE: "Iron Ingot", "Oak Log"
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag SubstanceTag; // Item.Substance.Iron
UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Substance.Class.Metal
UPROPERTY(EditDefaultsOnly) FGameplayTag RarityTag; // Substance.Rarity.Common
UPROPERTY(EditDefaultsOnly) FGameplayTag StateTag; // Substance.State.Raw | .Processed
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag;
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer WorkableBy; // Domain.Forge ...
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ArcaneAffinity; // which enchantment themes it accepts
UPROPERTY(EditDefaultsOnly) int32 BaseValue = 5;
UPROPERTY(EditDefaultsOnly) float DurabilityFactor = 1.f; // dragonbone 1.5, oak 0.1
UPROPERTY(EditDefaultsOnly) FLinearColor PaletteColour; // the substance IS the palette in flat-shaded low poly
// heat, for the forge: working band and burn point in arbitrary heat units, thermal mass
UPROPERTY(EditDefaultsOnly) float WorkingTempMin = 0.f, WorkingTempMax = 0.f, BurnPoint = 0.f, ThermalMass = 1.f;
UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false; // a log takes both hands
// processing: Raw -> Processed
UPROPERTY(EditDefaultsOnly) TObjectPtr<USubstanceDefinition> ProcessesInto;
UPROPERTY(EditDefaultsOnly) int32 ProcessInputCount = 1, ProcessOutputCount = 1;
UPROPERTY(EditDefaultsOnly) FGameplayTag ProcessDomainTag;
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStaticMesh> Mesh;
};
UCLASS(BlueprintType)
class UEnchantmentDefinition : public UPrimaryDataAsset // "of Embers"
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag EnchantmentTag;
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToSubstances; // affinity gate, both sides must agree
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // Theme.Fire
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer GrantsTags; // Item.Enchant.Fire.Penetration: reaches the weapon profile
UPROPERTY(EditDefaultsOnly) TSubclassOf<UGameplayEffect> HolderEffect; // optional: granted to whoever equips the item, e.g. GE_Immune_Burning (Stats.md)
UPROPERTY(EditDefaultsOnly) FGameplayTag DamageType; // Damage.Type.Magic, optional
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "of Embers", the suffix
UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0, SocketCost = 1;
};
USTRUCT()
struct FPartSlot
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Guard
UPROPERTY(EditDefaultsOnly) bool bRequired = false; // blade and handle yes; guard and pommel no
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer Accepts; // piece-type tags this slot takes
UPROPERTY(EditDefaultsOnly) TArray<FGameplayTag> AttachesTo; // ordered parent preference: [Guard, Handle]
UPROPERTY(EditDefaultsOnly) FName ParentSocket; // "Socket_Attach_Blade" on the parent's mesh
UPROPERTY(EditDefaultsOnly) FIntPoint GridPos; // the assembly panel's layout, traces the silhouette
};
UCLASS(BlueprintType)
class UItemFamilyDefinition : public UPrimaryDataAsset // "Sword"
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag FamilyTag; // Item.Family.Sword
UPROPERTY(EditDefaultsOnly) TArray<FPartSlot> Parts; // the schema; exactly one slot has an empty AttachesTo (the root)
UPROPERTY(EditDefaultsOnly) FText RootFragment; // "Sword", if no part supplies a root
UPROPERTY(EditDefaultsOnly) bool bIsWeapon = true;
UPROPERTY(EditDefaultsOnly) FSoftObjectPath Montage; // the swing animation every sword shares
UPROPERTY(EditDefaultsOnly) FGameplayTag HandSocket; // where an equipped one attaches
// IsDataValid: one root, no duplicate part tags, every AttachesTo names a part in this family, sockets non-empty.
};
UCLASS(BlueprintType)
class UCraftingConfig : public UPrimaryDataAsset // the weights every pure rule reads; one asset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) float SubstanceSynergyWeight = 3.f, ThemeSynergyWeight = 4.f, CompletenessWeight = 5.f;
UPROPERTY(EditDefaultsOnly) float SubstanceMajorityThreshold = 0.5f; // share of PartWeight one substance needs to be named
UPROPERTY(EditDefaultsOnly) int32 MaxNameWords = 5;
UPROPERTY(EditDefaultsOnly) FVector2D QualityFactorRange = FVector2D(0.85f, 1.15f); // what MaterialQuality and CraftQuality scale between
UPROPERTY(EditDefaultsOnly) float DamagePerQualityPoint = 0.2f; // the seam's exchange rate
UPROPERTY(EditDefaultsOnly) float ScrapReturnFraction = 0.2f;
};
```
The sword family, as data:
```
DA_Family_Sword (Item.Family.Sword)
Handle required root (AttachesTo empty)
├── Guard optional AttachesTo [Handle] ParentSocket "Socket_Attach_Guard"
│ └── Blade required AttachesTo [Guard, Handle] ParentSocket "Socket_Attach_Blade"
└── Pommel optional AttachesTo [Handle] ParentSocket "Socket_Attach_Pommel"
```
`Blade.AttachesTo = [Guard, Handle]` is what makes optional parts work without special cases: resolution walks
the list and attaches to the first ancestor present, so a guardless sword's blade lands on the handle's own
`Socket_Attach_Blade`, which every handle mesh therefore carries. Other families (axe: haft root with head and grip;
bow: riser root with limbs and string; shield: face root with rim, boss and straps) are the same shape and are
content, not code. A part tag is unique within a family; a second instance of the same kind of part is its own tag
(`Item.Part.Pauldron.Left`), which the part-keyed map enforces structurally.
Sockets are the engine's static mesh sockets, authored on the mesh asset and resolved by name, never by index, so
an artist can move `Socket_Attach_Guard` without touching gameplay data. A missing socket logs a content error and
degrades to the parent's origin rather than failing: a greybox item must always assemble.
## Runtime state
```cpp
USTRUCT()
struct FProvenance
{
GENERATED_BODY()
UPROPERTY() FGameplayTag Kind; // Provenance.Starting | .Crafted | .Found | .Reward ...; extensible, never an enum
UPROPERTY() FGuid Ref; // the crafter's player id, or whatever the kind refers to
UPROPERTY() int32 Day = 0; // reserved
};
USTRUCT()
struct FSubstanceInstance // THE physical object: one per ingot, log, hide
{
GENERATED_BODY()
UPROPERTY() FGuid ObjectId;
UPROPERTY() TObjectPtr<USubstanceDefinition> Type;
UPROPERTY() float Quality = 0.5f; // 0..1, set by the activity that made it
UPROPERTY() FProvenance Origin;
UPROPERTY() FGameplayTagContainer Marks; // Mark.Overheated, Mark.Salvaged, Mark.Dented: flavour and valuation
// Temperature is NOT here. It lives on the forge bed while the object is in it (HeatActivity), and it is not saved.
};
USTRUCT()
struct FPieceInstance // a crafted object filling one part
{
GENERATED_BODY()
UPROPERTY() FGuid PieceId;
UPROPERTY() TObjectPtr<UPieceTypeDefinition> Type;
UPROPERTY() TObjectPtr<UCharacteristicDefinition> Characteristic;
UPROPERTY() TObjectPtr<USubstanceDefinition> Substance;
UPROPERTY() float SubstanceQuality = 0.5f; // average of the objects consumed
UPROPERTY() TArray<FGuid> SourceObjectIds; // WHICH objects: provenance and unique-substance binding
UPROPERTY() float CraftQuality = 0.5f; // the shaping activity's contribution
UPROPERTY() TArray<TObjectPtr<UEnchantmentDefinition>> Enchantments; // bounded by Type->AddonSockets
// DERIVED, recomputed on load, never persisted: Value, Durability
};
USTRUCT()
struct FAssembledItem
{
GENERATED_BODY()
UPROPERTY() FGuid ItemId;
UPROPERTY() TObjectPtr<UItemFamilyDefinition> Family;
UPROPERTY() TMap<FGameplayTag, FPieceInstance> PartsFilled; // Item.Part.* -> piece. A map, so "what is in the Guard slot" is the question
UPROPERTY() int32 Durability = 0; // CURRENT: persisted, it is history
// DERIVED, recomputed on load: DerivedName, QualityScore, MaxDurability, the weapon profile
};
```
`SourceObjectIds` looks like overhead on the first day and is load-bearing later: it is what lets a finished sword
answer "is this the bone from that particular kill" years on, and it is the field an audit trail needs.
## The pure rules
All in `CraftingRules`, in the core module, no world, every one tested. They are the single authority consulted
by the bench preview, the server commit, and later anything that asks whether an item satisfies a request.
Preview and reality call the same function and cannot disagree.
```cpp
namespace CraftingRules
{
struct FSlotVerdict { bool bAccept; FGameplayTag Reason; }; // Craft.Reason.* on rejection
struct FAssemblyVerdict
{
bool bValid; FGameplayTag Reason; FGameplayTag OffendingPart;
FText PreviewName; int32 PreviewQuality; int32 PreviewSynergy; int32 PreviewSubstanceValue;
};
struct FAttachment { FGameplayTag Child, Parent; FName Socket; };
// pure. Both sides must agree: the slot accepts the type, the type fits the family, the shape fits the part.
// No scale gating: a greatsword blade on a dagger handle is legal because it is funny; ScaleRange bounds it.
FSlotVerdict CanFillSlot(const FPieceInstance& Piece, const FPartSlot& Slot, const UItemFamilyDefinition& Family);
// Type->PartTag != Slot.PartTag -> WrongPart
// !Slot.Accepts.HasTag(Type->PieceTypeTag) -> SlotRejectsType
// !Type->FitsFamilies.HasTag(Family.FamilyTag) -> FamilyMismatch
// !Characteristic->ApplicableToParts.HasTag(part) -> IllegalShape
// pure. Every required slot filled, every filled slot legal, no foreign part, every attach chain resolves.
FAssemblyVerdict ValidateAssembly(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts,
const UCraftingConfig& Config);
// MissingPart, ForeignPart (a pommel on a bow), UnreachablePart (an authoring error), else Valid with previews
// pure. Walks each slot's AttachesTo and emits child -> first present parent -> socket, parent before child.
TArray<FAttachment> ResolveAttachChains(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts);
// pure. Prefix + [substance] + root + suffix, at most one fragment per role. Word salad is structurally impossible.
FText ComposeName(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts, const UCraftingConfig& Config);
// dominant = the piece with the highest PartWeight
// prefix = dominant.Characteristic->NameFragment
// core = the substance holding >= SubstanceMajorityThreshold of total PartWeight, else nothing
// (this is what stops "Iron Dragonhide Mithril Sword" from ever being generated)
// root = dominant.Type->NameFragment, else Family.RootFragment
// suffix = the highest-value enchantment's NameFragment across all pieces
// over MaxNameWords: drop suffix, then core, then prefix. Prefix sharing a stem with root drops the prefix.
// pure. A coherent item beats an expensive one.
int32 ScoreCoherence(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts,
const UCraftingConfig& Config, int32* OutSynergy = nullptr);
// base = Σ PieceValue(piece) * PartWeight
// synergy = SubstanceSynergy (n parts of one substance score n(n-1)/2 pairs: commitment, not accident)
// + ThemeSynergy (matching pairs among all characteristic and enchantment themes)
// + CompletenessBonus (optional parts filled; without it "required parts only" is strictly optimal)
int32 PieceValue(const FPieceInstance& Piece, const UCraftingConfig& Config);
// (Type->BaseValue + Substance->BaseValue + Characteristic->ValueDelta + Σ enchant ValueDelta)
// * lerp(QualityFactorRange, SubstanceQuality) * lerp(QualityFactorRange, CraftQuality)
int32 PartDurability(const FPieceInstance& Piece, const UCraftingConfig& Config);
// Type->BaseDurability * Substance->DurabilityFactor * the two quality factors
int32 MaxDurability(const FAssembledItem& Item, const UCraftingConfig& Config); // Σ PartDurability
// pure. The seam to Combat.md. See "From item to weapon".
FWeaponProfile MakeWeaponProfile(const FAssembledItem& Item, const UCraftingConfig& Config);
}
```
The reference example, run through: dominant is the blade (weight 4), prefix "Blunt"; mithril holds blade plus
guard, six of nine weight, so core "Mithril"; root "Sword"; the handle's fire enchantment supplies "of Embers".
*Blunt Mithril Sword of Embers*. That is the first automation test.
## Substances
**The pipeline, enforced by one tag.** A raw object is never valid input to a shaping station and a processed
object is never valid input to a processing station; `StateTag` alone enforces it, with no per-station recipe list.
```
RAW PROCESSED PIECE
Iron Ore -> Iron Ingot -> Iron Blade
Oak Log -> Oak Plank -> Oak Handle
Coal -> (fuel: never processed, burned by the forge bed)
```
Cost is authored on the piece type, not per type and substance, so substance choice is a free axis: any workable
substance makes any piece of that type at the same object cost, with different value, colour and heat behaviour.
Combinatorial variety, no combinatorial authoring.
**Object semantics.** Identity always. No merging, no splitting: objects are created and consumed whole.
Aggregated for display only ("Iron Ingot ×12, avg quality 0.62" is a HUD affordance over twelve distinct actors).
Consumption picks **worst-fit-first** by default: a station needing three ingots from a rack of nine takes the three
lowest-quality qualifying ones, shows which before starting, and the player may override. Good stock is saved for
good work by default.
`ASubstanceActor` is a carryable ([Interaction.md](Interaction.md)) with a `UStaticMeshComponent` tinted by the
substance's palette colour through custom primitive data (no dynamic material instance per object), net dormancy on
while at rest, and its `FSubstanceInstance` replicated to whoever is in range. Rare and unique objects on the floor
are never swept away by any future tidy-up rule; they glow faintly.
**Quality variance is the natural case.** The smelt activity's result lands in `Quality` and scales the object's
value; a careless smelt is worth less, a careful one more, both are usable. **Class bonus applies to time and
quality, never to yield**: a better smith's smelt is faster and purer, never more numerous, because a yield bonus is
an efficiency lock. Concretely, a bonus is the `WorkSpeed` and `WorkQuality` attributes on the body's stat block
([Stats.md](Stats.md)), which the activity runtime reads; a class is base numbers there and nothing else.
Supply, for these steps, is a spawner in the gym: `bs.SpawnSubstance <tag> <count>` and a crate that refills. Where
substances come from in a world (purchase, contracts, the world itself) is [`../Ideas.md`](../Ideas.md).
## Stations
```cpp
UCLASS(BlueprintType)
class UStationDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag StationTag; // Station.Type.Anvil
UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge; empty on the assembly bench
UPROPERTY(EditDefaultsOnly) TObjectPtr<UActivityDefinition> Activity; // null for a plain station
UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1, always, everywhere
UPROPERTY(EditDefaultsOnly) int32 InputCapacity = 4, OutputCapacity = 4;
UPROPERTY(EditDefaultsOnly) float BaseSpeedMultiplier = 1.f;
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer AcceptsInsert; // Substance.State.Processed for an anvil, Item.PieceType for a bench
};
/**
* Every station. An interactable (join, leave, take output) and an insert receptacle. Operators are a LIST because
* the anvil takes two smiths; a second operator raises the ceiling and never the floor. Work at a station PAUSES
* when everyone leaves and never fails: losing progress would punish exactly the behaviour a co-op game wants
* (dropping what you are doing to help someone). The clock that matters runs elsewhere.
*/
UCLASS()
class AStationActor : public AActor, public IInteractable
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) TObjectPtr<UStationDefinition> Definition;
UPROPERTY(Replicated) FGameplayTag StateTag; // Station.State.*
UPROPERTY(Replicated) TArray<TObjectPtr<APlayerState>> Operators;
UPROPERTY(Replicated) FGuid ActivitySession; // 0 when idle
UPROPERTY(Replicated) TArray<FGuid> InputBuffer, OutputBuffer; // object ids; the forge's "buffer" is its bed
// IInteractable: Interact.Verb.Operate joins (or leaves when already an operator); the prompt names who is on it
// when full, in their colour, and nothing else happens: occupied stations do not queue you. TakeOutput is a
// second verb when the output buffer holds something and your hands are free.
// Insert (the receptacle path from Interaction.md): validate AcceptsInsert and DomainTag against the carried
// object's tags; reject WrongDomain or NotAccepted with the reason; move the object into InputBuffer.
// ServerStartWork: an operator, inputs present -> ActivityRuntime->ServerBeginActivity(...). On completion,
// if the output buffer has no room the station is Blocked, never destroying anything, until someone empties it.
// No queueing. Batching is physical: the forge bed holds many items at once because it is a bed.
};
```
**The assembly bench** is a station with no activity and no domain. Inserting a piece stages it into the part its
type fills (the map makes a second piece in one part structurally impossible); every insertion re-runs
`ValidateAssembly` and shows the preview name, quality, synergy segment and the failing reason on the bench's
world-space panel; the `Assemble` verb commits through `ServerAssembleItem`, which re-runs the verdict server-side
and never trusts the preview. The session's initiator commits; anyone may insert. The assembled item appears in
the output buffer as an `AAssembledItemActor` built from the resolved attach chains, each piece's mesh on its
parent's named socket, tinted by its substance.
## Activities
```
[SALVAGED] One runtime, so that the forge's interactivity is not lost because the anvil learned to take two
players, and so that five copied minigames cannot drift into five bug surfaces. A minigame overrides OnBegin,
ApplyInput, Tick, EvaluateProgress and CollectFaults. It cannot invent its own participation model, save shape or
way of producing quality; those are closed.
```
```cpp
UCLASS(BlueprintType)
class UActivityDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag ActivityTag; // Activity.Strike
UPROPERTY(EditDefaultsOnly) TSubclassOf<UActivityRules> Rules; // UStrikeActivity, UHeatActivity
UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1: serialised as a constant so it is visible
UPROPERTY(EditDefaultsOnly) FVector2D QualityBand = FVector2D(0.85f, 1.15f); // guarantee 2: skill modulates this and nothing else
UPROPERTY(EditDefaultsOnly) float BaselineSeconds = 6.f; // "steady work" duration at one operator
UPROPERTY(EditDefaultsOnly) float BaselineScore = 0.4f; // what steady work scores: guarantee 1
UPROPERTY(EditDefaultsOnly) bool bAllowAutoPlay = true; // hold-to-work is a first-class setting
UPROPERTY(EditDefaultsOnly) float AssistWindowScale = 1.f; // accessibility: widens timing tolerances, never gated
};
USTRUCT()
struct FActivityResult // the ONE funnel; nothing else may produce quality
{
GENERATED_BODY()
float QualityContribution = 0.f; // 0..1, mapped through QualityBand by the consumer
float TimeSpent = 0.f;
TMap<FGuid, float> ContributionByPlayer; // attribution: never clawed back, feeds telemetry and later XP
FGameplayTagContainer Faults; // Fault.Overheated, Fault.Misstruck
};
/** UWorldSubsystem, server. Owns every session; the station owns none of this. */
UCLASS()
class UActivityRuntime : public UTickableWorldSubsystem
{
GENERATED_BODY()
public:
FGuid ServerBeginActivity(APlayerState* Instigator, AStationActor* Host, const UActivityDefinition& Def, const FActivityPayload& Payload);
bool ServerJoinActivity(APlayerState* Who, FGuid Session); // additive, always optional; ActivityFull when at max
void ServerLeaveActivity(APlayerState* Who, FGuid Session); // keeps their contribution; last one out pauses
void ServerActivityInput(APlayerState* Who, FGuid Session, const FActivityInput& Input); // THE per-frame path
// validates participant and reach (with a short leash and a visible warning before dropping out),
// delta = Rules->ApplyInput(session, who, input); accumulates and attributes
// Tick: Rules->Tick per running session; steady-work accrual when auto-play is engaged; progress advances at
// the operators' summed WorkSpeed and the quality floor is raised by their best WorkQuality (both attributes on
// the stat block, Stats.md); complete at progress 1:
// result -> Host->DeliverResult(result, payload) -> the substance or piece is made; emit activity_completed.
};
```
**The anvil: the growing zone.** Not a rhythm game; a reaction-and-placement game scored as a percentage. The
heated piece exposes 3 to 6 hot spots (authored per piece type, in the piece's own space so the data survives an
art swap). Each round one becomes the active zone, a circle that starts small and grows; the player moves the hammer
over it and strikes. Inside the zone, score is `InverseLerp(MaxRadius, MinRadius, radiusAtHit)`: tiny is excellent,
huge is poor. Outside is `Fault.Misstruck` and the zone keeps growing. Never struck, the round expires at zero. No
input at all, each round auto-resolves at `BaselineScore`: completion is never gated on reaction. After the authored
round count, `QualityContribution` is the mean. **Temperature gates the whole thing:** below the substance's working
band the metal resists, growth speeds up and scores are capped. Go back to the forge. That one rule is the loop
between the two stations. Two strikers alternate the active zone; each hit is attributed.
Because the zone's radius is a deterministic function of time, a strike is lag-compensated by arithmetic, not
history: the client stamps its input, the server evaluates `radius(clientTime)` directly, clamped to a 250 ms
window and never scoring better than the best achievable at the earliest legitimate arrival. Two strikers resolve
in timestamp order. See [Networking.md](Networking.md), tier three; it is the only timing-scored input in the game.
**The forge: heat in a bed.** A coal bed is a fixed 5×5 grid of cells with temperature and fuel. On the server at
10 Hz: fuel burns into temperature, temperature conducts to four neighbours (never all pairs), items in the bed
sample the cells they overlap and move toward that temperature at their own conductivity over thermal mass, and an
item over its substance's burn point takes `Fault.Overheated`. Inputs: place and move items, rake coals, add fuel,
work the bellows (an airflow spike, the cleanest second role in the game: pure help, zero risk). An item is done
when it has held its working band for the hold time; quality is time inside the band against time outside. The
bed holds many items at once, which is the only batching there is.
Heat runs at **two levels of detail**: the full grid while a player is within the sim radius, one lumped
temperature and fuel pool ticked at 1 Hz when nobody is, with the spatial pattern stored across the collapse so
the bed you walk back to is the bed you left. The binding rule: far-mode outcomes must match near-mode outcomes
on average over the same wall-clock time, or walking away becomes an exploit. The comparison harness is thirty
lines and is written with the first heat prototype, not after. Heat is scoped to a bed; an object outside any
bed cools on a curve and stops simulating at ambient. Replication: 25 quantised bytes at 5 Hz to near clients, one
byte to far ones, coal glow and metal colour driven locally from the values.
**Discovery.** Working metal turns up shapes nobody told you about: a run of excellent strikes on an unusual
hot-spot pattern, an odd substance at an odd temperature, a fault that turned out well. `ServerDiscoverCharacteristic`
adds the definition to the player's known set (per player, on the player state, so it travels with them), fires
`OnCharacteristicDiscovered` as a moment (the piece named and glowing on the anvil, a line in the log, never a
screen-centre banner), and from then on the characteristic is an ordinary choice at the station. The glyph
discovery the earlier project planned for enchanting is the same shape and should generalise this rather than
grow beside it; for these steps enchantments are a fixed authored set.
**Scrapping** runs no activity: a plain interaction returning `ScrapReturnFraction` of the constituent objects,
rounded down, quality scaled, marked `Mark.Salvaged`. A frictionless sink for failed work.
## Enchanting
Applied to a piece, before or after assembly, into addon sockets bounded by the piece type. Both filters must
agree: the enchantment's `ApplicableToSubstances` and the substance's `ArcaneAffinity` meet in the middle, so a new
substance declares what it accepts rather than every enchantment being edited. Enchanting an assembled item routes
to the same call per target piece; there is no second code path.
```cpp
// UCraftingSubsystem
bool ServerApplyEnchantment(APlayerState* Instigator, FGuid PieceId, const UEnchantmentDefinition& Ench);
// Validate: at a Domain.Enchant station; UsedSockets + SocketCost <= Type->AddonSockets else NoFreeSocket;
// affinity both ways else SubstanceRejectsEnchant. Add; recompute derived; OnPieceEnchanted; emit piece_enchanted.
```
## Server flows
```cpp
// UCraftingSubsystem (world, server). Every entry validates the instigator, re-runs the pure verdict, mutates
// through the registry, then notifies. In solo exactly as in co-op.
bool ServerProcessSubstance(APlayerState* Instigator, AStationActor* Station, TArray<FGuid> Inputs);
// all inputs one type and Raw else AlreadyProcessed; station domain matches ProcessDomainTag else WrongDomain;
// count >= ProcessInputCount else NotEnough. Begin the heat activity with the inputs as payload.
// On result: consume inputs; emit ProcessOutputCount objects of ProcessesInto with Quality from the result,
// Origin Crafted(instigator), Mark.Overheated if the fault is present. OnSubstanceProcessed.
bool ServerShapePiece(APlayerState* Instigator, AStationActor* Station, const UPieceTypeDefinition& Type,
const UCharacteristicDefinition& Char, TArray<FGuid> Objects);
// station domain == Type->DomainTag; Char applicable to Type->PartTag; Char known to instigator;
// Objects.Num() >= Type->SubstanceObjects, one substance, all at working temperature (the anvil reads the bed).
// Begin the strike activity. On result: new FPieceInstance with SubstanceQuality = avg, SourceObjectIds,
// CraftQuality = result.QualityContribution; consume objects; output buffer; OnPieceShaped.
bool ServerAssembleItem(APlayerState* Instigator, AAssemblyBench* Bench, const UItemFamilyDefinition& Family);
// verdict = ValidateAssembly(Family, Bench->Staged, Config); if invalid reject(verdict.Reason) even if the
// client's preview said otherwise. New FAssembledItem; Durability = MaxDurability; consume pieces (they cease
// to exist as objects); spawn the item actor into the output buffer; OnItemAssembled; emit item_assembled.
```
## From item to weapon
```
[PROPOSED] A crafted item of a weapon family IS a weapon. Nothing about the melee ability, the damage funnel or the
action bar knows whether the sword in hand was authored or crafted; both are an FWeaponProfile.
```
```cpp
USTRUCT()
struct FWeaponContribution // on a piece type and on a characteristic: what this part adds
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly) float Damage = 0.f, ReachCm = 0.f, WindupSeconds = 0.f, SwingSeconds = 0.f;
UPROPERTY(EditDefaultsOnly) float DamageMultiplier = 1.f; // characteristics multiply; piece types add
};
// CraftingRules::MakeWeaponProfile, pure:
// Damage = (Σ piece Type->Weapon.Damage) * Π characteristic DamageMultiplier
// + QualityScore * Config.DamagePerQualityPoint // coherence is what makes it hit harder
// ReachCm = Σ Type->Weapon.ReachCm (the blade supplies most of it), scaled by the blade piece's authored scale
// Windup, Swing = Σ contributions, clamped to sane bounds
// DamageType = the highest-value enchantment's DamageType if set, else Damage.Type.Physical
// GrantedTags = ∪ enchantment GrantsTags // the funnel and future rules read these
// bTwoHanded = the family says so, or the blade piece's scale is past its range's midpoint
// Montage = Family.Montage; WeaponTag = Family.FamilyTag
```
Equipping: an `AAssembledItemActor` whose family is a weapon offers `Interact.Verb.Equip` when it is on the
ground or in your hand; the server builds an `AWeaponActor` from the profile, attaches it to the family's hand
socket, applies every piece's enchantment `HolderEffect` to the wielder for as long as it is held, and drops the
previously equipped weapon where you stand. One weapon equipped, whatever you carry. The combat step that proves
this is step 13: a crafted sword's coherence score visibly changes the damage number on a dummy.
## Persistence shape
Nothing persists in these steps; the shapes are written down now because the data-contract rules are cheap on the
first day and expensive later.
```
FSubstanceRecord { FGuid Id; FPrimaryAssetId Type; float Quality; FProvenance Origin; TArray<FName> Marks; }
FPieceRecord { FGuid Id; FPrimaryAssetId Type, Characteristic, Substance; float SubstanceQuality, CraftQuality;
TArray<FGuid> SourceObjectIds; TArray<FPrimaryAssetId> Enchantments; } // no Value, no Durability
FItemRecord { FGuid Id; FPrimaryAssetId Family; int32 Durability; TMap<FName, FGuid> Parts; } // current durability only
- Content by primary asset id, tags by name. Derived fields recomputed on load, so a rebalanced config re-scores old items.
- Pieces recorded once and referenced by id from items, so a partial write can never duplicate one.
- Each record carries a schema_version and an adjacent migration function.
```
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Substance and piece objects | Server | Replicated actors, dormant at rest, relevant by distance |
| Station state, operators, buffers | Server | Replicated properties on the station |
| Bench staging and the preview | Client previews, server verdicts | Same pure function on both; the client's preview is a question, never a claim |
| Activity sessions and inputs | Server | Inputs are server RPCs; the strike is timestamped |
| Heat grid | Server | 25 bytes at 5 Hz near, 1 byte far |
| Assembled items | Server | Replicated actor; the item struct replicated to the owner for the panel |
| Known characteristics | Server | On the player state, replicated to the owner |
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `substance_processed` | Smelt completes | `type`, `count`, `quality_avg`, `faults` |
| `piece_shaped` | Strike completes | `type`, `characteristic`, `substance`, `craft_quality`, `rounds`, `round_scores[]` |
| `piece_enchanted` | Enchant applied | `piece_type`, `enchantment` |
| `item_assembled` | Commit succeeds | `family`, `piece_ids[]`, `name`, `quality`, `synergy`, `substance_value`, `time_at_bench_s` |
| `assembly_rejected` | Commit or insert refused | `family`, `reason`, `part` |
| `activity_completed` | Any activity | `activity`, `operators`, `quality`, `auto_play`, `faults`, `contribution_by_player` |
| `characteristic_discovered` | The moment | `characteristic`, `cause` |
| `weapon_equipped` | Equip | `family`, `crafted` (bool), `damage`, `quality` |
`assembly_rejected` by reason is the friction signal on the crafting UX before anyone says "I didn't get it";
`round_scores[]` against `auto_play` says whether the anvil is fun or a chore.
## Tests
- Automation, every rejection reason named: `CanFillSlot` for the four reasons; `ValidateAssembly` for missing,
foreign and unreachable parts and for the guardless sword validating; `ComposeName` for the reference example,
no prefix, no suffix, competing substances suppressing the core, the word limit and the stem collision;
`ScoreCoherence` for synergy rising with matching themes and for completeness making optional parts worth it;
`PartDurability` and `MaxDurability` for the dragonbone-hilt-oak-blade case; `MakeWeaponProfile` for a crafted
sword landing inside the authored sword's numbers at mid quality; the heat LOD equivalence harness.
- Functional: `FT_Crafting_Bench` spawns three pieces, inserts, commits and asserts the name and score; two clients
see the same assembled mesh with the blade on the handle's socket; `FT_Crafting_Anvil` with no input completes
at `BaselineScore`.
## Open questions
- **Q1. Is the known-characteristic set per player or per world?** Per player, on the player state: it travels
with them and gives a visiting veteran something real to bring. Cheap to move if wrong.
- **Q2. Two strikers: alternating or simultaneous?** Alternating first; it is the one that still works when the two
players have very different reaction speeds.
- **Q3. Hot-spot count and growth curve.** 3 to 6 spots and the radius numbers are authored per piece type and the
feel lives entirely there. A greybox pass, not a decision.
- **Q4. Where do pieces and items live beyond hands and station buffers?** Nowhere yet. Storage as a place
(containers with slots, no auto-pull, no infinite chest) is the earlier design and is parked in Ideas until a
step needs it. The two-hand rule and the object model already assume it.
- **Q5. Alloys.** A kiln combining two raw types into one processed type wants `ProcessesInto` to become a small
recipe struct. Not now, and **nobody hard-codes 1:1 processing** in the meantime.
- **Q6. Does substance quality want a visible tell?** A dull versus bright ingot at a glance would make the smith's
care legible across a room. Cheap (a tint lerp), competes with substance identity colour. An art call.
+320
View File
@@ -0,0 +1,320 @@
# Interaction, carrying and throwing
Owns the one system through which a player acts on the world: what can be touched, how it is targeted, what the
prompt says, how the request reaches the server, and what happens to things a player picks up. It does **not** own
any prop's behaviour: a bench belongs to [Crafting.md](Crafting.md), a downed teammate to [Combat.md](Combat.md).
This document says how a player acts on a thing; the feature docs say what the thing does.
Read [Architecture.md](Architecture.md) first. Built in step 6, before combat, because reviving a teammate and
inserting a piece into a bench are the same act on different props and neither should invent its own mechanism.
## One system
Before the earlier projects had this, their design docs described four implicit interaction models: a raycast
"use", a proximity push, a trigger volume you stood on, and an undefined "confirm". In a game where the world is
the interface that becomes a dozen mechanisms that each feel slightly different. One system instead, and one
interface, which is justified by the count: the two source projects between them named a bench, a station, a
container, a ticket, a contract card, a counter, a locker, a plinth, a lever, a cart's handles, a cart being righted,
and a downed player. Twelve implementations is not speculative abstraction.
**The boundary that keeps it one system:** an interaction is a **discrete act on a fixed prop**. Carrying is
**continuous possession of a moving object**. Pushing is continuous force and is neither. Picking something up
therefore goes through the carry component, not the interaction service, even though both are on the same key;
the interaction component routes the press.
## Layout
```
Source/<Project>/Interaction/
├── Interactable.h // IInteractable, the UInterface every prop implements (on an actor or a component)
├── InteractionTypes.h // FInteractionPrompt, FInteractionQuery, the reason tags
├── InteractionSubsystem.h // UWorldSubsystem: registry, server-side validation, the one dispatch path
├── InteractionComponent.h // on the player: targeting, prompt, the request RPC, hold-to-confirm
├── InteractionHighlightComponent.h // on a prop: custom depth outline, in-range and targeted states
├── Carryable.h // ICarryable and UCarryableComponent: what can be held, and by whom
└── CarryComponent.h // on the player: hands, pick up, drop, throw, hand over
Content/Interaction/
├── M_Outline_PP // the post-process outline material reading custom stencil
└── WBP_InteractionPrompt // one prompt widget, see UI.md
```
Trace channel `Interactable` is `ECC_GameTraceChannel1`, declared in `DefaultEngine.ini`; every interactable's
collider responds to it and nothing else does. Carryables additionally sit on `Carryable` (`ECC_GameTraceChannel2`).
## Types at a glance
| Type | Lifetime | Notes |
| --- | --- | --- |
| `IInteractable` | interface | Implemented by a prop actor, or by a component when one actor holds several props |
| `FInteractionPrompt`, `FInteractionQuery` | value | The prompt is the output of one function, on both peers |
| `UInteractionSubsystem` | world | Registry and the server-side funnel |
| `UInteractionComponent` | per player body | Owner only; the only thing that sends the request |
| `UInteractionHighlightComponent` | per prop | Presentation |
| `ICarryable`, `UCarryableComponent` | per carryable actor | Replicated `CarriedBy` |
| `UCarryComponent` | per player body | Two hands |
## The interactable contract
```cpp
USTRUCT(BlueprintType)
struct FInteractionQuery
{
GENERATED_BODY()
TObjectPtr<AController> Instigator; // who is asking
TObjectPtr<APawn> Body; // their body, for range and carried state
FGameplayTagContainer BodyTags; // State.Carrying, State.Downed, ... from the ability system
};
USTRUCT(BlueprintType)
struct FInteractionPrompt
{
GENERATED_BODY()
FGameplayTag Verb; // Interact.Verb.Open, .Take, .Insert, .Revive, .Pull ...; localised by the widget
FText TargetName; // "Assembly bench", "Rainer" (a downed player's name is a name, not a key)
bool bEnabled = true;
FGameplayTag Reason; // Interact.Reason.HandsFull, .Occupied, .NotYours, .OutOfReach ... shown when disabled
float HoldSeconds = 0.f; // > 0 for irreversible verbs: scrap, cancel, leave
};
UINTERFACE(BlueprintType)
class UInteractable : public UInterface { GENERATED_BODY() };
/**
* Anything a player can touch. The prompt and the permission come from ONE function so what the prompt says is
* possible and what the server allows can never disagree. Interact() runs on the authority only.
*/
class IInteractable
{
GENERATED_BODY()
public:
virtual FInteractionPrompt GetPrompt(const FInteractionQuery& Query) const = 0; // pure, called every frame locally
virtual void Interact(const FInteractionQuery& Query) = 0; // SERVER ONLY, after re-validation
virtual FVector GetInteractionLocation() const = 0; // what the server measures reach against
virtual bool IsLocalOnly() const { return false; } // true: acts on this machine's own state, never forwarded
virtual float GetPriorityBias() const { return 1.f; } // a person outranks the furniture they stand at
virtual FGameplayTag GetPropTag() const = 0; // stable identity for telemetry: Prop.Bench, Prop.ReviveTarget
};
```
`IsLocalOnly` exists for props like a personal locker or a settings surface: things that only change this
player's own local state. They run their `Interact` on the client without a round trip and are never sent to the
server. Everything touching shared state stays forwarded. Without the flag, an earlier project had a guest's locker
setting the host's class.
## The subsystem: one funnel
```cpp
UCLASS()
class UInteractionSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
// Reach. Tokens, not literals, so they are tuned in one place and the prompt and the server agree.
static constexpr float ReachCm = 180.f;
static constexpr float HighlightCm = 350.f;
static constexpr float ReachToleranceCm = 50.f; // the client's frame and the server's are a tick apart
bool IsInReach(const FVector& BodyLocation, const IInteractable& Target, float Tolerance = 0.f) const;
/**
* SERVER ONLY. The only path an interaction takes. Never trusts the client's claim that it was close enough
* or that the prompt was enabled: re-checks reach with tolerance, re-runs GetPrompt for permission, then calls
* Interact. Emits prop_interacted. One distance comparison is the difference between a convenience and an
* exploit.
*/
bool ServerTryInteract(const FInteractionQuery& Query, TScriptInterface<IInteractable> Target);
// Registry, so the highlight sweep and later systems can ask "what is near" without a world search.
void Register(TScriptInterface<IInteractable> Prop); void Unregister(TScriptInterface<IInteractable> Prop);
void QueryNear(const FVector& Point, float Radius, TArray<TScriptInterface<IInteractable>>& Out) const;
};
```
Props register in `BeginPlay` on every peer and unregister in `EndPlay`. The RPC carries the prop as an object
reference: map-placed actors and replicated actors both resolve on the server through their network identity, which
is why there is no hand-rolled handle table. A prop that is neither placed nor replicated cannot be interacted with
over the network, by construction, which is the correct failure.
## The player's component
```cpp
/**
* The targeting, the prompt, the press. Owner only. Finds what you are looking at, offers it, and on the press
* either routes to the carry component (a carryable with free hands) or sends the request to the server.
*/
UCLASS()
class UInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
TScriptInterface<IInteractable> GetCurrentTarget() const;
FInteractionPrompt GetCurrentPrompt() const; // the HUD reads this; it never computes its own
void OnInteractPressed(); void OnInteractReleased(); // released matters for hold-to-confirm
protected:
// TickComponent, owner only:
// 1. Sweep a 15 cm sphere from the camera along its forward for ReachCm on the Interactable channel.
// What you LOOK at wins; the earlier overlap-and-angle-score model is used only for the highlight set.
// 2. If the hit resolves to an IInteractable (on the hit component first, then its owner) and it is in
// reach per the subsystem, it is the target; GetPrompt(query) is the prompt. Otherwise no target, no prompt.
// 3. Every 0.25 s, QueryNear(HighlightCm) drives the in-range outline set; the target gets the strong outline.
// Press:
// target is ICarryable and hands are free -> Carry->RequestPickUp(target) (not an interaction)
// carrying and target accepts an insert -> ServerRequestInteract(target) (Insert is an interaction)
// target.IsLocalOnly() -> target->Interact(query) locally
// prompt.HoldSeconds > 0 -> start the hold; send on completion; release cancels silently
// otherwise -> ServerRequestInteract(target)
UFUNCTION(Server, Reliable) void ServerRequestInteract(UObject* Target);
// builds the query from the owning controller and calls the subsystem's ServerTryInteract.
// The client sends the target and nothing else; the server reads the body's position itself.
};
```
Two players interacting with one prop in the same frame resolve host-side by arrival order: the second fails the
re-run `GetPrompt` if the first changed the prop's state. Contested props that need a soft lock ("someone is using
this", with a name) get it in their own `GetPrompt`; the system does not know what contention means.
## Carrying
```
[SALVAGED] Two hands, and things you carry are real. Most objects take one hand; big ones take both and, in
first person, sit in the middle of your view. Carrying something two-handed blocks Interact, Insert and attacking
until you put it down. Drop is always available and instant. Nothing you carry is ever destroyed by dropping,
throwing or bumping into a wall with it.
```
```cpp
UINTERFACE() class UCarryable : public UInterface { GENERATED_BODY() };
class ICarryable
{
GENERATED_BODY()
public:
virtual UCarryableComponent* GetCarryable() = 0;
};
/** On any actor that can be held. The actor is the object; this is its carry state. */
UCLASS(meta = (BlueprintSpawnableComponent))
class UCarryableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false;
UPROPERTY(EditDefaultsOnly) float CarryWeight = 1.f; // against the carrier's CarryCapacity, see Stats.md
UPROPERTY(EditDefaultsOnly) bool bBlocksAttack = false; // one-handed loot does not; a crate does
UPROPERTY(EditDefaultsOnly) FName AttachSocket = TEXT("carry_r"); // or carry_both
UPROPERTY(ReplicatedUsing = OnRep_CarriedBy) TObjectPtr<APlayerState> CarriedBy; // null on the ground
bool IsCarried() const { return CarriedBy != nullptr; }
// Server: attach to the carrier's mesh socket, physics off, collision to Ignore, dormancy off.
// Release: detach, physics on, collision back, an impulse if thrown, dormancy back on when at rest.
// OnRep_CarriedBy does the same attach/detach on clients so the carried object rides the body without
// per-frame movement replication.
};
/** On the player. Two hands, the verbs, and the encumbrance effect that is the only way weight slows a body. */
UCLASS()
class UCarryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(Replicated) TArray<TObjectPtr<UCarryableComponent>> Hands; // 0, 1 or 2 entries; two-handed fills both
bool HasFreeHandsFor(const UCarryableComponent& Object) const;
UCarryableComponent* GetHeld() const; // the one-handed object, or the two-handed one
void RequestPickUp(UCarryableComponent* Object); // owner -> ServerPickUp
void RequestDrop(); // G tap
void RequestThrow(float ChargeSeconds); // G held then released; the interaction component tracks the hold
UFUNCTION(Server, Reliable) void ServerPickUp(UCarryableComponent* Object);
// Validate: in reach (subsystem, with tolerance), not carried by anyone, not State.Downed, hands free.
// Object->AttachTo(this). ApplyStatus(GE_Encumbered, CarryMath::SpeedFactor(weight, CarryCapacity), infinite):
// weight is an effect on MoveSpeed like mud or a slow (Stats.md). Grant State.Carrying(.TwoHanded).
// Emit object_picked_up.
UFUNCTION(Server, Reliable) void ServerDrop();
// Always legal while carrying. Release at the hands with no velocity. Remove GE_Encumbered and the tags. Emit object_dropped.
UFUNCTION(Server, Reliable) void ServerThrow(FVector_NetQuantizeNormal Aim, float ChargeSeconds);
// Two-handed objects cannot be thrown (reason: TooHeavy). power = clamp(charge / MaxCharge) * ThrowForce(mass).
// Release with impulse along Aim. It lands where it lands. Fragile things take a Mark (Crafting.md), never damage.
// Emit object_thrown.
UFUNCTION(Server, Reliable) void ServerHandOver(UCarryComponent* Other);
// Both in reach, other has free hands. The co-op verb. Emit object_handed_over.
// A down drops what you hold (Combat.md calls RequestDrop on the server when State.Downed is granted).
};
```
The attach-not-simulate approach is the whole reason carrying feels instant: while held, the object is a child of
the carrier's mesh and rides its predicted movement; there is no physics to predict and nothing to reconcile. The
moment it leaves the hands it is a server-simulated physics body again, replicated with the engine's predictive
interpolation mode, and a thrown object arriving a tick late on a remote screen reads as weight, not lag.
**Throwing is in, and it is allowed to be scrappy.** It is the cheapest physical comedy available and it serves
coordination: tossing an ingot across a room beats walking it, at the cost of accuracy. Rules that keep it fun:
objects never break from a throw; a thrown object that comes to rest is an ordinary floor object; thrown objects
collide with the world but never knock something out of another player's hands; catching is not a mechanic, aim at
the feet.
**Blocking is deliberate.** A two-handed object commits your hands and, in first person, your eyes. The deadzone
widening while carrying ([Movement.md](Movement.md)) is the safety valve: you can peek round your load without
turning your body. If playtests read the block as friction rather than weight, the valve is per-object
(`bTwoHanded` is data) and never a global rule change.
## Highlighting
Custom depth with stencil. `UInteractionHighlightComponent` sets `bRenderCustomDepth` and a stencil value on the
prop's primitives: 1 for in-range, 2 for targeted, 0 otherwise. One post-process material in the player camera's
volume draws the outline from the stencil, reading its two colours from the theme ([UI.md](UI.md)). The prompt says
*what*; the outline says *which*, so two ingots lying on each other show which one the press takes. No prop draws
its own outline and no prop chooses a colour.
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Targeting and the prompt | Client, owner | Local every frame; cosmetic; never replicated |
| The request | Client asks, server decides | `ServerRequestInteract` on the player's own component |
| Reach and permission | Server | Re-checked with tolerance; the client's claim is never trusted |
| The prop's effect | Server | Whatever the owning feature does; replicated by that feature |
| Local-only props | Client | Never sent |
| Held objects | Server | `CarriedBy` replicated; attachment mirrored in `OnRep` |
| Thrown and dropped objects | Server | Physics simulated on the server, replicated movement |
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `prop_interacted` | A successful interaction | `prop`, `verb`, `held_seconds`, `player_class` |
| `interaction_refused` | The server refused a request | `prop`, `reason` |
| `object_picked_up` | Carry begins | `object`, `two_handed`, `weight` |
| `object_dropped` | Drop or forced drop | `object`, `forced` |
| `object_thrown` | Throw | `object`, `charge`, `distance_landed` |
| `object_handed_over` | Hand over | `object` |
`prop_interacted` earns its place because a world-as-interface game has a failure mode a menu game does not: a
player who cannot find the bench has nothing to fall back on. If a prop exists and nobody in a session ever
touches it, it is not discoverable, and this event is the only way to learn that before someone says so.
`interaction_refused` by reason is the cheapest possible measure of whether prompts explain themselves.
## Tests
- Automation: `IsInReach` with and without tolerance; the prompt of a test prop is disabled with `HandsFull` when
the query carries `State.Carrying.TwoHanded`.
- Functional: two clients and a server; client A picks up a crate, client B sees it on A's body; A throws it, both
see it land within tolerance of the server's resting place; a request from beyond reach plus tolerance is
refused and `interaction_refused` carries `OutOfReach`.
## Open questions
- **Q1. Contested props.** Arrival order is the rule. Whether a bench or a station wants a visible soft lock with a
name is each prop's call in its own `GetPrompt`; the first prop that needs one sets the pattern.
- **Q2. A greyed prompt or no prompt for an unusable prop.** Greyed, with the reason: more discoverable and more
honest. Revisit if prompts clutter a crowded room.
- **Q3. Hold-to-interact as an accessibility option.** The hold mechanism exists for irreversible verbs; making it
optional for every verb is a settings toggle over the same code. With the settings pass, not before.
- **Q4. Which objects are two-handed.** Data per object. The first crate, the first log and the first assembled
greatsword decide the initial list; the earlier projects chose crates, logs, ore chunks and packed stations.
- **Q5. A satchel.** The earlier smithing design added purchasable pocket slots later in progression. Not now;
parked in [`../Ideas.md`](../Ideas.md). Hands only until something needs more.
+445
View File
@@ -0,0 +1,445 @@
# Movement
Owns the character, the movement component, input, the look model, the two camera modes, the gym level and the
hooks other systems use to change how a body moves. It does **not** own abilities that move a body (dodge, blink,
charge: [Combat.md](Combat.md), built on the root-motion hooks here) or picking things up
([Interaction.md](Interaction.md), which supplies the carry-weight penalty this document consumes).
Read [Architecture.md](Architecture.md) first. This is the first system built and the one everything else stands
on, which is why it gets three steps of its own in [`../Steps.md`](../Steps.md) before a single enemy exists.
## Why the controller comes first
A crafting game with bad movement is a menu with a walk between screens. A combat game with bad movement is unfair
before the first swing. Both earlier projects put movement in a corner of a combat step and tuned it by feel once,
against one enemy; neither ever wrote down what "good" meant. This time the controller is the first thing built,
it is built against a level made of nothing but movement problems, and it is not called done until a written
checklist passes with a person at the keyboard.
## Decisions
```
[DECIDED] UCharacterMovementComponent, extended. Not a custom controller, not the Mover plugin.
The engine's character movement is server-authoritative with client prediction and server correction built in,
which is exactly the posture Architecture.md demands and the thing the earlier Unity project never had (its
movement was client-authoritative, which does not survive contact with a real server). It integrates with root
motion sources, which is how abilities move a body without a second movement system. It is mature, documented and
what the engine's own animation tooling assumes.
The Mover plugin is the engine's future answer and is still marked experimental. Q2 keeps it in view; the
extension points used here (a saved-move flag, a tuning asset, root motion sources) are the ones Mover also
exposes, so a later move is a port, not a rewrite.
```
```
[DECIDED] One rig, two camera modes. The camera is presentation and never enters the authority path.
First person is the engine's mannequin seen from a socket on its own head; third person is the same mannequin
seen over its shoulder. There is no first-person arms rig, no second animation set. Every activity, prop and
fight must be completable in both modes, and nothing about the camera is ever replicated, saved or used by the
server to decide anything. Which mode is the default is Q1; both exist from step 3 so the answer can be played
rather than argued.
```
```
[DECIDED] Units are the engine's: centimetres, Z up, 1 uu = 1 cm. Every figure in this doc is in those.
```
## Layout
```
Source/<Project>Core/Movement/
├── LookModel.h / .cpp // pure: the deadzone-then-body-follows look model
└── MovementTuning.h // the tuning asset class and its validation
Source/<Project>/Movement/
├── BaseCharacter.h // ACharacter subclass shared by players and enemies: mesh, team, ASC access
├── PlayerCharacter.h // adds camera, input, interaction and carry components
├── ExtendedCharacterMovement.h // the CMC subclass: sprint flag, coyote time, jump buffer, speed multipliers
├── CameraModeDefinition.h // UPrimaryDataAsset per mode
└── CameraModeComponent.h // owns the camera and spring arm, blends between modes, applies the look model
Content/Movement/
├── Definitions/DA_Tuning_Player, DA_CameraMode_FirstPerson, DA_CameraMode_ThirdPerson
├── Input/IMC_Gameplay, IA_* // one mapping context for play; a second (IMC_Menu) arrives with the menu
├── BP_PlayerCharacter // sets the mesh, animation blueprint, tuning and camera assets. No logic.
├── Surfaces/PM_Mud, PM_Ice // UPhysicalMaterialWithTags for the gym's surfaces (Stats.md), step 5
└── Maps/L_Gym // the movement test level
```
## Types at a glance
| Type | Module | Lifetime | Notes |
| --- | --- | --- | --- |
| `FLookModelParams`, `FLookModelState`, `LookModel::Tick` | Core | value | pure, tested without a world |
| `UMovementTuning` | Core | asset | numbers, with `IsDataValid` checks |
| `ABaseCharacter` | Gameplay | per body | `IAbilitySystemInterface`, `IGenericTeamAgentInterface` |
| `APlayerCharacter` | Gameplay | per body | camera, input binding, interaction, carry |
| `UExtendedCharacterMovement` | Gameplay | per body | the CMC subclass, predicted |
| `UCameraModeDefinition` | Gameplay | asset | one per mode |
| `UCameraModeComponent` | Gameplay | per player body | local only, never replicated |
## Input
Enhanced Input. One mapping context, `IMC_Gameplay`, added at priority 0 in `APlayerCharacter::SetupPlayerInputComponent`
through the local player's `UEnhancedInputLocalPlayerSubsystem`. Actions are assets under `Content/Movement/Input/`
and are bound by the action asset, never by key, so a rebind is a settings change and not a code change. Rebinding
itself uses the engine's `UEnhancedInputUserSettings` (player-mappable key settings on each action), which persists
to the player's save folder; no custom rebinding code is written.
The default layout. Keyboard bindings are the ones the earlier project settled on, with the one conflict resolved
in Q4's favour: sprint takes Shift, the fourth ability slot moves.
| Action | Keyboard and mouse | Gamepad | Value | Notes |
| --- | --- | --- | --- | --- |
| `IA_Move` | W A S D | Left stick | Axis2D | Relative to the body in first person, to the camera in third |
| `IA_Look` | Mouse | Right stick | Axis2D | Mouse deltas are **not** scaled by delta time; stick rates are |
| `IA_Jump` | Space | A | Bool | Buffered and coyote-timed, see below |
| `IA_Sprint` | Left Shift (hold) | Left stick click | Bool | A predicted movement flag, not an ability |
| `IA_Crouch` | Left Ctrl (toggle) | B (hold) | Bool | Engine crouch, capsule shrinks |
| `IA_Dodge` | Left Alt | B (tap) | Bool | Activates the dodge ability once it exists, step 5 |
| `IA_Attack` | Left mouse | Right trigger | Bool | Owned by Combat; bound here so the map is in one place |
| `IA_Interact` | F | X | Bool | Owned by Interaction |
| `IA_Drop` | G (tap drops, hold throws) | Y | Bool | Owned by Interaction |
| `IA_Ability1..4` | Q, E, R, C | LB, RB, Y, LT | Bool | Owned by Combat, routed through input tags |
| `IA_CameraToggle` | V | D-pad down | Bool | Swaps camera mode |
| `IA_Menu` | Escape | Start | Bool | Owned by UI |
Every action carries an `Input.*` gameplay tag in its player-mappable key settings so the ability system, the prompt
and the action bar can ask "which key is `Input.Interact` on the device this player touched last" and get the
live binding back. No view ever prints a literal key.
## The character and the movement component
```cpp
// Source/<Project>Core/Movement/MovementTuning.h
UCLASS(BlueprintType)
class UMovementTuning : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// Speeds, cm/s. All guesses until the gym says otherwise. Walk is the old project's 4.5 m/s.
UPROPERTY(EditDefaultsOnly, Category = "Speed") float WalkSpeed = 450.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float SprintSpeed = 650.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float CrouchSpeed = 250.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float MaxAcceleration = 2048.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float BrakingDeceleration = 2048.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float AirControl = 0.35f;
// Jump. Apex ≈ JumpZ² / (2 · 980 · GravityScale): 560 at 1.5 gravity is about 106 cm, the old 1.1 m jump.
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpZVelocity = 560.f;
UPROPERTY(EditDefaultsOnly, Category = "Jump") float GravityScale = 1.5f;
UPROPERTY(EditDefaultsOnly, Category = "Jump") float CoyoteTime = 0.10f; // seconds after leaving a ledge a jump still counts
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpBufferTime = 0.12f; // seconds before landing a press is remembered
// Ground. The engine defaults are right for stairs up to 45 cm and slopes to 45 degrees; listed so they are tuned here.
UPROPERTY(EditDefaultsOnly, Category = "Ground") float MaxStepHeight = 45.f;
UPROPERTY(EditDefaultsOnly, Category = "Ground") float WalkableFloorAngle = 45.f;
// Landing. A drop taller than this costs a brief recovery; taller than the second, fall damage (Combat.md).
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingHeight = 300.f;
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingRecovery = 0.2f;
#if WITH_EDITOR
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
// SprintSpeed > WalkSpeed > CrouchSpeed > 0; CoyoteTime and JumpBufferTime under 0.3 s; angles in (0, 90).
#endif
};
```
```cpp
// Source/<Project>/Movement/ExtendedCharacterMovement.h
/**
* The engine's character movement plus the four things every feel pass ends up adding: a sprint flag that
* predicts correctly, coyote time, a jump buffer, and the ground surface trace that turns mud into an effect.
*
* Sprint is a compressed flag in the saved move, which is the engine's mechanism for predicted input state.
* It is deliberately NOT a gameplay ability: an ability round-trips through the ability system for something the
* movement component already replicates for free. Dodge and blink ARE abilities, because they apply root motion.
*/
UCLASS()
class UExtendedCharacterMovement : public UCharacterMovementComponent
{
GENERATED_BODY()
public:
void ApplyTuning(const UMovementTuning& Tuning); // called by the character on BeginPlay and on tuning change
// Input state, set by the owning character, carried in the saved move
void SetWantsToSprint(bool bWants);
void PressJumpBuffered(); // remembers a press for JumpBufferTime
// Speed is the state's tuning value times the body's MoveSpeed attribute (Stats.md), and nothing else. Carrying,
// being downed, mud, a haste and gear all change that one attribute through effects; this component never
// holds a multiplier of its own. Before the stat block exists (steps 3 and 4) the attribute reads as one.
float GetMoveSpeedAttribute() const; // 1.0 when the owner has no ability system component yet
void UpdateGroundSurface(); // server, ~5 Hz: the floor's UPhysicalMaterialWithTags -> its surface effect
// UCharacterMovementComponent
virtual float GetMaxSpeed() const override; // walk/sprint/crouch by state, times MoveSpeed
virtual bool CanAttemptJump() const override; // grounded, OR within CoyoteTime of leaving the ground
virtual void UpdateFromCompressedFlags(uint8 Flags) override;
virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
virtual void OnMovementModeChanged(EMovementMode PrevMode, uint8 PrevCustomMode) override; // starts the coyote clock
protected:
bool bWantsToSprint = false;
float TimeLeftGround = -1.f;
float JumpBufferedAt = -1.f;
TWeakObjectPtr<UPhysicalMaterial> CurrentSurface;
FActiveGameplayEffectHandle SurfaceEffect;
// FSavedMove_Character subclass carrying bWantsToSprint in FLAG_Custom_0; FNetworkPredictionData_Client_Character
// subclass allocating it. Standard engine pattern; see the engine's own ACharacter crouch flag for the shape.
};
```
```cpp
// Source/<Project>/Movement/PlayerCharacter.h
/**
* The player's body. Owner-only concerns (camera, input, interaction, carrying) live here as components;
* everything shared with enemies is on ABaseCharacter. The character decides nothing: it reads input, hands it to
* the movement component and the ability system, and lets the camera component look.
*/
UCLASS()
class APlayerCharacter : public ABaseCharacter
{
GENERATED_BODY()
public:
APlayerCharacter(const FObjectInitializer& OI);
protected:
UPROPERTY(EditDefaultsOnly, Category = "Movement") TObjectPtr<UMovementTuning> Tuning;
UPROPERTY(EditDefaultsOnly, Category = "Input") TObjectPtr<UInputMappingContext> GameplayContext;
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraModeComponent> CameraMode;
UPROPERTY(VisibleAnywhere) TObjectPtr<UInteractionComponent> Interaction; // Interaction.md
UPROPERTY(VisibleAnywhere) TObjectPtr<UCarryComponent> Carry; // Interaction.md
// Replicated so remote players can see where this one is looking. RemoteViewPitch is the engine's; yaw is ours.
UPROPERTY(Replicated) uint8 RemoteHeadYaw; // HeadYaw compressed to a byte, written by the owner every tick
virtual void SetupPlayerInputComponent(UInputComponent* Input) override; // binds IA_* to the handlers below
void OnMove(const FInputActionValue& V); // AddMovementInput relative to body (FP) or camera (TP)
void OnLook(const FInputActionValue& V); // feeds CameraMode->AddLookInput
void OnJumpPressed(); void OnJumpReleased(); // Jump() plus the buffer
void OnSprint(const FInputActionValue& V); // Movement->SetWantsToSprint
void OnCameraToggle(); // CameraMode->CycleMode()
// Attack, Interact, Drop and Ability1..4 forward to Combat and Interaction; they decide nothing here.
virtual void Landed(const FHitResult& Hit) override; // hard-landing recovery, fall damage event, telemetry
};
```
`ABaseCharacter` sets `UExtendedCharacterMovement` as the movement class (so an enemy is slowed by the same mud a
player is), sets the capsule to the mannequin's 42 cm radius and 96 cm half-height, implements
`IAbilitySystemInterface` (returning the player state's component for players, its own for enemies) and
`IGenericTeamAgentInterface`, and carries the `USkeletalMeshComponent` and animation blueprint reference. It has no
input.
## The look model
```
[SALVAGED] The head turns freely inside a yaw deadzone; past it, the body eases around to follow. Inside the
deadzone the body does not move at all, and that "nothing" is the whole feel: you can glance at a teammate or the
thing on the bench beside you without stepping out of position. The deadzone widens while carrying something big,
so you can peek round your own load.
```
```cpp
// Source/<Project>Core/Movement/LookModel.h // pure, no UObject, tested
struct FLookModelParams
{
float YawDeadzone = 45.f; // degrees either side before the body turns
float YawDeadzoneCarry = 70.f; // while carrying a two-handed object
float MaxTurnRate = 540.f; // degrees per second the body may turn to catch up
float MovingDeadzoneScale = 0.35f; // smaller while walking: you face where you go
float ReCenterRate = 90.f; // degrees per second the head drifts forward while moving
float PitchMin = -80.f, PitchMax = 70.f;
float SpinePitchShare = 0.5f; // how much of the pitch bends the spine, for the visible body
};
struct FLookModelState { float HeadYaw = 0.f; float BodyYaw = 0.f; float Pitch = 0.f; };
struct FLookModelInput { FVector2D LookDelta; bool bMoving; bool bStrafing; bool bCarryingTwoHanded; float DeltaSeconds; };
namespace LookModel
{
// pure. Returns the new state; the caller writes BodyYaw to the actor and BodyYaw + HeadYaw to the camera.
FLookModelState Tick(const FLookModelState& State, const FLookModelParams& Params, const FLookModelInput& In);
// HeadYaw += LookDelta.X
// deadzone = (carrying ? YawDeadzoneCarry : YawDeadzone) * (moving ? MovingDeadzoneScale : 1)
// if |HeadYaw| > deadzone: turn = min(|HeadYaw| - deadzone, MaxTurnRate * dt) * sign; BodyYaw += turn; HeadYaw -= turn
// if moving && !strafing: HeadYaw = MoveTowards(HeadYaw, 0, ReCenterRate * dt)
// Pitch = clamp(Pitch + LookDelta.Y, PitchMin, PitchMax)
}
```
How it meets the engine: `bUseControllerRotationYaw` is off. In **first person** the character's actor yaw is
`BodyYaw`, the camera yaw is `BodyYaw + HeadYaw`, and movement input is relative to the body, so you walk where you
face and look elsewhere. In **third person** the camera is free (spring arm on control rotation),
`bOrientRotationToMovement` is on so the body faces where it walks, and the model drives only the head and spine aim
in the animation blueprint so teammates can see where you are looking. One implementation, both modes; `Pitch`
reaches the rig through the engine's replicated `RemoteViewPitch`, `HeadYaw` through `RemoteHeadYaw`.
## Camera modes
```cpp
UCLASS(BlueprintType)
class UCameraModeDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag ModeTag; // Camera.Mode.FirstPerson / .ThirdPerson
UPROPERTY(EditDefaultsOnly) float FieldOfView = 90.f;
UPROPERTY(EditDefaultsOnly) bool bFirstPerson = false;
// third person
UPROPERTY(EditDefaultsOnly) float ArmLength = 300.f;
UPROPERTY(EditDefaultsOnly) FVector SocketOffset = FVector(0, 60, 40); // over the right shoulder
// first person
UPROPERTY(EditDefaultsOnly) FName HeadSocket = TEXT("head");
UPROPERTY(EditDefaultsOnly) FVector EyeOffset = FVector(10, 0, 0);
UPROPERTY(EditDefaultsOnly) float HeadStabilization = 20.f; // damping stiffness; 0 = rigidly on the bone (do not ship that)
UPROPERTY(EditDefaultsOnly) bool bHideLocalHead = true;
UPROPERTY(EditDefaultsOnly) FLookModelParams Look;
UPROPERTY(EditDefaultsOnly) float BlendSeconds = 0.25f;
};
/** Owns the camera and spring arm, blends between definitions, runs the look model. Local player only. */
UCLASS()
class UCameraModeComponent : public UActorComponent
{
GENERATED_BODY()
public:
void SetMode(FGameplayTag ModeTag); void CycleMode();
void AddLookInput(FVector2D Delta);
FRotator GetCameraRotation() const; // BodyYaw + HeadYaw, Pitch
// TickComponent: run LookModel::Tick, write BodyYaw to the owner, position the camera:
// FP: camera location = damped follow of Mesh->GetSocketLocation(HeadSocket) + EyeOffset, in LateUpdate order
// (tick group PostUpdateWork) so it runs after animation; rotation from the look model, NEVER from the bone.
// Mesh->HideBoneByName(head) on the locally controlled pawn only; other clients see the whole body.
// TP: spring arm with collision test on, length and socket offset from the definition, camera on control rotation.
protected:
UPROPERTY(EditDefaultsOnly) TArray<TObjectPtr<UCameraModeDefinition>> Modes;
UPROPERTY(VisibleAnywhere) TObjectPtr<USpringArmComponent> SpringArm;
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraComponent> Camera;
FLookModelState Look;
};
```
The stabilised head socket is the make-or-break piece. Rigid parenting to the bone turns every walk-cycle bob into
camera shake, which the earlier project found out the hard way and fixed twice. The camera follows the socket's
position through a damped spring and takes its rotation from the look model only. An optional procedural bob (off
by default, scaled by the motion accessibility setting) is the only bob there is.
Comfort settings are launch requirements for a first-person mode, not options: stabilisation strength, FOV per
mode, bob toggle, and the motion scale that also governs camera kicks in combat.
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Position, velocity, movement mode | Server, client predicts | Character movement's own prediction and correction |
| Sprint, crouch | Server, client predicts | Compressed flags in the saved move |
| Jump | Server, client predicts | Engine jump plus the buffered press, resolved in the predicted move |
| Look pitch | Owner writes | `RemoteViewPitch`, engine built-in, for the rig only |
| Head yaw | Owner writes | `RemoteHeadYaw` byte, for the rig only |
| Camera mode, FOV, stabilisation | Local only | Never replicated |
| `MoveSpeed`, `JumpPower` | Server | Attributes on the stat block, replicated; a server-applied change costs one small correction on the owner, see [Stats.md](Stats.md) |
Remote bodies interpolate through the movement component's network smoothing (exponential). Test every step of this
document with the editor's network emulation profile set to 100 ms and 5 % loss, and `p.NetShowCorrections 1` on;
a correction you can see at those settings is a bug in the saved move.
## The gym
`L_Gym` is a level made only of movement problems, greyboxed from engine primitives with a material per problem
kind. It is the level every movement step is proved in, and it stays in the project forever as the regression
test for the controller.
| Section | What it holds |
| --- | --- |
| Stairs | Risers of 15, 20, 30 and 45 cm, straight and spiral |
| Slopes | 15, 30, 45 and 60 degrees, up and down; the last must not be walkable |
| Gaps | 150, 200, 250 and 300 cm, flat; 250 makeable at sprint only |
| Ledges | Drops of 100, 200, 300 and 500 cm onto flat ground |
| Doorways | 110 by 220 cm, and a 90 by 200 cm one that a crouch fits |
| Beams | 30 cm wide walkways over a drop |
| Corridor | A 40 m straight for speed and stop-distance measurement, marked every 5 m |
| Surfaces | Patches of mud and ice on the corridor's second half, on tagged physical materials; inert until step 5 |
| Arena | An open 30 by 30 m circle for the combat steps later |
## The feel checklist
Step 4 closes when a person at the keyboard ticks every line, in both camera modes, and the numbers that made it
pass are committed in `DA_Tuning_Player`.
- Stairs of every riser at walk and sprint: no camera stutter, no snag, no bounce at the top.
- Slopes: walkable to 45 degrees, slides off 60; speed on a 30 degree climb reads as effort, not a wall.
- Gaps: 150 and 200 at walk, 250 at sprint, 300 never. The 250 is the one that teaches sprint.
- Coyote time: stepping off a ledge and pressing jump within a tenth of a second still jumps. Buffer: pressing jump
just before landing jumps on landing.
- Landing from 100 and 200 cm: nothing. From 300: a visible knee-bend and a fifth of a second of no input. From 500:
fall damage (once Combat exists) and the same recovery.
- Stop distance from sprint under 150 cm; from walk under 60 cm. Turning at sprint has a radius, not a pivot.
- Air control is enough to correct a jump onto the beam, not enough to reverse mid-air.
- Doorways: the wide one at sprint without touching, the narrow one only crouched.
- First person: a five-minute walk of the whole gym without discomfort with stabilisation at its default. Looking
down shows your own feet. The deadzone lets you glance at a wall sign without turning.
- Third person: the spring arm never clips through a wall; the body faces where it walks; the head turns to follow
the look.
- Two clients and a dedicated server in editor, emulation at 100 ms and 5 %: the remote body is smooth on stairs,
slopes and jumps, and no correction snap is visible on the local one.
## Hooks for other systems
- **`MoveSpeed` and `JumpPower`** on the stat block ([Stats.md](Stats.md)) are the only way anything slows or
speeds a body: carrying, being downed, mud, gear and every buff or debuff are effects on those two attributes,
and the movement component multiplies. It never holds a multiplier of its own.
- **Root motion sources** are how abilities move a body. Dodge is a `FRootMotionSource_ConstantForce` over its
duration with i-frames granted as a `State.Invulnerable` tag; blink is a `MoveToForce` along the aim, flattened;
the shoulder charge is a constant force with a hit window. All three predict through the movement component's
existing root-motion prediction and are specified in [Combat.md](Combat.md).
- **Downed** sets the crouch capsule, overrides `MoveSpeed` to zero through `GE_Downed` and disables jump; the
camera drops to the downed eye height through the camera component, which reads the `State.Downed` tag.
- **Impulses** (a shove, a heal-launch) call `LaunchCharacter`, which the movement component already replicates.
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `movement_sample` | Every 5 s while moving | `speed`, `mode`, `camera_mode`, `sprinting` |
| `jump` | A jump begins | `coyote` (bool), `buffered` (bool) |
| `land` | `Landed` | `fall_height`, `hard` (bool) |
| `camera_mode_changed` | Mode swap | `from`, `to` |
| `settings_changed` | A comfort or binding setting is committed | `setting_id`, `value` |
`movement_sample` is what later says which camera mode people actually live in and how much of the gym's speed
range is used, which is what decides Q1 and the sprint tuning with data instead of taste.
## Tests
- `LookModel` automation: inside the deadzone the body yaw does not change; an overshoot turns the body by the
overshoot and never faster than `MaxTurnRate`; moving without strafing recenters the head; carrying widens the
deadzone; pitch clamps. Five tests, no world.
- `UMovementTuning::IsDataValid` refuses sprint slower than walk, negative times, and angles outside (0, 90).
- Functional: `FT_Gym_Stairs` drives an `APlayerCharacter` up each riser with `AddMovementInput` and asserts it
reaches the top; `FT_Gym_Slopes` asserts the 60 degree slope is not climbable; `FT_Gym_Gaps` asserts the 300 cm
gap is not crossable at sprint.
## Open questions
- **Q1. Which camera mode is the default?** Both earlier projects chose first person: it is where close manual
work reads best and it is cheapest to make feel good with one rig. The case for third person is seeing your own
crafted gear and a better read of a crowded fight. Build both in step 3, play the gym and the first fight in each,
and let `camera_mode_changed` and `movement_sample` settle it by step 8.
- **Q2. The Mover plugin.** Revisit when it leaves experimental. The port cost is bounded by keeping the custom
surface to the saved-move flag, the tuning asset and root-motion sources.
- **Q3. Does sprint cost stamina?** Not in step 3. If combat wants a stamina attribute, sprint may draw from it
through the ability system's attribute, but the flag itself stays in the movement component.
- **Q4. The Shift conflict.** The earlier layout put the third ability slot on Shift because that game had no sprint.
This one does. Decided for now: Shift sprints, the slots are Q, E, R, C. Revisit if the fourth slot is unreachable
in a fight.
- **Q5. Head bob.** Off by default, one amplitude setting, scaled by the motion setting. Whether it earns a place
in first person is a playtest question after step 4.
- **Q6. Mantling and vaulting.** Not now. The gym has no ledge you are meant to climb. If the world later wants it,
it is a movement ability on root motion, not a change to the component.
+205
View File
@@ -0,0 +1,205 @@
# Networking
Owns the authority model, the responsiveness tiers, the posture on prediction and tick rate, the session shape,
and the two seams (identity and persistence) that let the game outgrow one machine without a rewrite. It does
**not** own gameplay: what a shove does is in [Combat.md](Combat.md), what an insert does in
[Crafting.md](Crafting.md). Each feature spec states what is authoritative where; this document is the rule they
all follow and the reasoning behind it.
Read [Architecture.md](Architecture.md) first.
## The decision
```
[DECIDED] Server-authoritative from the first line, on the engine's own replication, with the dedicated server
as the real target.
There is no listen-server mode in the design and no "are we multiplayer" branch anywhere in gameplay code.
Standalone and listen-server play exist because the editor offers them and they are convenient; every actor,
subsystem and RPC is written as if the server were a separate process on another machine, and it is tested that
way from step 1 with Play In Editor's dedicated server option.
The engine's actor model already is this posture: AGameModeBase exists only on the server, properties replicate
one way, Server RPCs need an owning connection, movement is server-corrected. The work is to never undermine it,
not to build it.
```
Why this and not the earlier project's listen server with a relay: a host-authoritative peer is the one topology
that cannot grow. It ties the simulation to a player's machine, makes the host able to cheat freely, and turns
every later step toward a persistent shared world into a rewrite. Choosing the server posture now costs nothing
(the engine defaults to it) and closes no door.
## The authority table
The rollup. Each feature spec carries its own rows and the two must agree.
| State | Authority | Mechanism |
| --- | --- | --- |
| Player movement, jump, sprint, crouch | Server, owner predicts | Character movement's prediction and correction |
| Look pitch and head yaw | Owner writes | Replicated bytes, for the rig only; the server never reads them to decide anything |
| Camera | Local only | Never replicated, saved or read by the server |
| Ability activation | Client requests, server decides | GAS prediction keys; the owner's animation starts immediately |
| Damage, health, downed, dead | Server | The one execution; replicated attributes and tags |
| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim; fine in cooperative play |
| Cooldowns | Server, client predicts | Cooldown effects; the HUD shows the prediction and takes the correction |
| Enemy brains and spawning | Server | StateTree ticks on the server; bodies replicate |
| Interaction | Client requests, server re-validates reach and permission | One RPC on the player's own component |
| Held objects | Server | Attached to the carrier, `CarriedBy` replicated; no physics while held |
| Dropped and thrown objects | Server | Server physics, replicated movement, predictive interpolation |
| Station state, buffers, operators | Server | Replicated properties |
| Crafting previews | Client computes | Hypotheticals; a wrong preview is a UI bug, never an exploit |
| Crafting verdicts and commits | Server | The same pure function, on the server, is the only one that counts |
| Activity inputs | Server | RPCs; the anvil strike is timestamped |
| Attributes and statuses on a body | Server; effects from a body's own abilities predicted | The stat block replicates; a server-applied speed change costs one small correction, see [Stats.md](Stats.md) |
| Player identity, class, known discoveries | Server | On the player state; replicated to the owner |
| Session membership | Server | Game mode and game state |
## Three responsiveness tiers
Several players in one room, all touching the same physical objects. The question is what each may do instantly
and what has to wait for the server, and one test assigns the tier: **does a rollback have a visible victim?**
```
TIER 1: PURELY LOCAL, never networked authority
camera and look, interaction targeting and prompts, every crafting preview, HUD, and your own movement
through the engine's prediction. Remote players interpolate; head and spine aim replicate so gaze survives.
TIER 2: PREDICT THE ANIMATION, NOT THE OWNERSHIP
pick up, insert, take output, equip, hand over, fire a projectile.
The reach or throw ANIMATION starts instantly and locally. The object changes hands when the server confirms.
Predicting your own arm costs nothing when wrong. Predicting who got the last ingot costs a snatch-back the
whole room can see, and with substances as individual contested objects that case is frequent, not theoretical.
TIER 3: TIMESTAMPED AND LAG-COMPENSATED
scored activity inputs: the anvil strike, and nothing else today.
The client stamps the input; the server evaluates it against what was true then. Because the zone's radius is
a deterministic function of time, that is arithmetic, not a rewind buffer: radius(clientTime), clamped to a
250 ms window, never scoring better than the best achievable at the earliest legitimate arrival.
```
**Standing rule:** 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 (tier 2), or make it a pure function of time
(tier 3).
## Ask, then show
Every commit-shaped moment in the game (an item assembled, a substance processed, a weapon equipped, later a
purchase or a payout) already has a beat of feedback: the assembled sword materialising on the bench, the ingot
coming out of the bed. That beat is the latency budget. A server round trip fits inside it with room to spare, so
the client asks, waits for the commit, and then shows the result. No prediction, no reconciliation path, no
possibility of a corrected number. **Never show a number as final before it is committed.** A UI that does not lie
needs no reconciliation.
## Posture
This is a game played with friends, not a tick-perfect competition. Nothing here is adversarial, ranked or decided
by milliseconds, so the bar is "feels good to a few people in a room", and anything argued for on competitive
integrity grounds is arguing from a genre this game is not in.
| Not building | Why not |
| --- | --- |
| Rollback netcode or deterministic lockstep | Solves fighting-game and RTS problems; this is a cooperative sim |
| High tick rates | 20 to 30 Hz is ample when one input in the game is timing-scored |
| Client prediction beyond own movement and animation | Every further step buys responsiveness we do not need and costs rollbacks players can see |
| Lag compensation anywhere but the anvil | Nothing else scores timing |
| An anti-cheat arms race | Server authority removes the motive: no PvP, nothing to win by cheating but spoiling your own game |
The tie-breaker when a choice is close: prefer the option that fails gracefully and legibly over the one that is
technically optimal. Rejections explain themselves, nothing is destroyed silently, a stalled station says why.
## Prediction hygiene
- Test every replicated feature with the editor's network emulation at 100 ms and 5 % loss before calling it
done, and `p.NetShowCorrections 1` on. A correction you can see is a bug in a saved move or an ability's
prediction, not a fact of life.
- Server RPCs go through components on actors the client owns: the player's pawn, player state and controller.
Never through a prop. An RPC on an unowned actor silently drops.
- Every `Server` RPC validates its inputs and its instigator. `WithValidation` is not enough; the body re-checks
reach, permission and state, because the client's claim is a claim.
- Replicated arrays with per-element churn (buffers, hands, a future inventory) use `FFastArraySerializer`, not a
plain `TArray`, so a change sends the element and not the array.
- Actors at rest are dormant (`DORM_DormantAll`, flushed on change). A room of two hundred ingots must cost nothing
while nobody touches them.
- Relevancy is by distance from day one (`NetCullDistanceSquared` on every replicated actor that is not a player).
A world larger than one room cannot afford everything being relevant to everyone, and a rule that exists from the
first actor is free.
- Never do per-client work on the server that scales with the square of the player count. If a system needs
every player to know about every player, that is a design flag.
## Sessions
There is one session shape: a server running a map, players joining it. The game mode handles login, spawns a
`APlayerCharacter` per controller at a spawn point and re-spawns on death. Party size is the game state's player
count and is read by anything that scales (the encounter budget) and stamped onto every telemetry envelope.
Steam, lobbies, invitations and matchmaking are not built in these steps and are not designed here. The earlier
project's Steam lobby and friends-list join are in [`../Ideas.md`](../Ideas.md); when they arrive they sit in front
of the session and never inside gameplay.
**Two instances on one machine:** Play In Editor with two clients and the dedicated server option is the everyday
test. A packaged server (`<Project>Server` target) needs the engine built from source and arrives when the first
packaged build does; nothing in the code changes for it.
## Identity
```
[DECIDED] A player is an identity that outlives a body, a session and a server. Everything about a player that
would matter tomorrow lives on the player state or behind the persistence provider, never on the character actor.
```
Concretely: `APlayerState` carries a `FGuid PlayerId` minted by the server on first login (hashed for telemetry,
never a platform id or a name), the class kit, the ability system component with its attributes and cooldowns,
known characteristics, and later progression. The character is a body the world lends the player. A body dying,
a map changing or a player reconnecting never loses any of it.
## Persistence
```
[DECIDED] Persistence is behind one provider interface with a local-file implementation. Gameplay writes records
to the provider and never to disk, so a service-backed provider is a second implementation and not a refactor.
```
```cpp
// Source/<Project>Core/Persistence/PersistenceProvider.h
class IPersistenceProvider
{
public:
virtual ~IPersistenceProvider() = default;
virtual bool Load(const FString& Key, FString& OutJson) = 0; // records are JSON snapshots, idempotent, never deltas
virtual bool Save(const FString& Key, const FString& Json) = 0; // atomic: temp file then move
virtual void Remove(const FString& Key) = 0;
};
// FLocalFilePersistenceProvider writes under FPaths::ProjectSavedDir()/Records/. Registered on the
// persistence game-instance subsystem at startup. Nothing is persisted in steps 1 to 15; the seam exists so the
// first thing that is (a player's class, a crafted sword) does not choose the file system by accident.
```
Record rules, from the first record: identity is `FGuid`; content is referenced by primary asset id; every record
carries a `schema_version` with an adjacent migration function and a monotonic `revision`; derived fields are
recomputed on load; save-facing records are separate types from runtime instances.
## Telemetry over the wire
Every peer emits its own events. There is no forwarding to the server, because the interesting questions (did this
client's frame rate drop, which player did the shoving, did this preview disagree with the verdict) are per-peer
by nature. `is_server` and `player_id` on the envelope let a run be reassembled later. A `session_id` is minted
by the server and adopted by every client on join, so one session's events are one session and not four.
## Tests
- Functional, run with emulation on: two clients see the same held object on a third's body; a request from beyond
reach is refused; an ability activated at 100 ms shows no visible correction; a thrown object lands within
tolerance on every peer; the anvil's strike scores the same for a client at 0 ms and one at 150 ms who pressed
at the same moment.
## Open questions
- **Q1. Reconnect.** A dropped player should reconnect into the same session with the world exactly as it
continued without them. The identity decision makes it possible; the session shape does not yet do it. Write the
rule down before it is discovered in a playtest.
- **Q2. Voice.** Proximity voice is a large part of what makes a room of friends a room. Not in these steps;
parked in Ideas.
- **Q3. Seamless travel.** One map today. Travelling between maps with the party and the player state intact is
the engine's seamless travel and arrives with the second map.
- **Q4. Replication at scale.** The Replication Graph and the newer Iris system exist for worlds with many actors
and many players. Not now; the dormancy and relevancy rules above are what keep the option cheap.
+91
View File
@@ -0,0 +1,91 @@
# Specifications
These documents are written for an implementer that reads carefully and lifts code, which today means Claude and
whoever reviews its pull requests. They are not the human summary; that is [`../Design.md`](../Design.md). Read the
summary first, then [`Architecture.md`](Architecture.md), then the document for the system you are touching.
Each spec is pseudocode plus the reasoning behind it. The pseudocode is C++-shaped Unreal code and it is meant to be
implemented, not admired: a class skeleton here is the class we expect to find in `Source/`, with the same name, the
same members and the same comments about why. Where a spec says `// pure`, that is a promise about testability the
real code is expected to keep.
## Documents
| Doc | Owns | Read when |
| --- | --- | --- |
| [Architecture.md](Architecture.md) | Modules, lifetimes, authority, data assets, gameplay tags, the C++/Blueprint boundary, testing, conventions | Before writing any code |
| [Stats.md](Stats.md) | The one stat block every body carries, effects and their sources (abilities, areas, surfaces, items, the world), stacking, counters | Step 5, and anything that changes a number on a body |
| [Movement.md](Movement.md) | The character, the movement component, the look model, camera modes, input, the gym level | Steps 3 to 5 |
| [Interaction.md](Interaction.md) | The one interaction system every prop uses, carrying, throwing | Step 6, and any prop |
| [Combat.md](Combat.md) | Attributes, the one damage funnel, melee hit detection, enemies, abilities, kits, downed and revive | Steps 7 to 11 |
| [Crafting.md](Crafting.md) | Families, parts, pieces, traits, substances, the pure rules, stations, activities, the link back to combat | Steps 12 to 15 |
| [Networking.md](Networking.md) | The authority model, the responsiveness tiers, prediction posture, persistence and identity seams | Any replicated feature |
| [Telemetry.md](Telemetry.md) | The sink, the envelope, the event catalogue | Any feature (every feature emits) |
| [UI.md](UI.md) | HUD, prompts, the theme asset, world-space text, localisation | Any screen or prop text |
## Notation
```
Server*(...) runs on the authority only. Validates the instigator and re-checks every precondition,
then mutates. A UFUNCTION(Server, Reliable) RPC or a plain method guarded by HasAuthority().
Get* / Preview* side-effect-free query. Safe to call from UI every frame, on any peer.
// pure deterministic, no world access, unit-tested, reused by the client preview and the server verdict.
On* (delegate) a multicast delegate a service raises. UI subscribes; UI never polls.
*_Cosmetic a BlueprintAssignable hook a designer may wire. Never load-bearing.
UFooDefinition a UPrimaryDataAsset, authored content.
FFooInstance runtime state with identity (an FGuid).
FFooRecord the persisted snapshot of an instance. Separate type from the instance on purpose.
TAG_Foo_Bar a native gameplay tag, "Foo.Bar", declared once in code. Never a string literal at a call site.
```
Status markers inside a spec:
```
[DECIDED] settled, with the reason. Logged in ../Decisions.md.
[PROPOSED] the recommended shape, not yet built on. Becomes [DECIDED] when the step that builds it closes.
[SALVAGED] an idea carried over from the two earlier projects, reshaped for this one. See ../Ideas.md.
Qn an open question, collected at the end of each document.
```
## Cross-cutting rules
Every spec obeys these. They are stated once here so no spec has to restate them, and any spec that appears to
break one is wrong.
1. **The server decides. The client asks, and predicts only its own body.** Every mutation has an explicit
instigator and is re-validated on the authority, even when the game is being played alone. Standalone and
listen-server play are development conveniences; the dedicated server is the real target and nothing may assume
it is absent. See [Networking.md](Networking.md).
2. **One formula, many consumers.** A preview and the real thing call the same pure function. Damage, coherence,
naming, assembly validation, spec matching and interaction prompts all follow this. If a UI-side "fast copy" of a
rule ever appears, it is a bug.
3. **One funnel per kind of consequence.** All damage goes through one execution. All interactions go through one
service. All activity quality goes through one result type. A second path is where rules quietly diverge.
4. **Rules are plain C++ in the core module; actors and components adapt them to the world.** The core module knows
no `AActor`. This is what makes rules testable without a level and runnable on a headless server.
5. **Content is data, addressed by gameplay tag or primary asset id, never by string.** A new sword, enemy or
substance is an asset, not a code change. If adding a variant needs code, the model is wrong.
6. **Every rejection carries a reason a player can read.** A refused interaction, an illegal assembly, a blocked
ability: each returns a reason tag that the prompt or panel shows. Silent refusal is a failed step.
7. **Nothing is destroyed silently.** Dropped things persist on the floor, full containers refuse with a reason,
thrown things land where they land. The rare deliberate deletion is announced before it happens.
8. **Bonuses, never locks.** A class, a level or a piece of gear changes speed, quality or numbers. It never changes
what a station accepts or what an assembly permits, and it never grants another class's signature ability.
9. **Solo viability is a gate, not a mode.** Every rule is checked against one player. Where it costs a solo player
something, the spec says so and names the tuning knob.
10. **Derived state is recomputed on load, never trusted from storage.** A rebalance re-scores old items everywhere.
11. **Telemetry from the first line.** Every feature emits through the injected sink into a catalogue that exists
before there is anyone to measure. Retrofitting emit calls is the tax this avoids.
12. **Determinism where it is promised.** Anything generated from a seed uses its own `FRandomStream`, integer
coordinates and ordered collections, and never reads physics, time or frame counts. Nothing today is generated
from a seed; the rule exists so the first thing that is inherits it.
13. **One stat block; every outside influence is an effect on it.** Every body carries the same attribute set.
A buff, a debuff, ground, weight, gear and base values are all gameplay effects applied to it; counters are
tags and attributes on the receiver. No system keeps its own multiplier. See [Stats.md](Stats.md).
## How a spec relates to the steps
[`../Steps.md`](../Steps.md) schedules; these documents specify. A step links to the sections it builds and states
what proves it done. If a step and its spec disagree, the spec wins and the step is corrected. A spec that turns out
wrong in the building is corrected in the same pull request as the code, under a **What was built, and where it
differs** heading at the end of the affected document, so the next reader is not misled by the sketch.
+307
View File
@@ -0,0 +1,307 @@
# Stats and effects
Owns the one stat block every body carries, the vocabulary of effects that change it, where effects come from
(abilities, areas, surfaces, items, the world), how they stack, and the counters (immunity, resistance, cleanse)
that stop them. It does **not** own what any stat means to a system: the movement component reads `MoveSpeed`
([Movement.md](Movement.md)), the damage funnel reads `Armour` ([Combat.md](Combat.md)), the activity runtime
reads `WorkSpeed` ([Crafting.md](Crafting.md)). This document says what the numbers are and how they change; the
system docs say what they do.
Read [Architecture.md](Architecture.md) first. Built in step 5, before combat, because the first debuff and the
first patch of mud must land on a system that already exists rather than each inventing one.
## The problem this exists to prevent
Every system that can affect a body is tempted to keep its own number: a speed multiplier on the movement
component, a slow timer on the enemy brain, a carry penalty on the carry component, a mud check in the character.
Then the goblin is not slowed by the mud the player is slowed by, the haste buff cannot cancel the carry penalty
because they live in different places, and nobody can say why a body is at forty percent speed. That is the
spaghetti, and it is not hypothetical: the first draft of [Movement.md](Movement.md) had exactly that multiplier
map. It is gone.
```
[DECIDED] One stat block, and everything with a body has it.
Players, enemies, NPCs: one UAttributeSet on the body's ability system component, the same class for all of
them. A thing that does not have one cannot be affected, and giving it one is how a thing is made affectable.
A wooden crate that should burn gets a stat block; a wall that should not does not.
```
```
[DECIDED] Every outside influence is a gameplay effect on the block. No exceptions.
A buff, a debuff, muddy ground, carried weight, an aura, gear, a class's base numbers, being downed: each is a
UGameplayEffect applied to the body's component. No system writes an attribute. No system keeps its own list
of modifiers. If you find yourself adding `float SpeedMultiplier` to a component, stop: it is an effect.
```
```
[DECIDED] Counters live on the receiver, as tags and attributes.
Immunity blocks an effect by tag. Resistance scales an effect by attribute. Cleanse removes effects by tag.
A counter is therefore something a kit, an enchantment, a piece of gear or an ability GRANTS, and it works
against every source of that status at once, including ones written next year. Every status has a visible
tell, and being immune to one is visible too.
```
```
[DECIDED] The same status from several sources: the strongest applies and the longest remaining duration is
kept. Different statuses multiply. A slow and a haste both apply and the product is the speed.
```
## Layout
```
Source/<Project>Core/Stats/
├── StatMath.h / .cpp // pure: Resisted(magnitude, kind, resist)
└── CarryMath.h / .cpp // pure: SpeedFactor(weight, capacity)
Source/<Project>/Stats/
├── StatBlockAttributeSet.h // the block
├── StatBlockDefaults.h // FStatBlockDefaults
├── ExtendedAbilitySystemComponent.h // input-tag routing (Combat.md), ApplyDefaults, ApplyStatus
├── EffectVolume.h // areas
└── PhysicalMaterialWithTags.h // surfaces
Content/Stats/
├── Effects/GE_InitStats, GE_Status_Slow, GE_Status_Haste, GE_Encumbered, GE_Downed, GE_Immune_Slow, GE_Cleanse ...
├── Cues/GC_Status_*, GC_Status_Blocked
└── DT_StatusIcons // Status.* tag -> icon, read by the HUD
```
## The block
One `UAttributeSet` subclass, `UStatBlockAttributeSet`, in `Source/<Project>/Stats/`. Multipliers have a
baseline of one; flat values of zero; fractions run from zero to one. Every attribute clamps in
`PreAttributeChange` to the range in the table, so no effect can push a body into nonsense.
| Attribute | Kind | Baseline | Range | Read by |
| --- | --- | --- | --- | --- |
| `Health`, `MaxHealth` | flat | from defaults | 0 .. Max | Combat: downed at zero |
| `HealthRegen` | flat per second | 0 | 0 .. | a periodic regen effect, when one exists |
| `Stamina`, `MaxStamina`, `StaminaRegen` | flat | from defaults | 0 .. Max | reserved; nothing draws yet |
| `MoveSpeed` | multiplier | 1 | 0 .. 3 | Movement: every state's speed times this |
| `JumpPower` | multiplier | 1 | 0 .. 3 | Movement: jump velocity times this |
| `AttackPower` | multiplier | 1 | 0 .. 5 | Combat: outgoing damage |
| `AttackSpeed` | multiplier | 1 | 0.25 .. 3 | Combat: wind-up and swing divided by this |
| `Armour` | flat | 0 | 0 .. | Combat: mitigation on physical damage |
| `MagicResist` | fraction | 0 | 0 .. 0.9 | Combat: mitigation on magic damage |
| `StatusResist` | fraction | 0 | 0 .. 0.9 | Stats: scales every incoming status toward neutral |
| `KnockbackResist` | fraction | 0 | 0 .. 1 | Combat: scales impulses; 1 is unmovable |
| `WorkSpeed` | multiplier | 1 | 0.25 .. 3 | Crafting: activity baseline duration divided by this |
| `WorkQuality` | flat | 0 | -0.5 .. 0.5 | Crafting: added to an activity's quality floor |
| `CarryCapacity` | flat | 1 | 0 .. | Interaction: weight a body carries before slowing |
| `IncomingDamage`, `IncomingHeal` | meta | | | the funnel writes, `PostGameplayEffectExecute` consumes, never replicated |
The block is the whole list of what an outside influence may touch. A system that wants to be affected by
something not on it adds an attribute here, in its own pull request, with a row in this table and a reader. A
system that wants to affect something reads this table to learn the name, and never invents a second one.
### Base values are an effect too
```cpp
USTRUCT(BlueprintType)
struct FStatBlockDefaults // the numbers a designer authors on a kit or an enemy definition
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly) float MaxHealth = 100.f;
UPROPERTY(EditDefaultsOnly) float MaxStamina = 100.f;
UPROPERTY(EditDefaultsOnly) float Armour = 0.f;
UPROPERTY(EditDefaultsOnly) float AttackPower = 1.f;
UPROPERTY(EditDefaultsOnly) float MoveSpeed = 1.f;
UPROPERTY(EditDefaultsOnly) float CarryCapacity = 1.f;
// and so on for every non-meta attribute, each with the table's baseline as its default
};
// UExtendedAbilitySystemComponent
void ApplyDefaults(const FStatBlockDefaults& Defaults);
// One GE_InitStats with an Override modifier per attribute, magnitudes SetByCaller from the struct, applied
// once on grant (a kit) or spawn (an enemy), then Health = MaxHealth and Stamina = MaxStamina.
// One way in, so that everything after the defaults is an effect like everything else.
```
A kit's `BaseStats` and an enemy definition's `BaseStats` are both this struct. Designers edit numbers; code
applies them through one effect; there is no per-kit or per-enemy effect asset to keep in step.
## Effects
An effect in this project is a `UGameplayEffect` asset that follows one shape, so that every reader (the HUD,
the cleanse, the immunity, telemetry) can treat them alike:
| Part | Engine mechanism | Rule here |
| --- | --- | --- |
| What it is | asset tags (`UAssetTagsGameplayEffectComponent`) | exactly one `Status.*` tag; `Status.Buff` or `Status.Debuff` as its parent |
| What it grants while active | granted tags (`UTargetTagsGameplayEffectComponent`) | the same `Status.*` tag, so `HasTag(Status.Slow)` answers "is this body slowed" |
| What it changes | modifiers | attributes from the table only; multiply for multipliers, add for flats |
| How strong | magnitude | `SetByCaller` under `Data.Magnitude`, or attribute-based when the source's stats matter |
| How long | duration policy | instant (a hit), timed (a debuff), infinite (ground, weight, a state); never a hand-rolled timer |
| Over time | period | periodic effects tick the funnel or a regen; never a tick in a component |
| What it looks like | gameplay cues | `GameplayCue.Status.<Name>` while active: the visible tell, on every peer |
| Who it stacks with | stacking | aggregate by target, limit one; the helper below decides who wins |
```cpp
// UExtendedAbilitySystemComponent, the one helper every status goes through
FActiveGameplayEffectHandle ApplyStatus(TSubclassOf<UGameplayEffect> Status, float Magnitude, float DurationSeconds,
const FGameplayEffectContextHandle& Context);
// SERVER, or a locally predicted ability applying to its own owner.
// 1. If this body is immune (the effect's asset tags match an active immunity), the engine blocks it and raises
// OnImmunityBlockGameplayEffectDelegate; play GameplayCue.Status.Blocked, emit status_blocked, return null.
// 2. Magnitude = StatMath::Resisted(Magnitude, kind, StatusResist): mult' = 1 + (mult - 1) * (1 - resist);
// flat' = flat * (1 - resist). Pure, in the core module, tested.
// 3. If an active effect with the same Status.* tag exists: keep the stronger magnitude and the longer remaining
// duration, refreshed, and return the existing handle. Strongest wins; durations do not add.
// 4. Otherwise apply with SetByCaller Data.Magnitude and Data.Duration. Emit status_applied.
```
The `Status.*` vocabulary, top level. A status is a name for an effect's *kind*, not for its source: a slow from
mud and a slow from a frost bolt are both `Status.Slow` and the counter for one is the counter for the other.
```
Status.Buff.* Haste, Fortified, Empowered, Regenerating, Focused (work speed), Steady (work quality)
Status.Debuff.* Slow, Rooted, Weakened, Exposed (armour down), Poisoned, Burning, Marked, Taunted, Stunned, Encumbered
```
`State.*` tags are different and stay: they describe what a body *is* (`State.Downed`, `State.Carrying`,
`State.Dodging`, `State.Invulnerable`), granted by the systems that own those states. Some states are also
effects (downed applies `GE_Downed`, which overrides `MoveSpeed` to zero); the tag says what the body is, the
effect says what it does to the numbers.
## Where effects come from
Five sources, and every one ends in the same call on the receiver's component.
| Source | Mechanism | Who applies | Example |
| --- | --- | --- | --- |
| **An ability** | the ability applies to its target or itself | server; the owner predicts self-applied ones | Haste on self; Weakened on a struck enemy; Taunted on everything in range |
| **An area** | `AEffectVolume`: an actor with an overlap shape, an effect class and a magnitude; applies on enter, removes on leave, with the handle kept per body | server | a frost cloud, a healing circle, a poison bog |
| **A surface** | `UPhysicalMaterialWithTags` on the floor material carries `Surface.*` tags and an effect; the movement component's ground trace applies it when the surface changes and removes it when it changes again | server | mud slows, ice removes braking, a hot plate burns |
| **An item or a kit** | granted on equip or grant, removed on unequip; infinite duration | server | a kit's base stats; an enchanted blade's `Immune.Status.Burning`; a class's `WorkSpeed` |
| **The world** | the same as an ability, from a non-body instigator | server | fall damage through the funnel; carried weight through `GE_Encumbered` |
```cpp
UCLASS()
class AEffectVolume : public AActor
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> Effect; // a Status.* effect
UPROPERTY(EditAnywhere) float Magnitude = 0.6f; // Data.Magnitude
UPROPERTY(EditAnywhere) bool bAffectsPlayers = true, bAffectsEnemies = true;
UPROPERTY(VisibleAnywhere) TObjectPtr<UShapeComponent> Shape;
// Server: OnActorBeginOverlap -> if the actor has a component, ApplyStatus(Effect, Magnitude, infinite) and
// remember the handle by body; OnActorEndOverlap -> RemoveActiveGameplayEffect(handle). A body that dies inside
// is cleaned up by the component's own teardown. Nothing here ticks.
};
UCLASS()
class UPhysicalMaterialWithTags : public UPhysicalMaterial
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) FGameplayTagContainer Tags; // Surface.Mud, Surface.Ice, Surface.Hot
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> SurfaceEffect; // optional: what standing on it does
UPROPERTY(EditAnywhere) float SurfaceMagnitude = 1.f;
};
// UExtendedCharacterMovement::UpdateGroundSurface(), server, on floor change (a short trace with
// bReturnPhysicalMaterial at ~5 Hz, the same trace footsteps will use): if the surface's effect differs from the
// one applied, remove the old and ApplyStatus the new. Enemies use the same movement component, so the goblin
// is slowed by the same mud, which is the whole point.
```
Carried weight is the world case: `UCarryComponent` applies `GE_Encumbered` on pick-up with a magnitude from
`CarryMath::SpeedFactor(weight, CarryCapacity)` (pure, in the core module) and removes it on drop. A haste that
multiplies `MoveSpeed` therefore counteracts a heavy crate exactly as it counteracts mud, because they are the
same number.
## Counters
Three kinds, each doing one thing, each granted like any other effect so that a kit, an enchantment or a piece of
gear can hand it out.
| Counter | Mechanism | Effect on the incoming status | Example grant |
| --- | --- | --- | --- |
| **Immunity** | an infinite `GE_Immune_<Status>` with an immunity component (`UImmunityGameplayEffectComponent`) matching `Status.<Name>` | blocked entirely; the blocked cue plays; nothing is applied | a boss immune to `Status.Taunted`; boots granting `Immune.Status.Slow`; a dodge's `State.Invulnerable` |
| **Resistance** | the `StatusResist` attribute (and `MagicResist`, `KnockbackResist` for their kinds) | scaled toward neutral by the helper before application | a Warrior's base 0.25 status resist; a Fortified buff |
| **Cleanse** | an instant effect with a remove-by-tag component (`URemoveOtherGameplayEffectComponent`) matching `Status.Debuff` | every active debuff removed | the Cleric's Mend removes one debuff; a shrine removes all |
| **Suspension** | ongoing tag requirements on the status (`UTargetTagRequirementsGameplayEffectComponent`) | the effect stays applied but stops modifying while a tag is present | `Status.Slow` suspended while `State.Dodging` |
Immunity tags are `Immune.Status.<Name>` and `Immune.Damage.<Type>`; the damage funnel reads the second kind and
discards matching damage, so fire immunity is one tag and not a branch in every fire ability. Being immune is
never silent: the blocked cue is the "Immune" flash every player has seen in every game with a status system.
**Counters are obvious by construction.** A player learns that mud slows and that the frost cloud slows; the
same boots stop both, because both are `Status.Slow`. If two things that feel the same need different counters,
they are different statuses and should be named so.
## Stacking
- **One status class per kind.** Every slow is `GE_Status_Slow`; a frost bolt and a mud patch differ in magnitude,
duration and cue context, never in class. This is what makes the helper's strongest-wins rule possible without
a custom aggregator.
- **Strongest wins within a status**, longest remaining duration is kept, durations never add. Three goblins
hitting you with the same slow is one slow.
- **Different statuses multiply.** `MoveSpeed` is the product of every active multiplier on it; a 0.6 slow and a
1.3 haste give 0.78. That is the engine's default aggregation for multiplicative modifiers and it is kept.
- **Flat values add.** Armour from gear and Armour from Fortified sum.
- **Overrides win and do not stack.** Downed overrides `MoveSpeed` to zero whatever else is applied.
## Who reads what
The readers, so that a new effect knows what it will move and a new reader knows what to read.
| System | Reads | How |
| --- | --- | --- |
| Movement | `MoveSpeed`, `JumpPower` | `GetMaxSpeed` = the state's tuning speed × `MoveSpeed`; `JumpZVelocity` × `JumpPower`; nothing else scales movement |
| Combat, outgoing | `AttackPower`, `AttackSpeed` | the funnel captures `AttackPower`; the swing divides its timings by `AttackSpeed` |
| Combat, incoming | `Armour`, `MagicResist`, `KnockbackResist`, `Immune.Damage.*` | the funnel and the impulse |
| Crafting | `WorkSpeed`, `WorkQuality` | the activity runtime: baseline duration ÷ `WorkSpeed`; quality floor + `WorkQuality`. This is where "class bonuses, never locks" lives: a class is a `BaseStats` with better work numbers and nothing else |
| Interaction | `CarryCapacity` | `CarryMath::SpeedFactor` |
| The HUD | active `Status.*` tags, remaining durations | a row of status icons from a tag-to-icon table on the theme |
| Enemies | everything, the same way | an enemy body's component is the same class with the same block |
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Attributes | Server | replicated attribute set, `REPNOTIFY_Always` so the HUD sees every change |
| Effects from own abilities | Client predicts, server confirms | the engine's effect prediction |
| Effects from areas, surfaces, other players, the world | Server | applied on the server; the attribute replicates |
| Immunity and resistance | Server | the helper runs where the effect is applied |
| Cues | Everywhere | unreliable multicast; presentation only |
The one cost worth naming: a server-applied speed change lands on the owning client one round trip after the
server saw it, and the client's predicted moves in between are corrected. At 100 ms that is a few centimetres at
the moment you step into mud, smoothed by the engine. Accepted; it is the tier-two rule from
[Networking.md](Networking.md) applied to a number instead of an object. If it ever reads as a snag, the owning
client may predict *surface* effects (it knows the floor too); nothing else.
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `status_applied` | The helper applies or strengthens a status | `status`, `magnitude`, `duration`, `source_kind` (ability, area, surface, item, world), `source`, `target_kind` |
| `status_blocked` | Immunity blocks one | `status`, `immunity`, `source_kind` |
| `status_removed` | Expiry, cleanse or leaving the source | `status`, `cause`, `time_active_s` |
`status_applied` by `source_kind` against `target_kind` is the first thing to look at when a fight feels unfair:
it says whether players or enemies are the ones spending the fight slowed.
## Tests
- Automation: `CarryMath::SpeedFactor` (capacity and above is one; heavier is slower; never below the floor);
`StatMath::Resisted` (a 0.6 slow at 0.5 resist is 0.8; a flat -10 at 0.5 is -5; 1.0 resist is neutral); the
clamp ranges in the table.
- Functional: a body in `AEffectVolume(Slow, 0.6)` moves at 60 %; a second stronger volume raises nothing when
weaker and replaces when stronger; leaving restores 100 %; a body with `GE_Immune_Slow` in the same volume
moves at 100 % and the blocked cue fired; walking onto the gym's mud slows a player and a goblin alike; a haste
applied while encumbered multiplies rather than replacing.
## Open questions
- **Q1. Should the owning client predict surface effects?** Not until the correction on entering mud is felt.
The trace runs on both; the effect application is the only thing that would move client-side.
- **Q2. Resistances per damage type.** `Armour` and `MagicResist` cover physical and magic. Fire, frost and
poison as damage types with their own resist would each be a row in the table; add them when a second
elemental type exists, not before.
- **Q3. A block on things that are not bodies.** A wooden crate that burns, a door that freezes shut. The rule
says give it a component and a block; the cost is a component per affectable prop. Decide with the first one.
- **Q4. Stamina as a cost.** Reserved in the block; whether sprint or abilities draw from it is Movement Q3 and
Combat Q2, and the answer is a cost effect either way.
+180
View File
@@ -0,0 +1,180 @@
# Telemetry
Owns the seam gameplay emits events through, the envelope, the format on disk and the catalogue of events. It
does **not** own any backend, storage or analysis; there is none, and the format is chosen so one can be bolted
on later without touching the game.
Read [Architecture.md](Architecture.md) first. Step 2 in [`../Steps.md`](../Steps.md), before the character
exists, on purpose.
## Why this exists before there is anyone to measure
Two reasons, and the second is the real one.
1. **Retrofitting emit calls is expensive and lossy.** Instrumenting a finished system means re-reading it and
guessing what mattered at the time. Instrumenting as it is written costs a line.
2. **Some questions can only be answered with data that starts early.** Which camera mode people live in, whether
the anvil is fun or a chore, whether a crafted sword's damage curve is sane, whether prompts explain themselves.
Every one of those is a distribution over sessions, and the sessions that never emitted are gone.
So the schema is fixed now, the sink writes a local file, and the catalogue grows one step at a time the way the
cheats do.
## Decisions
```
[DECIDED] One subsystem, one sink interface, three sinks on day one: null (the shipping default), log (the
editor), JSON Lines to a file (playtests). The emitter never knows where events go.
```
```
[DECIDED] JSON Lines. One event per line, UTF-8, snake_case keys. Append-only, so a crash mid-write loses one line
and not the file; schema-flexible, so a new field breaks no old reader; greppable during development; and the
native food of every log and analytics pipeline that might sit downstream. Events, never aggregates: every metric is
computed from raw facts later, because aggregation choices change and raw events do not.
```
```
[DECIDED] No personal data. Player identity on the wire is the server-minted player id, hashed. No names, no
platform ids, no chat, no free text. Established now because a field is much harder to remove than to never add.
```
## Layout
```
Source/<Project>Core/Telemetry/
├── TelemetrySink.h // ITelemetrySink, FNullTelemetrySink, FLogTelemetrySink, FJsonlTelemetrySink
├── TelemetryEvent.h // FTelemetryEvent, FTelemetryEnvelope
└── TelemetryEvents.h // the event name constants: TelemetryEvents::PlayerDowned etc. Never a literal at a call site.
Source/<Project>/Core/
└── TelemetrySubsystem.h // UGameInstanceSubsystem: owns the sink, stamps the envelope, exposes Emit
```
## Types
```cpp
// Source/<Project>Core/Telemetry/TelemetryEvent.h
struct FTelemetryEvent
{
FName Name; // from TelemetryEvents, never a literal
TSharedPtr<FJsonObject> Payload; // event-specific fields; built with a small fluent helper
// The subsystem stamps the envelope; call sites fill Name and Payload only.
};
struct FTelemetryEnvelope // stamped by the subsystem onto every event
{
int32 SchemaVersion = 1;
FGuid EventId; // unique per emission, for de-duplication
FDateTime TimestampUtc;
float GameTime; // seconds since the world began
FGuid SessionId; // minted by the server, adopted by clients on join
FString PlayerId; // hashed server-minted id; empty on the server
bool bIsServer; // authoritative events versus observed ones
int32 PartySize;
FString Build; // FApp::GetBuildVersion() plus the git hash from Scripts/
FString Map;
bool bCheatsUsed; // sticky true after the first cheat this session: tainted sessions are filterable, not deleted
};
// Source/<Project>Core/Telemetry/TelemetrySink.h
class ITelemetrySink
{
public:
virtual ~ITelemetrySink() = default;
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
virtual void Flush() {}
};
// FNullTelemetrySink: does nothing. The shipping default until there is somewhere to send anything.
// FLogTelemetrySink: one line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
// FJsonlTelemetrySink: appends to Saved/Telemetry/session_<utc>_<id>.jsonl through a queue drained by a background
// task every two seconds and on Flush; the game thread only builds a small object per event. Flushed on quit and
// from the unhandled-exception handler so a crash loses at most two seconds of events.
```
```cpp
// Source/<Project>/Core/TelemetrySubsystem.h
UCLASS()
class UTelemetrySubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
void Emit(FName Name, TSharedPtr<FJsonObject> Payload = nullptr); // stamps the envelope, hands to the sink
void BeginSession(FGuid SessionId); // server mints; a client adopts the replicated id
void SetSink(TUniquePtr<ITelemetrySink> Sink); // Log in the editor, Jsonl with -telemetry, Null otherwise
void MarkCheatUsed(); // called by every bs.* console command
};
```
**There is no static `Telemetry::Log(...)` and there will not be one.** Gameplay code resolves the subsystem from
its game instance once and keeps the pointer. A static helper is a global with gameplay state in it, which
[Architecture.md](Architecture.md) bans, and it makes every emitting class untestable.
## Where the sink lives on each peer
Every peer emits its own events to its own file. The server's file is the authoritative record of what happened;
a client's file is the record of what that player saw and did. `session_id` is the same across all of them and
`is_server` tells them apart. Nothing is forwarded, because the interesting questions are per-peer by nature.
Which sink is installed: the log sink in the editor, the file sink when the executable is launched with
`-telemetry` or the console variable `telemetry.File 1` is set, the null sink otherwise. Playtests run with the file
on and the folder is collected by hand afterwards; that is the whole backend for now.
## The catalogue
Each feature spec's Telemetry section lists what that feature emits; this table is the rollup and the two must
agree. A row is added to a spec and here in the same pull request as the emit call.
| Group | Events |
| --- | --- |
| Session | `app_started`, `session_started`, `session_ended`, `player_joined`, `player_left`, `settings_changed`, `cheat_used` |
| Movement | `movement_sample`, `jump`, `land`, `camera_mode_changed` |
| Interaction | `prop_interacted`, `interaction_refused`, `object_picked_up`, `object_dropped`, `object_thrown`, `object_handed_over` |
| Stats | `status_applied`, `status_blocked`, `status_removed` |
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
### The rows that carry weight
```
item_assembled family, piece_ids[], name, quality, synergy, substance_value, time_at_bench_s
weapon_equipped family, crafted, damage, quality
enemy_killed enemy, killer_class, weapon, ability, time_to_kill_s
```
Together these are the crafting-to-combat loop as data: does a better-made sword kill faster, and by how much.
That is the one question the whole prototype exists to answer, and it cannot be answered from memory.
```
activity_completed activity, operators, quality, auto_play, faults, contribution_by_player
```
`quality` against `auto_play` says whether the anvil is fun or a chore: if the hold-to-work floor is where most
sessions live, the minigame is not earning its place.
```
interaction_refused prop, reason
assembly_rejected family, reason, part
```
The two cheapest possible measures of whether the game explains itself. A reason that dominates is a prompt that
does not.
## Tests
- Automation: the JSONL sink writes one valid line per event and never blocks the calling thread longer than
building the object; the envelope carries every field; an event with no payload serialises as an empty object.
- Functional: launching the gym with the log sink installed emits `app_started` and `session_started` in that
order; every event name a feature test triggers is a constant in `TelemetryEvents`.
## Open questions
- **Q1. Sampling.** `movement_sample` and `player_damaged` are the high-frequency rows. Every 5 s and every hit
are fine at prototype scale; revisit when a file exceeds a few megabytes per session.
- **Q2. An analytics provider.** The engine has an `IAnalyticsProvider` interface that several backends implement.
When there is a backend, a fourth sink adapts to it; the emitter does not change.
- **Q3. Opt-out.** A playtest build needs a telemetry toggle in settings before strangers play it. With the
settings pass, not before.
- **Q4. Typed payloads.** A JSON object per event is convenient and unsafe. A typed struct per event name is the
later answer if typos in payload keys start costing analysis time.
+177
View File
@@ -0,0 +1,177 @@
# UI
Owns what interface exists, what it is built on, and the rules that keep it one interface rather than a dozen:
the HUD, the prompt, the action bar, the one theme asset, world-space text on props, localisation and the menu.
It does **not** own what any readout means; the health number belongs to [Combat.md](Combat.md), the bench panel to
[Crafting.md](Crafting.md).
Read [Architecture.md](Architecture.md) first. There is deliberately little here. A prototype about moving,
fighting and crafting needs a crosshair, a health number, a prompt, four ability slots and a way to quit.
## The rule
```
[SALVAGED] The player acts on the world physically; the game reports state conventionally.
Everything the player DOES is a thing in the world reached through the interaction system: a bench, a station,
a weapon on the ground, a downed teammate. Screen space is for readouts: health, the prompt, the crosshair, the
action bar. The test for anything new: can the player do it by touching something in the world? If yes it is a
prop. If it only tells them something, it may be HUD.
The earlier guild project made this a pillar and forbade all menus but Escape. This project keeps the test and
drops the absolutism: a world this is headed for will need screens the prototype does not, and the rule's job is
to make every one of them earn its place, not to forbid them.
```
## Decisions
```
[DECIDED] UMG with CommonUI. Widgets are Blueprint children of C++ base classes that own the data binding; the
Blueprint owns layout and look only. CommonUI supplies input routing, gamepad focus and per-device glyphs, which
is exactly the part nobody wants to write twice.
```
```
[DECIDED] One theme asset. Every colour, type size, spacing step and motion duration is a token on one data asset,
named by role and never by hue. No widget holds a literal colour, size or duration. A colourblind palette is a
second asset with the same roles.
```
```
[DECIDED] Prop text is world-space and has no canvas. Text on a thing in the world is a UTextRenderComponent, or a
world-space UWidgetComponent when it needs layout (the bench panel). It reads the same theme.
```
```
[DECIDED] Everything a player reads is FText from a string table. FString is for identifiers and logs.
```
## Layout
```
Source/<Project>/UI/
├── UITheme.h // UPrimaryDataAsset: the tokens
├── ThemedText.h // UCommonTextBlock subclass reading a type token and a colour role
├── HudWidget.h // the base: health, crosshair, prompt, action bar, downed state; binds to the local player
├── InteractionPromptWidget.h // verb, target, reason, hold fill, live glyph
├── ActionBarWidget.h // four slots: glyph, name, cooldown; reads the ability system and the input subsystem
├── MenuWidget.h // Escape: Resume, Settings, Quit. Never pauses.
└── SettingsWidget.h // comfort sliders, motion scale, rebinding rows through the engine's user settings
Content/UI/
├── DA_Theme_Default
├── WBP_Hud, WBP_InteractionPrompt, WBP_ActionBar, WBP_ActionBarSlot, WBP_Menu, WBP_Settings
└── ST_Game // the string table; one key per readable string, prop prompts included
```
## The theme
```cpp
UCLASS(BlueprintType)
class UUITheme : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// Roles, never hues. A widget names a role; the asset says what it looks like.
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor HudNeutral, HudGood, HudWarning, HudDanger;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor PromptAvailable, PromptBlocked;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor OutlineInRange, OutlineTargeted;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor Scrim, PanelSurface, PanelText;
UPROPERTY(EditDefaultsOnly, Category = "Colour") TMap<FGameplayTag, FLinearColor> DomainColours; // Domain.Forge, .Wood, .Enchant
UPROPERTY(EditDefaultsOnly, Category = "Colour") TArray<FLinearColor> PlayerColours; // by join order, low saturation, rings not fills
// Type scale, in steps rather than free numbers. HUD sizes in pixels at 1080p; world sizes in centimetres.
UPROPERTY(EditDefaultsOnly, Category = "Type") FSlateFontInfo HudPrimary, HudSecondary, Prompt, Caption;
UPROPERTY(EditDefaultsOnly, Category = "Type") float PropTitleCm = 12.f, PropBodyCm = 7.f, PropSmallCm = 5.f;
UPROPERTY(EditDefaultsOnly, Category = "Space") TArray<float> SpacingSteps = {4, 8, 16, 24, 40};
UPROPERTY(EditDefaultsOnly, Category = "Motion") float Fast = 0.12f, Normal = 0.2f, Slow = 0.4f; // every one scaled by the motion setting
};
```
Meaning never rides on hue alone: a blocked prompt is grey **and** worded differently; a danger readout is red
**and** changes shape. `HudGood` leans teal and `HudDanger` leans orange so the two separate under deuteranopia.
Substance colours are not theme tokens; they are on the substance definitions because the substance is the palette.
The placeholder look: neutral, stylised, legible. The two source projects each had a fully specified palette (a
parchment-and-ink guild hall; a warm forge with domain tints). Neither is this game's, so the default asset is
plain white HUD over greybox and the domain tints, and an art direction decides the rest when there is one.
## The HUD
One widget, added by the player controller for the local player, reading the local player state's ability system
and the pawn's interaction component. It never computes: health is the attribute, the prompt is
`UInteractionComponent::GetCurrentPrompt`, the cooldown is the cooldown tag's remaining time.
| Element | Reads | Shows |
| --- | --- | --- |
| Crosshair | nothing | four pixels, `HudNeutral` |
| Health | `Health`, `MaxHealth`, `State.Downed` | number plus a status line; colour by fraction, and shape when downed |
| Prompt | `GetCurrentPrompt` | `[glyph] Verb Target`, greyed with the reason when blocked, a radial fill while holding |
| Action bar | the four `Input.Ability.n` abilities, the input subsystem | glyph (live binding, per device), name, seconds left; dim while empty or cooling |
| Downed | `State.Downed`, `GE_BleedOut` remaining | "Downed: 24 s" and who is nearest |
| Carry | `UCarryComponent::GetHeld` | the held object's name, and "hands full" on a blocked verb |
| Statuses | active `Status.*` tags and remaining durations | a row of icons from `DT_StatusIcons`, debuffs first; the "Immune" flash on a blocked one comes from the cue, not the HUD |
Two canvases, and the split is a rebuild-cost decision: the static one (crosshair, chrome) and the volatile one
(everything that changes). Neither has hit testing; the HUD is never clicked.
## Input modes
One place decides what the cursor does and which mapping context is live, and it is CommonUI's input routing on
the activatable widget stack. Gameplay input stops while the menu is up; **the world does not.** Enemies keep
moving, the forge keeps burning. This is the difference between a mode switch and a pause, and it is the design:
opening Settings in a fight is a bad idea, which is correct, and it means no second behaviour for the same key that
would be untested in one of the two modes.
The prompt's glyph and the action bar's keys come from the engine's Enhanced Input user settings through the
input subsystem, by the device the player touched last. No widget prints a literal key.
## World-space text
Text on a prop is a `UTextRenderComponent` reading the theme's world sizes and a colour role, on the prop itself,
with no canvas: it stays readable as you walk round it and lights with the scene. The surface rule: light surface,
`PanelText`; dark surface, `HudNeutral`. The bench's assembly panel, which needs slots and a gauge, is a
`UWidgetComponent` in world space reading the same theme and laid out by the family's authored grid so a sword
reads as a sword. Its quality gauge shows the synergy segment separately, so players learn why matching themes
score higher.
## The menu
Escape: Resume, Settings, Quit. Settings: FOV per mode, head stabilisation, bob, motion scale, text scale, and a
rebind row per bindable action through the engine's user settings. Nothing else, until a step needs something
else and says why here.
## Localisation
Every readable string is a key in `ST_Game`, resolved through `FText::FromStringTable`. Prompts are keyed by verb
tag (`Interact.Verb.Revive` resolves to `prompt.verb.revive`), reasons by reason tag, ability names on the ability
class as `FText`. The engine's localisation dashboard gathers from string tables and `FText` properties; nothing
else is needed until there is a second language.
## Accessibility hooks
Hooks now, content later; none of the later work touches a widget.
| Hook | Exists | Filled in later |
| --- | --- | --- |
| Text scale | `ThemedText` multiplies every size by it | a slider |
| Colourblind palettes | roles on the theme, no literals in widgets | alternative theme assets |
| Motion scale | every theme duration, camera kick, hit stop and bob multiplies by it | the slider ships in step 3, a comfort requirement for first person |
| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings |
| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb |
## Telemetry
`settings_changed` with `setting_id` as the dotted path (`camera.fov.first_person`, `input.bindings.interact`) and
the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and
`prop_interacted` already answers whether players find things.
## Open questions
- **Q1. World-space widget legibility.** A `UWidgetComponent` at bench distance in both camera modes has to be
checked once with real text before the assembly panel is built on it. Ten minutes in step 13, not a spike.
- **Q2. A font.** The engine's default until a look exists. When one is chosen it is one face with material
presets, not three font assets.
- **Q3. Does the HUD scale with resolution?** Scale with screen size at 1080p reference is assumed; ultrawide needs
one look.