In a recent Godot game project (as yet unfinished) I took the plunge into WebRTC multiplayer after finding the default ENet implementation a little too tricky to get to work over the internet. I don't want to have to instruct my players on how to open ports on their routers - and UPNP is often disabled by default these days, and even when enabled, didn't seem to work on mobile devices.
There isn't too much out there on using the WebRTC high level multiplayer features of Godot, and I pieced this together bit by bit with many evenings of struggle. I used the GodotExplorer/ECMAScript module to write the code in TypeScript, but it should be fairly applicable to a GDScript project too (I may update this in future with GDScript examples, as well as a minimal setup project).
Despite WebRTC being a browser technology primarily, the choice to use ECMAScript was a coincidence; I had already written most of the high level net code before implementing WebRTC, and this all works just as well in GDScript.
Eventually I will open source the whole game, but it uses some copyrighted assets (that I have permission to use, but not distribute freely). For now I have just published the Typescript contents of the game on Github.
WebRTC in Godot
I have to admit, I knew basically nothing about the technical details of WebRTC before embarking on this maddening venture, and to be honest, I still don't get the full scope of it. The Godot documentation has a few pages on using WebRTC for multiplayer, but even reading them multiple times it wasn't entirely clear:
There's also the fact that I was developing native desktop and mobile applications, but by default Godot only has a HTML5 implementation of WebRTC for web builds. I mean, why not, it's apparently a browser technology. But it actually works surprisingly well as a multiplayer networking stack.
In order to allow Godot to use the WebRTC library on other platforms, an "official" GDNative plugin must be used:
Initially I had a lot of trouble getting this to work (I'll come onto that) and decided to compile the native libs, dlls and sos myself, which wasted probably a good 4 or 5 evenings, but I did learn a lot in the process. That's probably outside the scope of this post though; lesson learned, just use the precompiled binaries provided on Github.
Caveat: GDNative is currently unsupported on iOS. I wasn't intending to support iOS, but this is a bit of a problem if you are.
STUN/TURN and Lobbies
Part of the issue I was aiming to get around with WebRTC is NAT punching; being able to make a direct connection between two arbitrary devices around routers. WebRTC achieves this with STUN and TURN, which either figures out the best way to do that, or else relays packets via a server. I've just made this part of the code public on Github in all its messy glory, so go nuts:
The short version is that after much trial and error trying to implement the TURN mechanism myself, I used the
node-turn NPM package to provide the service to the API in practically a one-liner (again, many nights wasted prior to deciding this, lesson: always use the tried and tested solution!!!)
I also implemented a simple lobby service as a derivative of the godot Github example networking code that would allow the game to request a random join code to share with their friends, who would then enter it into their clients to join a pre-game lobby, where they can chat and wait for players (much like Among Us, for example). The Lobby system also handles the WebRTC/WebSocket signalling, which makes for some lovely clunk in that code that would be cleaned up if I had any commercial interest in this project ;)
Speaking of which...
Before one can set up the Godot high level multiplayer classes to communicate via WebRTC, a connection must be established between players. Since I'd already implemented the ENet version of this, which uses a single "host" player who is in control, and several peer players who are not-so-in-control, I continued in this vein, though true peer to peer is also possible with this system.
In very simple (and likely technically inaccurate) terms, you set up connections between peers via a WebSocket server (established in the lobby system above, and running on my Linode server), which gives the peers an initial client-server-client relay through which to send signals. These signals come in a few flavours, but essentially the process is that a peer sends an "offer" to other connected peers, who in return send an "answer", and if the offer and answer match up and the TURN server can figure out how to connect them, the socket server sends them an ICE candidate data packet which allows them to create a peer connection (which, hopefully, is a direct connection, but could also be a relayed connection - ultimately the game doesn't care).
This process is managed by the Signalling class in the game, which is added to the Godot project as a global singleton script (hence it being a
godot.Node). This class is not at all tidy, but it essentially handles all of the necessary API for establishing a WebRTC connection between peers, as well as using an external X509 certificate to establish a secure connection to the Lobby server.
The Godot docs recommend using a single script for both the "master" (ie host) and "puppet" peers, using if statements to diverge in logic if the current player is the host or not, but I found this a bit clunky and split my connection code into Client and Server node classes to simplify things a bit. Essentially the Menu node triggers the instantiation of either depending on what they chose in the menu (yes yes, refactoring...). In either case, a join code is supplied, either returned by API request on creating a host game, or typed by the user when connecting as a client. This kicks off the signalling server to connect to the lobby API, figure out the WebRTC peer connections, and set up the high level multiplayer in godot.
Importantly, the way the high level multiplayer works in Godot is that each peer gets an ID number, and individual nodes, by default, are "owned" for all clients by the "master" client. They can then be manually set to be owned by each individual player; for example, only the host can initiate the game to start, and the host's client also controls all of the enemies in the game, but each player's player character is set to only be controlled by their own client, via the
set_network_master function. By default, the master player has ID 1, and all nodes have a network_master of 1. By setting the
network_master of the player instance to the
network_unique_id of the peer, the player gains control over the host on their possessed node.
Another simple demonstration of this is the client sending a signal to indicate to the host that their game is ready to start playing: they simply make an
rpc_id call to the player with id 1 (ie, the host), labelled
playerReady, passing their
network_unique_id for the host to record in the Server class as ready.
At this point one can start using the Godot RPC process as seen in the CharacterSelector/Lobby screen:
You set methods as callable by RPC via the
rpc_config method, and can label whether they're allowed to be called by the host or puppets, and if they're synced methods (ie, if a host calls a method, is it called just on the puppets, or also on the host). In this case there is a simple
addChatMessage function which allows the game clients to send chat messages peer to peer once the WebRTC connection is established.
The rest of the multiplayer functionality is basically achieved the same way; once the WebRTC connection is established, any of the high level Godot multiplayer functionality such as
rset and so on can be used without giving a second thought to WebRTC; the webrtc-native plugin does the rest for you.
An example is the
Entity base class for players and enemies; it registers a few methods are RPC callable, for example to communicate the entity's position and rotation if it has changed since the last frame.. Each client will receive the new position from the host, and update their copy of the scene tree accordingly. Similar calls are made for attacks, and
rset is used for the health, which allows one to update a single variable for all connected when it changes without an explicit method.
Any peers connected to the player get their nodes replaced with a
NetworkPlayer node - this essentially just strips out any physics calculations and relies upon the RPC received data for positioning, attacks, damage and so on. If an Entity receives a
translation value over the network, it uses a
linear_interpolation to smoothly transition between the old and new positions, to account for latency and prevent jerky and unnatural movement.
By using a simple Command pattern, I was also able to neatly abstract any damage or attacks dealt to players or enemies via a
DamageCommand, for example, which checks if the recipient of the damage is the host/controller of the node being damaged, and if so, sends that damage via RPC to the other peers, to keep them all in sync.
Overall, the experience was kind of painful, quite a fun challenge, and having found no resources like this other than the basic demos on the godotengine Github org, and to be honest, I'm not certain how much I've cleared up with this post! But I hope this and the attached code is somewhat useful for someone attempting the same in the future.
GDNative and ECMAScript
One caveat I said I would come back to mention, which is really only relevant if you're using the ECMAScript bindings for Godot, is that the ECMAScript bindings only wrap the internal Godot API, and not any GDNative plugins, and there isn't a very neat way to do this. As a result, when trying to set up the WebRTCPeerConnection in Signalling, I instead had to use some GDScript (pictured below), and call that node from ECMAScript to return the initialised peer connection. Without doing that, the overridden C++ code for the WebRTCPeerConnection wouldn't be picked up by the game, and the connection would fail without much of a reason why (some mysterious error about the configuration being null, or something). This is why I ended up going down the route of compiling the webrtc-native module myself, a route I don't recommend since it involves also compiling WebRTC, the process of which is so cumbersome it hurts!!