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:

  • FSLDamageMultipliers on FSLWeaponFireMode — 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).
  • BoneGroupAnchors on ASLCharacterBaseTMap<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 Overshield tag is active: clamp CurrentShield to [0, MaxShield * 2]
  • Otherwise: clamp to [0, MaxShield]

If shield took damage (Delta < 0):

  1. NotifyHitDirection — calculates an 8-way ESLHitDirection from 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
  1. Shield depleted cue — if CurrentShield is now 0, executes GameplayCue.Character.ShieldDepleted on the ASC. GAS multicasts this to all clients. Implement GC_SL_ShieldDepleted Blueprint for the shield break sound and flash.
  1. Overshield burned off — if the Overshield tag was active and CurrentShield is now ≤ MaxShield, removes the loose Overshield tag. OnOvershieldChanged(0) fires on the owning client's health widget.
  1. Shield regen — only if there is no active overshield (or it just burned off): applies GE_SL_ShieldRegenDelay (resets the delay timer) and GE_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: removes GE_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 EffectCauser in Character->PendingKiller
  • Sends SLTags::Events::Character::Death gameplay event → activates GA_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 EventWidgetValue
OnHealthChanged(float Percent)USLHealthWidgetCurrentHealth / MaxHealth clamped [0, 1]
OnShieldChanged(float Percent)USLHealthWidgetCurrentShield / MaxShield clamped [0, 1]
OnOvershieldChanged(float Percent)USLHealthWidget(CurrentShield - MaxShield) / MaxShield clamped [0, 1]
OnHealthChanged(float Percent)USLDamageOverlayWidgetSame 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

StepServerOwning ClientOther Clients
GE_SL_Damage applied
USLDamageExecution
PostGameplayEffectExecute
GameplayCue.Character.ShieldDepletedExecutes✓ (via GAS multicast)✓ (via GAS multicast)
Client_OnTookDamage RPC
OnImpact(Direction) BP event
Attribute delegates → HUD events✓ (replicated attributes)
LastHitDirectionReplicatedSet✓ (replicated)✓ (replicated, used by Anim BP)

#Adding a new damage source

  1. Get the target's UAbilitySystemComponent
  1. Build a FGameplayEffectContextHandle — call Context.AddInstigator(Causer, Causer) using the world actor that fired (not the controller)
  1. Make a spec from GE_SL_Damage and assign SetByCaller magnitude with tag SLTags::Data::Damage
  1. Call ASC->ApplyGameplayEffectSpecToSelf (or ApplyGameplayEffectSpecToTarget if applying from an external actor)

Everything else — shield absorption, death, hit direction, HUD update — happens automatically.