AnimNotify is triggered multiple times during a single playback

I am working on a multiplayer TPS game and am currently developing the reload functionality. However, I encountered the following problem: when the host (server) reloads, the reload animation plays normally on both the client (other users) and the server, and the ammo count updates correctly. But when another user (client) tries to reload, there is a bug where AnimNotify triggers multiple times, causing the ammo count to be updated incorrectly.

Specifically, I placed an AnimNotify at the end of the reload Montage animation. Normally, each time the animation plays, it triggers the AnimNotify, which then updates the ammo count once. However, in the abnormal situation when the client reloads, the animation triggers the ammo update twice, meaning the AnimNotify is triggered twice within a short time during a single animation playback.

Here is the relevant logic:

void UCombatComponent::Reload()
{
    // This part is triggered by user input
    
    if (CarriedAmmo > 0 && CombatState != ECombatState::ECS_Reloading
        && EquippedWeapon
        && EquippedWeapon->GetAmmo() < EquippedWeapon->GetMagCapacity())
    {
        // If the user is on the client, send a ServerRPC request; on the server, it's treated as a normal function call
        ServerReload();
    }
}

void UCombatComponent::ServerReload_Implementation()
{
    if (Character && EquippedWeapon && Character->HasAuthority())
    {
        CombatState = ECombatState::ECS_Reloading;
        HandleReload();
    }
}

When the client reloads, the server receives the request, updates the state, and handles the reload:

void UCombatComponent::HandleReload()
{
    if (Character)
    {
        Character->PlayReloadMontage();
    }
    if (Character->HasAuthority())
    {
        UE_LOG(LogTemp, Log, TEXT("HandleReloadOnServer"));
    }
}

The server plays the Montage animation:

void APlayerCharacter::PlayReloadMontage() const
{
    if (Combat == nullptr || Combat->EquippedWeapon == nullptr) return;
    if (HasAuthority())
    {
        UE_LOG(LogTemp, Log, TEXT("PlayMontageOnServer"));
    }
    if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance(); AnimInstance
        && ReloadMontage && !AnimInstance->Montage_IsPlaying(ReloadMontage))
    {
        AnimInstance->Montage_Play(ReloadMontage);
        FName SectionName;

        switch (Combat->EquippedWeapon->GetWeaponType())
        {
        case EWeaponType::EWT_AssultRifle:
            SectionName = FName("Rifle");
            break;
        case EWeaponType::EWT_RocketLauncher:
            SectionName = FName("Rifle");
            break;
        case EWeaponType::EWT_Pistol:
            SectionName = FName("Pistol");
            break;
        case EWeaponType::EWT_SubmachineGun:
            SectionName = FName("Rifle");
            break;
        case EWeaponType::EWT_ShotGun:
            SectionName = FName("ShotGun");
            break;
        case EWeaponType::EWT_SniperRifle:
            SectionName = FName("SniperRifle");
            break;
        case EWeaponType::EWT_GrenadeLauncher:
            SectionName = FName("Rifle");
            break;
        default:
            break;
        }

        AnimInstance->Montage_JumpToSection(SectionName);
    }
}

Then, when the Montage animation reaches the end, the AnimNotify placed at the end is triggered. The AnimNotify triggers:

void UCombatComponent::FinishReloading()
{
    UE_LOG(LogTemp, Log, TEXT("%d"), Character->GetLocalRole());
    UE_LOG(LogTemp, Log, TEXT("Finish Reloading ■■■■ ■■■■ why????????????"));
    if (Character == nullptr) return;
    if (Character->HasAuthority() && CombatState == ECombatState::ECS_Reloading)
    {
        UE_LOG(LogTemp, Log, TEXT("Finish Reloading HasAuthority ■■■■ ■■■■ why????????????"));
        CombatState = ECombatState::ECS_Unoccupied;
        UpdateAmmoValue();
    }
}

void UCombatComponent::UpdateAmmoValue()
{
    UE_LOG(LogTemp, Log, TEXT("Update Ammo"));
    if (EquippedWeapon == nullptr || Character == nullptr) return;

    int32 ReloadAmount = AmountToReload();
    if (CarriedAmmoMap.Contains(EquippedWeapon->GetWeaponType()))
    {
        CarriedAmmoMap[EquippedWeapon->GetWeaponType()] -= ReloadAmount;
        CarriedAmmo = CarriedAmmoMap[EquippedWeapon->GetWeaponType()];
    }

    EquippedWeapon->AddAmmo(ReloadAmount);
    Controller = Controller == nullptr ? Cast<AMyPlayerController>(Character->Controller) : Controller;
    if (Controller)
    {
        Controller->SetHUDCarriedAmmo(CarriedAmmo);
    }
}

At this point, the server updates the ammo count. According to debug logs, FinishReloading is triggered twice even though PlayReloadMontage is only triggered once. This means the AnimNotify is being triggered twice. While this doesn’t affect the final ammo count in this case, it causes issues with the shotgun later, where the reload animation results in two bullets being loaded instead of one, affecting gameplay.

I don’t understand why the AnimNotify is triggered multiple times in these two cases, even though the Montage is only played once. Can anyone help me figure this out?

I think I have found the problem. It should be caused by ServerMovePacked triggering multiple times. I printed the stack trace in AnimNotify, and the results are as follows:

Normal case:

LogTemp: [16:57:16.103] AnimNotify Triggered
LogTemp: Callstack:

Abnormal case:

LogTemp: [17:21:57.748] AnimNotify Triggered
LogTemp: Callstack:
    /Script/Engine.Character.ServerMovePacked
LogTemp: [17:21:57.784] AnimNotify Triggered
LogTemp: Callstack:
void UShotGunReloadAnimNotify::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,  const FAnimNotifyEventReference& EventReference)
{
	Super::Notify(MeshComp, Animation);
	
	if(MeshComp && MeshComp->GetOwner())
	{
		if(APlayerCharacter* PlayerCharacter =
			Cast<APlayerCharacter>(MeshComp->GetOwner());
			PlayerCharacter && PlayerCharacter->GetCombat() && PlayerCharacter->HasAuthority())
		{
			FString Timestamp = FDateTime::Now().ToString(TEXT("%H:%M:%S.%s"));
			UE_LOG(LogTemp, Log, TEXT("[%s] AnimNotify Triggered"), *Timestamp);

			FString Callstack = FFrame::GetScriptCallstack(true);
			UE_LOG(LogTemp, Log, TEXT("Callstack:\n%s"), *Callstack);
			PlayerCharacter->GetCombat()->ShotGunShellReload();
		}
	}

	
}

Can someone tell me how to fix this?

Hi!

I just faced the same problem myself… and using your same debugging outputs I also got it was the Engine.Character.ServerMovePacked the one firing an additional time the notify in server.

In my case, don’t ask my why… enabling Root Motion in the Anim Sequence the behaviour dissapeared. But I would also love to understand why exactly this happens and how to avoid it in server without having to use root motion, in my case the animation doesn’t move the character, but in case you want to avoid root motion this is still an issue.

I hope at least it helps you with your situation.
Cheers!