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

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

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


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

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

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

20 KiB
Raw Blame History

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), the damage funnel reads Armour (Combat.md), the activity runtime reads WorkSpeed (Crafting.md). This document says what the numbers are and how they change; the system docs say what they do.

Read 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 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

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
// 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
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 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.