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)
| Decision | Choice | Why it's the fast path |
|---|---|---|
| Cooking | None | Removes hold-timing + fuse-already-running + prediction complexity from the ability. |
| First type | Frag only | One reference type; plasma's stick-on-hit deferred to Increment 2. |
| Throw model | Simple throw, fuse starts on release | Single press → spawn projectile with velocity. No charge state. |
| Count storage | Replicated 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. |
| Damage | Authority sphere sweep + per-target damage GE with distance falloff | Reuses the existing GE/SetByCaller damage pipeline; no engine ApplyRadialDamage integration work. |
| Config | USLGrenadeDataAsset (mirror USLWeaponDataAsset) | Same data-driven pattern; designer-tunable. |
| Projectile bounce | UProjectileMovementComponent reusing the ASLPickupBase bounce setup + the BUG-019 IgnoreActorWhenMoving sibling fix | Mid-air-freeze trap already solved; don't re-derive it. |
#Architecture (GAS-first mapping)
| Concern | Mechanism |
|---|---|
| Throw action | GA_ThrowGrenade — GAS ability (Local Predicted), triggered by SLTags.Events.Grenade.Throw |
| Release timing | A 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 spawn | Authority — 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 / bounce | ASLGrenadeProjectile (replicated) + UProjectileMovementComponent |
| Detonation | Authority fuse timer → Explode(): sphere sweep → per-target damage GE; ExecuteGameplayCue for FX |
| Explosion FX | GameplayCue.Grenade.Frag.Explosion → GC_SL_Grenade_Frag_Explosion (Burst) — VFX, sound, camera shake; multicast by GAS |
| Count | Replicated int32 GrenadeCount on ASLCharacterBase, OnRep_GrenadeCount → OnGrenadeCountChanged (HUD binds) |
| Action lock | Throw 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)
| Field | Purpose |
|---|---|
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, lowMaxBounces,
bounce off WorldStatic + WorldDynamic).
- Reuse the BUG-019 fix: on spawn,
IgnoreActorWhenMovingthe 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
FuseTimetimer →Explode(): - Sphere overlap at the grenade location (radius =
DamageOuterRadius,ECC_WeaponTraceor pawn query). - For each hit pawn:
Falloff = 1insideInnerRadius, linear to0atOuterRadius; apply the damage ASC->ExecuteGameplayCue(ExplosionCueTag, Params)(location/normal) — authority only, GAS multicasts.Destroy().
GE with SetByCaller magnitude = BaseDamage * Falloff. Skip the thrower unless bDamageSelf.
- 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;onASLCharacterBase.
OnGrenadeCountChanged(BlueprintAssignable) broadcast fromOnRep_GrenadeCountand 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).
LoadDefaultLoadoutinitializesGrenadeCount = DefaultGrenade->StartingCountand sets
CurrentGrenadeData (DefaultGrenadeClass on the loadout asset).
ThrowActiveGrenade()(authority) gates onGrenadeCount > 0, spawns, thenSetGrenadeCount(GrenadeCount - 1).
Increment 2 generalizesGrenadeCountto per-type (frag/plasma) + aCurrentGrenadeTypeand 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):
| Field | Purpose |
|---|---|
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):
CommitAbility→ Branch.Cast avatar → SLCharacterBase(Char); gateChar->GetGrenadeCount() > 0(False →EndAbility).- Cosmetic montages:
PlayMontageAndWait(ThrowMontageTP) for replication +Play Montage - Authoritative spawn (parallel):
Delay(GetCurrentGrenadeData->ThrowReleaseTime)→ - Block re-throw via
Busy/RefireLock+ the count gate. TheGrenadeReleaseanim notify is not
(ThrowMontageFP, FP/local). OnCompleted/BlendOut/Interrupted → EndAbility.
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).
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:
| Direction | Mechanism |
|---|---|
| Throw blocked while sidearm out | Sidearm 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-throw | Grenade 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:
- GrenadeBP_GA_SL_Grenade_Throw→ addStates.Weapon.SidearmActivetoActivationBlockedTags(above).
- SidearmBP_GA_SL_SidearmMode→ already covered: itsActivationBlockedTagsalready includes
States.Weapon.Busy(SidearmMode.md §2), so the grenade'sBusyownership 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 existingAbilitiesnamespace convention
- (Reuse
States::Weapon::Busyfor the action lock; add a throwing anim flag/state only if the ABP needs it.)
#Input
IA_SL_Grenade→GrenadeAction, bound inBP_SL_PlayerControllerSetupInputComponent(C++):
Started → HandleGameplayEvent(ASC, Events.Grenade.Throw). One press = one throw.
#Animation
MC_FP_Grenade_Throw/MC_TP_Grenade_Throwmontages (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
PlayMontageAndWaitso observers see the throw (GAS montage replication).
- A
GrenadeReleaseAnimNotify 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
OnGrenadeCountChangedfrom
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, - cue),
ASLCharacterBasecount +OnGrenadeCountChanged(BUG-020) + loadout +ThrowActiveGrenade()
ASLGrenadeProjectile (bounce + BUG-019 ignore + fuse + Explode() = sphere-sweep GE falloff + line-of-sight
(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_Explosioncue, 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) ✅
| Asset | State |
|---|---|
DA_SL_Grenade_Frag | Scalars 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_Frag | Created + wired (placeholder Sphere mesh) |
IA_SL_Grenade | Created (Boolean) |
DA_DefaultLoadout.DefaultGrenade | → DA_SL_Grenade_Frag |
BP_SL_PlayerController.GrenadeAction | → IA_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:
| Field | Value |
|---|---|
| AbilityTags | Abilities.ThrowGrenade |
| Activation Owned Tags | States.Weapon.Busy |
| Activation Blocked Tags | States.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):
CommitAbility→ Branch → (False)EndAbility.Cast avatar → SLCharacterBase(varChar) →Char.GetGrenadeCount() > 0→ Branch → (False)EndAbility.- Cosmetic montages:
Play Montage and Wait(ThrowMontageTP) +Play Montage(ThrowMontageFP, FP/local). On itsCompleted/BlendOut/Interrupted→EndAbility. - Spawn (parallel branch):
Delay(Char.GetCurrentGrenadeData().ThrowReleaseTime) →Char.ThrowActiveGrenade().
⚠ Drive the spawn with theDelay, never aGrenadeReleasenotify — 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 montages — MC_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)bindsOnGrenadeCountChanged - 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(subclassUSLGrenadeIndicator: 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(subclassASLGrenadePickup) under/Game/SystemLink/Grenades/Frag/.
ASLPickupBase provides only the BounceCollision (root) / PickupTrigger spheres + ProjectileMovement — no 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
TestMapto test world pickup; test the death-drop separately.
Bridge can do (once the assets exist): setProjectileClass✅ done,ThrowMontageFP/TP,ExplosionCueTag,GrenadeIcon,ThrowReleaseTimeon the data asset, and grant the ability inAS_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 atOuterRadius.
- [ ] 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.
- [ ]
bDamageSelfbehaves as set (thrower takes/ignores splash).
- [ ] Throw blocked while
Dead; throw + fire/melee/equip mutually exclusive viaBusy.
- [ ] 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'sProjectileMovementsetup.
- Count broadcast on host: BUG-020 — broadcast on the authoritative change, not just
OnRep.
- Montage replication: GAS montage memory (
PlayMontageAndWaitfor TP,Play Montagefor FP).
- HUD binding: ammo strip / sidearm indicator pattern (
InitializeLocalPlayerHUD, C++ delegate bind).
- Action lock:
Docs/WeaponActionLock.md(Busy).