Gameplay · Updated Jun 26, 2026

Grenade System — Design & Build Plan

Grenade System — Design & Build Plan

Halo-style throwable grenades. Throw on a button press; the grenade arcs, bounces, and detonates on a timed fuse dealing radial damage. No cooking (fuse starts on release — cooking is explicitly out of scope). Built GAS-first, reusing the existing ability / damage / cue / projectile-movement / data-asset patterns. Frag is the reference type (the way the AR was the reference weapon); plasma + type-switching come in Increment 2.

Status: PLAN ONLY (2026-06-16) — nothing implemented yet. This is the locked design + build order.

#Locked decisions (the fastest viable path)

DecisionChoiceWhy it's the fast path
CookingNoneRemoves hold-timing + fuse-already-running + prediction complexity from the ability.
First typeFrag onlyOne reference type; plasma's stick-on-hit deferred to Increment 2.
Throw modelSimple throw, fuse starts on releaseSingle press → spawn projectile with velocity. No charge state.
Count storageReplicated int32 on ASLCharacterBase (+ OnRep → delegate)No new attribute set / decrement GE. Resets naturally on respawn (new pawn), mirrors CarriedWeapons. Counts don't need GAS prediction/GE math.
DamageAuthority sphere sweep + per-target damage GE with distance falloffReuses the existing GE/SetByCaller damage pipeline; no engine ApplyRadialDamage integration work.
ConfigUSLGrenadeDataAsset (mirror USLWeaponDataAsset)Same data-driven pattern; designer-tunable.
Projectile bounceUProjectileMovementComponent reusing the ASLPickupBase bounce setup + the BUG-019 IgnoreActorWhenMoving sibling fixMid-air-freeze trap already solved; don't re-derive it.

#Architecture (GAS-first mapping)

ConcernMechanism
Throw actionGA_ThrowGrenade — GAS ability (Local Predicted), triggered by SLTags.Events.Grenade.Throw
Release timingA Delay(ThrowReleaseTime) in the ability — NOT an anim notify. Anim notifies only fire when the mesh ticks its pose (unreliable on a dedicated server: OnlyTickPoseWhenRendered), and the spawn is authoritative, so a notify could drop the grenade entirely. Delay = server-reliable timer. Same "don't depend on notifies/one-shot on the server" family as BUG-014.
Projectile spawnAuthority — on the Release event, the (Local Predicted) ability calls ASLCharacterBase::ThrowActiveGrenade(), which is authority-guarded (no Server RPC needed — the ability already runs server-side, exactly like Equip_Attach). It spawns ASLGrenadeProjectile, aims it from the control rotation, and decrements the count.
Flight / bounceASLGrenadeProjectile (replicated) + UProjectileMovementComponent
DetonationAuthority fuse timer → Explode(): sphere sweep → per-target damage GE; ExecuteGameplayCue for FX
Explosion FXGameplayCue.Grenade.Frag.ExplosionGC_SL_Grenade_Frag_Explosion (Burst) — VFX, sound, camera shake; multicast by GAS
CountReplicated int32 GrenadeCount on ASLCharacterBase, OnRep_GrenadeCountOnGrenadeCountChanged (HUD binds)
Action lockThrow integrates with the Weapon Action Lock (Docs/WeaponActionLock.md): owns/blocks States.Weapon.Busy so it can't overlap fire/melee/equip; blocked by Dead

#USLGrenadeDataAsset (mirror USLWeaponDataAsset)

FieldPurpose
ProjectileClass (TSubclassOf<ASLGrenadeProjectile>)What to spawn on throw
FuseTime (float, s)Time from spawn to detonation
ThrowForce (float)Initial speed along aim direction
ThrowArcUpBias (float)Added upward velocity for the arc
ThrowReleaseTime (float, s)Delay from throw start to the authoritative spawn (server-reliable; tune to the montage's release frame, keep ≥ ~0.3s)
BaseDamage (float)Damage at the center
DamageInnerRadius / DamageOuterRadius (float)Full damage inside inner; linear falloff to 0 at outer
bDamageSelf (bool)Frags hurt the thrower (Halo-true); tunable
MaxCount / StartingCount (int32)Carry cap + spawn count
DroppedPickupClass (TSubclassOf<ASLGrenadePickup>)Pickup spawned on death carrying the held count; null = drop nothing (mirrors the weapon data field)
BounceSound (USoundBase*)Cosmetic, played locally at each bounce
MaxBounceSounds (int32, default 4)Cap on bounce-sound plays — first N bounces play, settling micro-bounces stay silent; 0 = unlimited
MaxBounces (int32, default 0)Hard cap on bounces — once hit the grenade stops bouncing and settles; 0 = unlimited
ThrowMontageFP / ThrowMontageTP (UAnimMontage*)Throw anims
ExplosionCueTag (FGameplayTag, meta=(Categories="GameplayCues"))Detonation cue
ExplosionCameraShake (TSubclassOf<UCameraShakeBase>)Optional, if not driven by the cue
GrenadeIcon (UTexture2D*)HUD icon

#ASLGrenadeProjectile (new actor)

  • bReplicates = true. Static mesh + UProjectileMovementComponent (bShouldBounce, low MaxBounces,
  • bounce off WorldStatic + WorldDynamic).

  • Reuse the BUG-019 fix: on spawn, IgnoreActorWhenMoving the thrower (and any sibling grenades) so it
  • doesn't instantly bounce off the owner/other grenades and freeze. (Crib the bounce-collision config from ASLPickupBase.)

  • Authority sets a FuseTime timer → Explode():
    • Sphere overlap at the grenade location (radius = DamageOuterRadius, ECC_WeaponTrace or pawn query).
    • For each hit pawn: Falloff = 1 inside InnerRadius, linear to 0 at OuterRadius; apply the damage
    • GE with SetByCaller magnitude = BaseDamage * Falloff. Skip the thrower unless bDamageSelf.

    • ASC->ExecuteGameplayCue(ExplosionCueTag, Params) (location/normal) — authority only, GAS multicasts.
    • Destroy().
  • Cosmetic: clients see it fly/bounce via replicated movement; the explosion reaches everyone via the cue.

#Count model

  • UPROPERTY(ReplicatedUsing=OnRep_GrenadeCount, BlueprintReadOnly) int32 GrenadeCount; on ASLCharacterBase.
  • OnGrenadeCountChanged (BlueprintAssignable) broadcast from OnRep_GrenadeCount and from the
  • authority decrement (so the host updates too — see BUG-020: authority has no OnRep, so broadcast on the authoritative change as well, or use a setter that broadcasts).

  • LoadDefaultLoadout initializes GrenadeCount = DefaultGrenade->StartingCount and sets
  • CurrentGrenadeData (DefaultGrenadeClass on the loadout asset).

  • ThrowActiveGrenade() (authority) gates on GrenadeCount > 0, spawns, then SetGrenadeCount(GrenadeCount - 1).
Increment 2 generalizes GrenadeCount to per-type (frag/plasma) + a CurrentGrenadeType and a switch input.

#Pickup + death-drop (DONE — C++, 2026-06-23)

Mirrors the weapon pickup + drop-all-on-death system. Both pieces are authority-only and data-driven off USLGrenadeDataAsset.

ASLGrenadePickup (World/SLGrenadePickup.h/.cpp, subclass of ASLPickupBase) — a world pickup that tops up the collector's grenade count. Modeled on ASLAmmoPickup (auto-collect on overlap, no prompt):

FieldPurpose
GrenadeData (USLGrenadeDataAsset*)The type this pickup grants
GrenadeAmount (int32)How many to grant (clamped to the type's MaxCount)

OnCollected(Character) (authority): grants only if Character->GetCurrentGrenadeData() == GrenadeData (same-type top-up only in Increment 1 — a mismatched-type pickup is ignored, left in the world), and refuses (also left in world) when the character is already at MaxCount. Otherwise SetGrenadeCount(GetGrenadeCount() + GrenadeAmount) (which clamps) → Super::OnCollected (FX multicast + destroy). No new character API — uses the existing public GetGrenadeCount / GetCurrentGrenadeData / SetGrenadeCount.

Increment 2: when grenade types become switchable, let a pickup of a different type change the active
type instead of being ignored.

ASLCharacterBase::DropGrenadesOnDeath() (authority) — spawns one DroppedPickupClass pickup carrying the full current count (GrenadeAmount = GrenadeCount), launches it with the same DropSpawnOffset / DropLaunchPitch as weapon drops, then SetGrenadeCount(0). No-op when the count is 0 or the active type has no DroppedPickupClass. Called from USLGameplayAbility_Death::ApplyDeathEffects right after DropAllWeaponsOnDeath() (deterministic, before the BP OnDeathStarted hook), so the full loadout — carried weapons + sidearm + grenades — drops regardless of any BP death override.


#GA_ThrowGrenade

  • Parent: USLGameplayAbility (pure BP is fine — no heavy server math; the authority spawn lives in
  • ThrowActiveGrenade() on the character — authority-guarded, invoked from the ability's server instance).

  • Net Execution: Local Predicted (predict the throw anim locally; projectile spawns on authority).
  • CDO: AbilityTags = Abilities.ThrowGrenade; ActivationOwnedTags = Busy; `ActivationBlockedTags =
  • Dead, Busy, RefireLock, SidearmActive (RefireLock blocks throwing mid-burst — the exclusive-action lock pattern shared with the sidearm draw + equips; Busy already covers melee since melee owns Busy). SidearmActive is required, not optional — see Left-hand mutual exclusion below. Triggers[0] = Events.Grenade.Throw` (GameplayEvent).

  • Graph (Delay-based release — server-reliable):
    1. CommitAbility → Branch.
    1. Cast avatar → SLCharacterBase (Char); gate Char->GetGrenadeCount() > 0 (False → EndAbility).
    1. Cosmetic montages: PlayMontageAndWait(ThrowMontageTP) for replication + Play Montage
    2. (ThrowMontageFP, FP/local). OnCompleted/BlendOut/InterruptedEndAbility.

    1. Authoritative spawn (parallel): Delay(GetCurrentGrenadeData->ThrowReleaseTime)
    2. Char->ThrowActiveGrenade(). Runs on the server instance (Local Predicted), so the spawn is reliable regardless of mesh-tick settings; the client copy of ThrowActiveGrenade no-ops (authority-guarded).

    • Block re-throw via Busy/RefireLock + the count gate. The GrenadeRelease anim notify is not
    • used for the spawn (optional, for a local hand cosmetic only).


#Left-hand mutual exclusion (sidearm ↔ grenade)

The sidearm and the grenade throw both animate the left hand, so they must never be active at once. This is enforced purely with the existing GAS tags — no new tag needed — exploiting their different lifetimes:

DirectionMechanism
Throw blocked while sidearm outSidearm mode owns States.Weapon.SidearmActive for its entire duration (persistent mode tag). Grenade throw lists SidearmActive in ActivationBlockedTags → can't throw while the pistol is drawn.
Sidearm draw blocked mid-throwGrenade throw owns States.Weapon.Busy only during the throw window (transient). The sidearm mode ability must list States.Weapon.Busy in its ActivationBlockedTags → can't draw the pistol while a grenade is being thrown.

Net effect: holding the sidearm forbids throwing for as long as it's out; throwing forbids drawing only for the brief throw. Both are activation blocks (footgun: ActivationBlockedTags blocks activation, never cancels a running ability), which is exactly right here since neither can have started while the other was blocking.

Editor action items:
- Grenade BP_GA_SL_Grenade_Throw → add States.Weapon.SidearmActive to ActivationBlockedTags (above).
- Sidearm BP_GA_SL_SidearmModealready covered: its ActivationBlockedTags already includes
States.Weapon.Busy (SidearmMode.md §2), so the grenade's Busy ownership blocks the draw mid-throw with
no further change. The throw side is the only edit needed.

#Native tags (SLTags.h/.cpp)

  • Events::Grenade::Throw (SLTags.Events.Grenade.Throw), Events::Grenade::Release (SLTags.Events.Grenade.Release)
  • Abilities::ThrowGrenade (SLTags.Abilities.ThrowGrenade) — flat, matching the existing Abilities namespace convention
  • (Reuse States::Weapon::Busy for the action lock; add a throwing anim flag/state only if the ABP needs it.)

#Input

  • IA_SL_GrenadeGrenadeAction, bound in BP_SL_PlayerController SetupInputComponent (C++):
  • StartedHandleGameplayEvent(ASC, Events.Grenade.Throw). One press = one throw.


#Animation

  • MC_FP_Grenade_Throw / MC_TP_Grenade_Throw montages (purely cosmetic). The spawn is driven by the
  • ability's Delay(ThrowReleaseTime), NOT a notify — so tune ThrowReleaseTime to the clip's release frame.

  • TP montage plays via PlayMontageAndWait so observers see the throw (GAS montage replication).
  • A GrenadeRelease AnimNotify is optional — only if you want a local hand-release cosmetic. It must
  • NOT be the spawn trigger (see the Release-timing note — notifies are unreliable on a dedicated server).


#HUD

  • Grenade count indicator (icon + count), bound to OnGrenadeCountChanged from
  • ASLPlayerCharacter::InitializeLocalPlayerHUD (same C++-binding pattern as the ammo strip / sidearm indicator). Persistent; greys out / shows 0 when empty.


#Build order

#Increment 1 — Frag, end-to-end playable (the fast path)

  • C++ — DONE ✅: tags (Events.Grenade.Throw/Release, Abilities.ThrowGrenade), USLGrenadeDataAsset,
  • ASLGrenadeProjectile (bounce + BUG-019 ignore + fuse + Explode() = sphere-sweep GE falloff + line-of-sight

    • cue), ASLCharacterBase count + OnGrenadeCountChanged (BUG-020) + loadout + ThrowActiveGrenade()
    • (authority-guarded), controller GrenadeAction. Pickup + death-drop (ASLGrenadePickup, DroppedPickupClass, DropGrenadesOnDeath() wired into the death ability) — 2026-06-23.

  • Editor — in progress: BP_GA_SL_Grenade_Throw (Delay-based release, no notify), grant, throw montages
    • ThrowReleaseTime, GC_SL_Grenade_Frag_Explosion cue, HUD count, IMC key, projectile mesh. → **See
    • "Increment 1 — Editor checklist" below for the step-by-step.**

  • Test (checklist further down).

#Increment 2 — Plasma + polish (later)

  • Plasma grenade: stick-on-hit (stop movement + attach on overlap with a pawn/surface), separate data asset.
  • Per-type counts + CurrentGrenadeType + switch input + HUD shows both.
  • Trajectory arc preview, throw SFX/voice, bounce SFX, scorch decal.

#Increment 1 — Editor checklist

#Already done (C++ + MCP bridge) ✅

AssetState
DA_SL_Grenade_FragScalars set (fuse 2.5, force 1800, arc 300, dmg 120, radii 150/500, self-dmg on, max 4, start 2); DamageEffect=GE_Damage; ProjectileClass=BP_SL_GrenadeProjectile_Frag
BP_SL_GrenadeProjectile_FragCreated + wired (placeholder Sphere mesh)
IA_SL_GrenadeCreated (Boolean)
DA_DefaultLoadout.DefaultGrenadeDA_SL_Grenade_Frag
BP_SL_PlayerController.GrenadeActionIA_SL_Grenade

Still unset on the data asset (need the assets first): ThrowMontageFP/TP, ExplosionCueTag, GrenadeIcon, ThrowReleaseTime (defaults 0.4).

#To do — in the editor

1. BP_GA_SL_Grenade_Throw — the throw ability (biggest piece; easiest to duplicate BP_GA_SL_SidearmMode and adapt).

  • Class settings: parent USLGameplayAbility · Net Execution = Local Predicted · Instancing = Instanced Per Actor.
  • CDO → Tags:
FieldValue
AbilityTagsAbilities.ThrowGrenade
Activation Owned TagsStates.Weapon.Busy
Activation Blocked TagsStates.Character.Dead, States.Weapon.Busy, States.Weapon.RefireLock, States.Weapon.SidearmActive
Triggers[0]Tag Events.Grenade.Throw, Source = Gameplay Event

(RefireLock blocks throwing mid-burst; Busy already covers melee since melee owns Busy; SidearmActive enforces left-hand mutual exclusion — see below.)

  • CDO → Triggers: one entry — Events.Grenade.Throw, On Gameplay Event.
  • Graph (Delay-based release — NOT an anim notify):
    1. CommitAbility → Branch → (False) EndAbility.
    1. Cast avatar → SLCharacterBase (var Char) → Char.GetGrenadeCount() > 0 → Branch → (False) EndAbility.
    1. Cosmetic montages: Play Montage and Wait(ThrowMontageTP) + Play Montage(ThrowMontageFP, FP/local). On its Completed/BlendOut/InterruptedEndAbility.
    1. Spawn (parallel branch): Delay(Char.GetCurrentGrenadeData().ThrowReleaseTime) → Char.ThrowActiveGrenade().
⚠ Drive the spawn with the Delay, never a GrenadeRelease notify — notifies don't reliably fire on a dedicated server and the spawn is authoritative.

2. Grant the ability — add BP_GA_SL_Grenade_Throw to AS_AbilitySet_Default.

3. Throw montagesMC_FP_Grenade_Throw / MC_TP_Grenade_Throw (cosmetic). Assign to DA_SL_Grenade_Frag (ThrowMontageFP/TP), then tune ThrowReleaseTime to the clip's release frame. (A GrenadeRelease notify is optional — local hand cosmetic only; never the spawn trigger.)

4. Explosion cue — register tag GameplayCue.Grenade.Frag.Explosion → create GC_SL_Grenade_Frag_Explosion (GameplayCueNotify_Burst: VFX/sound/shake) → set DA_SL_Grenade_Frag.ExplosionCueTag.

5. HUD count (C++ widget base DONE 2026-06-26 — mirrors the ammo/sidearm pattern; needs rebuild):

  • C++: USLGrenadeIndicator (UI/SLGrenadeIndicator.h/.cpp) — Initialize(Character) binds OnGrenadeCountChanged
    • pushes current count/type; BIEs OnGrenadeCountChanged(Count, MaxCount) + OnGrenadeTypeSet(GrenadeData)
    • for the BP visuals. USLHUDWidget gains a GrenadeIndicator (BindWidgetOptional) + InitializeGrenadeIndicator, called from ASLPlayerCharacter::InitializeLocalPlayerHUD (first spawn + respawn). Persistent.

  • BP (editor): create WBP_SL_GrenadeIndicator (subclass USLGrenadeIndicator: Image + count Text) — implement
  • OnGrenadeCountChanged (set count text, grey-out/hide at 0) + OnGrenadeTypeSet (set Image from GrenadeData->GrenadeIcon, hide if null). Place it in WBP_SL_HUDWidget named GrenadeIndicator (case-sensitive BindWidget). Set DA_SL_Grenade_Frag.GrenadeIcon.

6. IMC — in IMC_Default, map IA_SL_Grenade to a key (e.g. G + a gamepad bumper). (By hand — the bridge couldn't build an FKey in 5.7.)

7. Projectile mesh — in BP_SL_GrenadeProjectile_Frag, swap the placeholder Sphere on MeshComp for the real frag mesh; optionally tune CollisionComp radius and ProjectileMovement Bounciness/Friction.

8. Grenade icon — set DA_SL_Grenade_Frag.GrenadeIcon.

9. Grenade pickup (C++ done — ASLGrenadePickup + DropGrenadesOnDeath; needs a rebuild to surface the new class + DroppedPickupClass field):

  • Create BP_SL_GrenadePickup_Frag (subclass ASLGrenadePickup) under /Game/SystemLink/Grenades/Frag/.
  • ASLPickupBase provides only the BounceCollision (root) / PickupTrigger spheres + ProjectileMovementno mesh, so add a Static Mesh Component parented under BounceCollision, set it to the frag mesh, and set its collision to NoCollision (cosmetic; the BounceCollision sphere owns the physics). Size the BounceCollision radius to fit the mesh. Then set GrenadeData = DA_SL_Grenade_Frag, GrenadeAmount (placed default, e.g. 2).

  • Set DA_SL_Grenade_Frag.DroppedPickupClass = BP_SL_GrenadePickup_Frag.
  • Place one in TestMap to test world pickup; test the death-drop separately.
Bridge can do (once the assets exist): set ProjectileClass ✅ done, ThrowMontageFP/TP, ExplosionCueTag, GrenadeIcon, ThrowReleaseTime on the data asset, and grant the ability in AS_AbilitySet_Default. The BP graph, cue notify, montage notifies, and IMC key are manual.

#Test checklist (Increment 1)

  • [ ] Press grenade input → throw montage plays (FP local, TP on observers).
  • [ ] Projectile arcs, bounces off floor/walls, does NOT freeze mid-air (BUG-019 ignore working).
  • [ ] Detonates after FuseTime; explosion cue (FX/sound/shake) plays on all clients.
  • [ ] Radial damage: full inside InnerRadius, falls off to 0 at OuterRadius.
  • [ ] Count decrements on throw; cannot throw at 0; HUD count updates live.
  • [ ] Listen-server host AND client both show the correct starting count from spawn (BUG-020).
  • [ ] Multiplayer: damage applied authority-side; observers see the projectile + explosion.
  • [ ] bDamageSelf behaves as set (thrower takes/ignores splash).
  • [ ] Throw blocked while Dead; throw + fire/melee/equip mutually exclusive via Busy.
  • [ ] World grenade pickup: walk over → count rises (capped at MaxCount); ignored/left in world when full.
  • [ ] Death-drop: die holding grenades → one pickup drops carrying the held count → collect it back.

#Reuse references

  • Damage: Docs/DamagePipeline.md (GE + SetByCaller). Radial is a new application of it.
  • Cues: weapon cue conventions in memory (GameplayCues.* tag, GameplayCueNotify_Burst, cue scan path).
  • Projectile bounce: BUG-019 (Docs/BugTracker.md) + ASLPickupBase's ProjectileMovement setup.
  • Count broadcast on host: BUG-020 — broadcast on the authoritative change, not just OnRep.
  • Montage replication: GAS montage memory (PlayMontageAndWait for TP, Play Montage for FP).
  • HUD binding: ammo strip / sidearm indicator pattern (InitializeLocalPlayerHUD, C++ delegate bind).
  • Action lock: Docs/WeaponActionLock.md (Busy).