Multiplayer in Godot
Misc. notes
Section titled Misc. notes- Couldn’t figure out how to set a timeout on
peer.create_client
. All I know is that after some amount of time (seems like >30s), it will hit themultiplayer.connection_failed
signal. I assume I could just start a timer on another thread and check for a connection if I need to adjust the timeout. - All high-level networking is done over UDP (i.e. they implement their own reliability by resending packets when needed) (reference).
- This example mentions “ludus”, which isn’t a real protocol. Here is the line of code where the engine uses the protocol, which shows that it’s just passed to the
WebSocket
constructor. At that point, you can hunt down the documentation for the protocols (e.g. here), which eventually leads to this page of “sub-protocols” (here). However, that documentation says that the protocol “may be a custom name jointly understood by the client and the server.” That line can be safely omitted completely, as well. - When it comes to
multiplayer.get_remote_sender_id()
, I couldn’t find documentation saying whether this can be trusted on the server or whether the client can spoof other players’ IDs. server_relay
(reference) can be set tofalse
to prevent clients from knowing that other clients connected/disconnected. It also prevents them from being able to send packets to each other through the server.- It’s a good idea to color the current client’s sprite different from everyone else’s when prototyping, e.g.
- Regarding timeouts:
wtfblub: did some research on the connect timeout because i was bored LUL for enet you can set it by calling get_peer(1).set_timeout(0, 0, YOUR_TIMEOUT_MS) on the ENetMultiplayerPeer and by default its set to 30 seconds inside the enet library. the peer id 1 is hardcoded to be your client created by create_client. the first 2 timeout params can be set to 0 which will use the enet defaults (32 and 5000). and for WebSocketMultiplayerPeer its simply setting the handshake_timeout property
Client-side prediction and server reconciliation
Section titled Client-side prediction and server reconciliationOn its own, Godot doesn’t handle client-side prediction or server reconciliation (read more about what those are and why they’re needed here). You can use a library like netfox for this. However, I don’t exactly think there’s a one-size-fits-all approach. I tested netfox’s example game, Forest Brawl, and it seemed like the collisions and explosions were janky (and there was a bug with body.register_hit(fired_by)
being called with fired_by==null
. I had tested both with a web export (for which I had to modify the code myself) and with the prebuilt versions of the game using noray. Both versions made it seem like my own projectiles looked bad when they hit others, but others’ projectiles looked good when they hit me.
Valve has some great articles on handling client-side prediction, lag compensation, etc. here:
- https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking
- https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization
MultiplayerSpawner
(reference)
Section titled MultiplayerSpawner (reference)This is typically used to replicate nodes from the server to all clients. If the client isn’t connected, then it’ll be replicated upon connecting.
For example, if you go from world 1 to world 2, you would have the server add the node to the scene tree, then you’d have a MultiplayerSpawner point to the path where the node was added, so it will know to synchronize that newly added node on all clients. You can also use this for bullets, power-ups, etc. (reference).
The auto-spawn list is a list of scenes that will be automatically replicated when added to the specified spawn path in the scene (reference).
Notes:
- Spawner also replicate the deletion of nodes, so you could have code like this:
- Spawners only replicate the nodes’ creation/deletion; it won’t continually update the nodes (e.g. their positions). For that, you want a
MultiplayerSynchronizer
. - I did some basic testing, and it seems like there’s a way to get custom properties to be replicated, but I don’t know how exactly it happened. Maybe it was from having a
MultiplayerSynchronizer
? E.g. I had a customid
property:
Manually spawning nodes
Section titled Manually spawning nodesIf you just have a scene that doesn’t need to be initialized (e.g. imagine a Confetti
scene for a victory screen), then you can rely on the “Auto Spawn List” (which is accessed via add_spawnable_scene). However, if you need to initialize a scene yourself (e.g. to set a $NameLabel
on a player), then you may want to consider manually spawning nodes. It’s pretty easy:
MultiplayerAPI
(reference)
Section titled MultiplayerAPI (reference)Godot has a MultiplayerAPI
that is accessible as the global multiplayer
(although there can be a scene-tree-specific instance if you need to override it for some case).
MultiplayerAPI
signals
Section titled MultiplayerAPI signalsThe signals you’ll almost certainly want to handle are:
MultiplayerAPI
quick notes
Section titled MultiplayerAPI quick notes- Checking if you are the server (in a function that can be called on either the server or the client):
multiplayer.is_server()
Synchronizing physics
Section titled Synchronizing physicsGodot physics are not deterministic, which means the same inputs can lead to different outputs (reference). If it were deterministic, then all you would need to do is make sure that each client has the same inputs to their physics simulations. Since that’s not a possibility in default Godot, here are some other options:
- Option 1a: have only the server simulate physics and synchronize the position and rotation all the time to all clients. This is what they do in this demo with
MultiplayerSynchronizer
. Note that in that demo, they’re runningphysics_process
on every client for every player. I would add this code to_ready
:set_physics_process(player == multiplayer.get_unique_id() or multiplayer.is_server())
- Option 1b: have ALL endpoints simulate physics. Have the server synchronize at a high frequency and then interpolate the current state with the new state. This is what this demo does.
- Option #2: use a deterministic physics engine like SG Physics 2D and make sure all clients have the same inputs.
Based on what I’ve read, I think the best combination of “easy” + “good” is based on this post and this demo:
- Server:
- Have the server be the authority for physics for all players/environments.
- Client:
- Run
_physics_process
for the character(s) you “own” to cut down on latency. - Interpolate between the last few snapshots of the state to figure out the position of everything.
- Run
At least back at the end of 2022, the recommendation was to have the server be the authority on player positions and take in client inputs (reference). Then, you would need client-side prediction to cut down on the input latency.
RPC reference (reference)
Section titled RPC reference (reference)RPC annotations
Section titled RPC annotationsRPCs are annotated with @rpc
like so: @rpc(mode, sync, transfer_mode, transfer_channel)
. The defaults are starred below:
mode
- ⭐️
authority
: indicates that only the server can call the function remotely any_peer
: indicates that clients are allowed to call this remotely
- ⭐️
sync
- ⭐️
call_remote
: when the function is invoked, it will only happen on the remote end, not the local peer call_local
: the function can be invoked on the local peer
- ⭐️
transfer_mode
- ⭐️
unreliable
: packets are not acknowledged, can be lost, and arrive in any order unreliable_ordered
: packets are received in order, but still unreliably (there can be packet loss)reliable
: packets are re-sent until they are received (and acknowledged) in the right order. Has a performance penalty.
- ⭐️
transfer_channel
: the channel index. Read more about channels here.
Calling RPCs
Section titled Calling RPCsUse .rpc()
to invoke the method on every peer and .rpc_id(GODOT_ID)
to invoke on a specific one, e.g.:
You can invoke functions on other clients (this goes through the server first; clients aren’t directly connected to each other unless one of them is the server). For example, suppose you have 3 instances of Godot running: server
, Client A
, and Client B
. A
can call into B
with something like this:
Running multiple instances of Godot
Section titled Running multiple instances of GodotFrequently, you’ll want to test with at least 2 instances of Godot open. It’s very easy to set that up via Debug → Run Multiple Instances → Run X Instances:
Hosting a multiplayer server
Section titled Hosting a multiplayer serverNote that Cloudflare tunnels do not support UDP traffic (reference), which means your options for hosting a server are:
- Option #1: convert to websockets (reference). This will have a performance impact.
- Note that WebRTC is only for peer-to-peer connections but should be more performant (reference)
- Option #2: set up a VM in the cloud somewhere
- Option #3: expose your public IP address
Converting ENetMultiplayerPeer
→ WebSocketMultiplayerPeer
Section titled Converting ENetMultiplayerPeer → WebSocketMultiplayerPeerThis is very straightforward
Pointing Cloudflare Tunnels at a local websocket server
Section titled Pointing Cloudflare Tunnels at a local websocket serverThe setup is this:
- The game client is either…
- …hosted on the cloud somewhere.
- …hosted on your own machine via a tunnel.
- Either way, players would be connecting over
https://
(secure), so that means the websocket connection couldn’t work overws://
(not secure).
- The game server is hosted on your own machine.
This took a lot of trial and error due to ports and tunneling and having to upload the game a bunch of times. Here was the setup that worked:
- Use the official websocket_multiplayer demo (canonical link in case that changes)
- Modify
main.gd
- Set
DEF_PORT
to443
- In the
peer.create_client
call, set the protocol towss://
- Set
- Set up a Cloudflare Tunnel
- The service should be
http://localhost:443
- This should not be
https
, although I don’t know why. 😢
- This should not be
- Note that you shouldn’t try to change the port; Cloudflare uses a proxy for tunneling, and their proxies expect certain ports (reference).
- The service should be
- Modify
- Regardless of where the client is, you’ll have to connect through the Cloudflare tunnel. In other words,
localhost
won’t work anymore because you don’t have a certificate on your own machine; Cloudflare is handling that through the tunnel. If you want to connect throughlocalhost
, then either set up your own certificates or change the protocol tows://
.
Creating a dedicated server (reference)
Section titled Creating a dedicated server (reference)There’s no need to run Godot with the entire GUI and audio driver; you can run a dedicated, headless server. There are two ways to do this:
- At runtime:
- Either specify the
--headless
argument to Godot, e.g./Applications/Godot.app/Contents/MacOS/Godot --path /path/to/MultiplayerPlatformerPrototype --headless
or wait for this PR to be present.
- Either specify the
- At export time: export the entire project as a dedicated server (see the next section in this note).
To detect that you’re running on a dedicated server (reference), I just expose a function in an auto-loaded script that looks like this:
If you want to require that the only server that can run is a dedicated one, then just only expose hosting functionality after checking is_dedicated_server()
(since multiplayer.is_server()
only becomes true
after calling create_server
).
Exporting as a dedicated server
Section titled Exporting as a dedicated serverTo set this up:
- Go to Project → Export… and make a new export template that matches the platform where the server will run:
- Click “Resources” and then select Export as dedicated server:
- For the rest of the steps, read the reference link above (Godot was lagging too badly to finish this process)
Troubleshooting
Section titled TroubleshootingMultiplayerSynchronizer
doesn’t always seem to replicate a value
Section titled MultiplayerSynchronizer doesn’t always seem to replicate a valueI never figured this out, but I had added in a did_teleport
boolean that I was having my synchronizer synchronize. The authoritative client was definitely setting it correctly, but then the other client wasn’t always printing that it was set to true
.
I don’t know why this could happen. I was doing this with websockets over localhost
, so there shouldn’t have been any unreliability. I also don’t know how there could be a race condition. It just felt like the synchronizer wouldn’t send the updated values sometimes.