Multiplayer low-bandwidth procedural generation in Unreal Engine w/ Zlo
In this article we’ll learn how to achieve a low-bandwidth and efficient procedural generation for big multiplayer worlds.
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
FNetworkGUIDcan 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.
Let’s start with the Server code. Once the GameState initializes, we generate the random seed and we start the procedural generation:
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:
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.
Once the client is done generating it will set
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
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:
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
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.
Sometimes we want these procedural Actors to support replication. By setting
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:
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.
If you did everything accordingly you should be seeing something like this, in which all the Actors are net addressable and support replication:
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.
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
IsNetRelevantForto support replication.
Of course, if you decide to tackle any of the open points please, share with us the process!