Gameplay · Updated May 20, 2026
Damage Pipeline
Damage Pipeline
What happens, in order, from the moment a bullet connects to the moment the HUD updates.
#0. Per-bone damage multipliers (weapon-side, before dispatch)
Before the damage GE is even constructed, the dispatch site computes a per-bone multiplier from the hit result:
const ASLCharacterBase* HitCharacter = Cast<ASLCharacterBase>(Hit.GetActor());
const ESLBoneGroup BoneGroup = HitCharacter ? HitCharacter->GetBoneGroup(Hit.BoneName) : ESLBoneGroup::Body;
const float Multiplier = FireMode.DamageMultipliers.Get(BoneGroup);
const float FinalDamage = FireMode.BaseDamage * Multiplier;
Two pieces of data drive this:
FSLDamageMultipliersonFSLWeaponFireMode— per-weapon, per-group multipliers. Defaults: Body 1.0, Head 2.0, Arms 0.75, Legs 0.75. Override on a weapon's data asset to tune (e.g.DA_SL_Shotgun.PrimaryFireMode.DamageMultipliers.Head = 1.5).
BoneGroupAnchorsonASLCharacterBase—TMap<FName, ESLBoneGroup>of anchor bones.GetBoneGroup(BoneName)walks UP the bone parent chain until it hits an anchor; descendants of an anchor inherit its classification automatically. For the Mannequin, only ~5 entries needed (neck_01→ Head,upperarm_l/r→ Arms,thigh_l/r→ Legs).
The character's Physics Asset bodies block ECC_WeaponTrace (per-bone collision), so Hit.BoneName reliably reflects the body part struck. See Docs/EditorTasks.md → "Hit Detection Refinement" for the editor-side setup, and feedback_animbp_* memories for related work.
Both authoritative damage call sites apply this logic identically — USLWeaponsComponent::Server_ProcessShots (remote client predicted shots) and USLGameplayAbility_Fire::ExecuteFire_ListenServerHost (listen-server host's own shots).
#1. Caller applies GE_SL_Damage
Any damage source (bullet, explosion, melee, test emitter) applies GE_SL_Damage to the target's ASC and sets the magnitude via SetByCaller:
FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(DamageEffectClass, 1.f, Context);
UAbilitySystemBlueprintLibrary::AssignTagSetByCallerMagnitude(Spec, SLTags::Data::Damage, DamageAmount);
ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
The effect context should have AddInstigator(Causer, Causer) set so hit direction can be calculated from the causer's world location.
#2. USLDamageExecution::Execute_Implementation (authority only)
GAS runs this because GE_SL_Damage lists USLDamageExecution as its Execution. It captures live values of CurrentShield and CurrentHealth from the target, then applies shield-first absorption:
shieldDamage = min(incomingDamage, CurrentShield)
healthDamage = incomingDamage - shieldDamage
Outputs two modifiers:
CurrentShield -= shieldDamage(if any)
CurrentHealth -= healthDamage(if any)
The execution only outputs modifiers — no RPC calls, no tag changes, no death logic here.
#3. USLHealthAttributeSet::PostGameplayEffectExecute (authority only)
GAS calls this after applying each modifier. Fires once for CurrentShield and once for CurrentHealth if both were modified.
#CurrentShield branch
Clamping:
- If the
Overshieldtag is active: clampCurrentShieldto[0, MaxShield * 2]
- Otherwise: clamp to
[0, MaxShield]
If shield took damage (Delta < 0):
NotifyHitDirection— calculates an 8-wayESLHitDirectionfrom the causer's world location relative to the target's control rotation, then:- Sets
Character->LastHitDirectionReplicated(replicated, used by simulated proxies) - Calls
Character->Client_OnTookDamage(Direction)RPC → see step 4
- Shield depleted cue — if
CurrentShieldis now 0, executesGameplayCue.Character.ShieldDepletedon the ASC. GAS multicasts this to all clients. ImplementGC_SL_ShieldDepletedBlueprint for the shield break sound and flash.
- Overshield burned off — if the Overshield tag was active and
CurrentShieldis now≤ MaxShield, removes the loose Overshield tag.OnOvershieldChanged(0)fires on the owning client's health widget.
- Shield regen — only if there is no active overshield (or it just burned off): applies
GE_SL_ShieldRegenDelay(resets the delay timer) andGE_SL_ShieldRegen(re-arms the regen tick, inhibited by the delay tag). While overshield is active, regen is skipped entirely — the overshield owns the shield level above MaxShield.
If shield was restored (Delta > 0, e.g. regen ticking):
- If no overshield and
CurrentShield >= MaxShield: removesGE_SL_ShieldRegen(shield is full, stop ticking).
#CurrentHealth branch
Clamping: CurrentHealth clamped to [0, MaxHealth].
Notifies the owning client via Character->Client_OnHealthChanged(HealthPercent) → OnHealthChanged fires on the health widget.
Hit direction — same NotifyHitDirection call as shield branch. Both fire if both attributes were modified.
Death check — if CurrentHealth <= 0 and the Dead tag is not already present:
- Stores
EffectCauserinCharacter->PendingKiller
- Sends
SLTags::Events::Character::Deathgameplay event → activatesGA_Death
- Calls
Character->StartServerDeathTimeout()— server fallback timer for respawn
#4. Character->Client_OnTookDamage(Direction) RPC
Runs on the owning client only. The base class (ASLCharacterBase) sets PendingHitDirection = Direction, which BuildAnimSnapshots reads on the next tick to drive hit-reaction animations.
ASLPlayerCharacter overrides this to also forward the direction to the HUD:
HealthWidget->OnImpact(Direction); // directional damage vignette on shield bar
DamageOverlayWidget->OnImpact(Direction); // full-screen directional hit flash
Both are BlueprintImplementableEvent — implement in WBP_SL_HealthWidget and WBP_SL_DamageOverlay.
#5. Attribute change delegates → HUD widgets
GAS fires GetGameplayAttributeValueChangeDelegate for every attribute that changed. USLHealthWidget and USLDamageOverlayWidget are subscribed and call BroadcastCurrentValues, which fires:
| BP Event | Widget | Value |
|---|---|---|
OnHealthChanged(float Percent) | USLHealthWidget | CurrentHealth / MaxHealth clamped [0, 1] |
OnShieldChanged(float Percent) | USLHealthWidget | CurrentShield / MaxShield clamped [0, 1] |
OnOvershieldChanged(float Percent) | USLHealthWidget | (CurrentShield - MaxShield) / MaxShield clamped [0, 1] |
OnHealthChanged(float Percent) | USLDamageOverlayWidget | Same health percent — drives low-health vignette |
These fire on the owning client via GAS attribute replication. Simulated proxies also receive the replicated attributes but do not have a HUD.
#Summary — what fires on which machine
| Step | Server | Owning Client | Other Clients |
|---|---|---|---|
GE_SL_Damage applied | ✓ | — | — |
USLDamageExecution | ✓ | — | — |
PostGameplayEffectExecute | ✓ | — | — |
GameplayCue.Character.ShieldDepleted | Executes | ✓ (via GAS multicast) | ✓ (via GAS multicast) |
Client_OnTookDamage RPC | — | ✓ | — |
OnImpact(Direction) BP event | — | ✓ | — |
| Attribute delegates → HUD events | — | ✓ (replicated attributes) | — |
LastHitDirectionReplicated | Set | ✓ (replicated) | ✓ (replicated, used by Anim BP) |
#Adding a new damage source
- Get the target's
UAbilitySystemComponent
- Build a
FGameplayEffectContextHandle— callContext.AddInstigator(Causer, Causer)using the world actor that fired (not the controller)
- Make a spec from
GE_SL_Damageand assignSetByCallermagnitude with tagSLTags::Data::Damage
- Call
ASC->ApplyGameplayEffectSpecToSelf(orApplyGameplayEffectSpecToTargetif applying from an external actor)
Everything else — shield absorption, death, hit direction, HUD update — happens automatically.