8 minute read

In this article we’ll learn how to achieve a low-bandwidth and efficient procedural generation for big multiplayer worlds.

Introduction

Procedural generation is a concept that has been well explored in a handful of games, as this type of content creation can provide replayability if designed properly.

In this post we are not going to tackle generation algorithms, as that is very game-specific, but we are going to explain how to make your procedural generation multiplayer friendly in Unreal Engine.

Most of these concepts have been explained to me by a very good friend of mine, Zlo, for that reason, we joined efforts to create this article and a sample code so that the community can learn about the technicalities and gotchas of procedural generation in multiplayer.

This article requires understanding Unreal’s networking system (RPCs and replication), so before deep-diving into the fluff that I’m about to write below, I recommend revising the following resources:

Anyways, let’s get on topic! But first, let’s check what we should never do.

How to NOT do multiplayer procedural generation?

If you plan on generating a level composed by thousands or hundreds of Actors, that is supposed to work in multiplayer, please, do not replicate all the Actors. It is a terrible idea even with a relatively low quantity of Actors. The initial replication would send data about every actor to all your connections, and if your game supports late joining, you’ll be sending information of the total quantity of Actors every time someone connects.

The text above assumes the setup is running using dormancy, if that isn’t the case then, all your Actors will be considered for replication every net update, increasing the overhead of NetBroadcastTickTime that we explored in a previous article.

For that reason, I think it is clear enough you shouldn’t use this approach unless you are sure your budget affords it. For example, if you want to procedurally generate a dozen of Actors, it is completely fine. So be reasonable about it and… use the profiler!

How to properly do multiplayer procedural generation

In this Section we will explain how to properly approach multiplayer-friendly procedural generation. Have in mind that this might not be the only way to achieve the same result, but comparable solutions might use a subset of the techniques employed here. But first, let’s check some key concepts.

Net startup Actor and Net addressable

To build our multiplayer procedural generation, we are relying on a niche concept that we don’t speak about often in multiplayer articles: Net startup Actors (also called Net static Actors).

So… What is a Net startup Actor? A Net startup Actor is an Actor that is loaded directly from the map, and for networking purposes can be addressed by its full path name. In addition Net startup Actors don’t get destroyed when they stop being relevant for the Client.

However, we can only achieve this network addressable property if bNetLoadOnClient is set to true, which means that the actor gets loaded on network clients during map load.

So, in order to achieve a net addressable Net startup Actor, we have to accomplish the following conditions:

  • The Actor should exist in Server and Client at a map load time: A Server Actor should relate to a Client Actor.
  • The Actor should be stably named: The Server and Client Actor should have the same “path name” so that the FNetworkGUID can be generated.

Now that we learned what a Net startup Actor is, let’s see how we can use this concept to build our multiplayer and super-reliable procedural generation.

Being net addressable doesn’t mean being replicated.

Building our procedural generation solution

Our procedural generation solution will make use of these properties to create net addressable Net startup Actors in runtime.

Server side

Let’s start with the Server code. Once the GameState initializes, we generate the random seed and we start the procedural generation:

void AMyGameMode::InitGameState()
{
	Super::InitGameState();

	for (AProcGenSpawner* Spawner : TActorRange<AProcGenSpawner>(GetWorld()))
	{
		ProcGenSpawner = Spawner;
		break;
	}

	if (ProcGenSpawner == nullptr) return;

	RandomSeed = FMath::Rand();
	ProcGenSpawner->ProcGen(RandomSeed);
}

In my case I decided to use an Actor placed on the map (AProcGenSpawner), to do this. It is also convenient to delay the Server-side BeginPlay until all the Client controllers finish the procedural generation process (bClientFinishedProceduralGeneration). For that we’ll override the following two functions:

bool AMyGameMode::ReadyToStartMatch_Implementation()
{	
	// Check if clients have finished procgen: We delay begin play of world Actors until clients have finished procgen
	for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
	{
		if (APCPlayerController* PC = Cast<APCPlayerController>(Iterator->Get()))
		{
			if (!PC->bClientFinishedProceduralGeneration)
			{
				return false;
			}
		}
	}

	// If bDelayed Start is set, wait for a manual match start
	if (bDelayedStart)
	{
		return false;
	}

	// By default start when we have > 0 players
	if (GetMatchState() == MatchState::WaitingToStart)
	{
		if (NumPlayers + NumBots > 0)
		{
			return true;
		}
	}
	return false;
}

void AMyGameMode::HandleMatchIsWaitingToStart()
{
	if (GameSession != nullptr)
	{
		GameSession->HandleMatchIsWaitingToStart();
	}
}

Client side

Our controller will request the proc-gen seed to the Server on PostNetInit by means of a Server reliable RPC (ServerRequestProcGenData). From the Server, the Client will receive the Server seed by means of a Client reliable RPC (ClientReceiveProcGenData). Once the Client receives the data, in my case a single number (but it can be extended to whatever your procedural generation algorithm needs), it can begin the procedural generation.

void APCPlayerController::ServerClientFinishedProcGen_Implementation()
{
	bClientFinishedProceduralGeneration = true;
}

void APCPlayerController::ServerRequestProcGenData_Implementation()
{
	ATestMultiplayerProfGameMode* MyGM = GetWorld()->GetAuthGameMode<ATestMultiplayerProfGameMode>();
	// Client rpc forwarding the Gamemode generated seed
	ClientReceiveProcGenData(MyGM->RandomSeed);
}

void APCPlayerController::ClientReceiveProcGenData_Implementation(uint32 randomSeed)
{
	AProcGenSpawner* ClientSpawner = nullptr;

	for (AProcGenSpawner* Spawner : TActorRange<AProcGenSpawner>(GetWorld()))
	{
		ClientSpawner = Spawner;
		break;
	}

	if (ClientSpawner == nullptr) return;

	ClientSpawner->ProcGen(randomSeed);
	ServerClientFinishedProcGen();

	if (AMyGameState* GS = GetWorld()->GetGameState<AMyGameState>())
	{
		GS->StartBeginPlay();
	}
}

void APCPlayerController::PostNetInit()
{
	Super::PostNetInit();
	// Send server rpc asking for level generation data (might be my seed)
	ServerRequestProcGenData();
}

void APCPlayerController::PostInitializeComponents()
{
	Super::PostInitializeComponents();
	if (IsLocalController() && GetWorld()->GetNetMode() < ENetMode::NM_Client)
	{
		bClientFinishedProceduralGeneration = true;
	}
}

Once the client is done generating it will set bClientFinishedProceduralGeneration to true by means of a reliable Server RPC (ServerClientFinishedProcGen). As we did in the Server, it is convenient to delay the Client-side BeginPlay until we are done generating, for that, override the following function from AGameState:

void AMyGameState::StartBeginPlay()
{
	const APCPlayerController* PC = Cast<APCPlayerController>(GetWorld()->GetFirstPlayerController());
	if (PC && PC->bClientFinishedProceduralGeneration && bReplicatedHasBegunPlay && GetLocalRole() != ROLE_Authority)
	{
		GetWorldSettings()->NotifyBeginPlay();
		GetWorldSettings()->NotifyMatchStarted();
	}
}

void AMyGameState::OnRep_ReplicatedHasBegunPlay()
{
	StartBeginPlay();
}

Creating net startup Actors

As I mentioned above, the key of procedural generation is creating net startup Actors following the requirements exposed in the previous Section:

static uint32 procGenIndex = 0;

void AProcGenSpawner::ProcGen(uint32 randomSeed)
{
	procGenIndex = 0;
	FRandomStream RandomStream(randomSeed);

	for (uint32 i = 0; i < NumberOfActors; ++i)
	{
		SpawnAt(RandomStream);
	}
}

void AProcGenSpawner::SpawnAt(FRandomStream& RandomStream)
{
	const float X = RandomStream.RandRange(-1000, 1000);
	const float Y = RandomStream.RandRange(-1000, 1000);
	const FVector LocStream(X, Y, 92.f);

	FActorspawnParameters SpawnParams;
	SpawnParams.Name = FName(*FString(ProceduralActor->GetName() + "_" + FString::FromInt(procGenIndex)));
	SpawnParams.NameMode = FActorspawnParameters::ESpawnActorNameMode::Required_Fatal;
	SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
	SpawnParams.bDeferConstruction = true;
	LastProcActor = GetWorld()->SpawnActor<AProceduralActor>(ProceduralActor, LocStream, FRotator::ZeroRotator, SpawnParams);
	LastProcActor->bNetStartup = true;
	// This let us differentiate procedurally generated Actors from other Actors in a generic way
	LastProcActor->Tags.Add(TEXT("ProcGen"));
	LastProcActor->FinishSpawning(FTransform(FRotator::ZeroRotator, LocStream, FVector::OneVector));

	procGenIndex++;
}

In the above snippet, we see AProcGenSpawner, which is the Actor I’m using to do the procedural generation. In it, I’m using a static integer appended to the Actor name to create deterministically named Actors. Another very important requirement is setting bNetStartup to true, as I’m doing in the SpawnAt function. After doing that, we’ll obtain Net addressable Net startup Actors!

Note that this generation algorithm serves as a proof of concept to explain how to achieve multiplayer procedural generation by creating Net startup Actors at runtime. Proper procedural generation algorithms might make use of level instances to create collections of content permutations (level instance granularity) from which we can select a candidate using the random seed. In such case, each Actor contained in the level instance should be spawned individually following the exposed requirements.

Supporting replication

Sometimes we want these procedural Actors to support replication. By setting bNetStartup to true, we are telling the engine to treat those Actors as if they were loaded from the level. Because of this, Unreal Engine assumes that the Client is able to acknowledge the FNetworkGUID for those Actors any time the Server sends them. This might not be the case in our scenario, as there is no guarantee that the Client has finished spawning them yet. Therefore, we must prevent those Actors from replicating before we know that the Client can acknowledge their existence.

For that reason, we have to make the Actor not relevant for the connection until the Client-side procedural generation algorithm finishes. And we are in luck, since we created bClientFinishedProceduralGeneration, so we can now reuse this boolean for this purpose. One way to make the Actor not relevant for the connection is by overriding IsNetRelevantFor in the procedurally generated Actor:

bool AProceduralActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
	if (const APCPlayerController* PC = Cast<APCPlayerController>(RealViewer))
	{
		return PC->bClientFinishedProceduralGeneration && Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
	}
	return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}

There are probably better ways to do this by means of the replication graph, but I’m not going to cover it on this article as it goes beyond its scope.

Final results

If you did everything accordingly you should be seeing something like this, in which all the Actors are net addressable and support replication:

Procedural generation results

Note that this toy example uses random locations from a seed to place the Actors. And as we can see the positions are equivalent in Server and Client.

Conclusion

Today we learned the principles of how to achieve a multiplayer friendly procedural generation.

Don’t be afraid of contacting me for questions or anything that comes to your mind.

Now is up to you to extend this approach with cool generation algorithms and more!

Some ideas that pop in my head are:

  • Better way to deterministically name the Actors.
  • Make it compatible with world partition Server and Client streaming!
  • Context driven procedural generation (ie: Use more data than just the seed!)
  • Using the replication graph instead of IsNetRelevantFor to support replication.

Of course, if you decide to tackle any of the open points please, share with us the process!

Enjoy, vori.