Playing Animation Montages in Multiplayer Games

· Read in about 7 min · (1462 Words)

You can use Animation montages to play ad-hoc animations on a character. While your animation blueprint is stateful, montages are useful to just do a one-off animation like a reload, a hit reaction, or an emote.

But, if you’re making a multiplayer game, calling “PlayAnimMontage” on the character is only going to play it locally. If you want other players to see it, you need to replicate it. Ideally, you want that replication to handle lag, so that animations are mostly synced up across players.

There are quite a few videos on YouTube about this, but no blog posts about it. If you’d rather skim a blog post than watch a 15-minute video, you’re in the right place. Plus, I’ll show you how to do it in C++ here. You can totally do it in Blueprints too if you want, but there are videos for that already.

We’ll use property replication to make this happen. We could use a NetMulticast function, but that has several problems, including not playing well with network relevance in the case of longer animations, and not really being able to deal with lag as well.

If you’re using the Gameplay Ability System, montages invoked through that already do something very similar to this. However they require an associated ability, and sometimes you just want to fire off a montage for other reasons. So it’s still useful to have a more direct way.

Step 1: Define a data structure

We’ll replicate a structure of data across to all clients:

/// Information about playing a montage which makes it easy to replicate 
USTRUCT(BlueprintType)
struct FCharacterPlayMontageInfo
{
	GENERATED_BODY()

	UPROPERTY(BlueprintReadWrite)
	UAnimMontage* Montage = nullptr;

	UPROPERTY(BlueprintReadWrite)
	float PlayRate = 1.0f;

	UPROPERTY(BlueprintReadWrite)
	FName StartSectionName = NAME_None;

	/// The time which this was requested
	UPROPERTY(BlueprintReadWrite)
	double TimeRequested = 0.0f;

	/// Used to stop the same montage playing; useful if not being replaced by another
	UPROPERTY(BlueprintReadWrite)
	bool RequestStop = false;

};

You can define this as a Blueprint structure if you prefer as well, but we’ll do things in C++ here. I’ve made the properties BlueprintReadWrite mostly so you can mix & match if you want.

Step 2: Add the property to your character

In your base character class, add a property of this new type and make sure it’s marked with the ReplicatedUsing option, which names a function we’ll be called on when the clients receive an update:

// Your header file
class YOURGAME_API AYourCharacterBase : public ACharacter
{
	GENERATED_BODY()
protected:
    // Define our property
    UPROPERTY(ReplicatedUsing=OnRep_PlayMontageInfo)
    FCharacterPlayMontageInfo PlayMontageInfo;

Of course, we need to declare that “OnRep” function, and also make sure we add PlayMontageInfo to the GetLifetimeReplicatedProps function, giving us these changes to our header:

// Your header file
class YOURGAME_API AYourCharacterBase : public ACharacter
{
	GENERATED_BODY()
protected:
    // Define our property
    UPROPERTY(ReplicatedUsing=OnRep_PlayMontageInfo)
    FCharacterPlayMontageInfo PlayMontageInfo;

    // This function gets called when PlayMontageInfo is replicated to clients
	void OnRep_PlayMontageInfo();

    // We need this to finish replication in C++
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

The implementation for GetLifetimeReplicatedProps is:

void AYourCharacterBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(AYourCharacterBase, PlayMontageInfo);
}

We’ll come back to the “OnRep” function in the next section. For now, that’s all the boilerplate; now we need functions to actually perform the animation.

Step 3: Declare the Play / Stop functions

Now we need functions to both populate the replicated montage struct, and to respond to it being replicated. Let’s declare PlayAnimMontageReplicated and StopAnimMontageReplicated in the header; here’s the combined version:

// Your header file
class YOURGAME_API AYourCharacterBase : public ACharacter
{
	GENERATED_BODY()
protected:
    // Define our property
    UPROPERTY(ReplicatedUsing=OnRep_PlayMontageInfo)
    FCharacterPlayMontageInfo PlayMontageInfo;
public:

    // Play a montage everywhere in multiplayer
	UFUNCTION(BlueprintCallable, Server, Unreliable)
	void PlayAnimMontageReplicated(UAnimMontage* AnimMontage, float PlayRate = 1, FName StartSectionName = NAME_None);
    // Stop playing a montage everywhere in multiplayer
	UFUNCTION(BlueprintCallable, Server, Unreliable)
	void StopAnimMontageReplicated(UAnimMontage* AnimMontage);

protected:

    // This function gets called when PlayMontageInfo is replicated to clients
	void OnRep_PlayMontageInfo();

    // We need this to finish replication in C++
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
}

Notice how we’ve used “Server, Unreliable” in the method definitions. “Server” means that if you call this on a client, the request will instead be passed to the server. If you call this on the server, it will be executed locally immediately; and in most cases, that’s how we’ll be using it.

“Unreliable” means UE is allowed to discard this request if network bandwidth is short. This is probably OK, because animations are usually cosmetic, and we can always invoke the important ones on the server anyway. Replication of the animation information from server to clients always happens; this “Server” designation is to allow us to invoke it on a client if we want (but mostly, we won’t).

For the rest of this post, we’ll presume you’re calling PlayAnimMontageReplicated on the server. Just be aware that you can call it on the client too and have it happen, it just won’t be 100% reliable.

Step 4: The PlayAnimMontageReplicated function

This is the main function, and will always be called on the server. We want to do 2 things:

  1. Execute the animation locally
  2. Update the PlayMontageInfo property so it gets replicated to clients
void AYourCharacterBase::PlayAnimMontageReplicated_Implementation(UAnimMontage* AnimMontage,
	float PlayRate,
	FName StartSectionName)
{
    // Update the replicated struct property so clients get this info
	PlayMontageInfo.Montage = AnimMontage;
	PlayMontageInfo.PlayRate = PlayRate;
	PlayMontageInfo.StartSectionName = StartSectionName;
	PlayMontageInfo.RequestStop = false;
	if (auto GS = UGameplayStatics::GetGameState(this))
	{
        // Record when we received the request
		PlayMontageInfo.TimeRequested = GS->GetServerWorldTimeSeconds();
	}

    // Locally play the montage
	PlayAnimMontage(PlayMontageInfo.Montage, PlayMontageInfo.PlayRate, PlayMontageInfo.StartSectionName);
}

Most of this should look straight forward; the only interesting part is that we’re using GetServerWorldTimeSeconds to timestamp the request. This is so that when we get the replicated struct on clients, we know when it was intended to start.

Once we’ve set these replicated struct members, clients will eventually get told about the changes. Exactly when depends on lag, the queue of netwrok updates, and network relevancy.

We also need a way to stop a montage if we want. We do that by setting a stop flag in the same replicated struct, like this:

void AYourCharacterBase::StopAnimMontageReplicated_Implementation(UAnimMontage* AnimMontage)
{
    // We only set the stop flag if it's for the same montage
	if (AnimMontage == PlayMontageInfo.Montage)
	{
		// Tell clients
		PlayMontageInfo.RequestStop = true;

		// Local stop
		StopAnimMontage(AnimMontage);
	}

So what about clients? Well, when the struct property comes through to a client, the OnRep_PlayMontageInfo function will be called, so let’s deal with that.

Step 5: Client OnRep_PlayMontageInfo

Clients will receive the struct changes at some point and will have their OnRep_PlayMontageInfo called. In this function we’ll probably want to play the montage…with some caveats.

The main caveat is that if, by the time the struct replication comes through, enough time has passed that the animation would be over, we just skip playing it. You might think that lag would never be that bad, but network relevancy can delay replication for actors. An actor could become relevant to a client and then suddenly have a play request from minutes ago replicated to them, along with all the other properties. So we want to discard requests like that.

We also want to advance the start time of the animation based on the time it took to receive the property, so that the animation roughly syncs up on all clients. This means the starts of animations will regularly be skipped; worth bearing in mind when designing your animations!

OK let’s get down to the client OnRep function implementation. This is the most complicated part.

void AYourCharacterBase::OnRep_PlayMontageInfo()
{
	if (IsValid(PlayMontageInfo.Montage))
	{
		// We only stop montages when we know it's our own, to avoid montages started by others being stopped
		if (PlayMontageInfo.RequestStop)
		{
			StopAnimMontage(PlayMontageInfo.Montage);
		}
		else
		{
			// We want to advance the montage to account for lag and sync up the animations everywhere as best we can
			// It's possible that because of extreme lag, or network relevancy meaning this is replicated long after
			// it was requested, that we don't need to play this montage.

			float PlayOffset = 0;
			const float Duration = PlayMontageInfo.Montage->GetPlayLength() / PlayMontageInfo.PlayRate;
			if (auto GS = UGameplayStatics::GetGameState(this))
			{
				PlayOffset = GS->GetServerWorldTimeSeconds() - PlayMontageInfo.TimeRequested;
				if (PlayOffset >= Duration)
				{
					// Skip, this play was requested too long ago
					return;
				}
			}
				
			// We need to use the lower level play montage function so we have access to start time
			if (auto M = GetMesh())
			{
				
				if (auto AnimInstance = M->GetAnimInstance())
				{
				
					const float TimeLeft = AnimInstance->Montage_Play(PlayMontageInfo.Montage, PlayMontageInfo.PlayRate, EMontagePlayReturnType::Duration, PlayOffset);

					// I think this possibly breaks the lag compensation, so maybe don't use this if you want good sync
					if (TimeLeft > 0.f && PlayMontageInfo.StartSectionName != NAME_None)
					{
						AnimInstance->Montage_JumpToSection(PlayMontageInfo.StartSectionName, PlayMontageInfo.Montage);
					}
				}
			}	

		}
	}
}

And that’s it! You can now play animation montages across multiplayer clients and have them sync up, and cope with delays caused by lag and network relevancy. I hope it’s been useful!