0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI COMBAT_SYSTEM
UTC 00:00:00
◀ RETURN
COMBAT_SYSTEM.md 12369 words ~56 min read Updated 2026-07-03

CRADL Combat System

Companion to ARCHITECTURE.md. This document is the contract combat must satisfy — its data shape, replication model, cancellation channels, animation routing, and trigger surface. Implementation patterns, weapon catalogs, balance numbers, and per-feature design briefs live elsewhere; what's here does not change without a deliberate edit to this file.

North Star

Combat is OSRS-derived: select a target, roll the swing, resolve the result. No real-time aiming, no twitch dodging. Three combat styles (melee, ranged, magic) carry full type granularity (stab/slash/crush + ranged + magic), each with separate attack and defense bonuses. Cadence is continuous real-time (per-weapon swing intervals, not a discrete game tick) — combat feels responsive while the underlying model stays roll-and-resolve. Combat reuses every GAS pattern the skilling side established: it adds verbs, not foundation.

Quick Reference

Topic Answer Section
Implementation channel GAS abilities + GameplayEffects only; no parallel state machines or PC RPCs GAS-Only Combat
Net policy LocalPredicted for every combat ability Net Execution Policy
Engagement model Long-running auto-attack ability; mirrors UGatherAbility::StartNextTick Engagement Loop
Cadence Continuous real-time; per-weapon SwingInterval, scaled by AttackSpeed attribute Cadence
Attack cadence / weaving One shared GAS cooldown Cooldown.Combat.Attack: melee/ranged/autocast/manual offensive cast all stamp+gate it (weaving a cast costs a tick); prayer & self-cast keep separate anti-spam Shared Attack Cooldown and Weaving
Engagement range Per-weapon AttackRange override; per-type settings fallback (Melee / Ranged / Magic) Engagement Range
Chase / move hand-off Pawn-side IPawnCombatant + UChaseComponent (owns FCradlChaseState); controller-side IMoveIssuer issues moves; chase runs indefinitely — caller-side cancellation only Chase & Movement
Combat skills 5: Melee, Ranged, Magic, Defense, Hitpoints Combat Skills
Combat styles 3 per weapon: Aggressive / Defensive / Controlled — XP routing + effective-level bonus + (melee only) which damage-type axis the swing consumes Combat Styles
Damage types 5 first-class: Stab/Slash/Crush + Ranged + Magic Damage Types
Equipment data Combat bonuses live on FItemRow (extended) Equipment Combat Data
Ammo New Item.Slot.Ammo; ranged abilities consume per swing Ammo Slot
Health Health / MaxHealth / Damage (meta) on UCradlAttributeSet; MaxHealth = f(Hitpoints level) Health & Damage Application
Damage formula OSRS-derived; canonical home in UCradlCombatMath Damage Formula
Cancellation Per ARCH #13 + reuse of QueuedInteract pattern for player-intent move cancel Cancellation Channels
In-combat clock Status.InCombat GE (settings-driven duration), refresh-on-stack from dealing or receiving In-Combat State
Targeting entry Click-on-hostile-pawn → Action.Trigger.QueuedInteract → engage Targeting & Engagement Entry
Targeting state UCombatTargetingComponent on PlayerState; spectator vis via FActivityDescriptor Targeting Component
Multi-attacker cap Default 3 simultaneous attackers per pawn (configurable) Multi-Attacker Ceiling
Spellbook Single learned-spell book at v1; architected for swappable Spellbook & Spells
Animation/FX Per ARCH #16 — montage parallel to ability timing, GameplayCues for hits, AnimBP tag map for stance Animation / FX / Audio
Death Item retention; lost items go to per-profile cold storage Death & Cold Storage
OOC recovery Food + rest stations only; no passive regen Out-of-Combat Recovery
NPCs / AI Out of v1 scope; contract is forward-compatible NPC / AI Compatibility
PvP Out of v1 contract PvP
Tag namespaces Combat.*, Skill.Combat.*, Action.Combat.*, Action.Trigger.Combat.*, additions under existing Status.* / Item.Slot.* Tag Taxonomy
Forward code surfaces Future PRs land in named, predictable places Forward Code References
Open questions PvP, AI, cold-storage retrieval, specials, status-effect catalog, ammo recovery, loot, etc. Open Questions

GAS-Only Combat

Rule: Every combat verb (auto-attack, spell cast, death, damage application) is a UCradlGameplayAbility triggered through ASC->HandleGameplayEvent; damage is exclusively a UGameplayEffect flow. There is no combat-manager actor, no PlayerController RPC for combat actions.

Why: Mirrors the dispatch contract in ARCH #18 (UI dispatches via ASC events; abilities own validation/authority). One channel for activation, one channel for state mutation — no drift between input sources, no per-controller authority code.

Implementation surface: - Files: Source/CRADL/Abilities/, Source/CRADL/Abilities/CradlGameplayAbility.h - Classes: UCradlGameplayAbility (base), UCradlAbilitySystemComponent

Related: ARCH #2 (GAS from day one), ARCH #18 (widgets dispatch via ASC events).


Net Execution Policy

Rule: All combat abilities use NetExecutionPolicy = LocalPredicted. Damage rolls are server-authoritative; clients predict swing animation and ability activation.

Why: Matches the precedent set by skilling and modal abilities (ARCH #15). Prediction keeps swings feeling immediate; server authority on the roll keeps the result tamper-proof. LocalPredicted is also load-bearing for spectator visibility — ActivationOwnedTags reach simulated proxies through the standard ASC path, so other players' AnimBPs see your stance change without per-tag replication code.

Implementation surface: - Classes: UAutoAttackAbility, UCastSpellAbility, UDeathAbility — set NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted in their constructors. - Cross-peer event broadcast: UCradlAbilitySystemComponent::FireReplicatedGameplayEvent for outcome publication (hit/miss/death) — same channel skilling success/fail uses.

Footguns: Downgrading any spectator-visible combat ability to LocalOnly silently breaks remote AnimBPs (no compile-time signal — same load-bearing decision called out in ARCH #16's UGatherAbility reference).

Related: ARCH #15 (modal abilities established LocalPredicted precedent), ARCH #16 (spectator-visibility load-bearing on policy choice).


Engagement Loop

Rule: Auto-attack is a long-running ability whose loop structurally mirrors UGatherAbility::StartNextTick — Activate → snapshot effective stats → WaitDelay(SwingInterval)HandleSwingFinished rolls accuracy + damage and applies a damage GE to the target, fires the hit cue → schedule next swing or EndSelf.

Why: OSRS-style "click monster, keep swinging until something stops you" maps cleanly to a long-running ability with an internal swing loop. Mirroring UGatherAbility lets the same engineers reason about both — same task structure, same end-condition pattern, same snapshot-stats discipline.

Implementation surface: - Files: Source/CRADL/Abilities/AutoAttackAbility.{h,cpp} (new); reference Source/CRADL/Abilities/GatherAbility.h for loop shape. - Tags: Action.Combat.AutoAttack (both AbilityTags and ActivationOwnedTagsAbilityTags is what UAbilitySystemComponent::CancelAbilities matches against, so an in-flight engagement is reachable from the controller's fresh-nav-cancel path; ActivationOwnedTags is what observers like the HUD / AnimBP read), Action.Trigger.Combat.Engage (trigger). - Tasks: UAbilityTask_WaitDelay (gameplay timing — source of truth), UAbilityTask_PlayMontageAndWait (parallel decoration).

End conditions (any one ends the ability): - Target actor dead, invalid, or IsPendingKill - Target lost LOS past tolerance - Cancellation tag added (see Cancellation Channels) - Modal opened (Action.Modal.*) - Owner died (Status.Dead) - Player issued explicit cancel via the controller (e.g. fresh nav goal, Esc)

Range is not an end condition. The swing loop hands off to the chase primitive on out-of-range and resumes swinging the moment the chaser closes back in — see Chase & Movement. Chase itself never gives up: it runs indefinitely until one of the above end conditions fires (typically a player movement input or modal open for the PC path; for AI, the leash decorator from ENEMY_SYSTEM.md).

Mid-engagement chase: StartNextSwing snapshots stats first (so the resolved engagement range matches the equipped weapon's class — bow / wand / sword), then range-checks. Out-of-range calls IPawnCombatant::BeginChase(target) and reschedules a ChasePollInterval (default 0.1s) re-check; on return-to-range the ability resumes the swing loop without losing cadence beyond one missed beat. HandleSwingFinished re-checks range at the swing-fire moment too — a target that drifted during the SwingInterval window kicks back into the chase loop instead of getting hit at distance.

Footguns: - The ability causes its own movement (chase). Do not include State.Moving in CancelOnTagsAdded — same self-cancellation footgun documented on UQueuedInteractAbility. - Snapshot effective stats at the top of HandleSwingFinished, not at activation — equipment / style / status changes between swings must be reflected in the next roll. Same pattern as UGatherAbility::CurrentTickStats. - Snapshot before the range check, not after. IsTargetInRange reads CurrentSwingStats.EngagementRangeCm; an empty / stale snapshot would compare against the default 250cm fallback and silently out-of-range a legitimate bow / wand swing. - Don't reach for APlayerController from inside the ability to issue chase moves. Cast the avatar to IPawnCombatant and call BeginChase / EndChase — same primitive AI pawns will use, no controller-flavor branch in combat code.

Related: Cadence, Cancellation Channels, Targeting & Engagement Entry, Animation / FX / Audio.


Cadence

Rule: Continuous real-time. Each weapon authors a SwingInterval (seconds); UCradlAttributeSet::AttackSpeed (multiplier, default 1.0) scales it at swing-start.

Why: Departing from the OSRS 0.6s tick gives CRADL its own combat feel and lets us lean on UE's native time-domain ability tasks. AttackSpeed as a multiplier means buffs/debuffs/equipment can layer through standard GE math without a tick-conversion layer.

Implementation surface: - Attrs: UCradlAttributeSet::AttackSpeed (BlueprintReadOnly, replicated, default 1.0) - Fields: FItemRow::SwingInterval (float seconds; weapon authoring) - Computation site: top of HandleSwingFinishedinterval = WeaponSwingInterval / AttackSpeed

Footguns: Anything that wants to "speed up" combat (potion, ability) writes a multiplicative GE on AttackSpeed, never an absolute override on the weapon's SwingInterval. The weapon field is authoring data; AttackSpeed is the runtime knob.

Related: Engagement Loop, Equipment Combat Data.


Shared Attack Cooldown and Weaving

Rule: All player attacks share one first-class GAS cooldown — Cooldown.Combat.Attack, granted by a duration GE (UGE_Attack_Cooldown) whose duration is set per-attack via SetByCaller. It is stamped and respected by every attack: a melee swing, a ranged swing, an autocast swing, and a manual offensive cast (UCastSpellAbility, ECradlSpellCastMode::Targeted). Duration = SwingInterval / AttackSpeed for swings (AttackSpeed is the standard UCradlAttributeSet attribute — buffs/debuffs flow through it unchanged), USpellDefinition::CastInterval for casts (AttackSpeed does not accelerate casts). Because all four stamp and gate on the same cooldown, no two attacks resolve inside one window — weaving a cast into a melee engagement costs a tick, never lands free. OSRS global-attack-tick parity: melee → cast → melee is the same rate as either alone.

Explicitly NOT attacks: prayer toggle (UPrayerToggleAbility) and self-cast (UCastSelfSpellAbility, SelfEffect / OpenCraftingModal). These are free actions (OSRS parity — toggling a prayer or casting a self-buff does not consume your attack); delaying a melee swing for them would be wrong. They keep their separate anti-spam cooldown (Cooldown.Combat.Cast, see Tag Taxonomy) and never touch Cooldown.Combat.Attack. Two distinct GE classes, two distinct tags — do not collapse them. This carve-out is load-bearing.

One cooldown, two consumption shapes. The gate is a normal GAS cooldown; nothing here is a self-rolled clock. What differs is how each consumer reads it: - Per-activation abilities (the standalone manual cast) use GAS natively: CooldownGameplayEffectClass = UGE_Attack_Cooldown, CheckCooldown blocks activation while the tag is live, ApplyCooldown stamps it. Zero bespoke code. - The long-running auto-attack loop can't use per-activation CheckCooldown (it's one activation that loops internally), so it reads the cooldown as a level, not an edge: at each swing poll, remaining = max(ASC->GetActiveEffectsTimeRemaining(MatchAnyOwningTags{Cooldown.Combat.Attack})); if remaining <= eps resolve the attack, else WaitDelay(remaining) and re-poll. It re-stamps UGE_Attack_Cooldown on every resolved swing.

Why level-read, not edge-wait (this is the actual footgun, not "tags"). The failure people hit is not "using a cooldown tag" — it's a loop that WaitGameplayTagRemoveds on its own stamp and then blind-rewaits a full interval: it observes its own not-yet-expired cooldown (GE-expiry vs timer ordering is undefined) and burns an extra tick. Reading remaining as a level with an eps tolerance sidesteps it entirely — at expiry remaining ≈ 0 ≤ eps reads ready regardless of GE-removal timing, and a cast that pushed the cooldown out just makes the next poll reschedule for the real remainder. (Today's autocast branch already does the safe shape: it HasMatchingGameplayTag-checks before waiting and never blind-rewaits; generalizing that to all swings with a remaining-time read is the whole change.)

The one genuinely bespoke piece — the weave queue. GAS has no action-queue / "buffer the next input and preempt" primitive, so weaving needs app logic. UCombatTargetingComponent holds a one-deep TWeakObjectPtr<const USpellDefinition> PendingWeaveSpell (last-click-wins, same-target only, dropped on disengage or target death). The auto-attack loop is the sole executor while engaged: when the cooldown clears, if a weave is queued it resolves that cast (the weapon swing yields this tick) and stamps Cooldown.Combat.Attack for CastInterval; otherwise it swings and stamps for the swing interval. Do not let UCastSpellAbility self-time a queued weave in parallel — single executor, or the preempt races the swing and you lose the tick. Without this queue, "weaving never possible" returns regardless of the clock.

Dispatch routing (Action.Trigger.Combat.Cast consumer): - Engaged (auto-attack active on a valid same target) → enqueue PendingWeaveSpell and return; the loop executes it next tick. UCastSpellAbility does not run its own resolution. - Not engaged → standalone UCastSpellAbility as today — CheckCooldown-gated on Cooldown.Combat.Attack, stamps it on cast, so a follow-up engage's first swing sees the live cooldown via its level-read and doesn't swing early.

Implementation surface: - GE: UGE_Attack_Cooldown (HasDuration, GrantedTags = Cooldown.Combat.Attack, SetByCaller duration keyed by the tag) — mirrors UGE_SpellCast_Cooldown. - Cast: UCastSpellAbility switches CooldownGameplayEffectClass to UGE_Attack_Cooldown; its failure-tag check + ApplyCooldown key on Cooldown.Combat.Attack; ActivateAbility branches engaged→enqueue / not-engaged→standalone. - Autocast: UAutoAttackAbility::OnAutocastSwingFired_Authority stamps Cooldown.Combat.Attack (was Cooldown.Combat.Cast). - Loop: UCradlAutoAttackAbilityBase::HandleSwingFinished generalizes the existing bAutocasting cooldown check to all swings, reading remaining as a level and rescheduling; stamps UGE_Attack_Cooldown on every resolved swing/weave. - Queue: UCombatTargetingComponent gains PendingWeaveSpell + accessors; the loop consults and clears it. - HUD: the cooldown indicator repoints from Cooldown.Combat.Cast to Cooldown.Combat.Attackreuses the existing OnCooldownChanged / GetCooldownState channel (it already reads any Cooldown.* leaf via GetActiveEffectsTimeRemainingAndDuration); no new readout. Left as-is it would track only prayer/self anti-spam.

Replication / prediction: the cooldown is a standard LocalPredicted-applied GAS cooldown GE — it reconciles through the normal prediction path (no hand-rolled clock to predict). PendingWeaveSpell is set predictively on the owning client and authoritatively on the server via the existing HandleGameplayEvent trigger path. Per the P2P audit rule, the queue field needs a deliberate replication answer at implementation time — surface it in the PR.

Footguns: - The real rule: read the attack cooldown as a level (GetActiveEffectsTimeRemaining + eps) inside the loop — never WaitGameplayTagRemoved on your own stamp and never blind-rewait a full interval. That, not "using a cooldown tag," is the source of the lost-tick fear. The gate is a first-class GAS cooldown; only the read style in the long-running loop needs discipline. - Keep the executor singular. Exactly one thing resolves a tick (the loop, while engaged). A parallel self-timing cast re-introduces the race. - Cooldown.Combat.Attack and Cooldown.Combat.Cast are different gates. Attacks never touch the latter; prayer/self never touch the former. If a future "offensive self-cast" must cost a tick, promote it to an explicit attack on Cooldown.Combat.Attack — don't fold the free-action path in. - Drop the queue on disengage / target death. A stale PendingWeaveSpell would fire at the wrong target or after the loop ended. - eps must exceed timer jitter. Size it against the WaitDelay callback granularity, not zero, or a swing can read itself "not ready" by a frame.

Related: Cadence, Engagement Loop, Targeting Component, Spellbook & Spells, Net Execution Policy.


Engagement Range

Rule: Engagement range is two-tier. A weapon may author a FItemRow::AttackRange (cm); when set, that wins. Otherwise the value falls back to a per-damage-type setting on UCradlCombatSettingsMeleeEngagementRangeCm / RangedEngagementRangeCm / MagicEngagementRangeCm — collapsing Stab/Slash/Crush to Melee. A single resolver function returns the answer; every range gate (auto-attack swing, IInteractable::CanInteract for hostile pawns, future AI engage-decision) calls into it.

Why: A flat range works for melee but breaks for ranged and magic — a 250cm-range bow is just an awkward melee weapon. Per-damage-type fallbacks give v1 a sensible default tier (250 / 2000 / 2000 cm); per-weapon overrides match OSRS-style variance (longbow longer than shortbow, low-tier wand shorter than high-tier staff) without a code change. The resolver lives in one place because Phase 6's QueuedInteract gate and UAutoAttackAbility::IsTargetInRange must return the same answer for the same caller — diverging numbers cause walk-then-fail-to-swing ping-pong.

Implementation surface: - Fields: FItemRow::AttackRange (float cm; 0 = use settings fallback for this damage type) - Settings: UCradlCombatSettings::{Melee, Ranged, Magic}EngagementRangeCm, plus EngagementRangeCm as the unrecognized-type ultimate fallback - Resolver: free function (or UCradlCombatSettings method) ResolveEngagementRange(const FItemRow* Weapon, FGameplayTag DamageType) → float cm - Call sites: UAutoAttackAbility::IsTargetInRange (swing gate), IInteractable::CanInteract on hostile pawns (engage gate)

Footguns: - Symmetry is load-bearing. Both range gates must call the same resolver against the same caller — autocast spell when one is set, otherwise equipped weapon + WeaponDamageType. CradlCombat::ResolveEngagementRangeForPawn is the autocast-aware entry point the click-time CanInteract uses; the swing-time IsTargetInRange reads CurrentSwingStats.EngagementRangeCm which SnapshotSwingStats resolved via the same precedence (autocast → ResolveSpellRange, else weapon → ResolveEngagementRange). If either site forgets the autocast branch — or CanInteract reads the dummy's data, or IsTargetInRange reads a stale snapshot — the player walks into "in range to engage / out of range to swing" and ping-pongs forever. Don't inline the resolution at either site. - Authoring-vs-runtime split mirrors SwingInterval / AttackSpeed: weapon range is authoring data, the runtime value comes through the resolver. If buffs/debuffs ever want to extend range (haste, a "long-arrow" effect), introduce a RangeMultiplier attribute on UCradlAttributeSet rather than mutating FItemRow::AttackRange at runtime.

Related: Engagement Loop, Equipment Combat Data, Targeting & Engagement Entry.


Chase & Movement

Rule: Combat verbs that need to "chase a target until in range" (entry-time walk-up in UQueuedInteractAbility, mid-engagement chase in UAutoAttackAbility, future AI pursuit) all share one primitive: the avatar pawn implements IPawnCombatant, the controller implements IMoveIssuer, and a pawn-side UChaseComponent runs the re-issue state machine (FCradlChaseState). Abilities never reach for the controller directly to drive chase; they call IPawnCombatant::BeginChase / EndChase and let the pawn forward through IMoveIssuer to whichever controller is currently driving.

Why: Chase is a property of the body in motion, not the input source — a heavy pawn pursues differently than a light one regardless of whether a player or an AI is commanding. Splitting the surface across two interfaces (IPawnCombatant on the pawn for combat-flavored verbs, IMoveIssuer on the controller for "actually issue a nav move") gives PC and AI controllers exactly one specialization point: how to issue a move (PC: navmesh project + SimpleMoveToLocation + nav-target marker; AI: SimpleMoveToActor via UAIBlueprintHelperLibrary). Everything else — the poll cadence, the re-issue threshold — is identical between flavors and lives on the pawn. This is the same "abilities address the pawn, the pawn forwards through the controller" decoupling the targeting / interaction code already follows; combat just makes it doctrine for movement too.

No internal give-up. Chase runs indefinitely once BeginChase is called — there is no closing-distance failure, no stall timer, no IsChaseFailed flag. A stuck chaser is functionally the same UX as a forced give-up (the pawn isn't reaching the target either way), and an earlier closing-distance heuristic produced too many false positives on legitimate slow-pursuit cases (sloped terrain, narrow doorways, kiting at the edge of catchup). Cancellation is the caller's responsibility, surfaced through three channels: (a) player movement input cancels the engagement on the PC path (the controller's fresh-nav cancel already terminates Action.Combat.AutoAttack); (b) ability-level conditions (target died, modal opened, Status.Dead) end the ability via the standard CancelOnTagsAdded plumbing, which triggers EndChase; (c) for AI, the leash decorator from ENEMY_SYSTEM.md aborts the engage branch when the pawn wanders past LeashRadiusCm.

Re-issue rate-limited. Two gates: drift between target and last-issued location must exceed MoveRefreshThresholdCm, AND elapsed since last re-issue must exceed 1 / MaxMoveRefreshHz. Drift-only would burn navmesh queries on jittery targets; rate-only would re-issue identical moves on stationary ones. Both gates are necessary.

Implementation surface: - Interfaces: Source/CRADL/Combat/PawnCombatantInterface.h (IPawnCombatant::{BeginChase, EndChase, IsChaseActive, StopMovement}), Source/CRADL/Player/MoveIssuerInterface.h (IMoveIssuer::{IssueMoveTo, StopMove}). - Helper struct: Source/CRADL/Combat/CradlChaseState.hFCradlChaseState::Update(Chaser, Target, NowSeconds) returns EAction::{Idle, ReissueMove}. No engine-actor coupling; AI can re-use the struct from a behavior-tree task. - Component: Source/CRADL/Combat/ChaseComponent.h — owns the helper + poll timer, on ACradlCharacter. Tunables (MoveRefreshThresholdCm, MaxMoveRefreshHz, ChasePollHz) are EditDefaultsOnly per pawn class. - Implementers: ACradlCharacter is IPawnCombatant (delegates to UChaseComponent); ACradlPlayerController is IMoveIssuer (IssueMoveTo → existing TryResolveMoveLocation; StopMoveAPlayerController::StopMovement). AI controllers add their own IMoveIssuer impl when they land. - Callers: UQueuedInteractAbility (entry-time walk-up), UAutoAttackAbility (mid-engagement chase). Both reach the pawn via IPawnCombatant; neither references the controller directly. - Marker visuals: while a chase is active, ACradlPlayerController::OnNavRequestFinished skips its usual marker-clear so the nav-target indicator tracks the moving target across re-issues without flickering. UChaseComponent::EndChase does NOT call StopMove — the caller pairs EndChase with StopMovement when it wants the pawn halted (e.g. arrival in UQueuedInteractAbility), and leaves the pawn coasting under the prior move otherwise.

Authority model: Chase is local-machine state. LocalPredicted abilities call BeginChase on both peers; each peer's UChaseComponent runs its own poll timer. Server-side movement replicates to remote peers via bReplicateMovement; remote peers don't drive their own chase. The ability ends through the standard EndAbility path on cancellation tag / modal / target-dead — EndAbility calls EndChase as part of TeardownEngagement.

Footguns: - StopMovement on IPawnCombatantEndChase. Stopping motion and ending chase are decoupled on purpose: arrival pairs them (EndChase then StopMovement to halt cleanly before dispatch); cancellation usually only ends chase (the cancel reason — modal open, target died — drives the next motion). Don't conflate them in a "convenience" wrapper. - Chase has no built-in give-up — caller owns cancellation. A BeginChase against an uncatchable target will pursue indefinitely if the caller doesn't supply a termination signal. Player pawns get this for free (any fresh nav input cancels Action.Combat.AutoAttack); AI pawns get it via the leash decorator. A new chase caller without either path will produce a pawn that pursues forever — add an ability-level condition or a BT decorator, don't reach for closing-distance heuristics inside the chase struct. - Don't predict the target's future position. Tempting to walk to "where the target will be in N seconds" — separate design problem (lead/lookahead) with its own gotchas (target reverses direction = walk-the-wrong-way). Naive "walk to current location, refresh on a cadence" is the contract. - Don't subscribe UChaseComponent to State.Moving. Same self-cancellation footgun the abilities themselves face: chase causes movement; a State.Moving reaction loop would self-terminate. - Tunables tune the creature, not the controller. Re-issue threshold and refresh rate live on UChaseComponent (per pawn class) so a heavy boss-class pawn pursues differently from a nimble player pawn regardless of who's commanding.

Related: Engagement Loop, Targeting & Engagement Entry, Cancellation Channels, NPC / AI Compatibility.


Combat Skills

Rule: Five combat skills, each a USkillDefinition: Skill.Combat.Melee, Skill.Combat.Ranged, Skill.Combat.Magic, Skill.Combat.Defense, Skill.Combat.Hitpoints. HP XP is granted on every successful damage application (OSRS parity).

Why: Slimmer than OSRS's 7-skill model — we collapse Attack/Strength into a single "weapon-style" skill per type and defer Prayer entirely. Defense and Hitpoints stay separate because both are independently meaningful (Defense affects evasion math, Hitpoints sets the HP pool).

Implementation surface: - Files: skill assets under the same path conventions as existing skills (Mining, Woodcutting, etc. — see USkillRegistry). - Tags: Skill.Combat.{Melee, Ranged, Magic, Defense, Hitpoints} under existing Skill.* parent. - Component: existing USkillsComponent on ACradlPlayerState — no new component. - HP-XP grant site: UCradlAttributeSet::PostGameplayEffectExecute for the Damage meta (only on successful hits; see Health & Damage Application).

Related: ARCH #3 (skills as typed state on USkillsComponent), Combat Styles, Health & Damage Application.


Combat Styles

Rule: Three styles per weapon: Combat.Style.Aggressive (XP routes to the weapon-style skill), Combat.Style.Defensive (XP routes to Defense for melee — OSRS-faithful; splits for ranged/magic where OSRS has no pure-Defense mode), and Combat.Style.Controlled (XP always splits across the weapon-style skill and Defense). On melee weapons, the active style additionally selects which Combat.DamageType.* axis the swing consumes — a scimitar may swing Slash under Aggressive but Stab under Controlled, per the weapon's authored FItemRow::StyleDamageTypes array (each entry pairs a Combat.Style.* leaf with a Combat.DamageType.* leaf). Ranged and magic weapons swing their single WeaponDamageType regardless of style (OSRS has no per-style damage-type branching for either). The active style is published as a replicated loose tag on the player ASC (AddReplicatedLooseGameplayTag, server-authored); auto-attack reads it at swing-start.

XP routing matrix — rates per axis live in UCradlCombatSettings::{Aggressive,Defensive,Controlled}Routing (FStyleXpRouting), consumed by the canonical CradlCombat::GrantStyleDamageXp helper. Total XP/damage is invariant across modes for every damage type — mode is a training-preference choice, not an efficiency one, and no mode is strictly dominated. Magic's lower total (2× vs melee/ranged's 4×) matches OSRS's per-spell fixed damage caps; raising it would over-correct. Defensive and Controlled are intentionally identical for ranged/magic — OSRS has no pure-Defense ranged (Longrange IS the split) or pure-Defense magic (Defensive Casting IS the split) mode; the only damage type where they diverge is melee.

Damage type Aggressive Defensive Controlled Total
Melee (Stab/Slash/Crush) 4 → Melee 4 → Defense 2 → Melee, 2 → Defense
Ranged 4 → Ranged 2 → Ranged, 2 → Defense 2 → Ranged, 2 → Defense
Magic (auto-attack autocast & manual cast) 2 → Magic 1 → Magic, 1 → Defense 1 → Magic, 1 → Defense

StyleBonus on effective level — each mode contributes +3 total invisible level (CradlCombatMath::EffectiveLevel): - Aggressive: +3 to the weapon-style axis (Attack/Strength). - Defensive: +3 to the Defense axis. - Controlled: +1 to both axes (collapsed OSRS "+1 to Attack/Strength/Defence" — the three-way split becomes two-way in our 5-skill model). Accuracy/damage stays competitive across modes; no mode dominates the math layer either.

Style → Swing Axis (melee weapons). Per-weapon authoring on FItemRow::StyleDamageTypes (TArray<FStyleSwingAxis> — each entry pairs a Combat.Style.* leaf with a Combat.DamageType.* leaf) maps each active style to the damage-type the swing consumes — making the per-axis AttackBonus matrix entries reachable from gameplay. Mirrors OSRS's per-weapon style button mapping (scimitar: Chop/Slash/Block → Slash, Lunge → Stab; mace: Pound/Pummel → Crush, Spike → Stab; etc.) without surfacing four buttons of UI. Resolution is single-sited at CradlCombat::ResolveSwingDamageType(Weapon, ActiveStyle):

  1. If StyleDamageTypes has an entry for ActiveStyle, return it.
  2. Otherwise fall through to Weapon.WeaponDamageType (the "single-type for every style" case — also the natural shape for ranged/magic weapons and for melee weapons that don't bother with per-style branching).

The resolved type drives the snapshot's WeaponDamageType field once at swing-start; every downstream consumer (SumAttackBonus, ResolveEngagementRange, GE SetByCaller key, hit cue, WeaponStyleSkillFor XP routing) reads the snapshot, so no other site changes. Mid-engagement style toggles affect the next swing only (same contract as XP routing).

Why: Lighter than OSRS's 4-style picker — preserves the "how do I want to train this kill" choice without four buttons of UI surface. Loose-tag (not a GE) because style is a player-facing setting, not a buff with duration/stacks. Replicated loose, not plain loose, because the authoritative auto-attack swing runs on the server: a client-only AddLooseGameplayTag would route XP to the default style on the server while the client-side UI showed the toggle landing — silent client/server divergence with no visible failure mode. AddReplicatedLooseGameplayTag propagates through MinimalReplicationTags, so server reads the same value the player toggled, and spectators see a future stance state on the AnimBP for free.

Implementation surface: - Tags: Combat.Style.{Aggressive, Defensive, Controlled} plus the trigger Action.Trigger.Combat.SetStyle, all declared in CradlGameplayTags.h and the .ini. - Fields: FItemRow::DefaultStyle (FGameplayTag — authored per weapon; first-equip state), FItemRow::StyleDamageTypes (TArray<FStyleSwingAxis> — melee per-style axis selection; empty = use WeaponDamageType for every style) - Style switch (per ARCH #18): UI button → ASC->HandleGameplayEvent(Action.Trigger.Combat.SetStyle, payload-with-chosen-style-tag)USetCombatStyleAbility (LocalPredicted, instant) activates → on authority, strips every Combat.Style.* leaf and adds the chosen one via the paired AddLooseGameplayTag + AddReplicatedLooseGameplayTag call (UE 5.4 requirement — see Footguns), then EndAbility. The Debug_SetCombatStyle exec on ACradlPlayerController builds the same FGameplayEventData and dispatches through the same channel — cheat and HUD button share one path. Widget surface is (APlayerState, ASC, FGameplayTag) exactly per ARCH #18; no controller RPC, no direct ASC mutation from the widget. Read-side: CradlCombat::ResolveActiveStyle(ASC) is the canonical lookup (Defensive > Controlled > Aggressive priority on the ASC tags); both abilities and the debug overlay route through it. - DefaultStyle on equip (planned): the equip path will fire the same event with the weapon's DefaultStyle as the payload — one verb, one ability, one side-effect site. - Persistence: the active leaf is captured on save (UCradlPlayerProfile::CombatStyle) and re-applied on load via the same dual-call pattern. An invalid/missing tag (older save, never toggled, or Debug_SetCombatStyle None) falls through to the silent Aggressive default.

Footguns: - Style is read at swing-start, not at ability activation — switching style mid-engagement should affect the next swing's XP routing and damage-type axis, not retroactively. Both reads share a single SnapshotSwingStats call so they can't drift. - StyleDamageTypes entries must reference the weapon's own attack-bonus axes — authoring {Style: Aggressive, DamageType: Stab} on a weapon whose AttackBonus only populates Slash silently routes the swing through a 0-bonus axis. Validator warns; don't ignore the warning. - Don't write the style tag client-side via AddLooseGameplayTag alone. The XP-routing read happens on the server in UAutoAttackAbility::SnapshotSwingStats; client-only tag state is invisible there. All style writes flow through the gameplay-event channel above (both the cheat and the HUD button) so the server's ASC is authoritative. - Don't add a controller RPC for "set style" alongside USetCombatStyleAbility — that's exactly the ARCH #18 anti-pattern (per-controller verbs duplicating modal-gating / authority machinery the ability already provides). One channel. - Replicated loose tag, not plain loose alone. With USetCombatStyleAbility routing the verb through LocalPredicted, it's tempting to reason: "the body runs on both client and server, so plain AddLooseGameplayTag syncs both peers — replication is redundant." That's true for the owner and the server, but it loses two things: (1) spectator visibility — simulated proxies don't run the ability body, so a future AnimBP stance row keyed on Combat.Style.* only sees the tag if it's replicated; (2) reconnect / late-join — replicated loose rides MinimalReplicationTags and is re-delivered on connection, plain loose is session-local and silently drops. The replicated call is mandatory; the plain call is also mandatory (see next bullet). - UE 5.4: AddReplicatedLooseGameplayTag does not update the local tag count. In 5.4 it only mutates the replicated mirror (FMinimalReplicationTagCountMap); the local owned-tags count is updated solely via UpdateOwnerTagMap() on the receiving end of replication (see GameplayEffectTypes.cpp's UAbilitySystemComponent::NetSerialize path). On the writer (server), HasMatchingGameplayTag returns false immediately after the call — and the auto-attack swing-start runs on the server, so XP routing would silently never change. USetCombatStyleAbility works around this by pairing every AddReplicatedLooseGameplayTag with a local AddLooseGameplayTag, and the same for Remove; save/load applies the same pair. Don't drop the local call thinking the replicated variant covers both ends — it doesn't. Same pattern applies to every other server-readable loose tag in the codebase.

Related: Combat Skills, Equipment Combat Data.


Damage Types

Rule: Five first-class damage types: Combat.DamageType.{Stab, Slash, Crush, Ranged, Magic}. Equipment carries separate attack and defense bonuses per type. A damage GE carries exactly one type, conveyed via SetByCaller with the type tag as the magnitude key.

Why: Faithful to OSRS itemization — armour can counter weapon types meaningfully (a stab-resistant chestplate matters), slot/style choices have teeth. Five types is the minimum to capture the melee-substyle distinction; collapsing to three loses meaningful authoring decisions.

Implementation surface: - Tags: Combat.DamageType.{Stab, Slash, Crush, Ranged, Magic} - GE convention: damage GEs use UGameplayEffect::SetByCallerMagnitudes with the damage-type tag as key. - Resolution site: UCradlCombatMath::RollAccuracy and RollDamage consume the type to pick the right AttackBonus / DefenseBonus axis. The type itself is resolved once per swing in UAutoAttackAbility::SnapshotSwingStats via CradlCombat::ResolveSwingDamageType(Weapon, ActiveStyle) — see Combat Styles → Style → Swing Axis.

Footguns: If you find yourself adding a "primary type" + "secondary type" to a single damage application (e.g. for hybrid weapons), the pattern is two damage GEs (one per type), not one GE with two type tags. Keeps the formula one-axis-at-a-time.

Related: Damage Formula, Equipment Combat Data.


Equipment Combat Data

Rule: Combat bonuses live on FItemRow (extended) — not in a side struct or a separate DataAsset. Adding combat data to an item is an authoring edit on the existing items DataTable.

Why: The whole inventory/equipment pipeline already keys on FItemRow (per ARCH #5); a side struct doubles the lookup surface and invites desync. Editor-time validator under Source/CRADLEditor/Validators/ catches missing/invalid combat data alongside other item fields.

Implementation surface (new fields on FItemRow): - TMap<FGameplayTag, int32> AttackBonus — keyed by Combat.DamageType.*, 5 entries - TMap<FGameplayTag, int32> DefenseBonus — keyed by Combat.DamageType.*, 5 entries - int32 StrengthBonus - int32 RangedStrengthBonus - float MagicDamageBonus (percent) - FGameplayTag WeaponDamageType — default Combat.DamageType.* this weapon swings (empty for non-weapons). Used directly when StyleDamageTypes is empty or doesn't map the active style. - TArray<FStyleSwingAxis> StyleDamageTypes — per-style swing-axis override for melee weapons. Each entry: Style (Combat.Style.*) + DamageType (Combat.DamageType.{Stab,Slash,Crush}). Empty for ranged/magic and for melee weapons that don't branch. Duplicate Style across entries is a validator failure. See Combat Styles. - float SwingInterval — seconds (weapons only) - float AttackRange — cm (weapons only; 0 = "use the per-damage-type settings fallback" — see Engagement Range) - (Skill-level equip gates: reuse the existing FItemRow::EquipRequirements.Skills array — TArray<FSkillRequirement> already keys FGameplayTag → int32 Level. No parallel RequiredSkillLevels field; UEquipmentComponent::CanPlaceInSlot already consumes EquipRequirements.) - FGameplayTag DefaultStyle — initial Combat.Style.* on equip

Validator update: Source/CRADLEditor/Validators/ — items DataTable validator must check for missing WeaponDamageType on weapons, missing SwingInterval on weapons, that bonus map keys are valid Combat.DamageType.* tags, etc. AttackRange == 0 on a weapon is a warning, not a fail — it means "use the settings fallback for my damage type," which is a legitimate authoring choice but worth surfacing so the fallback is intentional rather than silent.

Read pipeline: The fields above describe authoring shape. How these values reach gameplay code at runtime — which become attributes on UCradlAttributeSet, which are summed by the equipment aggregator, which are direct row reads — is governed by STAT_PIPELINE.md. In particular, scalar bonuses (StrengthBonus, RangedStrengthBonus, MagicDamageBonus, and the future DefenseBonusFlat) are authoring sugar that emits a transient GE on equip; the per-DamageType matrices are read via UEquipmentComponent aggregator methods.

Related: ARCH #5 (item data is DataTable + FName ItemId), Damage Formula, STAT_PIPELINE.md.


Ammo Slot

Rule: A new equipment slot tagged Item.Slot.Ammo holds projectiles consumed by ranged abilities. One ammo per swing, server-authoritative consumption.

Why: Ammo is a first-class equipment slot in OSRS; modeling it as inventory consumables instead would lose per-shot-cost gating and equip-slot UI. Existing FEquipmentSlotDef (ARCH #11) already supports tag-gated slots — adding one is a data edit on UEquipmentComponent's slot list, not a new component.

Implementation surface: - Tags: Item.Slot.Ammo (under existing Item.Slot.*) - Component: existing UEquipmentComponent slot definition list (no new class) - Consumption site: UAutoAttackAbility::HandleSwingFinished for ranged swings; UCastSpellAbility for spell rune costs (separate channel, not ammo)

Footguns: Ammo recovery semantics are deferred (see Open Questions); for v1, treat every fired round as consumed. Don't write the "ground pickup" path until that decision lands.

Related: ARCH #11 (equipment slot tags), Spellbook & Spells (rune costs are the magic equivalent).


Health & Damage Application

Rule: UCradlAttributeSet gains four attributes — Health, MaxHealth, Damage (meta), AttackSpeed. Damage GEs accumulate Damage; PostGameplayEffectExecute consumes the meta value into Health, clamps [0, MaxHealth], fires GameplayCue.Combat.Hit.{Type}, refreshes Status.InCombat, and fires Combat.Event.Death if Health == 0. MaxHealth's base value is f(Skill.Combat.Hitpoints level) — the formula is project-tunable (default level * MultiplierConstant, optionally overridden by a UCurveFloat). On level-up the base is rewritten directly to the attribute's BaseValue; equipment / potion / buff GEs layer additively or multiplicatively on top via standard GAS aggregation. Health carries the runtime delta.

Why: Lyra-style damage meta-attribute gives one canonical clamp/death point and one cue trigger — every damage source funnels through the same execution. Tying MaxHealth's base (not its current value) to Hitpoints level avoids double bookkeeping while leaving buff layering open: a +20 MaxHealth potion is an additive Infinite GE on top of the level-driven base, not a re-derivation of f. Per ARCH #3, HP is exactly the kind of "crossover stat that combat effects need to manipulate" that belongs on the attribute set.

Implementation surface: - File: Source/CRADL/Abilities/CradlAttributeSet.h — add Health, MaxHealth, Damage (meta), AttackSpeed with OnRep_* and ATTRIBUTE_ACCESSORS. - File: Source/CRADL/Abilities/CradlAttributeSet.cpp — extend PostGameplayEffectExecute to handle the Damage meta attribute: subtract from Health, clamp, set meta back to 0, fire cues/events, grant Hitpoints XP on successful hit. - Tags: Status.InCombat (refresh-on-stack GE), GameplayCue.Combat.Hit.{Stab,Slash,Crush,Ranged,Magic}, Combat.Event.Death, Combat.Event.Hit, Combat.Event.Miss. - Hitpoints-level → MaxHealth recompute hook: subscribe to USkillsComponent::OnLevelUp for Skill.Combat.Hitpoints; on fire, write SetMaxHealth(f(level)) directly (writes to BaseValue; future buff GEs layer on top via aggregation). Formula source is UCradlCombatSettings (a UDeveloperSettings) holding MaxHealthPerHitpointsLevel (multiplier; default 4) and an optional UCurveFloat override — same curve-or-algorithmic-fallback pattern as USkillRegistry. The recompute is not itself a GE: GEs are reserved for layering buffs on top of the level-derived base.

Footguns: - Never write directly to Health from outside PostGameplayEffectExecute — even healing must flow through Damage (negative) so the cue/event pipeline stays uniform. Healing GEs apply negative Damage, get clamped, fire heal cues. The food-consume path (CONSUMABLES.md "Healing Channel") is the canonical caller and stays inside this rule. - HP-XP grant happens only on damage successfully reducing target HP — not on the swing roll, not on misses, and not on heal-channel writes (the DamageDelta <= 0 short-circuit already handles this). Site is the same PostGameplayEffectExecute block.

Related: ARCH #3 (attributes for crossover stats), Damage Formula, Animation / FX / Audio, Death & Cold Storage, CONSUMABLES.md "Healing Channel".


Damage Formula

Rule: OSRS-derived; one canonical implementation in UCradlCombatMath (BlueprintFunctionLibrary or static namespace — TBD at implementation time). The formula itself is part of the contract; tuning happens by replacing weapon/skill numbers, not by replacing the model.

Formula: - Effective level (5-skill model): effective = baseLevel + styleBonus + 8. styleBonus = +3 to the style's primary skill (Aggressive → weapon-style skill; Defensive → Defense). Controlled splits the +3 across both axes (+1 weapon-style, +1 Defense) — collapsed from OSRS's "+1 to Attack/Strength/Defence". No prayer multiplier (no Prayer skill in v1). - Accuracy roll: - attackRoll = effectiveAttackLevel * (equipmentAttack[type] + 64) - defenseRoll = effectiveDefenseLevel * (equipmentDefense[type] + 64) - hitChance = attackRoll > defenseRoll ? 1 - (defenseRoll + 2) / (2 * (attackRoll + 1)) : attackRoll / (2 * (defenseRoll + 1)) - Damage roll on hit: maxHit = floor(0.5 + effectiveStrengthLevel * (strengthBonus + 64) / 640); uniform integer roll in [0, maxHit].

Why: OSRS's formulas are well-understood by the genre's audience and have decades of balance tuning behind them. Codifying them here makes the formula a stable reference point — future docs and PRs link to this section instead of restating it.

Implementation surface: - File: Source/CRADL/Combat/CradlCombatMath.{h,cpp} (new) — single home for RollAccuracy(...), RollDamage(...), EffectiveLevel(...). - Call sites: UAutoAttackAbility::HandleSwingFinished, UCastSpellAbility::PerformCast. - RNG: server-only seeded RNG; result published via FireReplicatedGameplayEvent so clients see the same roll outcome.

Footguns: Effective-level math collapses Attack and Strength into the single "weapon-style" skill (per Combat Skills) — both effectiveAttackLevel and effectiveStrengthLevel derive from the same skill (Melee/Ranged/Magic). Don't try to read separate Attack and Strength values; they don't exist in CRADL's 5-skill model.

Where the inputs come from: equipmentAttack[type] / equipmentDefense[type] resolve via UEquipmentComponent::SumAttackBonus(DmgType) / SumDefenseBonus(DmgType) (multi-slot sum). strengthBonus and friends resolve via UCradlAttributeSet reads. The full read-path contract lives in STAT_PIPELINE.md; preserve UCradlCombatMath as the single damage choke point so future per-DamageType debuff multipliers (see STAT_PIPELINE.md "Defense Granularity") remain a one-line formula change.

Related: Damage Types, Combat Skills, Combat Styles, Equipment Combat Data, STAT_PIPELINE.md.


Cancellation Channels

Rule: Combat follows ARCH #13's three-channel model (Status.* GEs, State.* loose tags, Action.* ability tags). Player-intent move cancellation rides on the existing QueuedInteract pattern — the controller's existing Action.QueuedInteract cancel-on-fresh-nav-input is extended to also cancel Action.Combat.AutoAttack. Auto-attack does not subscribe to State.Moving directly.

Why: Combat causes its own movement (chase). An ability that publishes State.Moving (via the CMC bridge) and also listens for it on its CancelOnTagsAdded self-terminates on the first physics tick. UQueuedInteractAbility already documented this footgun and chose explicit controller-side cancellation as the answer; combat reuses that channel.

Channels combat listens to: - Status.Stunned, Status.Dead — replicated GEs; combat ends. - Action.Modal.* (parent-tag matching) — opening any modal cancels combat (combat and modals are mutually exclusive, same as skilling). - Controller-explicit cancel of Action.Combat.AutoAttack — fired from the same input handler that already cancels Action.QueuedInteract on fresh player nav input or other intent signals (Esc, etc.).

Footguns: - Any combat ability that causes its own movement must not subscribe to State.Moving. The self-cancel footgun is identical to the one called out on UQueuedInteractAbility — check that file's header comment if in doubt. - Modal cancellation uses parent-tag matching (Action.Modal matches Action.Modal.Banking, etc.). New modals added under Action.Modal.* automatically participate without modifying combat ability code.

Implementation surface: - UAutoAttackAbility::CancelOnTagsAdded populated with Status.Stunned, Status.Dead, Action.Modal (parent — see ARCH #15 footgun about parent-vs-leaf events; cancellation matching is parent-OK, observer-subscription is per-leaf). - UAutoAttackAbility::AbilityTags must include Action.Combat.AutoAttack (not just ActivationOwnedTags). UAbilitySystemComponent::CancelAbilities matches against AbilityTags; an ability with the leaf only on ActivationOwnedTags is invisible to the controller's cancel call. - Controller: extend the existing fresh-nav-cancel path in ACradlPlayerController to call ASC->CancelAbilities against both Action.QueuedInteract and Action.Combat.AutoAttack. - Chase teardown rides on the ability teardown. Every termination path in UAutoAttackAbility and UQueuedInteractAbility ends with IPawnCombatant::EndChase (idempotent — no-op if no chase ran). The pawn's UChaseComponent clears its poll timer + state; the controller's nav-target marker clears via EndChase's belt-and-suspenders path. No per-cancel-reason branch is needed: ending the ability ends the chase.

Related: ARCH #13 (cancel channels framework), ARCH #15 (modals + parent-vs-leaf footgun), Engagement Loop.


In-Combat State

Rule: Status.InCombat is a HasDuration GE applied with refresh-on-stack semantics; the duration comes from UCradlCombatSettings::InCombatDurationSeconds (default 20s, minimum 1s), captured into the GE CDO at module load — designer changes take effect on next editor restart. Refreshed by both dealing and receiving combat actions. Movement neither refreshes nor expires it. Entry fires Combat.Event.EnterCombat via FireReplicatedGameplayEvent; expiry is silent.

Why: A consistent in-combat clock gates downstream features (e.g. food cooldowns, regen suppression, fast-travel blocks) without each consumer maintaining its own timer. Refresh-on-receive ensures you can't sit and tank hits to "wait out" the timer; refresh-on-deal handles the "I'm still attacking" side. Movement-independent expiry means kiting/disengaging doesn't unlock OOC actions until the actual exchange has cooled off.

Implementation surface: - GE: GE_Combat_InCombat (new) — duration read from UCradlCombatSettings::InCombatDurationSeconds (default 20s, min 1s) at CDO construction, GrantedTags = Status.InCombat, no period. - Application sites: UCradlAttributeSet::PostGameplayEffectExecute for the Damage meta (both attacker and target reapply on every successful hit). - Event broadcast: Combat.Event.EnterCombat via UCradlAbilitySystemComponent::FireReplicatedGameplayEvent on first application after a clean expiry.

Disconnect rule: Quitting (EndPlay / SavePlayer) while Status.InCombat is active counts as death. The same death-and-cold-storage flow that fires on HP=0 also fires when the save subsystem captures profile state with the in-combat tag still present — the disconnected player completes the death pipeline (item retention, cold-storage transfer) before the profile lands on disk. Without this, a low-HP player has an incentive to alt-F4 to escape the exchange, and OSRS-style item-retention only carries weight if combat-state survives the disconnect. Also closes the loop on the related save-and-reload heal exploit (Health & Damage Application — Health is persisted, so quit-to-heal is already off the table; quit-to-escape is the second leg of the same incentive).

Footguns: Cosmetic listeners (HUD bordering red, music duck) subscribe to the tag itself, not to the entry/exit events — the events fire only on transitions; the tag is the right "is X true now?" source.

Related: ARCH #13 (Status.* channel), Health & Damage Application, Death & Cold Storage.


Targeting & Engagement Entry

Rule: Click-on-hostile-pawn enters combat through the existing Action.Trigger.QueuedInteract channel. The payload's FContextAction carries SourceActor = targetPawn and a combat-engage action tag; UQueuedInteractAbility requests a chase from the avatar pawn (IPawnCombatant::BeginChase) if the target isn't already in range, then on arrival dispatches via IInteractable::DispatchContextAction, which fires Action.Trigger.Combat.Engage on the player ASC, which triggers UAutoAttackAbility. Hostile pawns implement IInteractable so combat range / LOS rides on the existing CanInteract gate.

Why: Combat-engage is conceptually identical to clicking an out-of-range gathering node — walk up, then act. Reusing UQueuedInteractAbility gives us free movement-cancellation and free range gating without re-implementing any of it; routing the actual walk through the chase primitive (IPawnCombatant) makes the entry path automatically correct against moving targets and reusable by AI verbs that want the same "approach then act" semantic. Treating hostile pawns as IInteractables collapses two parallel "is this thing reachable?" code paths into one.

Implementation surface: - Controller: the existing ResolveTapAction click-routing in Source/CRADL/Player/CradlPlayerController.cpp already builds an FContextAction from any clicked IInteractable with ActionTag = I->GetPrimaryActionTag(). Combat-engage rides this path automatically — no new branch. The controller implements IMoveIssuer; chase reaches it through the pawn's UChaseComponent (per Chase & Movement). - FContextAction is purely tag-driven; combat-engage is just an action with ActionTag = Action.Trigger.Combat.Engage. No new flavor / variant — every IInteractable already picks its own ActionTag, and a new variant would be pure ceremony. - Hostile pawn class(es): implement IInteractable. The CanInteract implementation gates on engagement range (via the canonical resolver) + LOS using the same trace helpers as UInteractionComponent. GetPrimaryActionTag() returning Action.Trigger.Combat.Engage is the implicit "this is a hostile" classification — the controller doesn't branch on hostility, it just dispatches whatever ActionTag the interactable provides. - Trigger tags: - Action.Trigger.QueuedInteract (existing — entry from controller) - Action.Trigger.Combat.Engage (new — fired by IInteractable::DispatchContextAction after walk completes) - Action.Trigger.Combat.Cast (new — manual one-shot spellcast, payload OptionalObject = USpellDefinition*) - Action.Trigger.Combat.AutocastSet (new — set/clear the autocast spell)

Footguns: Don't add a parallel "click-to-attack" controller path that bypasses QueuedInteract — every "I want to attack this" intent must funnel through the queued channel so chase, cancel, and range-gate behave consistently. New entry points (hotkeys, AI) build the same FContextAction and dispatch the same gameplay event.

Related: Source/CRADL/Abilities/QueuedInteractAbility.h, ARCH #18 (event-dispatch contract), Targeting Component, Cancellation Channels.


Targeting Component

Rule: A new UCombatTargetingComponent lives on ACradlPlayerState, holding TObjectPtr<AActor> ActiveTarget and FGameplayTag ActiveAttackChannel (the leaf Combat.DamageType.* of the equipped weapon today; a 3-bucket {Melee/Ranged/Magic} collapse may land in Phase 7 if AnimBP stance routing wants it — see footgun below). Replicated COND_OwnerOnly. Spectator visibility (other clients seeing who you're attacking) rides on the existing FActivityDescriptor (ARCH #1) — reuse, do not parallel.

Why: Owner-only replication keeps targeting state cheap (no full-fanout). The FActivityDescriptor channel was designed for exactly this kind of "spectator-visible activity" — adding a parallel multicast for combat targets would duplicate replication state and invite "which one is right when they disagree?" bugs.

Implementation surface: - File: Source/CRADL/Combat/CombatTargetingComponent.{h,cpp} (new) - Owner: ACradlPlayerState (component added in constructor; replicated OwnerOnly) - Change broadcast: OnActiveTargetChanged(AActor*, FGameplayTag) — non-dynamic multicast delegate. Server-side SetActiveTarget / ClearActiveTarget broadcast directly; owning client fires from the shared OnRep_ActiveTarget rep notify. Subscribers (notably UMovementModePolicyComponent's facing drive) react instead of tick-polling. One delegate, both leaves replicated through the same rep notifyActiveTarget and ActiveAttackChannel are written together server-side, so a single broadcast covers both. - Spectator extension: FActivityDescriptor already replicates active ability tag + target actor + start time (per ARCH #1) — use the existing Target field for the engaged pawn; no new replicated state. - Read sites: combat HUD widgets, animation tag map (for "what's my active combat channel" stance state), UMovementModePolicyComponent (facing drive). - Facing drive lives on UMovementModePolicyComponent, not on the ability. The policy component subscribes to OnActiveTargetChanged and tick-rotates the pawn's yaw toward the active target while stationary (velocity ~0). While moving, bOrientRotationToMovement already orients along velocity (which, after chase tracking, points at the target); the facing drive only takes over once velocity drops to zero. Late-binding via ACradlCharacter::OnPlayerStateReplicated covers remote-client cases where PlayerState arrives after the pawn. Co-locating facing with the rest of rotation policy (bUseControllerRotationYaw, bOrientRotationToMovement) keeps every rotation decision in one place; abilities don't poke yaw directly.

Footguns: - When the active target dies / is destroyed, clear ActiveTarget immediately on both the targeting component and the FActivityDescriptor — stale references don't cause crashes (TObjectPtr GC-clears on destruction) but they do confuse spectator UI mid-frame until the next replication. - ActiveAttackChannel stores the leaf Combat.DamageType.* (Stab/Slash/Crush/Ranged/Magic) verbatim, not a collapsed Melee/Ranged/Magic bucket. Consumers that want the bucket must collapse at read-time (Stab|Slash|Crush → Melee) or wait for Phase 7 to introduce a Combat.AttackChannel.* namespace if AnimBP stance routing wants 3 states instead of 5. Don't re-purpose Combat.DamageType.Slash as a "melee channel" sentinel — that's the actual slash damage type and the conflation will surface as wrong-cue-on-stab bugs.

Related: ARCH #1 (FActivityDescriptor channel), Targeting & Engagement Entry, Animation / FX / Audio.


Multi-Attacker Ceiling

Rule: Default cap of 3 simultaneous attackers per pawn (configurable on the receiving pawn). A fourth attacker's engage attempt fails with a system message ("target is overwhelmed" or similar). PvE-only contract; the cap may need rethinking when PvP scope is decided.

Why: Without a ceiling, "swarm the boss with the whole party" trivializes single-target encounters and creates unbounded animation/network load. Three is small enough to keep mob fights from becoming pile-ons but large enough to support trio play. The cap is per-target so designers can override on raid bosses or open-PvP zones later.

Implementation surface: - Receiving pawn: a TSet<TWeakObjectPtr<AActor>> CurrentAttackers (server-only — TWeakObjectPtr doesn't replicate well and per-attacker identity isn't useful client-side) plus an int32 MaxAttackers (default 3, editable per pawn class). - Replicated count: an int32 CurrentAttackerCount mirrors CurrentAttackers.Num() on every mutation so remote clients can run a click-time WouldAcceptAttacker(invoker) peek from IInteractable::CanInteract and surface "overwhelmed" before walking. Idempotent: an invoker already in the set (server) returns true regardless of count. - Eviction: attacker-side EndAbility removes the attacker entry from the target's set; count is rewritten in lockstep. - Authoritative rejection: UAutoAttackAbility::ActivateAbility calls TryRegisterAttacker server-side; predicted rejection on client mirrors the result. The click-time peek is best-effort; a small TOCTOU window between peek and arrival can let a 4th attacker walk up to a now-full target, where the authoritative check then rejects. - Player feedback: click-time peek failure surfaces via UCradlMessageLogSubsystem::PostInteractFailure (the controller's failure-post path); arrival-time authoritative failure posts via UCradlGameplayAbility::PostServerMessage.

Footguns: Forgetting to evict on EndAbility (cancel paths, owner death) leaks attacker slots — make sure every termination path runs eviction. The base-class teardown is the cleanest place.

Related: Engagement Loop, PvP.


Spellbook & Spells

Rule: Spells are USpellDefinition : UPrimaryDataAsset, each tagged with the FGameplayTag BookTag of the spellbook it belongs to. The player is on a spellbook (CurrentBookTag) rather than learning spells individually — USpellbookComponent::HasLearned(Spell) is derived: true iff Spell->BookTag == CurrentBookTag and that tag still resolves to a known book in USpellbookRegistry. USpellbookComponent lives on ACradlPlayerState and replicates CurrentBookTag + UnlockedBooks (the books the player has access to) + AutocastSpell. Manual cast flows through UCastSpellAbility; autocast is a routing branch inside UAutoAttackAbility (see implementation surface below).

Why: OSRS model — you don't learn spells individually, you swap spellbooks. Deriving learned-state from book membership removes the orphan-prune surface entirely: a spell whose book asset was deleted simply stops being "known" the next time HasLearned runs, and the only orphan case left is CurrentBookTag itself (caught at ApplyPersistedState and replaced with UCradlCombatSettings::DefaultSpellbook). Soft-pointers per ARCH #6 (skills/recipes/nodes pattern).

Implementation surface (assets): - USpellDefinition : UPrimaryDataAsset — fields: DisplayName (FText), BookTag (FGameplayTag — the spellbook this spell belongs to; validator enforces shape under Spellbook.*), Description (FText, multi-line), Icon (soft UTexture2D), RuneCost (TMap), BaseDamage, MaxDamage, RequiredMagicLevel, CastInterval (dual role: autocast swing cadence AND the per-cast duration of the shared Cooldown.Combat.Cast gate that rate-limits manual cast and locks autocast to its own rhythm — see Cast Cooldown below), CastRangeCm (0 = fall back to UCradlCombatSettings::MagicEngagementRangeCm via CradlCombat::ResolveSpellRange), HitCueTag (defaults to GameplayCue.Combat.Hit.Magic), CastMontage (soft ref). AoE / Splash deferred — single-target v1. All v1 spells deal Combat.DamageType.Magic — no per-spell damage-type field; UCastSpellAbility hardcodes the type when building the damage GE. If a future spell needs a different axis, add an FGameplayTag DamageType field then; don't over-architect for it now. - USpellbookDefinition : UPrimaryDataAsset — fields: BookTag (FGameplayTag — the identity tag every USpellDefinition::BookTag references), DisplayName, Description. v1 ships one authored book (Spellbook.Standard) + the project default in UCradlCombatSettings::DefaultSpellbook, but the multi-book system is fully realized — additional books are a content change, not a refactor.

Implementation surface (components / abilities): - USpellbookComponent on ACradlPlayerState: CurrentBookTag (FGameplayTag) + UnlockedBooks (TArray) + AutocastSpell (TSoftObjectPtr) — all three replicated COND_OwnerOnly via a single ReplicatedUsing=OnRep_Spellbook notify. Server-only mutators: SetCurrentBook(NewBookTag) (gates on USpellbookRegistry::IsKnownBook + UnlockedBooks membership; clears AutocastSpell if it doesn't belong to the new book), UnlockBook(NewBookTag) (gates on IsKnownBook; no-op when already unlocked), SetAutocastSpell(Spell) (the autocast-side gate chokepoint — returns bool, rejects non-null spells that fail HasLearned or whose RequiredMagicLevel exceeds the player's Magic level). Mirrors the manual-cast gates in UCastSpellAbility::ActivateAbility so cheat / future-UI / scroll-learn callers can't bypass them by routing through autocast. Persisted via UCradlPlayerProfile. No swing-time re-check in UAutoAttackAbility — skill level is treated as monotonic, matching UCraftAbility / UGatherAbility (gate at activation, not per tick). - UCastSpellAbility : UCradlGameplayAbilitymanual one-shot only (Phase 8 single-loop decision). NetExecutionPolicy = LocalPredicted, matching UCraftAbility / UGatherAbility — the production cast UI fires HandleGameplayEvent client-side with OptionalObject = USpellDefinition* and GAS replicates the trigger payload to the server via NetGUID (see UI dispatch contract below for the registry-pin invariant that makes that safe). Triggered by Action.Trigger.Combat.Cast; validates target / spell-learned / Magic level / in-range / runes; consumes runes server-side; rolls accuracy + magic damage; ends. Out-of-range fails immediately with "Out of range." — there is no "click spell on target" walk-then-cast UX, and mid-engagement the avatar is already in melee range (spell range ≥ melee range), so engagement-time chase stays UAutoAttackAbility's job. Cooldown: CooldownGameplayEffectClass = UGE_SpellCast_Cooldown; overrides ApplyCooldown to stamp SetByCaller(Cooldown.Combat.Cast, Spell->CastInterval) per cast, and CanActivateAbility to surface a "Not ready yet." toast via GetGenericFailureText(ECradlAbilityFailure::Cooldown) when the gate blocks re-activation. Validation runs before CommitAbility so a rejected cast (no spell, not learned, out of range, etc.) doesn't burn the cooldown. See Cast Cooldown below. - Autocast routing lives inside UAutoAttackAbility, not as a separate engagement loop. When USpellbookComponent::AutocastSpell is set, SnapshotSwingStats reads it and the swing routes through magic damage (rune consume + spell damage roll + Magic XP) instead of weapon damage. Single engagement loop, one cancellation channel, one cadence source — matches the OSRS "autocast modifies your auto-attack" model. The originally-stated "long-running like auto-attack" wording in this doc described the same loop, not a parallel one; landing the routing inside auto-attack is the implementation that matches that intent. Future "set autocast" UI dispatches Action.Trigger.Combat.AutocastSet against a tiny instant ability that just writes USpellbookComponent::SetAutocastSpell and ends — the ability stays trivial because the learned-spell + Magic-level gate lives at the component, not the ability (mirror of the Phase-5-deferred USetCombatStyleAbility); Phase 8 currently has the cheat write the state directly. The autocast swing branch in HandleSwingFinished is also a participant in the shared cooldown channel: before consuming runes it HasMatchingGameplayTag(Cooldown.Combat.Cast) and skips the swing (reschedules) if the gate is up; after a successful rune consume it applies UGE_SpellCast_Cooldown with magnitude = AutocastSpell->CastInterval. The check + apply are server-only (HandleSwingFinished is already authority-gated). See Cast Cooldown below. - Registries: two lazy UGameInstanceSubsystems per ARCH #14 — USpellRegistry (scans the USpellDefinition PrimaryAssetType, indexes by asset FName) and USpellbookRegistry (scans USpellbookDefinition, indexes by BookTag, exposes IsKnownBook + GetBookByTag + GetSpellsInBook). Both EnsureBuilt on first use. Intentionally separate so each owns its own primary-asset scan and bBuilt latch.

UI: Spellbook page is a CommonUI UCradlActivatableWidget in UI.Layer.Game per ARCH #9. Selecting a spell sets autocast or fires manual cast via the same Action.Trigger.Combat.Cast / Action.Trigger.Combat.AutocastSet event channel as ARCH #18.

UI dispatch contract (load-bearing): The cast UI mirrors UCraftingMenuWidget::DispatchCraft and the gathering-node interactable: build an FGameplayEventData with EventTag = Action.Trigger.Combat.Cast, Target = victim pawn, OptionalObject = USpellDefinition*, and call ASC->HandleGameplayEvent(...) on the owning client. GAS LocalPredicted plumbing replicates the trigger payload to the server through Server_TryActivateAbility; OptionalObject deserializes server-side via NetGUID. The load-bearing invariant: USpellRegistry must be built on every peer that will receive a cast trigger — NetGUID resolution silently produces null if the asset isn't loaded on the receiver, and UCastSpellAbility::ActivateAbility would early-out with no diagnostic. The invariant is upheld by USpellbookComponent::ApplyPersistedState (server, profile load) and OnRep_Spellbook (owning client, first rep) both calling USpellRegistry::EnsureBuilt; the future spellbook UI iterating the registry to display "all known spells" will warm the client even earlier. Any new cast-trigger entry point must either go through USpellRegistry (which builds it) or call EnsureBuilt explicitly. Same NetGUID-pin contract that already governs UCradlRecipeRegistry / UCradlGatheringNodeRegistry for craft and gather triggers.

Cast Cooldown: Manual cast and the autocast swing branch share a single channel — Cooldown.Combat.Cast — granted by UGE_SpellCast_Cooldown (HasDuration, UTargetTagsGameplayEffectComponent grants the channel tag, duration arrives via SetByCallerMagnitude keyed by the channel tag itself). Per-cast duration is USpellDefinition::CastInterval — same field that drives autocast swing cadence, so author intent (how often this spell can fire) lives in one place. Standard GAS plumbing (CooldownGameplayEffectClass + CheckCooldown + ApplyCooldown) gates UCastSpellAbility re-activation; the autocast loop in UAutoAttackAbility::HandleSwingFinished reads the tag directly (HasMatchingGameplayTag → skip + reschedule the swing if up) and applies the same GE after a successful rune consume.

Shared channel rationale. With one tag covering both surfaces, a slow manual cast (long CastInterval) freezes the autocast cadence for its residual window — a fresh swing post-WaitDelay sees the gate up and reschedules instead of firing — and vice versa: an in-flight autocast swing's cooldown blocks a manual interject mid-cadence with the "Not ready yet." toast. OSRS combat-magic GCD parity. Future utility spells (alch, telegrab) that want true tick-manipulation room will live on a separate Cooldown.Combat.* leaf with its own GE class — adding the channel tag + GE + an FGameplayTag CooldownChannel field on USpellDefinition lands as one coherent change when the first utility spell arrives, not earlier.

Cooldown ordering. UCastSpellAbility::ActivateAbility resolves Spell from TriggerEventData and runs all read-only validation gates (target alive, spell learned, Magic level, in-range) before CommitAbility — a rejected cast doesn't burn the cooldown, only a successful commit does. The ApplyCooldown override needs Spell already set when it fires (which is mid-CommitAbility), hence the reorder vs the original code that committed first.

Toast routing. UCastSpellAbility::CanActivateAbility calls super, then on failure inspects the OptionalRelevantTags container for Cooldown.Combat.Cast and posts GetGenericFailureText(ECradlAbilityFailure::Cooldown) — "Not ready yet." — via the standard PostMessage channel. Other CanActivateAbility failure shapes (cost, blocked tags) flow through their own existing surfaces; we don't shotgun a toast on every failure. When the engine passes nullptr for OptionalRelevantTags (e.g. trigger-driven activation), the override routes through a local container so the failure-tag check still works.

Persistence: UCradlPlayerProfile stores CurrentBookTag, UnlockedBooks, and AutocastSpell (TSoftObjectPtr). Apply-time invariants in USpellbookComponent::ApplyPersistedState: orphan CurrentBookTag (book asset deleted between sessions) falls back to UCradlCombatSettings::DefaultSpellbook; the resolved book + the default book are union'd into UnlockedBooks so the player can always reach at least the fallback; AutocastSpell is kept only if its BookTag matches the resolved current book — cross-book autocast is conceptually meaningless and would mis-fire on the swing tick.

Footguns: USpellRegistry builds lazily, not eagerly — see ARCH #14 (cold-start PIE asset-registry race). Loose-typing the rune cost as FName ItemId (not a soft USpellDefinition ref) reuses the standard inventory consumption path. Any code path that fires Action.Trigger.Combat.Cast with OptionalObject = USpellDefinition* must guarantee USpellRegistry::EnsureBuilt has run on the receiving peer — NetGUID resolution returns null otherwise and the activation no-ops silently (same trap that governs UCradlRecipeRegistry for crafts). The spellbook component's OnRep_Spellbook and ApplyPersistedState cover the existing dispatch surfaces; new entry points need to think about it. Don't sync-load the autocast soft pointer from the swing tick — registry pinning via the same EnsureBuilt boundary keeps AutocastSpell.Get() cheap; bypassing the rep boundary (e.g. constructing a spellbook component without ever firing OnRep) reintroduces the cold-path sync load.

Related: ARCH #6 (PrimaryDataAsset for definitions), ARCH #9 (CommonUI), ARCH #14 (lazy registry pattern), ARCH #18 (UI dispatch via events), Targeting & Engagement Entry.


Animation / FX / Audio

Rule: Per ARCH #16. Each weapon swing uses UAbilityTask_PlayMontageAndWait in parallel with UAbilityTask_WaitDelay-driven gameplay timing — the ability is the source of truth for swing tick; the montage is a parallel visual layer. Per-type hit cues fire from UCradlAttributeSet::PostGameplayEffectExecute — the spec's damage-type SetByCaller key drives the cue lookup, so one generic GE_Combat_Damage covers every damage type instead of a per-type GE catalogue (matches Lyra's ULyraHealthSet pattern: type-conditional cues come from the attribute set, the GE's GameplayCues array is reserved for unconditional cues like a "Burning" duration tick). Stance / in-combat / casting AnimBP states bind via FGameplayTagBlueprintPropertyMap to the ASC tag set.

Why: Duplicates ARCH #16's contract verbatim — combat is the second major consumer of the framework after gathering; the same routing rules apply. Don't drive gameplay timing from montage callbacks; don't replicate cosmetic state outside GameplayCues.

Implementation surface: - Per-swing montage: UAbilityTask_PlayMontageAndWait with PlayRate derived from SwingInterval / MontageDuration (so the montage scales to fit the swing — same EGatherAnimTiming::ScaleToActionDuration pattern as UGatherAbility). - Hit cues: GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic} fired via the shared CradlCombat::FireHitCue helper from two sites — UCradlAttributeSet::PostGameplayEffectExecute for the damage-application path (magnitude > 0) and UAutoAttackAbility::HandleSwingFinished / UCastSpellAbility::PerformCast for the OSRS-parity 0-magnitude path (miss or accuracy-passed-but-rolled-0). Render side: UCradlHitCueNotify_Static is the C++ base; five BP-derived assets (one per damage type) each set GameplayCueTag to their leaf and HitSystem to a per-type Niagara asset — UGameplayCueManager keys CDOs by exact-tag (no parent-match), so per-tag content assets are how fan-out happens. The notify spawns the system via UNiagaraFunctionLibrary::SpawnSystemAttached (attached to the anchor returned by ICueAnchorProvider, falls back to actor root, then world location), sets Magnitude (int) + bMissed (bool) user parameters, and lets the spawned UNiagaraComponent self-destroy on simulation completion (bAutoDestroy=true). No pool, no widget — the cue notify CDO is stateless. Miss-vs-hit-for-0 carries via Combat.Event.Miss in FGameplayCueParameters::AggregatedSourceTags (set by FireHitCue when bWasMiss=true) — the same tag the event bus uses for misses, reused as a cue-param descriptor; hit is implicit by absence. The damage GE's GameplayCues array stays empty. Cue tags live under GameplayCue.* because UGameplayCueManager only indexes that subtree — cues outside it silently no-op at runtime. - Death cue: GameplayCue.Combat.Death (one-shot, fired from UCradlAttributeSet::PostGameplayEffectExecute when Health reaches 0). - AnimBP: bind Action.Combat.AutoAttack, Action.Combat.CastSpell, Status.InCombat via FGameplayTagBlueprintPropertyMap rows in UCradlAnimInstance — drive transition state from boolean properties.

Footguns: - Per ARCH #16: the AnimBP tag map matches by string at runtime — renaming or deleting a BP variable silently no-ops the row. Treat the row name as a contract. - Per ARCH #16: cancellation correctness flows through EndAbility tearing both the montage and the gameplay-timer task down. No per-task cancel callback needed; don't add one. - Per ARCH #16: LocalPredicted is load-bearing for spectator AnimBPs — see Net Execution Policy.

Related: ARCH #16 (animation/FX framework), Net Execution Policy, Engagement Loop.


Death & Cold Storage

Rule: Combat.Event.Death triggers UDeathAbility on the dying owner. Server snapshots inventory, sorts by UStorePricingSubsystem::GetSellPrice (closest semantic match for item value; OSRS uses GE/alch price for the same purpose), top-N items stay (N from UCradlCombatSettings::DeathItemRetention), the remainder transfers to UDeathColdStorageComponent (a UInventoryComponent subclass per ARCH #11). Pawn is teleported to the respawn transform; Status.Dead is cleared. Cold-storage retrieval/expiry is deferred to a downstream design pass.

Death triggers (any one runs the flow): - Health clamps to 0 from a damage application (live death path). - SavePlayer runs while Status.InCombat is active (disconnect-while-in-combat — see In-Combat State "Disconnect rule"). The death flow runs server-side before the profile is written, so the on-disk state already reflects post-death item layout and respawn HP.

Why: Item-retention preserves OSRS risk/reward economy; cold storage instead of dropping-to-the-ground gives us design room for gravestones, recovery quests, decay timers, or PvP looting later — none of which are locked yet. Subclassing UInventoryComponent for cold storage follows ARCH #11's slot-container pattern (same shape as Bank, no reimplementation of slot mechanics).

Implementation surface: - File: Source/CRADL/Inventory/DeathColdStorageComponent.{h,cpp} (new) — UInventoryComponent subclass per ARCH #11, larger default SlotCount. - Owner: ACradlPlayerState (one cold-storage component per profile, persisted via UCradlPlayerProfile). - Settings: UCradlInventorySettings::DeathItemRetention (int, default TBD), UCradlInventorySettings::DefaultRespawnTransform (FTransform). - Ability: UDeathAbility : UCradlGameplayAbility — server-only execution (or LocalPredicted with server-authoritative item movement); triggers from Combat.Event.Death. - Tags: Status.Dead (replicated GE that gates respawn-state UI), Combat.Event.Death.

Footguns: - The Status.Dead GE is the source of truth for "is this player dead right now" — UI/respawn-flow consumers subscribe to the tag, not to the death event (events fire once; the tag persists for the duration). - Item-value sort uses UStorePricingSubsystem::GetSellPrice per-unit; items missing a price sort as 0 and drop first. Quest items / soulbound goods with no sell price would be lost first today — revisit by adding an explicit FItemRow::DeathValue if/when a non-tradeable-but-valuable item lands.

Related: ARCH #11 (slot-container subclassing), Health & Damage Application, Open Questions (retrieval/expiry).


Out-of-Combat Recovery

Rule: Food consumption and (future) rest stations are the out-of-combat HP recovery channels. Food is shipped — see CONSUMABLES.md for the contract (action surface, cooldown, full-HP gate). Rest stations exist as IInteractables already; the recovery shape (ability flavor, regen rate, gating, whether Status.Resting is GE-driven or loose) is deferred to implementation. No passive regen.

Why: Making downtime a routing decision (where to rest, what to eat) instead of a wait-timer keeps positional play meaningful and creates pacing variety. Both channels are explicit player actions — players opt into recovery; the world doesn't time-heal them.

Implementation surface: Food side lives under CONSUMABLES.md "Healing Channel"UConsumeItemAbility writes a negative magnitude to the Damage meta attribute, reusing the same drain/clamp path documented in Health & Damage Application. Rest stations are TBD when the rest-station design lands; reference IInteractable station precedents (bank chest, crafting station). The Status.Resting tag (or whatever recovery-state tag is chosen) belongs in Status.* if GE-driven or State.* if loose-tag bridge — pick per ARCH #13.

Related: ARCH #13 (Status vs State), Health & Damage Application, CONSUMABLES.md.


NPC / AI Compatibility

Rule: NPCs and AI are out of v1 scope. The combat contract is forward-compatible: any pawn class with an ASC and a UCombatTargetingComponent participates as attacker or victim. AI controllers drive the same Action.Trigger.Combat.Engage event path that player input does — no separate AI-only damage code path.

Why: Splitting into "player damage" and "AI damage" code paths is the failure mode that creates "mobs hit you for 0 damage in some edge case" bugs. One channel; AI is just a different Instigator.

Implementation contract: Any future NPC pawn: - Owns or references an UAbilitySystemComponent (on the pawn or a backing controller) - Has a UCombatTargetingComponent (or duck-typed equivalent — TBD per AI doc) - Implements IInteractable if it can be a combat target - Implements IPawnCombatant (typically by hosting a UChaseComponent and forwarding) so the same combat abilities drive its movement; the AI controller implements IMoveIssuer (SimpleMoveToActor via UAIBlueprintHelperLibrary) so chase reuses FCradlChaseState verbatim - Drives engagement by sending the same Action.Trigger.Combat.Engage event payload the player path produces

Detailed AI design (behavior trees, perception, threat) is its own document.

Related: Targeting Component, Targeting & Engagement Entry, PvP.


PvP

Rule: PvP is out of v1 contract. Code paths target APawn* indiscriminately — no PlayerState-vs-PlayerState early-return — but the doc's behavioral guarantees apply only to PvE engagements.

Why: PvP introduces zone-gating, latency mitigation under P2P, griefing prevention, and ruleset variance (multi-combat, item-drop tier) — all of which need design attention but none of which should block v1 combat. Targeting APawn* indiscriminately keeps the door open without locking in a model.

Open questions: Zone model (open vs designated), P2P latency mitigation, griefing/safe-zone rules, multi-combat scope, PvP-specific death drops. See Open Questions.

Related: Multi-Attacker Ceiling (cap may need rethinking under PvP), NPC / AI Compatibility.


Tag Taxonomy

Delta vs current Config/DefaultGameplayTags.ini. New top-level / sub-namespaces this doc claims:

Combat.* — combat-specific facts and verbs not covered by Action / Status / State: - Combat.DamageType.{Stab, Slash, Crush, Ranged, Magic} - Combat.Style.{Aggressive, Defensive, Controlled} - Combat.Spec.* (reserved; not populated until specials land) - Combat.Event.{EnterCombat, ExitCombat, Death, Hit, Miss} (one-shots via FireReplicatedGameplayEvent. Combat.Event.Miss doubles as a cue-param descriptor: CradlCombat::FireHitCue adds it to FGameplayCueParameters::AggregatedSourceTags when bWasMiss=true so UCradlHitCueNotify_Static can branch the hitsplat render on miss-vs-hit-for-0. Same multi-role pattern Combat.DamageType.* already uses, no new namespace.)

GameplayCue.Combat.* — combat FX/SFX cue tags (live under GameplayCue.* so UGameplayCueManager indexes them; cues outside that namespace silently no-op): - GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic} - GameplayCue.Combat.Death

Action.Trigger.Combat.* — ability triggers (dispatched via ASC->HandleGameplayEvent): - Action.Trigger.Combat.Engage — fired by IInteractable::DispatchContextAction, not directly from input - Action.Trigger.Combat.Cast - Action.Trigger.Combat.AutocastSet - Action.Trigger.Combat.Disengage

Action.Combat.*ActivationOwnedTags while combat abilities are active: - Action.Combat.AutoAttack - Action.Combat.CastSpell - Action.Combat.Death

Cooldown.* — per-ability re-activation gates granted by duration GEs (read by UGameplayAbility::CheckCooldown). Purely a naming convention — unlike GameplayCue.* (which UGameplayCueManager indexes by prefix), no engine machinery scans Cooldown.*; the namespace just signals intent and keeps gate tags out of Status.* (where they'd get caught by "is the player impaired" sweeps): - Cooldown.Combat.Attack — the shared attack tick, granted by UGE_Attack_Cooldown. Stamped and gated by every attack: melee/ranged/autocast swings and the manual offensive cast (UCastSpellAbility). Per-attack duration via SetByCaller = SwingInterval / AttackSpeed (swings) or USpellDefinition::CastInterval (casts). Per-activation consumers gate on it via CheckCooldown; the long-running auto-attack loop reads it as a level (GetActiveEffectsTimeRemaining + eps), never WaitGameplayTagRemoved on its own stamp. See Shared Attack Cooldown and Weaving. - Cooldown.Combat.Cast — anti-spam gate for non-attack spell activations only: UPrayerToggleAbility and UCastSelfSpellAbility (SelfEffect / OpenCraftingModal). Granted by UGE_SpellCast_Cooldown, duration = USpellDefinition::CastInterval via SetByCaller. Distinct gate from Cooldown.Combat.Attack — offensive casts and swings do not touch this tag, and prayer/self do not touch the attack tag; collapsing them would make toggling a prayer delay your melee swing (OSRS-wrong). Historically this tag was also shared by UCastSpellAbility (manual) and the autocast branch in UAutoAttackAbility; the attack-cooldown migration moves those two onto Cooldown.Combat.Attack. Future utility-spell channels (alch, telegrab) get their own Cooldown.Combat.* leaves with their own GE classes when authored.

Status.* additions (under existing Status.*): - Status.Dead (new) - Status.Stunned, Status.InCombat already declared in Source/CRADL/CradlGameplayTags.h - Status.Resting (deferred with the rest-station shape decision)

Item.Slot.* addition: Item.Slot.Ammo

Skill.Combat.* — five new under existing Skill.* parent: - Skill.Combat.Melee, Skill.Combat.Ranged, Skill.Combat.Magic, Skill.Combat.Defense, Skill.Combat.Hitpoints

Footgun: Per ARCH #15, RegisterGameplayTagEvent on a parent (e.g. Combat.DamageType) fires with the parent tag in the callback, not the leaf — so a HUD wanting "which damage type is currently in play" must subscribe per-leaf, not on the parent. Tag cancellation matching is parent-OK (matches descendants); event subscription is per-leaf. Same engine-level behavior documented in ARCH #15.


Forward Code References

Names new code surfaces so future PRs land predictably. These are not implementation tasks for the doc itself; they're forward references the doc anchors.

Attribute set additions (Source/CRADL/Abilities/CradlAttributeSet.h): - Health, MaxHealth, Damage (meta), AttackSpeed - Per-attribute OnRep_* and ATTRIBUTE_ACCESSORS - PostGameplayEffectExecute extension for the Damage meta

FItemRow extensions: see Equipment Combat Data. Validator update in Source/CRADLEditor/Validators/ per CLAUDE.md.

UCradlInventorySettings additions: DeathItemRetention (int), DefaultRespawnTransform (FTransform).

New components on ACradlPlayerState: - UCombatTargetingComponent - USpellbookComponent - UDeathColdStorageComponent (subclass of UInventoryComponent per ARCH #11)

New components on ACradlCharacter: - UChaseComponent (Source/CRADL/Combat/ChaseComponent.h) — pawn-side chase driver per Chase & Movement; owns FCradlChaseState and the poll timer. No internal give-up — caller cancels via ability conditions, player input, or AI leash decorator.

New interfaces: - IPawnCombatant (Source/CRADL/Combat/PawnCombatantInterface.h) — pawn-side combat-flavored movement surface (BeginChase, EndChase, IsChaseActive, StopMovement). Implemented by ACradlCharacter; future NPC pawns implement the same interface. - IMoveIssuer (Source/CRADL/Player/MoveIssuerInterface.h) — controller-side primitive (IssueMoveTo, StopMove). Implemented by ACradlPlayerController; AI controllers add their own impl when they land.

New helper struct: - FCradlChaseState (Source/CRADL/Combat/CradlChaseState.h) — controller-agnostic chase state machine. No engine-actor coupling; AI behavior-tree tasks can re-use the struct verbatim.

New abilities under Source/CRADL/Abilities/: all inherit UCradlGameplayAbility. - UAutoAttackAbility - UCastSpellAbility - UDeathAbility

New data assets: - USpellDefinition - USpellbookDefinition - Validators added in lockstep per CLAUDE.md.

New helper: UCradlCombatMath — single canonical home for accuracy/damage formulas. BlueprintFunctionLibrary or static namespace, decided at implementation time.

Combat-engage entry through the existing tag-driven FContextActionActionTag = Action.Trigger.Combat.Engage, SourceActor = hostilePawn. The struct already supports any verb tag-by-tag; no new variant. UQueuedInteractAbility walks the pawn into range and dispatches via the hostile pawn's IInteractable::DispatchContextAction, which fires the trigger event on the player's ASC.

ACradlPlayerController extension: route click-on-hostile-pawn into the existing Action.Trigger.QueuedInteract channel; extend the existing controller-cancel-on-fresh-nav path to also cancel Action.Combat.AutoAttack. Implements IMoveIssuer so the pawn's UChaseComponent can drive it for chase moves; OnNavRequestFinished skips its usual marker clear while a chase is active so the nav-target indicator tracks the moving target across re-issues.


Open Questions

Explicitly deferred so they don't get re-litigated as "missing." Items here are known unknowns — the contract is silent on them by design.

  • PvP scope, zone model, P2P latency mitigation. Whole topic.
  • AI / NPC behavior — aggression range, threat / leashing, perception. Combat contract is forward-compatible; AI itself is its own document.
  • Cold-storage retrieval gameplay — gravestone? recovery quest? expiry timer? PvP looting?
  • Disconnect-while-in-combat resolution shape — the contract is set (In-Combat State "Disconnect rule": disconnect == death), but the where/when is open: does SavePlayer invoke the death pipeline synchronously and write the post-death profile, or does the profile carry a "died-while-disconnected" flag that the next LoadPlayer consumes? Synchronous keeps the on-disk state authoritative and avoids a "what if the player never logs back in" edge; flag-on-load is simpler to wire but leaves a window where the saved profile doesn't reflect the combat outcome. Phase 2 territory.
  • Specials. Combat.Spec.* namespace reserved; spec resource attribute and per-weapon spec authoring deferred.
  • Status effects beyond Status.Stunned (poison, freeze, bind, etc.). All cleanly map to Status.* per ARCH #13; concrete catalog deferred.
  • Ammo recovery semantics. Always-consumed vs partial-recovery vs ground-pickup. Affects projectile actor lifecycle.
  • Loot tables for mob drops. Couples to AI/NPC scope.
  • Combat-level scalar. OSRS computes a single number for matchmaking / aggro gating; whether CRADL wants one TBD.
  • ~~Hitsplat UI / damage feedback widgets.~~ Resolved: UCradlHitCueNotify_Static spawns a one-shot Niagara system per damage type via SpawnSystemAttached with bAutoDestroy=true — system self-cleans on simulation completion, no widget/actor pool. Per-type Niagara assets read Magnitude (int) and bMissed (bool) user params; miss-vs-hit-for-0 carried via Combat.Outcome.Miss on cue params.
  • Multi-target / AoE sizing for splash spells. USpellDefinition::Splash allows it; concrete spells TBD.
  • Equipment swap restrictions during combat. Default = freely allowed (OSRS standard); revisit if exploited.
  • Out-of-combat HP recovery shape — rest-station ability flavor, regen rate, Status.Resting channel (loose vs GE).
  • Healing cue fork. Health & Damage Application flags "negative Damage" vs a separate Healing meta as an implementation-time choice.