Multiplayer Solution

Completed | October 2024 |
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 - a framework I was excited to try because of its use in Stardew Valley and Terraria. I knew the framework's lack of multiplayer features would be a huge obstacle, so I wanted to tackle it upfront.Ultimately, using C#'s awesome 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 partially undocumented multiplayer capabilities.(I should add that the character model used in the demo is based upon ConcernedApe's Haunted Chocolatier trailer)Cool Implementation Details
Essentially, to implement RPCs, the solution requires RPC declarations to contain both a callable and an implementation method. The callable function is marked as such using C#'s attributes - with the three attributes I created being RunOnClient, RunOnServer, and Multicast (similar system used by Unreal Engine and Unity's Mirror).It's now 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 dictionary.One of the first steps undertaken by the game is to scan the codebase for functions marked as RPCs and inject instructions which send 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.// Example server RPC[RunOnServer]void ExampleRpc() { }void ExampleRpc_Implementation(){Console.WriteLine("easy");}
Here's how the RPC code for player creation ultimately ended up looking.// 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");}
And here's how simple player movement replication is :)[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);}}
// 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();}
Struggles
Undertaking a project of this complexity as one of your first large C# endeavours is somewhat stupid. Especially coming from C++'s lack of care about the programming decisions in your code, C#'s rules felt unnecessarily strict. When I felt it was required that I 'break the rules', I simply couldn't. At times it felt like walking blind around a maze.The primary design problem I encountered was with creating a silver bullet prefix to patch onto functions designated as RPCs - with each RPC having unknown arguments, it felt impossible. I ended up using C#'s enigmatic dynamic type to type pun and the even more enigmatic arguments that Harmony injects into method patches, which I felt wasn't desirable because of the performance cost of the dynamic type and the somewhat depressing code readability caused by the Harmony arguments. Though it felt like a necessary evil at the time, I would like to revisit it and find a way to improve.