Gameplay · Updated 2552.07.31.08.08
Melee
Halo-style melee — one punch, view-based sphere sweep, damage applied on the authority at the impact frame determined by an AnimNotify. Fires are Local Predicted.
Halo-style melee — one punch, view-based sphere sweep, damage applied on the authority at the impact frame determined by an AnimNotify. Fires are Local Predicted.
#What's Done (C++)
#USLGameplayAbility_Melee — Plugins/SystemLinkCore/Source/SystemLinkCore/Public/AbilitySystem/Abilities/SLGameplayAbility_Melee.h
Base ability class. Blueprint subclasses handle montage, CDO config, and cosmetics.
| Property | Default | Notes |
|---|---|---|
MeleeDamage | 100 | Applied through USLDamageExecution (shield-first) |
MeleeRange | 200 | cm forward from view origin |
MeleeRadius | 55 | Sphere radius |
MeleeDamageEffect | — | Set to GE_Damage in the BP CDO |
NetExecutionPolicy = LocalPredicted, InstancedPerActor.
Damage timing is timer-driven, NOT notify-driven. AnimNotifies are unreliable on the server (the pose may not tick for off-screen client characters; URO can skip the notify frame), which caused client melee to deal damage inconsistently — the AR especially, because its faster anim has a narrower notify window. So:
- Authority starts a timer in
ActivateAbilityforMeleeImpactDelay(on the weapon data asset)
→ ApplyServerMeleeDamage() → sweep + ApplyMeleeDamage() + impact cue. Deterministic, no animation dependency. Timer is cleared in EndAbility (so a cancelled swing applies no damage).
- Owning client still uses the
MeleeImpactnotify →PerformMeleeSweep()for PREDICTED FX
only (the client always renders itself, so its notify is reliable).
Ability END is C++-owned (both sides). ActivateAbility also starts a world timer for MeleeSwingDuration → EndMeleeFromTimer() → K2_EndAbility(). A plain world timer is NOT subject to ability-task prediction rollback, so it guarantees the Meleeing ActivationOwnedTag is removed even if a client-side rollback orphans the BP's Delay → EndAbility task. Without this, a stuck Meleeing tag permanently blocks melee AND fire on that client (it's in BlockAbilitiesWithTag and the fire abilities' ActivationBlockedTags). ActivateAbility also early-outs if the ability ended during Super (if (!IsActive()) return;) so a damage timer can't fire orphaned.
The BP's Delay → EndAbility is now redundant (C++ guarantees the end). Safe to leave it, or
remove it from BP_GA_SL_Melee to avoid two end paths. If kept, whichever fires first ends the
ability; the other no-ops (guarded by IsActive()).
PerformMeleeSweep() — owning-client FX only. Sphere-sweeps ECC_Pawn from the pawn's eye position along the aim (control rotation) direction. Character hit → OnMeleeHit + HitCameraShake. No character → ECC_Visibility wall sweep → OnMeleeWallHit + WallHitCameraShake. Does NOT apply damage. No-op on non-locally-controlled pawns.
FindMeleeTarget(OutHit) — shared sweep helper used by both the client FX path and the authority damage path. ApplyServerMeleeDamage() — authority timer callback: sweep + damage.
OnMeleeHit(Hit) — BlueprintImplementableEvent BlueprintCallable. Fire impact sound, screen shake, hit marker here. Only fires on the locally controlled client.
MeleeImpactDelaylives on the weapon data asset (per-weapon, likeMeleeSwingDuration). Set it
to match the frame the fist connects in that weapon's melee anim. Must be less than
MeleeSwingDuration or the ability ends (clearing the timer) before damage lands.
#Input — ASLPlayerController
MeleeAction (TObjectPtr<UInputAction>) bound on ETriggerEvent::Started. Dead-check guards the handler. Melee() fires SLTags::Events::Character::Melee to the pawn's ASC via HandleGameplayEvent.
IA_SL_Melee input action asset exists and is wired on the BP_SL_PlayerController CDO (R3 / right-stick-click on gamepad). ✅
#Tags — SLTags.h / SLTags.cpp
| Tag | String | Purpose |
|---|---|---|
SLTags::Events::Character::Melee | SLTags.Events.Character.Melee | Input → ability trigger |
SLTags::Events::Character::MeleeImpact | SLTags.Events.Character.MeleeImpact | AnimNotify fires this at the impact frame |
SLTags::States::Character::Meleeing | SLTags.States.Character.Meleeing | ActivationOwnedTag — blocks re-trigger and fire during the swing |
SLTags::Abilities::Melee | SLTags.Abilities.Melee | AbilityTag on the ability |
#Anim State
FSLAnimState::bIsMeleeing — snapshotted every tick in BuildAnimSnapshots() from ASC->HasMatchingGameplayTag(SLTags::States::Character::Meleeing). Available on all clients because gameplay tags replicate. Drive the Melee state in ABP state machines on this bool.
#Unarmed as a Default Weapon (2026-05-29)
Unarmed used to be a null state — UnequipWeapon linked nullptr, reverting to the bare base ABP graph, so ABP_MC_Unarmed was orphaned and unarmed melee had no anim/values home. Unarmed now follows the weapon pattern: a USLWeaponDataAsset equipped as the default whenever no real weapon is held (no mesh, no fire mode — just an anim layer + melee values).
C++ (USLWeaponsComponent):
DefaultUnarmedData(USLWeaponDataAsset*, EditDefaultsOnly) — set toDA_SL_Unarmedin BP.
GetEquippedWeaponData()falls back toDefaultUnarmedDatawhenEquippedWeaponis null, so
melee timing (MeleeSwingDuration, MeleeImpactDelay), obstruction values, and other per-weapon queries resolve to unarmed values instead of null.
OnRep_EquippedWeapon's null branch links the unarmed anim layer via the view drivers'
EquipWeapon (no mesh — AttachWeapon early-returns). Placed in OnRep (not UnequipWeapon) so it's consistent on the host and all clients — the null branch runs both when the server unequips and when EquippedWeapon replicates to null. (If linked in UnequipWeapon, OnRep's null branch would immediately undo it.)
Editor — create DA_SL_Unarmed:
ThirdPersonSkeletalMeshData.AnimLayerClass=ABP_MC_Unarmed; noWeaponMesh.
FirstPersonSkeletalMeshData.AnimLayerClass= FP unarmed layer (createABP_MC_FP_Unarmedif it
doesn't exist — FP melee needs its own layer with a Melee state; leaving it empty falls back to the base FP graph which has no melee state).
MeleeSwingDuration+MeleeImpactDelayfor the punch timing. No fire mode.
- Assign to
DefaultUnarmedDataonBP_SL_MasterChief's WeaponsComponent.
- Add a Melee state to
ABP_MC_Unarmed(+ the FP unarmed layer), driven bybIsMeleeing, same
pattern as AR/Shotgun.
Unarmed is a default equipped state, not an inventory weapon — it's never in CarriedWeapons,
not cyclable, not droppable. bHasEquippedWeapon becomes true while unarmed (it's "equipped"),
so sway/lag/obstruction apply to the bare arms — tune those on DA_SL_Unarmed if the bare-arm
behavior looks off.
#Animation Assets (Lyra library, already in project)
| Asset | Location |
|---|---|
MC_Rifle_Melee | Content/SystemLink/Characters/MasterChief/Anims/Weapons/AssaultRifle/Melee/ |
MC_Rifle_Melee_Additive | same |
MC_Rifle_Melee (Lyra source) | …/Library/Animations1/Lyra/Locomotion/Rifle/ |
MC_Pistol_Melee / _Additive | …/Lyra/Locomotion/Pistol/ |
MC_Shotgun_Melee / _Additive | …/Lyra/Locomotion/Shotgun/ |
#Still To Do (Editor)
#1. IA_SL_Melee Input Action — ✅ done (2026-05-24)
- Asset at
Content/SystemLink/Inputs/Actions/IA_SL_Melee(Digital bool)
- Mapped in
IMC_Default
- Gamepad: Right Stick Click (R3) — Doom Eternal style, not RB
- Wired to
MeleeActiononBP_SL_PlayerControllerCDO → Combat category
#2. BP_GA_SL_Melee Ability Blueprint — ✅ done (2026-05-24)
Location: Content/SystemLink/AbilitySystem/Abilities/Melee/BP_GA_SL_Melee. Parent class: USLGameplayAbility_Melee.
Implementation notes:
- Used
ActivationBlockedTags(Dead + Meleeing) instead ofBlockAbilitiesWithTagfor the re-trigger guard — same effect, more direct
- Activation graph: ActivateAbility → CommitAbility → Branch(True) → WaitGameplayEvent(MeleeImpact, OnlyTriggerOnce, OnlyMatchExact) → Event Received → PerformMeleeSweep; Async Task → Delay(0.6) → EndAbility
CDO settings:
| Field | Value |
|---|---|
AbilityTags | SLTags.Abilities.Melee |
ActivationOwnedTags | SLTags.States.Character.Meleeing |
BlockAbilitiesWithTag | SLTags.States.Character.Meleeing |
| Trigger[0] Tag | SLTags.Events.Character.Melee |
| Trigger[0] Source | GameplayEvent |
MeleeDamageEffect | GE_Damage |
MeleeDamage | 100 |
MeleeRange | 200 |
MeleeRadius | 55 |
Activation graph:
Event Activate Ability
├── CommitAbility
├── WaitGameplayEvent(SLTags.Events.Character.MeleeImpact)
│ └── On Event Received → PerformMeleeSweep
└── Delay(swing duration e.g. 0.5s) → EndAbility
Implement OnMeleeHit: play impact sound, camera shake, hit marker.
#3. Grant via Ability Set — ✅ done (2026-05-24)
Added BP_GA_SL_Melee to Content/SystemLink/Characters/AS_AblitySet_Default (note the existing "Ablity" typo — leave as-is for now).
#4. Block Fire During Swing — ✅ done (2026-05-26)
Added SLTags.States.Character.Meleeing to ActivationBlockedTags on the fire abilities (BP_GA_SL_AssualtRifle_PrimaryFire, BP_GA_SL_Shotgun_PrimaryFire, or their shared parent BP_GA_SL_PrimaryFireBase).
#5. ABP Melee State — ✅ done for AR + Shotgun (2026-05-29)
Per weapon-layer ABP (ABP_MC_FP/TP_AssaultRifle, ABP_MC_FP/TP_Shotgun):
- Melee state in the weapon-layer state machine, non-looping melee anim, Always Reset on Entry.
- Transitions on
AnimStateSnapshot.bIsMeleeing(in/out), Inertial Blend + Inertialization node.
- Remaining:
ABP_MC_Unarmedneeds the same Melee state (+ an unarmed melee anim).
#6. AnimNotify on Melee Anim — ✅ done
SLAnimNotify_SendGameplayEvent(SLTags.Events.Character.MeleeImpact) placed at the impact frame on the FP + TP melee anims. Note: the notify now only drives owning-client predicted FX. Authority damage is timer-driven via MeleeImpactDelay (see the damage-timing section) — set that per weapon.
#7. Remaining polish (editor + assets)
- Unarmed — C++ done (unarmed-as-default-weapon, see "Unarmed as a Default Weapon" above).
Editor: create DA_SL_Unarmed, wire DefaultUnarmedData, add Melee state to ABP_MC_Unarmed (+ FP unarmed layer), author an unarmed punch anim.
- SFX —
GC_SL_MeleeImpactcue (assign tag toMeleeImpactCueTag),OnMeleeHitpredicted hit
sound, OnMeleeWallHit wall thud.
- Camera shakes — create
CS_SL_MeleeSwing/CS_SL_MeleeHit(values above), assign to the
three shake fields on BP_GA_SL_Melee.
- Range tuning — drop
MeleeRange→~130,MeleeRadius→~40 on eachBP_GA_SL_MeleeCDO.
#Camera Shake
USLGameplayAbility_Melee exposes three TSubclassOf<UCameraShakeBase> fields, all fired in C++ on the owning client only (local cosmetic — no replication):
SwingCameraShake— fired atActivateAbility(swing start)
HitCameraShake— fired inPerformMeleeSweepwhen the sweep confirms a character hit
WallHitCameraShake— fired inPerformMeleeSweepwhen the swing connects with world geometry
(no character). Assign a harder "clang" shake, or reuse HitCameraShake for identical feel.
#Setup
- Create two Blueprints parented to
UCameraShakeBaseunderContent/SystemLink/Camera/Shakes/:
CS_SL_MeleeSwing and CS_SL_MeleeHit.
- On each, set the Root Shake Pattern to Wave Oscillator Camera Shake Pattern.
- Apply the values below.
- Assign both classes on the
BP_GA_SL_MeleeCDO.
#CS_SL_MeleeSwing (light, quick — sells the windup)
| Parameter | Value |
|---|---|
| Duration | 0.20 |
| Blend In Time | 0.03 |
| Blend Out Time | 0.10 |
| Rotation Amplitude Mult | 1.0 |
| Pitch — Amplitude / Frequency | 1.2 / 18 |
| Yaw — Amplitude / Frequency | 0.8 / 16 |
| Roll — Amplitude / Frequency | 0.6 / 14 |
| Location Amplitude Mult | 1.0 |
| X (fwd) — Amplitude / Frequency | 0.8 / 18 |
| Y (lat) — Amplitude / Frequency | 0.5 / 16 |
| Z — Amplitude / Frequency | 0 / 0 |
| FOV — Amplitude | 0 |
#CS_SL_MeleeHit (heavy, snappy onset — sells the impact)
| Parameter | Value |
|---|---|
| Duration | 0.35 |
| Blend In Time | 0.01 (near-instant punch) |
| Blend Out Time | 0.22 (slower release = weight) |
| Rotation Amplitude Mult | 1.0 |
| Pitch — Amplitude / Frequency | 3.0 / 22 |
| Yaw — Amplitude / Frequency | 1.8 / 18 |
| Roll — Amplitude / Frequency | 1.5 / 16 |
| Location Amplitude Mult | 1.0 |
| X (fwd kick) — Amplitude / Frequency | 3.5 / 20 |
| Y (lat) — Amplitude / Frequency | 1.5 / 18 |
| Z (vert) — Amplitude / Frequency | 2.0 / 20 |
| FOV — Amplitude / Frequency | 1.5 / 18 (optional subtle punch) |
Notes:
- Rotation amplitude is in degrees, frequency in Hz (oscillations/sec).
- The hit shake's low blend-in + longer blend-out is what makes it feel like a thud rather than a
rattle. Keep blend-in tiny.
- If it feels too strong, scale down the Rotation/Location Amplitude Multipliers rather than
editing every axis — one knob tunes the whole shake.
- Waveform can stay Sine for both. Perlin Noise reads more "organic/handheld" if you ever want a
less rhythmic feel.
#Test Checklist
- [ ] Melee input fires the ability in PIE (single player)
- [ ]
bIsMeleeingdrives the TP swing anim visibly
- [ ]
PerformMeleeSweephits a character standing at ~150 cm, misses at ~300 cm
- [ ] Authority applies damage: target loses shield/health on hit
- [ ]
OnMeleeHitfires on owning client (FX / shake)
- [ ] Melee does NOT fire while dead
- [ ] Fire ability is blocked during swing (
ActivationBlockedTags)
- [ ] Melee cannot be re-triggered mid-swing (
BlockAbilitiesWithTag)
- [ ] PIE two-player: swing anim visible on the non-owning client, damage applied server-side
#Target Lunge (2026-07-30)
Halo's melee feels close-range but generous because the character lunges at a detected target. Before this, the lunge was a fixed forward nudge — MeleeLungeSpeed along the actor's forward vector, identical whether an enemy was three metres away or nobody was there — so all the reach came from raw MeleeRange + MeleeRadius, which reads as a "reach attack" rather than a lunge.
#What it does now
- Direction points at the acquired enemy, not straight ahead. Horizontal only: lunging at someone
above or below closes the ground gap instead of flinging you off the floor.
- Speed interpolates with the gap —
MeleeLungeSpeedat zero distance, rising to
MeleeTargetLungeSpeed at MeleeLungeRange. A target already in your face keeps the old nudge; one at the edge of acquisition gets the full leap. Distance follows from speed under braking friction, so both grow together and there is no second knob to keep consistent.
- No target = exactly the old behaviour. Null is the ordinary case, not an error.
#Tuning (per weapon, on USLWeaponDataAsset)
| Property | Default | Notes |
|---|---|---|
MeleeLungeSpeed | 600 | Also the floor with a target, and 0 disables lunging entirely |
MeleeTargetLungeSpeed | 1600 | Values below MeleeLungeSpeed are ignored — acquiring an enemy must never slow you down |
MeleeLungeRange | 500 | Deliberately longer than MeleeRange; the point is covering ground the swing cannot. 0 disables target lunging |
MeleeLungeRadius | 90 | Wider than MeleeRadius on purpose — acquisition only chooses, it does not widen what the swing can hit |
The acquisition sweep uses ECC_Pawn, matching the melee damage sweep rather than the shot channel, so the thing that gets lunged at and the thing that gets hit agree.
#Replication contract — read this before changing it
The target is chosen once, by the machine that owns the input. ASLPlayerController::Melee calls USLGameplayAbility_Melee::FindLungeTarget and puts the result in FGameplayEventData::Target; GAS forwards the trigger data to the server for the predicted activation. Each machine then derives the lunge vector from its own copy of that actor, so only the choice crosses the wire.
Letting both sides sweep independently would risk them disagreeing about whether a target exists at all — a 1600-speed leap against a 600-speed nudge, which reads as a rubber-band. This is the dash direction bug in a second place (Docs/Footguns.md → GAS — abilities that move the character).
Two consequences worth knowing:
LaunchCharacteris not replayed on a correction.FSavedMove_Characterdoes not store
PendingLaunchVelocity. The lunge works because both machines apply a matching launch at nearly the same moment; the server's lands ~RTT/2 later, so a strafing target has moved and the two directions differ by a few degrees. Harmless at 600, worth watching at 1600. If it rubber-bands, the fix is a root motion source (as the dash did), not a smaller number.
- Damage is never affected by a bad target.
ApplyServerMeleeDamagestill runs its own independent
authority-side sweep at the impact frame, so the lunge target and the damage target are fully decoupled. A Target that fails to replicate costs you a nudge instead of a leap — never a wrong hit.
#Debugging
sl.Melee.Debug 1 logs the target, direction and speed on every machine that decides a lunge; 2 also draws the acquisition sweep (yellow) and the resolved lunge (server red, client green).
Run it on both machines in a two-player PIE and compare. The failure this exists to catch is silent: if Target does not survive the trip, the server falls back to a plain forward lunge while the client leaps, and the symptom is "melee feels inconsistent" rather than anything that looks like replication.
#Known gaps
- You can lunge off a ledge. Capsule collision stops you at walls, but nothing gates the lunge at
an edge. Left in deliberately — it is Doom-ish, and the Kill Z failsafe now handles the fall — but it is a design call, not an oversight.
MeleeImpactDelayis not gap-aware. It is a fixed 0.3 s, so a maximum-range lunge could in
principle land its damage sweep before you arrive. Watch for it at the far edge; the fix would be timing, not speed.
- Corpses are valid lunge targets.
ClassifyHitreturnsHostilefor anyASLCharacterBasethat
is not the viewer, dead or alive. The reticle and the melee damage sweep already behave this way, so this is consistent rather than special — but it is worth a pass if bodies start sticking around.
#Test checklist
- [ ] Lunge at an enemy ~4 m away is visibly faster and further than a swing at nothing
- [ ] Swing with no enemy in front is unchanged from before
- [ ] Enemy directly above/below does not launch you vertically
- [ ] As a non-host client, lunging at the host gives the fast leap (not the 600 nudge) — this is
the FGameplayEventData::Target replication check, and it fails silently
- [ ]
sl.Melee.Debug 1on host and client report the same target and similar speed
- [ ] No rubber-band on a long lunge at a strafing target
#Future Enhancements
Keep MeleeRange / MeleeRadius dialed conservatively (~130 / ~40) so stationary melee reach feels right — the lunge closes the gap, so the damage sweep does not need to be long. See the "hitting from too far" tuning discussion: effective reach is MeleeRange + MeleeRadius + target capsule radius.