0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI STAT_PIPELINE
UTC 00:00:00
◀ RETURN
STAT_PIPELINE.md 1706 words ~8 min read Updated 2026-07-03

Stat Pipeline

Status: Contract. Decided: 2026-05-11 Companion to: ARCHITECTURE.md, COMBAT_SYSTEM.md History: DesignDiscussionsArchive/APPENDIX_STAT_PIPELINE.md holds the discussion that produced this contract.

Scope

How player-affecting numerical state reaches the gameplay code that reads it. Covers stat contributions from gear, ships (loadouts), pilots (loadout modifiers), skills, and future buffs / potions / status effects. Does not cover stat balance, replication mechanics, or inventory/equipment representation — those have their own contracts.

The Four Contributor Surfaces

The project has four sources of numerical stat contribution that all need to compose. All four use GameplayEffects on the player's ASC — meaning any stat they need to touch must be reachable as a FGameplayAttributeData on UCradlAttributeSet.

Source Author site Pipeline
Gear FItemRow::EquipEffects GE on player ASC
Ship (loadout) ULoadoutDefinition::InnateStatsEffect GE on player ASC
Pilot (loadout modifier) ULoadoutModifierDefinition::StatsEffect GE on player ASC
Buffs / potions / status Ability or item trigger (CONSUMABLES.md for the consumable side) GE on player ASC

This makes GAS the project's standard pipeline for numerical contribution — not a "skilling-only bet" — and the routing rule below is the contract for how new stats fit into it.

The Routing Rule

For each numerical stat, classify by modification surface:

Bucket 1 — Buffable scalars → AttributeSet

Stats any of the four contributor surfaces can modify. Live as FGameplayAttributeData on UCradlAttributeSet. Read via the typed getters; UI binds via OnAttributeChange delegates. The buffs/potions surface is implemented by CONSUMABLES.md "Stat Boost Channel" — per-attribute UGE_Buff_* classes apply HasDuration GEs that compose with gear/loadout contributions through standard GAS aggregation.

Members (✱ = added by Stage 1 of the migration below):

  • Resource pools: Health, MaxHealth, Stamina, MaxStamina
  • Combat tempo: AttackSpeed
  • Combat damage scalars: Strength ✱, RangedStrength ✱, MagicDamage
  • Combat mitigation scalar: Defense ✱ (universal) + DefenseMultiplier[Stab/Slash/Crush/Ranged/Magic] (per-type multipliers, default 1.0 — see "Defense Granularity" below)
  • Skilling: GatherSpeed, CraftSpeed, GatherSuccessChance, CraftSuccessChance
  • Movement: MoveSpeed, MoveDrag, MoveAcceleration, EncumbranceLimit

Bucket 2 — Pure-gear combat-triangle matrices → Equipment aggregator

Per-Combat.DamageType.* attack and defense bonuses (the OSRS-style triangle matrix) stay as TMap<FGameplayTag, int32> on FItemRow. Reads go through aggregator methods on UEquipmentComponent:

  • UEquipmentComponent::SumAttackBonus(DmgType)
  • UEquipmentComponent::SumDefenseBonus(DmgType)

Both methods sum across all equipped slots — fixing the current MainHand-only read.

Ships and pilots do not author directly into this matrix. If a ship wants defensive character, it expresses it via the Defense scalar attribute or other Bucket-1 stats. The matrix is gear's expression of the combat triangle and stays gear-only.

Bucket 3 — Inherent item identity → Direct row reads

Properties that aren't summable stats — they are the equipped item — read directly from the row. There is conceptually one source.

  • FItemRow::WeaponDamageType — which axis of the triangle the weapon swings
  • FItemRow::SwingInterval — per-weapon base cadence (multiplied by AttackSpeed at runtime)
  • FItemRow::AttackRange — per-weapon engagement distance
  • FItemRow::DefaultStyle — initial style tag applied on equip

Item Authoring Shape (Bucket 1 stats on items)

Items contribute to Bucket 1 buffable scalars through one of two authoring paths. Per stat, per item, only one path is valid — the items DataTable validator enforces this.

Row scalar (preferred for static bonuses). FItemRow keeps the friendly scalar fields (StrengthBonus, RangedStrengthBonus, MagicDamageBonus, and a future DefenseBonusFlat for the universal Defense scalar). UEquipmentComponent synthesizes a transient infinite-duration GE per nonzero scalar at equip-time and removes it at unequip-time. Runtime reads the AttributeSet only — the row field is purely an authoring convenience that emits a GE.

EquipEffects GE (required for anything the row field can't express). Conditional bonuses (OngoingTagRequirements-based, slayer-style target-tag-conditional, set-bonus-style), per-skill scoped contributions, multiplier-style modifiers, or any GE shape that needs explicit author control all live in EquipEffects as hand-attached TSubclassOf<UGameplayEffect>. Composes through standard GAS aggregation alongside the synthesized GEs from row scalars.

Validator rule. For any Bucket 1 stat that has a row scalar field on FItemRow, an item authoring that stat must use either the row scalar or a single GE in EquipEffects modifying that stat — never both. This prevents silent double-counting when a content author adds a conditional GE for a stat that already has a row value.

Defense Granularity

Two scalars compose per attack:

  • Defense — universal flat scalar. "Weaken: -X% all defense" composes via a standard multiplicative GE on Defense. Added to SumDefenseBonus(DmgType) at the swing site before the roll.
  • DefenseMultiplier[Stab/Slash/Crush/Ranged/Magic] — per-type multipliers, default 1.0. Carrier GEs UGE_DefMult_* (5 classes, one per damage type) write Additive deltas, so a -0.20 GE resolves the matching attribute to 0.80. Authors BP-inherit and bake the magnitude in the property grid.

The single-choke-point damage formula lives in CradlCombatMath::RollAccuracy(EffAtk, EquipAtk, EffDef, EquipDef, DefenseMultiplier):

AttackRoll  = EffAtk * (EquipAtk + 64)
DefenseRoll = EffDef * (EquipDef + 64) * DefenseMultiplier

Deviation from earlier spec: an earlier draft scaled only (SumBonus + Defense) by the multiplier. That shape went silent on zero-EquipDef enemies (multiplier × 0 = 0), defeating the "weak to magic" authoring intent for low-armor archetypes. The shipped formula scales the full defense roll (including the +64 OSRS-style floor), so per-type biases bite regardless of equipment. The "single-choke-point in UCradlCombatMath" property is preserved — the math edit lives in RollAccuracy and RollAccuracyDetailed, callers just pass the multiplier alongside the other inputs.

Authoring paths:

  • Per-archetype defensive profileUEnemyDefinition::InnateStatsEffects is a TArray<TSubclassOf<UGameplayEffect>>; drop in BP children of UGE_DefMult_* (e.g. BP_GE_WeakToMagic_20). Reusable across enemy types — one BP serves goblin, imp, undead, anything fragile to magic.
  • Player-side per-type debuffs (Curse-style) — same 5 carrier GE classes, applied via spell triggers when authored. No additional engine work needed; the attributes and formula are in place.

No item re-authoring, no matrix migration, no read-site refactor — the formula change is one place in CradlCombatMath.

Target-Conditional Modifiers

Slayer-helm-style bonuses ("+15% magic damage vs Demons") reuse the skilling side's OngoingTagRequirements primitive, rotated for combat.

Authoring:

SlayerHelm_Magic.EquipEffects = [GE_Item_MagicDamage_Plus15_VsDemons]
// GE_Item_MagicDamage_Plus15_VsDemons:
//   Modifier: MagicDamage += 0.15
//   OngoingTagRequirements: Combat.Target.Type.Demon

Runtime:

  1. Combat ability captures the target's classification tags at swing/cast resolution
  2. Ability pushes those tags as loose owned tags on the attacker's ASC for the duration of damage resolution
  3. Conditional GEs activate; buffable-scalar attributes reflect the buff
  4. UCradlCombatMath::RollDamage reads the buffed scalar values
  5. Ability pops the loose tags

Constraints:

  • Push/pop lifetime spans exactly one damage resolution. RAII guard around the RollDamage call site (or, if damage migrates to a UGameplayEffectExecutionCalculation later, the execution scope handles it naturally).
  • AoE / multi-target abilities resolve damage sequentially per target (push → roll → pop) so one target's tags don't leak across resolutions.
  • Tag namespace: Combat.Target.Type.* (or analogous). Consolidate with any existing enemy-classification tag tree rather than paralleling it.

This is the same primitive the gather/craft abilities use to push Skill.Mining etc. during an action — authoring conventions transfer.

Enemy Composition

Enemies compose their effective stats through the same GAS aggregation the player uses — just with different author sites and lifecycles. Same UCradlAttributeSet, same damage formula in CradlCombatMath, no parallel "enemy stats" pipeline anywhere.

Source Author site Pipeline Lifecycle
Archetype identity UEnemyDefinition::InnateStatsEffects (array of GE classes) GE on enemy ASC spawn → pawn destroy
Per-variant tuning FWeightedEnemyVariant::VariantStatsEffect (single GE class) GE on enemy ASC spawn → pawn destroy
Per-spawn rolled tuning UEnemyDefinition::StatTuningUGE_Enemy_InnateStats rolled-magnitude GE on enemy ASC spawn → pawn destroy
Weapon (only equipment) FItemRow::EquipEffects via stamped WeaponItemId GE on enemy ASC weapon equip → unequip
Future buffs / debuffs Ability or item trigger GE on enemy ASC per-effect duration

InnateStatsEffects mirrors FItemRow::EquipEffects (the equipment compositor) rather than ULoadoutDefinition::InnateStatsEffect (which is single) — enemies want reusable per-type biases (BP_GE_WeakToMagic_20 shared across goblin/imp/undead), and the array shape supports both the "drop in many small reusable GEs" pattern and the "drop in one custom bundled GE" pattern.

VariantStatsEffect is single-GE (mirrors ULoadoutModifierDefinition::StatsEffect) — per-variant tuning is the same shape as a pilot modifier, one bundle per variant identity.

See ENEMY_SYSTEM.md "Variants" and ENEMY_SYSTEM.md "Stat Tuning" for the surrounding enemy-system contract.

Lyra Alignment

This contract is consistent with the Lyra sample's GAS conventions:

  • Small set of attributes for "resources + composable scalars"
  • Per-item variation (the matrix) lives as data on the item, not as attributes
  • Damage application centralized — currently static UCradlCombatMath::RollDamage; future option to migrate to UGameplayEffectExecutionCalculation if pluggable damage application is needed

Migration Stages

Each stage is independently shippable. The system runs after each stage; correctness improves monotonically.

Stage 1 — Buffable scalars → attributes

  • Add Strength, RangedStrength, MagicDamage, Defense to UCradlAttributeSet (with OnRep_* handlers and lifetime registration matching the existing pattern)
  • Add UEquipmentComponent GE-synthesis path that, on equip, emits a transient infinite-duration GE per nonzero FItemRow Bucket-1 scalar (StrengthBonus, RangedStrengthBonus, MagicDamageBonus, and a new DefenseBonusFlat for Defense) and removes it on unequip. Row scalar fields stay; runtime reads only the AttributeSet (see "Item Authoring Shape").
  • For non-row authoring (ships/pilots, conditional gear), author parameterized GE classes that authors instantiate or specialize (e.g., UGE_Loadout_StrengthBonus for ship innate effects).
  • Migrate UAutoAttackAbility::SnapshotSwingStats and UCastSpellAbility::ResolveMagicDamageBonus / ResolveMagicAttackBonus to read from the AttributeSet
  • Update Source/CRADLEditor/Validators/ item-table validator to enforce the one-path-per-stat-per-item rule from "Item Authoring Shape"

Stage 2 — Equipment aggregator for AttackBonus/DefenseBonus matrices

  • Add UEquipmentComponent::SumAttackBonus(DmgType) / SumDefenseBonus(DmgType)
  • Migrate SnapshotSwingStats, ResolveMagicAttackBonus, and target-side defense reads to call the aggregator
  • FItemRow::AttackBonus / DefenseBonus stay; only the read path changes
  • Resolves the current MainHand-only authoring-vs-runtime divergence

Stage 3 — Cross-references

  • Cross-link from COMBAT_SYSTEM.md "Equipment Combat Data" / "Damage Formula" sections into this document so the contract is discoverable from the system contracts that depend on it
  • Reference STAT_PIPELINE.md from CLAUDE.md alongside ARCHITECTURE.md under "Reference architecture decisions when needed"

Done — Per-DamageType defense multipliers

Landed alongside the UEnemyDefinition::InnateStatsEffects authoring surface. See "Defense Granularity" above for the formula and authoring paths, and "Enemy Composition" below for the full enemy-side compositor table.

Out of Scope

  • Skill XP / level state (lives on USkillsComponent by design — see CradlAttributeSet.h header comment)
  • Inventory / equipment data structure itself
  • Specific stat balance numbers
  • Replication mechanics (standard GAS replication on the AttributeSet; standard equipment replication on UEquipmentComponent)

Decision Owners

A, R, J — decided 2026-05-11.