On one of the coop games I worked on we got a QA report that sounded like a ghost story: an enemy attacked a player, the player got hit, and on that player’s screen the enemy never moved. The teammate standing right next to them saw the full attack animation. Same enemy and same attack, but the two screens were showing different things.
We will get to what that bug was ( and why the fix was not in the ability ). But to get there we need to talk about how montages travel through the Gameplay Ability System ( GAS ): what PlayMontageAndWait really does, what the Ability System Component ( ASC ) replicates when a montage starts, and why you should never call Montage_Play directly from an ability.
First, a 30 second recap of ability tasks
ActivateAbility is a normal function call, it runs and returns in a single call stack. It can not stop in the middle and wait three seconds, or wait for an animation to finish. So every time an ability “waits” for something, what is really happening is that the ability spawns an ability task: a small object that outlives the function call and calls back into the ability through delegates when the thing it was waiting for happens.
I wrote about the most useful ones a while ago in From Wait Delays to Play Montage: 10 useful GAS Ability Tasks. Today we care about the one that nearly every melee, cast or channel ability is built on: UAbilityTask_PlayMontageAndWait.
Playing a montage from an ability
The typical melee ability starts like this:
void UGA_MeleeAttack::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
UAbilityTask_PlayMontageAndWait* MontageTask =
UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
this, TEXT("Attack"), AttackMontage);
MontageTask->OnCompleted.AddDynamic(this, &UGA_MeleeAttack::OnMontageFinished);
MontageTask->OnInterrupted.AddDynamic(this, &UGA_MeleeAttack::OnMontageInterrupted);
MontageTask->OnCancelled.AddDynamic(this, &UGA_MeleeAttack::OnMontageInterrupted);
MontageTask->ReadyForActivation();
}
( If you work in Blueprint, the Play Montage and Wait node is exactly this, with the delegates as output pins. Blueprint also calls ReadyForActivation for you. In C++ you have to call it yourself, and if you forget it the task simply never starts, which is a classic. )
The factory has a few more parameters than the ones I am using, and some of them are worth knowing:
- Rate scales the playback speed.
- StartSection starts the montage at a named section, and StartTimeSeconds starts it at an offset into the timeline instead. The offset one is more useful than it looks, for instance to skip the wind up when a buffered combo continues into the next attack.
- AnimRootMotionTranslationScale scales whatever root motion is baked in the clip, so one ability can shorten a dash without touching the animation.
- bStopWhenAbilityEnds decides if a normal EndAbility stops the montage. Careful here: this flag only covers the normal end. An explicit ability cancel stops the montage no matter what you set.
You can see all of them in the engine source, in Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/Abilities/Tasks/AbilityTask_PlayMontageAndWait.h.
OnBlendOut and OnCompleted are different moments
This is the part that trips everybody at least once, me included. Seems that OnBlendOut and OnCompleted are two flavors of “the montage is over”, but they are bound to different montage notifications and fire at different moments.
OnBlendOut fires the instant the montage starts blending out, while the blend is still in progress. OnCompleted fires once the montage has fully finished, blend included, and only if nothing interrupted it. A montage that plays out normally fires both, OnBlendOut first and OnCompleted a little later. And if another montage plays over this one before it finishes, OnInterrupted fires and OnCompleted never does.
So the rule I follow: anything that means “the attack is truly finished” ( ending the ability, cleanup, checking a follow up ) goes on OnCompleted, and anything that means “start the next thing overlapping the tail of this one” goes on OnBlendOut. If you wire “truly finished” logic to OnBlendOut because it seems to fire reliably, your follow ups will start mid blend. And if you wire it to OnCompleted without handling OnInterrupted, an interrupted swing will never run its cleanup.
There is a fourth exit that people forget. If the ability calls EndAbility while the montage is still playing ( with bStopWhenAbilityEnds true ), the montage is stopped as a side effect of ending. From the point of view of the task this looks like an interruption even though no other montage played over it. So in the end you have three endings that are all real: OnCompleted, OnInterrupted, and “the ability simply ended for some other reason”. Write your cleanup assuming any of them can happen and you will not get caught out.
What the ASC replicates when a montage plays
Now the interesting part. Instead of touching the anim instance directly, PlayMontageAndWait calls PlayMontage on the owning ASC. The reason for the indirection is replication. The best way to understand it is that the ASC holds the state of “what montage is currently playing for this actor’s abilities”, and that state replicates like everything else the ASC owns. The anim instance just plays whatever it is told to play.
The ASC keeps a small replicated struct describing the current GAS driven montage ( FGameplayAbilityRepAnimMontage, you can read it in GameplayAbilityRepAnimMontage.h ): the montage asset, the play rate, the blend time, the section that should play next and a handful of flags. When a montage starts on the authority, the struct is written and replicated down, and on each client OnRep_ReplicatedAnimMontage reads it and starts the same montage locally. That is how a simulated proxy ( the enemy or the remote player you are just watching ) plays the same attack the server is playing.
Two details of this struct that I find really interesting:
Position is not replicated every frame. Streaming the montage position per tick would be expensive and also unnecessary, clients can play the montage on their own once they are told to start it. So position only travels when a correction is needed, and the receiving client nudges its local playback toward the corrected value instead of snapping to it every frame.
There is a field whose only job is replaying the same montage. Think about what happens when you play the same montage twice in a row ( ex: a rapid double tap of the same attack ). The second play would write the exact same values, so the struct would be byte identical and the “did it change” check would never trigger. Simulated proxies would silently miss the second swing. To avoid this the struct carries a small play counter that gets bumped on every start, which forces the replication callback to fire even for a repeat of the same asset. PlayMontage on the ASC handles this for you.
And this is exactly why calling Montage_Play on the anim instance from ability code is a trap. It plays locally, but the replicated struct is never written, so simulated proxies never see it. On your machine everything looks perfect, which makes it worse, because you will not notice until somebody tests with two players.
The client that never saw the attack
Back to the ghost story from the beginning. In our bug the montage was replicating correctly, and the struct arrived at the client with all the right data. The problem was on the receiving end: the montage arrived before that client had an anim instance for the enemy. The pawn had just become network relevant for that player, its mesh was not fully set up yet, and an anim instance that does not exist can not play anything.
The engine actually guards against this. OnRep_ReplicatedAnimMontage checks that the avatar is ready before playing ( there is a virtual, IsReadyForReplicatedMontage, that you can override with your own readiness conditions ), and if it is not ready, the engine defers the montage and replays it once the anim instance exists instead of dropping it. Which is incredibly useful, but it also means that if the avatar takes half a second to be ready, the attack animation plays half a second late, and for a fast enemy attack that reads as “the animation never played”.
So the fix was not in the ability, and in my experience it almost never is for this kind of bug. When one specific client misses an animation that everybody else saw, audit when that client actually creates the skeletal mesh and the anim instance for that character. And if you have overridden the readiness check yourself, confirm it eventually returns true, because a readiness check that never passes means every montage for that actor gets deferred forever.
Combos advance through the ASC too
Mid montage control follows the same rule as playback. If your combo jumps to a named montage section when the player buffers the next attack, do not call Montage_JumpToSection on the anim instance. The ASC has its own entry points, CurrentMontageJumpToSection and CurrentMontageSetNextSectionName, and they mirror the section change to the other machines through a server RPC, so every simulated proxy shows the same section at the same time.
This matters the moment your combat has combos at all. The buffered inputs from the input buffering post advance the attack montage section by section, and if those jumps went through the anim instance, the owning client and everyone watching would drift apart after the first combo, each machine playing a different section of the same montage.
Recap
- OnBlendOut fires when the blend out starts, OnCompleted only on an uninterrupted full finish, and an interrupted montage never fires OnCompleted at all. Put “the attack is truly done” on OnCompleted, and remember that OnInterrupted and “the ability ended early” are endings too.
- Every GAS montage operation goes through the ASC: PlayMontage to start, CurrentMontageJumpToSection and CurrentMontageSetNextSectionName to control sections. Montage_Play on the anim instance plays only on your machine, because the replicated montage struct is never written, and simulated proxies see nothing.
- When one specific client misses an animation, the ability is usually innocent. The ASC defers montages that arrive before the avatar’s anim instance exists and replays them later, so the real question is why that client built the mesh and anim instance late.


