Gameplay · Updated 2552.08.07.08.17

Mobility Cues — Dash & Double Jump Thrusters

How the dash and double-jump thruster cosmetics are wired, what was wrong with them, and what is left to author. Companion to Docs/MobilityAssistModule.md (the module itself) and D

How the dash and double-jump thruster cosmetics are wired, what was wrong with them, and what is left to author. Companion to Docs/MobilityAssistModule.md (the module itself) and Docs/WeaponsSystem.md (the weapon-cue contract this follows).


#The short version

The cue code was written on 2026-07-29 and compiled cleanly. It never fired, because a compiled UGameplayCueNotify_Static subclass is not enough — the GameplayCueManager registers cues from asset data in scanned content paths, not from native classes. There was no asset. So ExecuteGameplayCue found no handler and silently did nothing.

"It compiles" and "Live Coding: Succeeded" were both true and both irrelevant. Worth remembering: a cue that is not registered fails silently — there is no warning, no log line, nothing to notice.


#How cue registration actually works (verified in UE 5.7 source)

  1. Config/DefaultGame.ini lists the scan paths:
  2. +GameplayCueNotifyPaths=/Game/SystemLink/AbilitySystem/Cues

  1. At startup the manager asset-scans those paths and calls
  2. BuildCuesToAddToGlobalSet(AssetDataList, GET_MEMBER_NAME_CHECKED(UGameplayCueNotify_Static, GameplayCueName), ...) — it reads the GameplayCueName property out of asset registry data (GameplayCueManager.cpp:889).

  1. GameplayCueName is an AssetRegistrySearchable FName that mirrors GameplayCueTag. It is set by
  2. DeriveGameplayCueTagFromAssetName() on serialize/load (AbilitySystemGlobals.cpp:150GameplayCueName = GameplayCueTag.GetTagName()).

Consequences that bite:

  • A native-only C++ cue class is never scanned, so it is never registered. It needs a Blueprint
  • subclass sitting in a scanned path.

  • Tag derivation from the asset name is WITH_EDITOR + GIsEditor only, and only runs when
  • GameplayCueTag is still invalid. In a packaged build nothing derives it. That is why the native constructors now assign GameplayCueTag explicitly instead of leaning on the class name.

  • Derivation would also produce the wrong tag for this project's naming convention: an asset named
  • GC_SL_Mobility_Dash derives GameplayCue.SL.Mobility.Dash. Setting the tag explicitly on the native parent means the Blueprint inherits the correct tag regardless of what the asset is called.


#What exists now

#C++ — Mobility/GameplayCues/SLGameplayCue_MobilityThrusters.h/.cpp

ClassTagBehaviour
USLGameplayCue_MobilityThrusters— (abstract base)Spawns twin Niagara jets on the character's TP mesh
UGC_Mobility_DashGameplayCue.Mobility.DashExhaust points opposite the dash vector (CueParams.Normal)
UGC_Mobility_DoubleJumpGameplayCue.Mobility.DoubleJumpExhaust points down, larger and longer

Tunables (all EditDefaultsOnly, so the Blueprint subclass is the place to tune): ExhaustSystem, FallbackAttachBone (spine_04), ExhaustScale, BurstDuration, LeftNozzleOffset / RightNozzleOffset, bVerticalBurst.

Attachment prefers a MobilityModule_Socket on the mesh and falls back to spine_04, so it works on a skeleton that does not have the socket yet. Jets auto-deactivate and return to Niagara's component pool.

  • GC_Mobility_Dash → parent GC_Mobility_Dash
  • GC_Mobility_DoubleJump → parent GC_Mobility_DoubleJump

These exist only to put the classes in a scanned path. They are also where the effects get authored.

#Data asset

DA_SL_MobilityModule_MMA3 already carries DashCueTag and DoubleJumpCueTag. The abilities read the tag from the module rather than hardcoding it, so a future MMA-4 can ship different thrusters by data.


#Local prediction — why the cue is not behind an authority check

ExecuteGameplayCue already branches on authority itself (GameplayCueManager.cpp:1513):


if (bHasAuthority)            { /* multicast to everyone */ }

else if (bLocalPredictionKey) { /* play locally, right now */ }

Both abilities are LocalPredicted, so inside ActivateAbility the ASC's ScopedPredictionKey is the ability's key. Gating the cue on HasAuthority() — which is what the original code did — threw away the entire predicted branch, so the one player who most needs to see their thrusters instantly (the one who pressed the button) waited a full round trip for them.

The gate is now removed from both USLGameplayAbility_Dash and USLGameplayAbility_DoubleJump.

It does not double-play. The server's multicast is skipped on the predicting client:


// AbilitySystemComponent.cpp:1612

if (IsOwnerActorAuthoritative() || PredictionKey.IsLocalClientKey() == false) { InvokeGameplayCueEvent(...); }

What stays authority-only: spending the dash charge and NotifyDashSpent(). Those are game state, not cosmetics — the client gets the movement immediately and the charge count arrives with attribute replication a moment later.

#The cue really is immediate, and that depends on WHERE it is called

There is a pending queue, but nothing opens a send context on this path, so it flushes inline (GameplayCueManager.cpp, AddPendingCueExecuteInternal: "Not in a context, flush now"). The whole chain runs synchronously inside ActivateAbility — same frame, no round trip, for both the predicting client and the listen-server host.

That is only true while the cue is executed inside ActivateAbility, because that is where the
ASC's ScopedPredictionKey is valid. Move the call to a timer, an anim notify, or a
PlayMontageAndWait callback and the key is gone by then — so on a client bHasAuthority is false
and bLocalPredictionKey is false, and neither branch runs: the cue plays nothing at all.
Not a latency regression, total silence, and only on clients — it looks perfect on a listen-server
host. Relevant the moment a montage starts driving the thruster timing: keep the cue on activation,
or pass the prediction key through explicitly.
General rule: cosmetics in a predicted ability should be executed unconditionally and left to
ExecuteGameplayCue to route. Wrapping one in HasAuthority() is a latency bug that looks like a
design choice.

#Verify it is actually registered

Registration failure is silent, so check it deliberately rather than assuming.

  1. In PIE, dash and double jump. Jets should appear. If nothing happens, it is not registered.
  1. Check the registry mirror. This is the authoritative test — it is the exact property registration
  2. reads:


   ar = unreal.AssetRegistryHelpers.get_asset_registry()

   for a in ar.get_assets_by_path("/Game/SystemLink/AbilitySystem/Cues", recursive=True):

       if "Mobility" in str(a.asset_name):

           print(a.asset_name, a.get_tag_value("GameplayCueName"))

It must print the full tag. None means the cue will not fire from a cold start, no matter how correct GameplayCueTag looks in the details panel — see the naming trap above for why the two disagree.

This check was briefly written off as a false negative, because the cues did fire in the session
where the tag was re-picked by hand while it read None. That was a red herring:
PostEditChangePropertyHandleAssetAdded registers the cue for that session only, which
masks a genuinely broken asset until the next restart. The read was right the whole time.
  1. Confirm from a cold start. In-editor registration can come from the property-change hook rather
  2. than the startup asset scan, and only the scan exists in a packaged build. Restart the editor, then fire the cue. A comparison that makes any failure obvious: every correctly-authored cue in the folder reports a populated GameplayCueName, so a None next to fifteen populated rows is the bug, not the measurement.

#⚠ NAME THE BLUEPRINT SO ITS TAG DERIVES CORRECTLY — this is the whole trap

The Blueprint asset must be named so that stripping GC_ and turning _ into . produces the real tag. GC_Mobility_DashGameplayCue.Mobility.Dash ✅. The project's usual GC_SL_ prefix would give GameplayCue.SL.Mobility.Dash, which is not a registered tag, and that silently breaks registration — these two cues were named GC_SL_Mobility_* for a day and never fired.

The mechanism is an engine bug worth understanding, in AbilitySystemGlobals.h:129:


if (ParentCDO->GameplayCueTag.IsValid() && (ParentCDO->GameplayCueTag == CDO->GameplayCueTag))

{

    FGameplayTag ParentTag = ParentCDO->GameplayCueTag;

    CDO->GameplayCueTag = FGameplayTag();                 // clear it

    if (DeriveGameplayCueTagFromAssetName(CDO->GetName(), CDO->GameplayCueTag, CDO->GameplayCueName) == false)

    {

        CDO->GameplayCueTag = ParentTag;                  // tag restored...

    }

    return;                                               // ...GameplayCueName is NOT restored

}

Our native classes are named GC_Mobility_Dash / GC_Mobility_DoubleJump, so the editor derives a valid tag onto the native CDO. A Blueprint child then holds the same tag, which trips this branch: the tag is cleared, re-derivation from a GC_SL_-prefixed asset name fails, and the tag is put back — but GameplayCueName keeps the invalid tag's name, None.

That is why the symptom is so confusing: GameplayCueTag reads correctly in the editor and on the CDO, while the registry mirror the cue manager registers from is empty. Everything looks right and nothing fires.

The other fourteen cues in this project are immune only because they parent to engine classes, whose CDO tag is invalid — so they never enter this branch at all.

**Rule: any Blueprint cue whose parent is one of our GC_* native classes must be named exactly so its own name derives the intended tag.** Do not apply the SL prefix convention to these.


#Authoring the effects — where does the logic go?

UGameplayCueNotify_Static exposes two Blueprint surfaces, and which one to use depends on whether you are adding to the C++ or replacing it:


/** Generic Event Graph event that will get called for every event type */

UFUNCTION(BlueprintImplementableEvent, DisplayName = "HandleGameplayCue")

void K2_HandleGameplayCue(AActor* MyTarget, EGameplayCueEvent::Type EventType, const FGameplayCueParameters&) const;



/** Called when a GameplayCue is executed, this is used for instant effects or periodic ticks */

UFUNCTION(BlueprintNativeEvent, BlueprintPure)     // <- note: PURE

bool OnExecute(AActor* MyTarget, const FGameplayCueParameters& Parameters) const;

#Adding cosmetics on top of the C++ → use the HandleGameplayCue EVENT

This is the right hook for audio, camera shake, and extra one-off FX, because it adds rather than overrides. The native routing calls it first and then still runs OnExecute:


// GameplayCueNotify_Static.cpp:65

K2_HandleGameplayCue(MyTarget, EventType, Parameters);   // <- the Blueprint event

switch (EventType)

{

case EGameplayCueEvent::Executed:

    OnExecute(MyTarget, Parameters);                     // <- native jets, untouched

It returns void, so it is a real event with execution pins — no parent call, no pure-node rules, nothing that can be accidentally unwired. Branch on EventType == Executed for hygiene; it is the only type these cues ever send.

#Replacing the C++ behaviour → override OnExecute, and mind that it is PURE

USLGameplayCue_MobilityThrusters implements OnExecute_Implementation in C++ — that is what spawns the twin jets. A Blueprint override replaces it unless it calls the parent.

But OnExecute is declared BlueprintPure, so Parent: On Execute is a pure node with no execution pins, and that changes the rules:

  • A pure node only evaluates when something pulls on its output. Wire Parent: On Execute
  • Return Node's Return Value. If that connection is missing, the parent never runs at all — the jets silently stop spawning while the node sits in the graph looking connected.

  • Never feed it into two consumers. Pure nodes re-evaluate per consumer, so two pulls spawn the jets
  • twice.

  • Evaluation happens at the point of consumption, so with the parent on the Return Node the native jets
  • spawn after anything in the graph's exec chain.

The function graph itself does have exec flow (entry → Return Node), so impure nodes are fine in that chain — it is only the parent call that is pure.

#What the class cannot do

The notify is non-instanced (the CDO is the handler) and OnExecute is const:

  • No member state. Variables set on the cue have nowhere to live.
  • No latent nodes. No Delay, no Timeline. For timing, use a world timer as the C++ does, or
  • UGameplayCueNotify_BurstLatent, which exists for exactly that.

#Where each kind of thing belongs

WhatWhere
Niagara system, scale, burst duration, nozzle offsetsClass Defaults (already EditDefaultsOnly)
Audio, camera shake, one-off extra FXBlueprint HandleGameplayCue event (adds, cannot break the C++)
Dash vs double-jump differencesThe two separate Blueprints — they have different C++ parents
Anything needing state or delaysNot here: C++ timer, or BurstLatent

#Why not UGameplayCueNotify_Burst?

UGameplayCueNotify_Burst derives from _Static and overrides OnExecute_Implementation with a data-driven spawner: designer-editable lists of Niagara systems, sounds, camera shakes, force feedback and decals, with placement rules and spawn conditions, then a final OnBurst Blueprint event.

It is excellent for generic impacts and poor for this cue, because it cannot express what this cue is for: twin nozzles at authored local offsets, exhaust direction derived from the dash vector (CueParams.Normal), socket-with-fallback attachment, and pooled auto-release.

Hybrid option, not yet taken: reparent the native base from _Static to _Burst and call Super::OnExecute_Implementation(...) at the top of our override. That buys Burst's declarative sound and camera-shake lists — which is precisely the audio work still outstanding — while keeping the nozzle logic. Worth doing if a second module variant ever needs its own audio without a code change.


#Still to do

#1. Effects (the actual ask)

Right now both cues reuse NS_RocketTrail from the Niagara examples, hardcoded by path in the C++ constructor. That was a placeholder to prove the mechanism.

  • Author or pick a real thruster system. Content/Library/RocketThrusterExhaustFX/ was added to the
  • project for this and contains ~25 candidate systems (NS_RocketExhaust_Blue, _Afterburn_Jet, _SciFi, …). It is currently untracked in git — decide LFS vs ignore before depending on it.

  • Assign it on the Blueprint CDO (ExhaustSystem), not in C++, so it is tunable without a rebuild.
  • The C++ default should stay a safe fallback.

  • Dash and double jump should probably differ: dash reads as a horizontal burst, double jump as a
  • downward kick.

  • Audio — neither cue plays a sound. Add it in the Blueprint's OnExecute.
  • Placement pass — nozzle offsets have never been eyeballed. If the jets do not sit on Chief's back
  • plates, tune LeftNozzleOffset / RightNozzleOffset, or add a MobilityModule_Socket to MasterChief_Skeleton (the code already prefers it when present).

#2. Montage

Nothing animates the character during a dash or double jump — the pose is whatever locomotion says.

Constraints worth knowing before building it:

  • Use PlayMontageAndWait (the ability task), not Montage_Play. The task replicates to simulated
  • proxies; Montage_Play is local-only. This is already a recorded footgun.

  • The dash ability's lifetime is a WaitDelay, deliberately — do not hang it on the montage
  • finishing, and do not hang it on a root-motion task's OnFinish (that is the bug that made dash un-rechargeable for a session).

  • The dash moves via a root motion source. A root-motion montage would fight it. Keep the montage
  • cosmetic (upper body / additive) or replace the root motion source entirely — not both.

  • Double jump is CMC's native jump, so a montage there is purely cosmetic and safe.
  • FP and TP need separate treatment, as everywhere else in this project.

#3. HUD (Phase 5, separate)

Dash charge indicator is still unbuilt. USLMobilityComponent broadcasts OnDashChargesChanged, but nothing publishes double-jump availability yet — that delegate would need adding.


#Files

PathRole
Public/Mobility/GameplayCues/SLGameplayCue_MobilityThrusters.hCue base + the two concrete classes
Private/Mobility/GameplayCues/SLGameplayCue_MobilityThrusters.cppJet spawning, tag assignment
Private/AbilitySystem/Abilities/SLGameplayAbility_Dash.cppExecutes DashCueTag (predicted)
Private/AbilitySystem/Abilities/SLGameplayAbility_DoubleJump.cppExecutes DoubleJumpCueTag (predicted)
/Game/SystemLink/AbilitySystem/Cues/GC_Mobility_*Registration + where effects get authored
/Game/SystemLink/Data/DA_SL_MobilityModule_MMA3Holds both cue tags
Config/DefaultGame.iniGameplayCueNotifyPaths