# 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/Core/Stats/ ├── StatMath.h / .cpp // pure: Resisted(magnitude, kind, resist) └── CarryMath.h / .cpp // pure: SpeedFactor(weight, capacity) Source//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//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.` 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 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 Effect; // a Status.* effect UPROPERTY(EditAnywhere) float Magnitude = 0.6f; // Data.Magnitude UPROPERTY(EditAnywhere) bool bAffectsPlayers = true, bAffectsEnemies = true; UPROPERTY(VisibleAnywhere) TObjectPtr 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 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_` with an immunity component (`UImmunityGameplayEffectComponent`) matching `Status.` | 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.` and `Immune.Damage.`; 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.