Skip to content

Example: Sprint Toggle

Beginner

A toggle ability that increases movement speed while the input is held and drains stamina continuously. When you release the button (or run out of stamina), the sprint deactivates and movement returns to normal. This demonstrates toggle/held abilities, infinite-duration effects managed by the ability lifecycle, and periodic attribute modification.

What We're Building

  • Press to start sprinting, release to stop
  • Increases movement speed by 50% while active
  • Drains stamina continuously (3 per 0.5 seconds)
  • Blocked by root, stun, and death
  • Grants State.Sprinting tag while active
  • Automatically ends if stamina reaches 0

Prerequisites

This example assumes you've completed Project Setup and have:

  • A character with an Ability System Component
  • An AttributeSet with at minimum Health, Stamina, and a MoveSpeed attribute (or you use a MoveSpeedMultiplier attribute your movement component reads)
  • A base ability class (YourProjectGameplayAbility) with an InputTag property
  • An input binding system that routes Enhanced Input actions to abilities by tag

If any of that is missing, start with Project Setup.


Step 1: Create the Effects

We need two effects: one for the speed buff and one for the stamina drain. Both are Infinite duration (no natural expiration) because the ability itself manages their lifetime — applying them on activation and removing them on deactivation.

GE_Sprint_SpeedBuff

This effect boosts movement speed while sprinting. It also grants tags that mark the character's state.

Setting Value
Duration Policy Infinite
Modifiers[0] — Attribute YourProjectAttributeSet.MoveSpeed
Modifiers[0] — Modifier Op Multiply (Additive)
Modifiers[0] — Magnitude Scalable Float: 0.5

Why Multiply (Additive) with 0.5?

Multiply (Additive) adds to a multiplier pool before applying. A value of 0.5 means "+50% move speed." If another effect also adds 0.3, the total multiplier becomes 1 + 0.5 + 0.3 = 1.8 (80% increase). This is different from Multiply (Compound), which chains multiplications (1.5 * 1.3 = 1.95). Additive is usually what you want for stacking buffs. See Modifiers for the full breakdown of EGameplayModOp types.

Tags (via GE Components):

GE Component Tag Purpose
TargetTagsGameplayEffectComponent — Granted Tags State.Sprinting Marks the character as sprinting (other systems can check this)
TargetTagsGameplayEffectComponent — Granted Tags Status.Buff.Haste Categorizes this as a haste buff (for UI, cleanse abilities, etc.)

GE_Sprint_StaminaDrain

This effect periodically drains stamina while sprinting.

Setting Value
Duration Policy Has Duration
Duration Magnitude Scalable Float: 999.0 (effectively infinite, but allows Period)
Period 0.5 seconds
Modifiers[0] — Attribute YourProjectAttributeSet.Stamina
Modifiers[0] — Modifier Op Add (Base)
Modifiers[0] — Magnitude Scalable Float: -3.0

Why Has Duration instead of Infinite?

Infinite-duration effects do not support periodic execution. If you set Duration Policy to Infinite and add a Period, the periodic tick won't fire. Use Has Duration with a very large value (like 999 seconds) when you need periodic execution that the ability will manually remove. The ability removes this effect long before the 999-second duration expires, so the actual number doesn't matter — it just needs to be non-infinite.


Step 2: Create the Ability

Asset: GA_Sprint

Create a new Blueprint class with your YourProjectGameplayAbility as the parent.

Class Defaults

Property Value Why
Input Tag InputTag.Movement.Sprint Maps to your sprint input
Ability Tags Ability.Movement.Sprint Identifies this ability
Activation Blocked Tags CrowdControl.Root, CrowdControl.Stun, State.Dead Can't sprint while rooted, stunned, or dead
Instancing Policy InstancedPerActor Required — we store effect handles as member variables
Net Execution Policy LocalPredicted Sprint should feel instant on the client
Cost Gameplay Effect (leave empty) We handle stamina drain manually via the periodic effect
Cooldown Gameplay Effect (leave empty) No cooldown on sprint

No Cost GE?

Unlike most abilities, sprint doesn't have a one-time cost. Instead, it drains stamina continuously via GE_Sprint_StaminaDrain. If we used a Cost GE, it would deduct stamina once on activation and then the periodic drain would be separate — doubling the initial cost. The periodic effect handles everything.

Event Graph

Event Graph

  1. ActivateAbility fires
  2. Apply GE to SelfGE_Sprint_SpeedBuff → store handle as SpeedBuffHandle
  3. Apply GE to SelfGE_Sprint_StaminaDrain → store handle as StaminaDrainHandle
  4. Two tasks run simultaneously (the "race" pattern):

    • Wait Input Release — fires when the player lets go of sprint
    • Wait Attribute Change Threshold — fires when Stamina <= 0
  5. Whichever fires first triggers cleanup:

    • Remove Active GE (SpeedBuffHandle)
    • Remove Active GE (StaminaDrainHandle)
    • EndAbility (success)
flowchart LR
    A["ActivateAbility"]:::event --> B["Apply GE\nSpeedBuff"]:::func
    B --> C["Apply GE\nStaminaDrain"]:::func
    C --> D["WaitInputRelease"]:::task
    C --> E["WaitAttributeChange\nStamina <= 0"]:::task
    D -->|Released| F["Remove GEs\nEndAbility"]:::endpoint
    E -->|Out of Stamina| F

    classDef event fill:#b71c1c,stroke:#ef5350,color:#fff
    classDef func fill:#4527a0,stroke:#7e57c2,color:#fff
    classDef task fill:#0d47a1,stroke:#42a5f5,color:#fff
    classDef endpoint fill:#1b5e20,stroke:#66bb6a,color:#fff

Variables to add:

Variable Type
SpeedBuffHandle FActiveGameplayEffectHandle
StaminaDrainHandle FActiveGameplayEffectHandle

Two tasks, one outcome

Both Wait Input Release and Wait for Attribute Change Threshold run simultaneously. Whichever fires first triggers the deactivation. This is the "race" pattern — two ability tasks listening in parallel, both leading to the same cleanup logic.

// GA_Sprint.h
#pragma once

#include "CoreMinimal.h"
#include "YourProjectGameplayAbility.h"
#include "ActiveGameplayEffectHandle.h"
#include "GA_Sprint.generated.h"

UCLASS()
class UGA_Sprint : public UYourProjectGameplayAbility
{
    GENERATED_BODY()

public:
    UGA_Sprint();

    virtual void ActivateAbility(
        const FGameplayAbilitySpecHandle Handle,
        const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo,
        const FGameplayEventData* TriggerEventData) override;

    virtual void EndAbility(
        const FGameplayAbilitySpecHandle Handle,
        const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo,
        bool bReplicateEndAbility,
        bool bWasCancelled) override;

protected:
    UPROPERTY(EditDefaultsOnly, Category = "Effects")
    TSubclassOf<UGameplayEffect> SpeedBuffEffectClass;

    UPROPERTY(EditDefaultsOnly, Category = "Effects")
    TSubclassOf<UGameplayEffect> StaminaDrainEffectClass;

private:
    UFUNCTION()
    void OnInputReleased(float TimeHeld);

    UFUNCTION()
    void OnStaminaDepleted(bool bMatches, float CurrentValue);

    void RemoveEffectsAndEnd();

    FActiveGameplayEffectHandle SpeedBuffHandle;
    FActiveGameplayEffectHandle StaminaDrainHandle;
};
// GA_Sprint.cpp
#include "GA_Sprint.h"
#include "AbilitySystemComponent.h"
#include "Abilities/Tasks/AbilityTask_WaitInputRelease.h"
#include "Abilities/Tasks/AbilityTask_WaitAttributeChangeThreshold.h"

UGA_Sprint::UGA_Sprint()
{
    InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
    NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}

void UGA_Sprint::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerEventData)
{
    if (!HasAuthorityOrPredictionKey(ActorInfo, &ActivationInfo))
    {
        return;
    }

    UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
    if (!ASC)
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    // Apply the speed buff (infinite duration)
    if (SpeedBuffEffectClass)
    {
        FGameplayEffectSpecHandle SpecHandle =
            MakeOutgoingGameplayEffectSpec(SpeedBuffEffectClass, GetAbilityLevel());
        SpeedBuffHandle = ApplyGameplayEffectSpecToOwner(
            Handle, ActorInfo, ActivationInfo, SpecHandle);
    }

    // Apply the stamina drain (periodic)
    if (StaminaDrainEffectClass)
    {
        FGameplayEffectSpecHandle SpecHandle =
            MakeOutgoingGameplayEffectSpec(StaminaDrainEffectClass, GetAbilityLevel());
        StaminaDrainHandle = ApplyGameplayEffectSpecToOwner(
            Handle, ActorInfo, ActivationInfo, SpecHandle);
    }

    // Wait for input release
    UAbilityTask_WaitInputRelease* WaitRelease =
        UAbilityTask_WaitInputRelease::WaitInputRelease(this);
    WaitRelease->OnRelease.AddDynamic(this, &UGA_Sprint::OnInputReleased);
    WaitRelease->ReadyForActivation();

    // Wait for stamina to hit 0
    UAbilityTask_WaitAttributeChangeThreshold* WaitStamina =
        UAbilityTask_WaitAttributeChangeThreshold::WaitForAttributeChangeThreshold(
            this,
            UYourProjectAttributeSet::GetStaminaAttribute(),
            EWaitAttributeChangeComparison::LessThanOrEqual,
            0.0f,
            /*bTriggerOnce=*/ true);
    WaitStamina->OnChange.AddDynamic(this, &UGA_Sprint::OnStaminaDepleted);
    WaitStamina->ReadyForActivation();
}

void UGA_Sprint::OnInputReleased(float TimeHeld)
{
    RemoveEffectsAndEnd();
}

void UGA_Sprint::OnStaminaDepleted(bool bMatches, float CurrentValue)
{
    if (bMatches)
    {
        RemoveEffectsAndEnd();
    }
}

void UGA_Sprint::RemoveEffectsAndEnd()
{
    UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
    if (ASC)
    {
        if (SpeedBuffHandle.IsValid())
        {
            ASC->RemoveActiveGameplayEffect(SpeedBuffHandle);
        }
        if (StaminaDrainHandle.IsValid())
        {
            ASC->RemoveActiveGameplayEffect(StaminaDrainHandle);
        }
    }

    K2_EndAbility();
}

void UGA_Sprint::EndAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    bool bReplicateEndAbility,
    bool bWasCancelled)
{
    // Safety net: if EndAbility is called externally (e.g., ability cancelled),
    // make sure we clean up our effects
    UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
    if (ASC)
    {
        if (SpeedBuffHandle.IsValid())
        {
            ASC->RemoveActiveGameplayEffect(SpeedBuffHandle);
            SpeedBuffHandle.Invalidate();
        }
        if (StaminaDrainHandle.IsValid())
        {
            ASC->RemoveActiveGameplayEffect(StaminaDrainHandle);
            StaminaDrainHandle.Invalidate();
        }
    }

    Super::EndAbility(Handle, ActorInfo, ActivationInfo,
        bReplicateEndAbility, bWasCancelled);
}

Always clean up in EndAbility

The EndAbility override is a safety net. If something external cancels the ability (stun, death, manual cancellation), the effects still get removed. Without this, a cancelled sprint would leave the speed buff running forever.


Step 3: Wire Input

Sprint uses a held input pattern — the ability activates when the button is pressed and uses WaitInputRelease to detect when the button is released.

  1. Input Action: IA_Sprint (Value Type: Digital (Bool), Trigger: Pressed)
  2. Input Mapping Context: Map to your sprint key (e.g., Left Shift, left stick click)
  3. InputTag: InputTag.Movement.Sprint
  4. On the ability: Set InputTag to InputTag.Movement.Sprint

Pressed, not Held

The Input Action trigger should be Pressed (fires once on key down), not Held. The ability itself handles the "held" behavior through the WaitInputRelease task. If you use the Held trigger, the input system would keep re-firing the activation, which isn't what we want for a toggle.


Step 4: Test

Basic Sprint Test

  1. PIE
  2. Press and hold sprint key — character should move faster
  3. Check showdebug abilitysystem:
    • State.Sprinting tag should be present
    • GE_Sprint_SpeedBuff should appear in active effects
    • Stamina should be ticking down (3 every 0.5s)
  4. Release sprint key — speed returns to normal, tags removed, effects removed
  5. Sprint until stamina hits 0 — sprint should auto-cancel

Edge Cases

Scenario Expected Result
Sprint at 0 stamina Activates briefly, stamina threshold fires almost immediately, ends
Sprint while rooted Doesn't activate (Activation Blocked Tags)
Sprint interrupted by stun Ability cancelled externally, EndAbility cleans up effects
Sprint key tapped quickly Activates and deactivates rapidly — verify no leaked effects
Network test with net pktlag=100 Sprint should feel instant on client (LocalPredicted)

Connecting to UI

The stamina drain during sprint is a great candidate for a real-time UI bar. Use UAbilityAsync_WaitAttributeChanged to drive a stamina progress bar that updates reactively — no Tick polling needed. See Connecting GAS to UI for the full pattern.

The Full Flow

Player presses Sprint key
  |
  v
Input System: IA_Sprint -> InputTag.Movement.Sprint -> ASC finds GA_Sprint
  |
  v
GA_Sprint.ActivateAbility()
  +-- Check: Activation Blocked Tags (rooted? stunned? dead?)
  +-- Apply GE_Sprint_SpeedBuff (infinite, +50% MoveSpeed)
  |     +-- Tags granted: State.Sprinting, Status.Buff.Haste
  +-- Apply GE_Sprint_StaminaDrain (periodic, -3 Stamina every 0.5s)
  +-- Start WaitInputRelease task
  +-- Start WaitForAttributeChangeThreshold task (Stamina <= 0)
  |
  v  (some time passes, stamina drains...)
  |
  v  [Player releases key OR stamina hits 0]
  |
  v
RemoveEffectsAndEnd()
  +-- Remove GE_Sprint_SpeedBuff (speed returns to normal)
  +-- Remove GE_Sprint_StaminaDrain (drain stops)
  +-- Tags removed: State.Sprinting, Status.Buff.Haste
  +-- End Ability

Variations

Sprint with minimum stamina threshold

Instead of letting sprint activate at 0 stamina (and immediately cancel), add a CanActivateAbility override that checks Stamina > 10 before allowing activation. This prevents the awkward "activate then immediately deactivate" pattern.

Sprint with acceleration curve

Instead of an instant 50% boost, use a second periodic effect that gradually increases MoveSpeed over 1-2 seconds. Apply the ramp-up effect on activate and replace it with the full-speed effect after the ramp completes. Or use a curve table with the speed buff modifier for a smooth acceleration.

Sprint that drains stamina proportionally

Use a Modifier Magnitude Calculation on the stamina drain instead of a flat -3. The custom MMC can read the character's current MoveSpeed and drain proportionally — sprinting while buffed costs more stamina. See Magnitude Calculations.