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 Jump | Dash | |
|---|---|---|
| Resource | 1 extra jump per airtime (refreshes on land) | 2 charges, regen on a timer |
| Trigger | Second press of Jump while airborne | IA_SL_Dash input |
| Direction | Up | Forward / left / right (no back) |
| Air limit | 1 per airtime | 2 per airtime (limited by charges) |
| Cost | None (just the jump count) | -1 charge |
| HUD | Single 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
BlueprintAssignabledelegates for HUD binding — same pattern asUSLWeaponsComponent
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
#USLMobilityModuleDataAsset — Public/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");
};
#USLMobilityComponent — Public/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
OriginalJumpMaxCountfrom CMC, setsJumpMaxCount = 2. - Initializes
DashCharges = MaxDashChargesvia attribute set. - Broadcasts
OnModuleEquippedBP delegate.
- On unequip:
- Removes ability set, restores
JumpMaxCount, broadcastsOnModuleUnequipped.
- Delegates (BlueprintAssignable, for HUD):
OnDashChargesChanged(int32 Current, int32 Max)— bound to MobilityAttributeSet's OnRepOnDoubleJumpAvailableChanged(bool bAvailable)— bound toStates.Character.HasDoubleJumpedtag 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).
#USLMobilityAttributeSet — Public/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_Dash — Public/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):
| Field | Value |
|---|---|
AbilityTags | SLTags.Abilities.Mobility.Dash |
ActivationOwnedTags | SLTags.States.Character.Dashing |
BlockAbilitiesWithTag | SLTags.States.Character.Dashing, SLTags.States.Character.Meleeing |
ActivationBlockedTags | SLTags.States.Character.Dead, SLTags.States.Character.Dashing |
| Trigger[0] | SLTags.Events.Mobility.Dash, GameplayEvent |
CostGameplayEffectClass | GE_DashChargeCost (-1 to DashCharges, requires DashCharges > 0) |
Activate flow:
CommitAbility— runs cost check; bails out if DashCharges < 1 (no charges → no dash, no animation).
- Compute dash direction (see § "Dash Direction" below).
- Cache
OriginalGroundFriction,OriginalGravityScaleon CMC; zero gravity + ground friction so the
launch holds its velocity for the dash duration.
LaunchCharacter(DashDir DashSpeed, /XYOverride/ true, /ZOverride*/ true).
- Apply
GE_DashCooldown(HasDuration =DashRechargeDelay, grantsStates.Character.DashCooldown) —
this gates the recharge GE.
- Apply
GE_DashRecharge(Infinite, Period =DashRechargeTime, Modifier = +1 to DashCharges,
OngoingTagRequirements.MustNotHaveTags = DashCooldown, removed once at MaxDashCharges).
- Trigger
DashCueTag(thruster VFX + audio, replicated via GAS).
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_DoubleJump — Public/AbilitySystem/Abilities/SLGameplayAbility_DoubleJump.h
LocalPredicted, InstancedPerActor.
CDO:
| Field | Value |
|---|---|
AbilityTags | SLTags.Abilities.Mobility.DoubleJump |
ActivationOwnedTags | SLTags.States.Character.HasDoubleJumped |
ActivationBlockedTags | SLTags.States.Character.HasDoubleJumped, SLTags.States.Character.Dead |
| Trigger[0] | SLTags.Events.Mobility.DoubleJump, GameplayEvent |
Activate flow:
- Validate
CharacterMovement->IsFalling()(no double jump from grounded — that's just a normal jump).
LaunchCharacter(FVector(0, 0, DoubleJumpZVelocity), false, true)— Z override only, preserve horizontal momentum.
- Trigger
DoubleJumpCueTag.
EndAbilityimmediately.
HasDoubleJumped tag cleared on landing — OnLanded 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:
| Input | Result |
|---|---|
| W | Forward |
| A | Left |
| D | Right |
| WA | Forward-left (45°) |
| WD | Forward-right (45°) |
| S | Forward (back zeroed → fallback) |
| SA | Left (back zeroed) |
| SD | Right (back zeroed) |
| no input | Forward |
#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:
- Dash bar — N segments where N =
MaxDashCharges. Each segment lit ifi < CurrentDashCharges.
Segment styles: ready (cyan), recharging (dim pulse), empty (red outline only).
- Double Jump indicator — single chevron icon. Lit when
HasDoubleJumpedtag 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
BackpackSocketNamesocket
- 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.Mobilitytags
- [ ] Add
GameplayCues.Mobility.*toConfig/DefaultGameplayTags.ini
- [ ]
USLMobilityModuleDataAssetclass
- [ ]
USLMobilityAttributeSetclass (DashCharges, MaxDashCharges with OnRep + clamp)
- [ ] Build clean — no editor changes yet
#Phase 2 — Component + Equip Flow
- [ ]
USLMobilityComponentonASLCharacterBase(created in constructor)
- [ ]
EquipModule(DA)/UnequipModule()— grants/removes AbilitySet, tweaksJumpMaxCount
- [ ]
OnDashChargesChangeddelegate 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_Dashinput action asset
- [ ]
DashActionUPROPERTY +Dash()handler onASLPlayerController
- [ ]
USLGameplayAbility_DashC++
- [ ]
BP_GA_SL_DashBlueprint (CDO config, reads tuning from module DA via owner component)
- [ ]
GE_DashChargeCost,GE_DashCooldown,GE_DashRechargeBlueprint
- [ ] 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_DoubleJumpC++
- [ ]
BP_GA_SL_DoubleJumpBlueprint
- [ ]
ASLPlayerController::Jumpbranch — airborne + module + no tag → fire event
- [ ]
OnLandedoverride onASLCharacterBase→ removeHasDoubleJumpedtag
- [ ] 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
- [ ]
USLMobilityIndicatorWidgetC++ base (SetCharges(int32, int32),SetDoubleJumpAvailable(bool))
- [ ]
WBP_SL_MobilityIndicatorBP subclass with two visual elements
- [ ] Add as
BindWidgetchild onWBP_SL_HUD
- [ ]
USLHUDWidget::BindMobilityDelegates(Character)BIE, called fromInitializeLocalPlayerHUD
- [ ] Test: indicators update instantly on dash / double jump / land / recharge tick
#Phase 6 — Cosmetics
- [ ] Add
MobilityModule_SockettoSK_MasterChief(upper back, between shoulder blades)
- [ ]
GC_SL_Mobility_DashBurst cue (Niagara + audio + light shake)
- [ ]
GC_SL_Mobility_DoubleJumpBurst 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
- Dash + fire interaction: the lore says "cannot fire heavy weapons during dash." We're currently
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.
- Dash collision behavior: during dash, do we want to push through enemies or bounce off? Current
plan: standard collision (you stop on a wall). Doom Eternal lets you dash through enemies — could ignore Pawn channel during the dash window.
- Fall brake / stabilization (from lore doc): not in v1. Defer to a follow-up if/when fall damage
matters.
- Module as pickup: lore implies it's removable armor. For v1, the module is auto-equipped on
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— addDashAction,Dash()
Private/Player/SLPlayerController.cpp— bind input, route Jump → DoubleJump event when airborne
Public/Character/SLCharacterBase.h— addMobilityComponent, overrideOnLanded
Public/GameplayTags/SLTags.h+.cpp— new tags
#Editor (Content/SystemLink/)
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— setMobilityComponent.DefaultModule=DA_SL_MMA3
WBP_SL_HUD— add MobilityIndicator widget
IMC_SL_Default— map Left Shift / Left Bumper → IA_SL_Dash
SK_MasterChief— addMobilityModule_Socketon 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)
Concept Art