Multiplayer Solution

Go back
CompletedOctober 2024
View on GitHub

Summary

This project emerged from an interest in creating a 2D top down multiplayer game. A combination of Unity's controversies at the time and Unreal Engine's lack of viable 2D support led me to MonoGame. I knew the framework's lack of multiplayer features would be a huge obstacle and used this project to tackle it upfront.
Ultimately, using C#'s reflection capabilities, the magic of Harmony's runtime method patching, and Facepunch's .NET steamworks wrapper, I developed a multiplayer prototype which uses Steam sockets to seamlessly route RPCs over the internet. Months later, what I learnt in this project has been extremely useful in comprehending Unreal Engine's badly documented multiplayer implementation.

Implementation Details

To implement RPCs, the solution requires RPC declarations to contain both a callable and an implementation method. The callable function is marked using C#'s attributes - with the three attributes used being RunOnClient, RunOnServer, and Multicast (lended by Unreal Engine and Unity's Mirror).
// Example server RPC
[RunOnServer]
void ExampleRpc() { }
void ExampleRpc_Implementation()
{
Console.WriteLine("easy");
}
It's important to distinguish between bound and unbound RPCs, i.e. RPCs which are bound to a net-aware entity and those which aren't. This distinction allows the injection of an extra parameter, the target identity of the function call, to ensure that when an RPC is called on a specific net-aware entity on one machine the target machine calls the function on the same exact entity.
It's also important to note that in multiplayer games, every machine is running a completely different instance. References to objects in memory can't simply be sent over the network, because they reference something that only exists on the current machine. This necessitated the creation of entities which have an identity across the network, which I called NetEntities. NetEntities can only be created on the server and are guaranteed to exist on all machines. Even RPC methods themselves require an identity across the network, though their identities are simply keys in a standard and static dictionary.
The first step undertaken by the implementation is to scan the codebase for functions marked as RPCs and inject instructions which forward the function call and its parameters to the target machine. When any machine receives RPC data, I use C#'s reflection to search for a method with the name of the origin function - except with the "_Impl" (implementation) suffix.
// When ran on client, this calls the code that Harmony
// previously injected which will send the function call
// over to the server.
ExampleRpc();
// Declare RPC
[RunOnServer]
void ExampleRpc() { }
void ExampleRpc_Implementation()
{
// The server will print "easy"
Console.WriteLine("easy");
}
Here's how the RPC code for player creation ultimately ended up looking.
[ContainsRpc]
public static class RpcTesting
{
[RunOnClient]
public static void PossessPlayer(SteamId target, NetId playerId) { }
public static void PossessPlayer_Impl(NetBinaryReader args)
{
NetId id = args.ReadByte();
CreatePlayer(id, true);
}
[RunOnClient]
public static void PlayerJoined(SteamId target, NetId playerId) { }
public static void PlayerJoined_Impl(NetBinaryReader args)
{
NetId id = args.ReadByte();
CreatePlayer(id, false);
}
public static void CreatePlayer(NetId id, bool local)
{
Player player = new Player();
player.IsLocallyOwned = local;
player.SetNetIdentity(id);
player.ParentScene = World.Instance.CurrentScene;
player.Prepare();
player.Start();
World.Instance.CurrentScene.AddEntity(player);
}
}
And here's the notable code behind replicated player movement:
// within Player pawn class
[RunOnServer]
public void SetServerPosition(Vector2 position) { }
public void SetServerPosition_Impl(SteamId sender, NetBinaryReader data)
{
Transform.Position = data.ReadVector2();
foreach(var v in World.Instance.Networker.Connections.Keys)
{
if (v == sender)
continue;
BroadcastPosition(v, Transform.Position);
}
}
[RunOnClient]
public void BroadcastPosition(SteamId target, Vector2 position) { }
public void BroadcastPosition_Impl(NetBinaryReader data)
{
Transform.Position = data.ReadVector2();
}