Gameplay · Updated May 23, 2026

MMA-3 Mobility Assist Module — Implementation Plan

MMA-3 Mobility Assist Module — Implementation Plan

Doom Eternal-style mobility (double jump + air dash) provided by an equippable "Mobility Assist Module." Module is conceptually a piece of armor — equipping it grants both abilities; unequipping disables them.

Lore + visual spec lives in Docs/Mobility Assist Module.md. This doc is the engineering plan.


#Design Summary

Double JumpDash
Resource1 extra jump per airtime (refreshes on land)2 charges, regen on a timer
TriggerSecond press of Jump while airborneIA_SL_Dash input
DirectionUpForward / left / right (no back)
Air limit1 per airtime2 per airtime (limited by charges)
CostNone (just the jump count)-1 charge
HUDSingle binary indicator (available/used)2-segment charge bar

Mechanics chosen (locked from Q&A):

  • Separate pools — dash and double jump don't share charges
  • 2 air dashes per airtime (Doom Eternal feel) — ignores the lore doc's "1 before landing" line
  • Architecture: data asset + component + GAS ability bundle
  • Dash direction: WASD/stick projected onto forward+right plane, back component zeroed (S → forward fallback)

#Architecture

Mirrors the weapon system pattern: data asset (config) + component (state + HUD surface) + GAS abilities (behavior).


ASLCharacterBase

  └── USLMobilityComponent              (new — equip/state/HUD delegates)

        ├── EquippedModule (TObjectPtr<USLMobilityModuleDataAsset>)

        └── grants → USLAbilitySet:

              ├── USLGameplayAbility_Dash         (LocalPredicted)

              ├── USLGameplayAbility_DoubleJump   (LocalPredicted)

              └── USLMobilityAttributeSet         (DashCharges, MaxDashCharges)

#Why component + data asset, not pure GAS

  • The module is something the player "wears" — we want a single equip/unequip point that grants both
  • abilities + the attribute set + tweaks CMC settings (JumpMaxCount, AirControl) in one call.

  • Component exposes BlueprintAssignable delegates for HUD binding — same pattern as USLWeaponsComponent
  • driving the ammo strip.

  • Data asset means the module's stats are designer-tunable per-module variant (future MMA-4 with 3 charges,
  • longer dash distance, etc.) without code changes.


#Components

#USLMobilityModuleDataAssetPublic/Mobility/Data/SLMobilityModuleDataAsset.h


UCLASS()

class SYSTEMLINKCORE_API USLMobilityModuleDataAsset : public UPrimaryDataAsset

{

  // Display

  FText DisplayName;

  FText Description;

  UTexture2D* Icon;



  // Dash tuning

  float DashSpeed             = 3500.f;   // cm/s — gives ~7m in 0.2s

  float DashDuration          = 0.2f;     // sec

  int32 MaxDashCharges        = 2;

  float DashRechargeTime      = 2.f;      // sec per charge

  float DashRechargeDelay     = 1.f;      // sec after dash before regen starts

  float DashCameraFOVBoost    = 10.f;     // optional juicy FOV pop



  // Double jump tuning

  float DoubleJumpZVelocity   = 700.f;    // cm/s — ~+70% of base jump



  // Granted bundle

  USLAbilitySet* AbilitySet;              // grants GA_Dash, GA_DoubleJump, MobilityAttributeSet



  // Cosmetics (GameplayCues)

  FGameplayTag DashCueTag;                // GameplayCues.Mobility.Dash

  FGameplayTag DoubleJumpCueTag;          // GameplayCues.Mobility.DoubleJump



  // Attachment sockets (for thruster VFX origin)

  FName BackpackSocketName = TEXT("MobilityModule_Socket");

};

#USLMobilityComponentPublic/Mobility/SLMobilityComponent.h

Attached to ASLCharacterBase (created in constructor alongside WeaponsComponent).

Responsibilities:

  • Holds EquippedModule (the data asset).
  • On equip (EquipModule(DA)):
    • Applies ability set to the ASC via USLAbilitySet::GrantToASC.
    • Caches granted handles for unequip cleanup.
    • Stashes OriginalJumpMaxCount from CMC, sets JumpMaxCount = 2.
    • Initializes DashCharges = MaxDashCharges via attribute set.
    • Broadcasts OnModuleEquipped BP delegate.
  • On unequip:
    • Removes ability set, restores JumpMaxCount, broadcasts OnModuleUnequipped.
  • Delegates (BlueprintAssignable, for HUD):
    • OnDashChargesChanged(int32 Current, int32 Max) — bound to MobilityAttributeSet's OnRep
    • OnDoubleJumpAvailableChanged(bool bAvailable) — bound to States.Character.HasDoubleJumped tag changes
  • Default ticking off. Equip is server-authoritative (called from PossessedBy / loadout); attribute set
  • replication carries state to remote clients (consistent with how the ammo set works).

#USLMobilityAttributeSetPublic/AbilitySystem/Attributes/SLMobilityAttributeSet.h


FGameplayAttributeData DashCharges;        // replicated, BlueprintReadOnly

FGameplayAttributeData MaxDashCharges;     // replicated

PostGameplayEffectExecute clamps DashCharges to [0, MaxDashCharges]. OnRep_DashCharges broadcasts the component delegate. Mirrors the structure of USLAmmoAttributeSet.

#USLGameplayAbility_DashPublic/AbilitySystem/Abilities/SLGameplayAbility_Dash.h

LocalPredicted, InstancedPerActor. No BP subclass needed initially — all logic in C++; BP CDO subclass only exists to wire data values from the module DA.

CDO config (in BP subclass):

FieldValue
AbilityTagsSLTags.Abilities.Mobility.Dash
ActivationOwnedTagsSLTags.States.Character.Dashing
BlockAbilitiesWithTagSLTags.States.Character.Dashing, SLTags.States.Character.Meleeing
ActivationBlockedTagsSLTags.States.Character.Dead, SLTags.States.Character.Dashing
Trigger[0]SLTags.Events.Mobility.Dash, GameplayEvent
CostGameplayEffectClassGE_DashChargeCost (-1 to DashCharges, requires DashCharges > 0)

Activate flow:

  1. CommitAbility — runs cost check; bails out if DashCharges < 1 (no charges → no dash, no animation).
  1. Compute dash direction (see § "Dash Direction" below).
  1. Cache OriginalGroundFriction, OriginalGravityScale on CMC; zero gravity + ground friction so the
  2. launch holds its velocity for the dash duration.

  1. LaunchCharacter(DashDir DashSpeed, /XYOverride/ true, /ZOverride*/ true).
  1. Apply GE_DashCooldown (HasDuration = DashRechargeDelay, grants States.Character.DashCooldown) —
  2. this gates the recharge GE.

  1. Apply GE_DashRecharge (Infinite, Period = DashRechargeTime, Modifier = +1 to DashCharges,
  2. OngoingTagRequirements.MustNotHaveTags = DashCooldown, removed once at MaxDashCharges).

  1. Trigger DashCueTag (thruster VFX + audio, replicated via GAS).
  1. Delay(DashDuration) task → restore CMC values → EndAbility.

Networking: LaunchCharacter is replicated by CMC. Server runs the same activate path and consumes its own charge (no manual sync needed). Cue replicates via GAS to all clients.

#USLGameplayAbility_DoubleJumpPublic/AbilitySystem/Abilities/SLGameplayAbility_DoubleJump.h

LocalPredicted, InstancedPerActor.

CDO:

FieldValue
AbilityTagsSLTags.Abilities.Mobility.DoubleJump
ActivationOwnedTagsSLTags.States.Character.HasDoubleJumped
ActivationBlockedTagsSLTags.States.Character.HasDoubleJumped, SLTags.States.Character.Dead
Trigger[0]SLTags.Events.Mobility.DoubleJump, GameplayEvent

Activate flow:

  1. Validate CharacterMovement->IsFalling() (no double jump from grounded — that's just a normal jump).
  1. LaunchCharacter(FVector(0, 0, DoubleJumpZVelocity), false, true) — Z override only, preserve horizontal momentum.
  1. Trigger DoubleJumpCueTag.
  1. EndAbility immediately.

HasDoubleJumped tag cleared on landingOnLanded override in ASLCharacterBase calls ASC->RemoveLooseGameplayTag(HasDoubleJumped). Or use a GE_HasDoubleJumped Infinite GE with OngoingTagRequirements.MustNotHaveTags = States.Character.Grounded. Simpler: clear in OnLanded.

#Input plumbing (ASLPlayerController)

Add:


UPROPERTY(EditDefaultsOnly, Category="SystemLink|Input|Movement")

TObjectPtr<UInputAction> DashAction;



void Dash();

Existing Jump() handler grows a branch:


void ASLPlayerController::Jump()

{

    if (IsCharacterDead()) return;

    ACharacter* C = Cast<ACharacter>(GetPawn());

    if (!C) return;



    // Grounded → normal jump.

    if (!C->GetCharacterMovement()->IsFalling())

    {

        C->Jump();

        return;

    }



    // Airborne + module equipped + not used yet → fire double jump event.

    UAbilitySystemComponent* ASC = …;

    if (ASC && !ASC->HasMatchingGameplayTag(SLTags::States::Character::HasDoubleJumped))

    {

        ASC->HandleGameplayEvent(SLTags::Events::Mobility::DoubleJump, nullptr);

    }

}

Dash() fires SLTags::Events::Mobility::Dash with a FGameplayEventData whose InstigatorTags or OptionalObject carries the move input vector (so the ability can read it without re-querying input — same pattern as the fire ability's event payloads).

#Dash Direction

Build the dash direction from the player's current move input, projected to forward + right of camera, back component zeroed:


const FVector2D Input = GetCurrentMoveInputAxis();   // X = right, Y = forward

const FRotator YawRot(0, GetControlRotation().Yaw, 0);

const FVector  Fwd   = YawRot.Vector();

const FVector  Right = FRotationMatrix(YawRot).GetUnitAxis(EAxis::Y);



float FwdComp   = Input.Y;

float RightComp = Input.X;

if (FwdComp < 0.f) FwdComp = 0.f;  // no back dash — S key falls through to forward fallback



FVector Dir = (Fwd * FwdComp) + (Right * RightComp);

if (Dir.IsNearlyZero()) Dir = Fwd;

Dir.Normalize();

Truth table:

InputResult
WForward
ALeft
DRight
WAForward-left (45°)
WDForward-right (45°)
SForward (back zeroed → fallback)
SALeft (back zeroed)
SDRight (back zeroed)
no inputForward

#Tags (new)

Add to SLTags.h / SLTags.cpp:


namespace SLTags::Events::Mobility

{

    Dash;

    DoubleJump;

}



namespace SLTags::States::Character

{

    Dashing;

    DashCooldown;

    HasDoubleJumped;

    Grounded;             // optional, if we want a grounded gate via tag

}



namespace SLTags::Abilities::Mobility

{

    Dash;

    DoubleJump;

}

GameplayCue tags (registered via the tags ini, not C++):

  • GameplayCues.Mobility.Dash
  • GameplayCues.Mobility.DoubleJump

#HUD

New widget USLMobilityIndicatorWidget (C++ base + WBP_SL_MobilityIndicator BP subclass), placed in the lower-left of WBP_SL_HUD (or wherever the concept art shows). Two visual elements:

  1. Dash bar — N segments where N = MaxDashCharges. Each segment lit if i < CurrentDashCharges.
  2. Segment styles: ready (cyan), recharging (dim pulse), empty (red outline only).

  1. Double Jump indicator — single chevron icon. Lit when HasDoubleJumped tag is absent; greyed when present.

Binding (in InitializeLocalPlayerHUD alongside BindWeaponDelegates):


// USLHUDWidget::BindMobilityDelegates(Character)  ← BlueprintImplementableEvent

//   MobilityComp->OnDashChargesChanged.AddDynamic(MobilityIndicator, &USLMobilityIndicator::SetCharges);

//   MobilityComp->OnDoubleJumpAvailableChanged.AddDynamic(MobilityIndicator, …);

Same pattern as BindWeaponDelegates (per memory feedback_weapon_hud_binding) — bind from C++ in InitializeLocalPlayerHUD, not from a GAS ability. Race condition avoided.


#Cosmetics — GameplayCues

GC_SL_Mobility_Dash (GameplayCueNotify_Burst):

  • Niagara thruster jet from BackpackSocketName socket
  • Whoosh audio
  • Camera shake (light)
  • Optional: chromatic aberration / motion blur on owning client (filter in BP)

GC_SL_Mobility_DoubleJump:

  • Smaller downward thruster puff from feet sockets
  • Pop audio
  • No camera shake

Cue parameters carry the dash direction (for orienting the jet effect).


#Implementation Phases

Each phase is independently testable. Don't move on until the phase compiles, runs, and the test box is ticked.

#Phase 1 — Tags + Data Asset + Attribute Set

  • [ ] Add Events.Mobility, States.Character (Dashing, DashCooldown, HasDoubleJumped), Abilities.Mobility tags
  • [ ] Add GameplayCues.Mobility.* to Config/DefaultGameplayTags.ini
  • [ ] USLMobilityModuleDataAsset class
  • [ ] USLMobilityAttributeSet class (DashCharges, MaxDashCharges with OnRep + clamp)
  • [ ] Build clean — no editor changes yet

#Phase 2 — Component + Equip Flow

  • [ ] USLMobilityComponent on ASLCharacterBase (created in constructor)
  • [ ] EquipModule(DA) / UnequipModule() — grants/removes AbilitySet, tweaks JumpMaxCount
  • [ ] OnDashChargesChanged delegate driven by attribute set OnRep
  • [ ] Hook into PossessedBy / loadout flow: default module equipped on respawn
  • [ ] Test: equip → DashCharges == 2 (debug print), unequip → ability set removed

#Phase 3 — Dash Ability + Input

  • [ ] IA_SL_Dash input action asset
  • [ ] DashAction UPROPERTY + Dash() handler on ASLPlayerController
  • [ ] USLGameplayAbility_Dash C++
  • [ ] BP_GA_SL_Dash Blueprint (CDO config, reads tuning from module DA via owner component)
  • [ ] GE_DashChargeCost, GE_DashCooldown, GE_DashRecharge Blueprint
  • [ ] Wire input in BP_SL_PlayerController
  • [ ] Test:
    • Forward/left/right dashes work in PIE
    • 2 dashes mid-air both fire
    • 3rd attempt fails (no charges, no animation)
    • Recharge: lands → 1s later first charge ticks back, then second
    • Recharge ticks even airborne (after the cooldown), Doom Eternal style
    • Server-host PIE: damage during dash still registers, dash distance matches owning client

#Phase 4 — Double Jump Ability

  • [ ] USLGameplayAbility_DoubleJump C++
  • [ ] BP_GA_SL_DoubleJump Blueprint
  • [ ] ASLPlayerController::Jump branch — airborne + module + no tag → fire event
  • [ ] OnLanded override on ASLCharacterBase → remove HasDoubleJumped tag
  • [ ] Test:
    • First jump = normal jump
    • Second jump mid-air = thruster lift
    • Third jump = nothing
    • Landing → second jump available again
    • Dash → still allowed to double jump in same airtime (and vice versa)

#Phase 5 — HUD Indicator

  • [ ] USLMobilityIndicatorWidget C++ base (SetCharges(int32, int32), SetDoubleJumpAvailable(bool))
  • [ ] WBP_SL_MobilityIndicator BP subclass with two visual elements
  • [ ] Add as BindWidget child on WBP_SL_HUD
  • [ ] USLHUDWidget::BindMobilityDelegates(Character) BIE, called from InitializeLocalPlayerHUD
  • [ ] Test: indicators update instantly on dash / double jump / land / recharge tick

#Phase 6 — Cosmetics

  • [ ] Add MobilityModule_Socket to SK_MasterChief (upper back, between shoulder blades)
  • [ ] GC_SL_Mobility_Dash Burst cue (Niagara + audio + light shake)
  • [ ] GC_SL_Mobility_DoubleJump Burst cue
  • [ ] Optional: dash motion-blur post process toggled on owning client
  • [ ] Test: cues fire on all clients, oriented correctly relative to dash direction

#Phase 7 — Tuning Pass

  • [ ] Dial DashSpeed/Duration for the 6-9m range that feels right
  • [ ] Double jump height that feels like a meaningful boost without being floaty
  • [ ] Adjust DashRechargeTime so chained dashes feel earned, not free
  • [ ] Verify fall damage interaction (dash mid-fall — does it reset fall velocity?)

#Open Questions

  1. Dash + fire interaction: the lore says "cannot fire heavy weapons during dash." We're currently
  2. blocking nothing; the fire ability still works mid-dash. Decision: leave fire enabled for now (more fun, matches Doom Eternal). Revisit if it feels OP.

  1. Dash collision behavior: during dash, do we want to push through enemies or bounce off? Current
  2. plan: standard collision (you stop on a wall). Doom Eternal lets you dash through enemies — could ignore Pawn channel during the dash window.

  1. Fall brake / stabilization (from lore doc): not in v1. Defer to a follow-up if/when fall damage
  2. matters.

  1. Module as pickup: lore implies it's removable armor. For v1, the module is auto-equipped on
  2. spawn via a DefaultModule reference on ASLCharacterBase. Pickup/swap flow can come later.


#Files to Create

#C++ (Plugins/SystemLinkCore/Source/SystemLinkCore/)


Public/Mobility/

  SLMobilityComponent.h

Public/Mobility/Data/

  SLMobilityModuleDataAsset.h

Public/AbilitySystem/Attributes/

  SLMobilityAttributeSet.h

Public/AbilitySystem/Abilities/

  SLGameplayAbility_Dash.h

  SLGameplayAbility_DoubleJump.h



Private/Mobility/SLMobilityComponent.cpp

Private/Mobility/Data/SLMobilityModuleDataAsset.cpp

Private/AbilitySystem/Attributes/SLMobilityAttributeSet.cpp

Private/AbilitySystem/Abilities/SLGameplayAbility_Dash.cpp

Private/AbilitySystem/Abilities/SLGameplayAbility_DoubleJump.cpp

Modify:

  • Public/Player/SLPlayerController.h — add DashAction, Dash()
  • Private/Player/SLPlayerController.cpp — bind input, route Jump → DoubleJump event when airborne
  • Public/Character/SLCharacterBase.h — add MobilityComponent, override OnLanded
  • Public/GameplayTags/SLTags.h + .cpp — new tags

Mobility/

  DA_SL_MMA3.uasset                          (data asset instance)

  Abilities/

    BP_GA_SL_Dash.uasset

    BP_GA_SL_DoubleJump.uasset

  Effects/

    GE_DashChargeCost.uasset

    GE_DashCooldown.uasset

    GE_DashRecharge.uasset

  Cues/

    GC_SL_Mobility_Dash.uasset

    GC_SL_Mobility_DoubleJump.uasset

  UI/

    WBP_SL_MobilityIndicator.uasset



Input/Actions/

  IA_SL_Dash.uasset



AbilitySystem/AbilitySets/

  DA_SL_MobilityAbilitySet.uasset           (granted by module DA)

Modify:

  • BP_SL_PlayerController — add DashAction binding to IMC
  • BP_SL_MasterChief — set MobilityComponent.DefaultModule = DA_SL_MMA3
  • WBP_SL_HUD — add MobilityIndicator widget
  • IMC_SL_Default — map Left Shift / Left Bumper → IA_SL_Dash
  • SK_MasterChief — add MobilityModule_Socket on upper back

#Test Checklist (end-to-end)

  • [ ] Player spawns with module equipped — DashCharges = 2, double jump available
  • [ ] Dash forward (W) — moves ~7m in dash direction
  • [ ] Dash left (A) and right (D) — moves laterally
  • [ ] Dash with no input — defaults to forward
  • [ ] Dash with S (back input) — defaults to forward (no back dash)
  • [ ] Dash with WA / WD — diagonal
  • [ ] Dash with SA / SD — pure left/right (back component zeroed)
  • [ ] Two dashes mid-air — both fire, third blocked
  • [ ] Lands → dash cooldown delay (1s) → recharge ticks back to 2
  • [ ] Jump mid-air = double jump (lift), second mid-air jump does nothing
  • [ ] Land resets double jump availability
  • [ ] HUD indicator: dash bar drains/refills live, double jump chevron lights/dims
  • [ ] PIE 2-player: dash anim/cue visible on the other client, server damage during dash works
  • [ ] Dies during dash — dash ends safely (no stuck-in-dash state on respawn)
  • [ ] Respawn — module re-equipped, full charges, all tags cleared
  • [ ] Unequip module (debug) — both abilities go away, JumpMaxCount back to 1

#References

  • Concept + lore: Docs/Mobility Assist Module.md
  • Concept art: Docs/Screenshots/ChatGPT Image May 23, 2026, 02_16_05 PM.png
  • Pattern to mirror: Plugins/SystemLinkCore/Source/SystemLinkCore/Public/Weapons/SLWeaponsComponent.h
  • (component lifecycle, delegate-to-HUD pattern)

  • Attribute set pattern: Public/AbilitySystem/Attributes/SLAmmoAttributeSet.h
  • Recharge gate pattern: Docs/HealthSystem.md → shield regen (GE_ShieldRegenDelay + GE_ShieldRegen with
  • OngoingTagRequirements — same shape as DashCooldown + DashRecharge)