Example: Ranged Attack¶
Intermediate
Overview¶
A ranged projectile ability that demonstrates spawning actors from within an ability, passing a pre-built damage spec to the projectile, and having the projectile -- not the ability -- apply the damage on hit. This is the pattern for fireballs, arrows, energy blasts, and anything else that travels through space before dealing damage. The critical lesson here is how FGameplayEffectSpecHandle carries the caster's ASC context to the point of impact, ensuring correct damage attribution even though the projectile (not the caster) triggers the application.
What We're Building¶
- Mana cost of 20 per shot
- Cooldown of 1.5 seconds
- Projectile spawning aimed from the camera, not the character facing direction
- GE spec passing -- the ability creates the damage spec, the projectile applies it on hit
- SetByCaller damage of 30, data-driven via the spec
- Silence blocking -- can't cast while silenced (
CrowdControl.Soft.Silence), distinct from melee which only blocks on hard CC - PendingDamage meta attribute -- same damage pipeline as melee
Prerequisites¶
What you need before starting
This example assumes you have completed Project Setup and have:
- A character with an Ability System Component
- An AttributeSet with Health, Mana (or Magic), and PendingDamage attributes
- A base ability class (
YourProjectGameplayAbility) with anInputTagproperty - An input binding system that routes Enhanced Input actions to abilities by tag
- A static mesh or Niagara system for the projectile visual (a sphere will do for prototyping)
If any of that is missing, start with Project Setup.
Step 1: Create the Effects¶
GE_Cost_RangedAttack¶
| Setting | Value |
|---|---|
| Duration Policy | Instant |
| Modifiers[0] -- Attribute | YourProjectAttributeSet.Mana |
| Modifiers[0] -- Modifier Op | Add |
| Modifiers[0] -- Magnitude | Scalable Float: -20.0 |
GE_Cooldown_RangedAttack¶
| Setting | Value |
|---|---|
| Duration Policy | Has Duration |
| Duration Magnitude | Scalable Float: 1.5 (1.5 seconds) |
| GrantedTags | Cooldown.Ability.RangedAttack |
GE_Damage_Ranged¶
Same pattern as the melee damage effect -- SetByCaller magnitude targeting PendingDamage. You can reuse a single generic GE_Damage effect for both melee and ranged if you prefer.
| Setting | Value |
|---|---|
| Duration Policy | Instant |
| Modifiers[0] -- Attribute | YourProjectAttributeSet.PendingDamage |
| Modifiers[0] -- Modifier Op | Add |
| Modifiers[0] -- Magnitude Type | Set By Caller |
| Modifiers[0] -- Set By Caller Tag | SetByCaller.Damage |
Why not reuse GE_Damage_Melee?
You absolutely can -- and in many projects, you should. A single GE_Damage with SetByCaller works for any damage source. The ability sets the magnitude, the effect applies it. We use separate names here for clarity, but sharing a damage effect across abilities is a common and recommended pattern. See SetByCaller for more on this.
Step 2: Create the Ability¶
Create GA_RangedAttack with YourProjectGameplayAbility as the parent.
Class Defaults¶
| Property | Value | Why |
|---|---|---|
| Input Tag | InputTag.Combat.Secondary |
Maps to your ranged attack input |
| Ability Tags | Ability.Combat.RangedAttack |
Identifies this ability for queries and blocking |
| Activation Blocked Tags | State.Dead, CrowdControl.Hard, CrowdControl.Soft.Silence |
Can't cast while dead, stunned, or silenced |
| Instancing Policy | InstancedPerActor |
Required when using Ability Tasks |
| Net Execution Policy | LocalPredicted |
Feels responsive on the client |
| Cost Gameplay Effect Class | GE_Cost_RangedAttack |
Mana check and deduction |
| Cooldown Gameplay Effect Class | GE_Cooldown_RangedAttack |
1.5s re-activation delay |
Silence vs Root vs Stun
Notice we block on CrowdControl.Soft.Silence but not CrowdControl.Soft.Root. A rooted character can't move but can still cast ranged abilities -- that's the design distinction between root and silence. Compare with Melee Attack which blocks only on CrowdControl.Hard (stun) -- melee doesn't require "casting" so silence doesn't affect it. This is a deliberate design choice: silence shuts down casters, stun shuts down everyone.
Event Graph¶
Event Graph
- ActivateAbility fires
- CommitAbility checks mana cost and cooldown, applies both if successful. If it fails, EndAbility immediately
- MakeOutgoingGESpec creates the damage spec from
GE_Damage_Rangedwith the caster's ASC context baked in -- the spec remembers who created it - AssignSetByCallerMagnitude sets
SetByCaller.Damageto30.0on the spec - Get Aim Direction from the camera's forward vector (not the character's facing)
- SpawnActor creates
BP_Projectileat the character's position, aimed in the fire direction - Set DamageEffectSpecHandle on the spawned projectile -- passes the pre-built spec carrying the caster's context
- EndAbility -- the ability's job is done. The projectile handles damage on its own schedule
flowchart LR
A["ActivateAbility"]:::event --> B["CommitAbility"]:::func
B -->|Failed| C["EndAbility\n(cancelled)"]:::endpoint
B -->|Success| D["MakeOutgoingGESpec\nGE_Damage_Ranged"]:::func
D --> E["SetByCaller\nDamage = 30"]:::func
E --> F["Get Aim\nDirection"]:::func
F --> G["SpawnActor\nBP_Projectile"]:::func
G --> H["Set DamageSpec\non Projectile"]:::func
H --> I["EndAbility"]:::endpoint
classDef event fill:#5c1a1a,stroke:#ff6666,color:#fff
classDef func fill:#2a2a4a,stroke:#9b89f5,color:#fff
classDef task fill:#1a3a5c,stroke:#4a9eff,color:#fff
classDef endpoint fill:#1a4a2d,stroke:#6bcb3a,color:#fff
void UGA_RangedAttack::ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
AActor* AvatarActor = GetAvatarActorFromActorInfo();
if (!AvatarActor)
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
// --- Create the damage spec with caster context ---
FGameplayEffectSpecHandle DamageSpec =
MakeOutgoingGameplayEffectSpec(
DamageEffectClass, // UPROPERTY: TSubclassOf<UGameplayEffect>
GetAbilityLevel());
DamageSpec.Data->SetSetByCallerMagnitude(
FGameplayTag::RequestGameplayTag(
FName("SetByCaller.Damage")),
DamageAmount); // UPROPERTY: float, default 30.0
// --- Get aim direction from the player's camera ---
FVector SpawnLocation;
FRotator SpawnRotation;
GetAimDirectionAndLocation(SpawnLocation, SpawnRotation);
// --- Spawn the projectile ---
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = AvatarActor;
SpawnParams.Instigator = Cast<APawn>(AvatarActor);
SpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AProjectileBase* Projectile =
GetWorld()->SpawnActor<AProjectileBase>(
ProjectileClass, // UPROPERTY: TSubclassOf<AProjectileBase>
SpawnLocation,
SpawnRotation,
SpawnParams);
if (Projectile)
{
// Pass the pre-built spec to the projectile
Projectile->DamageEffectSpecHandle = DamageSpec;
}
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
}
void UGA_RangedAttack::GetAimDirectionAndLocation(
FVector& OutLocation,
FRotator& OutRotation) const
{
AActor* AvatarActor = GetAvatarActorFromActorInfo();
const FGameplayAbilityActorInfo* ActorInfo =
GetCurrentActorInfo();
if (APlayerController* PC = Cast<APlayerController>(
ActorInfo->PlayerController.Get()))
{
FVector CameraLocation;
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
OutRotation = CameraRotation;
// Offset to avoid self-hit with character capsule
OutLocation = AvatarActor->GetActorLocation()
+ (CameraRotation.Vector() * SpawnOffset);
// UPROPERTY: float, default 100.0
}
else
{
// Fallback for AI: use actor's forward vector
OutLocation = AvatarActor->GetActorLocation()
+ (AvatarActor->GetActorForwardVector() * SpawnOffset);
OutRotation = AvatarActor->GetActorRotation();
}
}
Getting Aim Direction¶
For a third-person game, the projectile should fire toward where the camera is looking, not where the character is facing. The ability queries the PlayerController's view point and spawns the projectile in that direction.
Spawn offset
The * 100.f offset prevents the projectile from spawning inside the character and immediately hitting the collision capsule. Adjust this based on your character's size. For a production setup, you'd typically use a socket on the character's mesh (weapon muzzle, hand, etc.) as the spawn point.
Step 3: Create the Projectile Actor¶
The projectile is a standard Unreal actor -- it's not a GAS class, but it needs to know how to apply a GAS damage effect. Create a new Blueprint or C++ actor: BP_Projectile (or AProjectileBase in C++).
Components¶
| Component | Type | Purpose |
|---|---|---|
| CollisionSphere | Sphere Collision | Hit detection. Set radius to 10-20 units. Set collision profile to Projectile or similar |
| ProjectileMovement | UProjectileMovementComponent | Handles velocity, gravity, homing. Set Initial Speed and Max Speed (e.g., 3000) |
| Mesh | Static Mesh or Niagara | Visual representation. A sphere is fine for prototyping |
Properties¶
The projectile needs one critical property: the damage spec from the ability.
Add a variable to BP_Projectile:
| Variable | Type | Purpose |
|---|---|---|
| DamageEffectSpecHandle | Gameplay Effect Spec Handle |
The fully configured damage spec, created by the ability |
UCLASS()
class AProjectileBase : public AActor
{
GENERATED_BODY()
public:
AProjectileBase();
/** Damage spec created by the spawning ability.
* Carries the caster's ASC context for correct attribution. */
UPROPERTY(BlueprintReadWrite, Meta = (ExposeOnSpawn = true))
FGameplayEffectSpecHandle DamageEffectSpecHandle;
protected:
UPROPERTY(VisibleAnywhere)
TObjectPtr<USphereComponent> CollisionSphere;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProjectileMovementComponent> ProjectileMovement;
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
FVector NormalImpulse,
const FHitResult& Hit);
virtual void BeginPlay() override;
};
OnHit: Applying Damage¶
This is the critical part. When the projectile hits something, it applies the damage GE spec to the target's ASC. The spec already contains the correct instigator, source, and damage magnitude -- the ability configured all of that before passing it to the projectile.
Event OnComponentHit (CollisionSphere)
|
v
Other Actor --> Get Ability System Component (via interface or cast)
|
v [Valid ASC?]
Target ASC --> Apply Gameplay Effect Spec to Self
|-- Spec Handle: DamageEffectSpecHandle (the stored spec)
|
v
Destroy Actor (self)
Important: also add a branch at the top to check Other Actor != Get Instigator() -- this prevents the projectile from damaging the caster if it spawns slightly inside them.
void AProjectileBase::BeginPlay()
{
Super::BeginPlay();
CollisionSphere->OnComponentHit.AddDynamic(
this, &AProjectileBase::OnHit);
// Destroy after 5 seconds if nothing is hit
SetLifeSpan(5.0f);
}
void AProjectileBase::OnHit(
UPrimitiveComponent* HitComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
FVector NormalImpulse,
const FHitResult& Hit)
{
// Don't damage the caster
if (!OtherActor || OtherActor == GetInstigator())
{
Destroy();
return;
}
// Get the target's ASC
UAbilitySystemComponent* TargetASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(
OtherActor);
if (TargetASC && DamageEffectSpecHandle.IsValid())
{
TargetASC->ApplyGameplayEffectSpecToSelf(
*DamageEffectSpecHandle.Data.Get());
}
Destroy();
}
Don't create a new GE spec in the projectile
A common mistake is having the projectile create its own MakeOutgoingSpec using its own context. The problem: the projectile isn't a GAS actor. It has no ASC, so MakeEffectContext() would have the wrong instigator and source. The damage would look like it came from nobody. Always pass the pre-built spec from the ability -- it carries the correct FGameplayEffectContext with the caster's ASC as the instigator.
Why Passing the Spec Handle Works¶
When the ability creates the GE spec via MakeOutgoingGameplayEffectSpec, the spec's FGameplayEffectContext is automatically populated with:
| Context Field | Value | Used For |
|---|---|---|
| Instigator | The caster's actor (from GetAvatarActorFromActorInfo()) |
Kill credit, damage logs |
| EffectCauser | The caster's actor (by default) | Source identification |
| InstigatorAbilitySystemComponent | The caster's ASC | Attribute lookups, source-side modifiers |
| AbilityInstance | The ability that created the spec | Ability-specific damage scaling |
This context travels with the spec handle as a shared pointer. When the projectile applies the spec to the target seconds later and potentially far from the caster, all attribution is correct. Damage logs, kill credit, and any PostGameplayEffectExecute logic that checks the source will work properly.
Setting the projectile as EffectCauser
If your damage pipeline needs to know that the damage came from a projectile (for knockback direction, hit effects, etc.), you can modify the context after creating the spec:
FGameplayEffectContextHandle Context =
DamageSpec.Data->GetEffectContext();
Context.AddActors({Projectile});
Or in Blueprint, use Get Effect Context on the spec handle, then set the source object. The instigator (caster) stays correct while the causer becomes the projectile.
Step 3: Wire Input¶
1. InputAction Asset¶
Create IA_RangedAttack (right-click > Input > Input Action). Set the Value Type to Digital (Bool).
2. InputMappingContext¶
In your IMC_Default, add:
- Input Action:
IA_RangedAttack - Key: Right Mouse Button (or your preferred ranged attack key)
3. Route Input to the ASC¶
In your Character Blueprint's Event Graph:
- Add an Enhanced Input Action event node for
IA_RangedAttack - From the exec pin, call Get Ability System Component on Self
- Iterate activatable abilities and try to activate any whose Input Tag matches
InputTag.Combat.Secondary
See Input Binding for the full production input routing setup.
4. Grant the Ability¶
Add GA_RangedAttack to your character's Startup Abilities array in Class Defaults.
Step 4: Test¶
Basic Test¶
- Hit Play
- Right-click -- a projectile should spawn and fly toward where the camera is looking
- Mana drops by 20 (check with
showdebug abilitysystem) - Spam right-click -- only fires every 1.5 seconds
- Aim at a target with an ASC -- confirm their Health drops by 30
- Apply
CrowdControl.Soft.Silenceto your character -- confirm the ability is blocked
ShowDebug Checklist¶
| Scenario | Expected Result |
|---|---|
| Fire at a target | Target's PendingDamage receives 30, Health drops after processing |
| Fire with insufficient mana | Nothing happens -- cost check fails |
| Fire during cooldown | Nothing happens -- cooldown tag blocks |
| Fire while silenced | Nothing happens -- CrowdControl.Soft.Silence in Activation Blocked Tags |
| Fire while rooted | Works -- rooted doesn't block casting |
| Projectile hits wall | Projectile destroys, no damage applied |
| Projectile hits caster | Projectile destroys (instigator check), no self-damage |
| Projectile misses everything | Destroys after lifespan expires (5 seconds) |
Edge Cases¶
- Caster dies after firing -- the projectile is already in flight with a valid spec. It will still deal damage and attribute it to the (now dead) caster. This is correct behavior
- Target gains
State.Invulnerableafter projectile fires -- the damage is checked at application time, so invulnerability at the moment of impact blocks it. This is correct - Multiple projectiles from rapid fire -- each carries its own spec handle (shared pointer), so they all attribute correctly and independently
Common issues
- Projectile hits self -- spawn offset is too small, or the instigator check in
OnHitis missing - No damage on target -- verify the target has an ASC and that
DamageEffectSpecHandleis set on the projectile after spawning - Wrong damage amount -- check that
SetSetByCallerMagnitudeis called before passing the spec to the projectile - Damage attributed to nobody -- you're creating a new spec in the projectile instead of using the one from the ability. This is the most common ranged ability bug
- Projectile doesn't move -- check that
ProjectileMovementComponenthasInitialSpeedandMaxSpeedset to non-zero values
The Full Flow¶
Here's the complete sequence from button press to damage application:
- Input fires
IA_RangedAttack - Input handler finds abilities with
InputTag.Combat.Secondaryand callsTryActivateAbility - The ASC checks activation requirements:
- Blocked tags: Does the owner have
State.Dead,CrowdControl.Hard, orCrowdControl.Soft.Silence?
- Blocked tags: Does the owner have
- If checks pass, the ability activates and
CommitAbilityruns:- Cost: Do we have 20+ Mana? If yes, apply
GE_Cost_RangedAttack(Mana -20) - Cooldown: Apply
GE_Cooldown_RangedAttack(tag granted for 1.5 seconds)
- Cost: Do we have 20+ Mana? If yes, apply
- The ability creates a damage spec via
MakeOutgoingGameplayEffectSpec(GE_Damage_Ranged). The spec'sFGameplayEffectContextis populated with the caster's actor, ASC, and ability reference SetSetByCallerMagnitudesets the damage value (30.0) on the spec- The ability gets the camera aim direction and spawns
BP_Projectileat an offset from the character - The ability passes
DamageEffectSpecHandleto the projectile. The spec (and its context) now lives on the projectile - The ability calls End Ability. The ability is done -- it does not wait for the projectile
- The projectile flies through space via
UProjectileMovementComponent - On hit, the projectile gets the target's ASC and calls
ApplyGameplayEffectSpecToSelfwith the stored spec - The target's
PostGameplayEffectExecuteprocessesPendingDamage-- armor, shields, invulnerability checks -- then applies the final result to Health - The projectile destroys itself
The key insight: the ability and the damage application are temporally and spatially separated. The spec handle bridges that gap, carrying the caster's full context from the moment of casting to the moment of impact.
Variations¶
Charge-Up¶
Hold the input to charge, release to fire. Use the WaitInputRelease ability task to detect when the player lets go. Scale the DamageAmount and projectile speed based on charge time. Apply a State.Charging tag via Activation Owned Tags to block other abilities during charge. The ability stays active until the player releases, which means End Ability is deferred -- you need to handle interruption (stun during charge) cleanly.
Homing Projectile¶
Set the ProjectileMovementComponent's bIsHomingProjectile to true and assign a HomingTargetComponent on the target actor. Populate the target via a line trace from the camera before spawning, or use GAS Target Actors for more sophisticated targeting.
Multi-Projectile (Shotgun)¶
Spawn multiple projectiles in a cone. Loop 5-8 times, each with a slightly randomized rotation offset from the aim direction. Use the same GE spec handle for all of them -- each projectile carries the same damage, and the spec handle is a shared pointer under the hood, so passing it to multiple actors is safe and efficient.
Related Pages¶
- Melee Attack -- same damage pipeline, but applied directly instead of via projectile
- Dodge Roll -- stamina cost, i-frames, root motion montage
- SetByCaller -- how the damage magnitude system works
- Targeting -- more sophisticated aim and target selection
- Damage Pipeline -- how PendingDamage flows through to Health
- Ability Tasks -- WaitInputRelease and other tasks for variations
- Immunity -- how State.Invulnerable blocks damage at application time