Skip to content

Multiplayer in Godot

  • 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 the multiplayer.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 to false 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.
    func _ready() -> void:
    if get_multiplayer_authority() == multiplayer.get_unique_id():
    $Sprite.modulate = Color(1, 0, 0)
  • 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 reconciliation

On 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:

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).


  • Spawner also replicate the deletion of nodes, so you could have code like this:
    func _on_player_disconnected(id: int) -> void:
    if multiplayer.is_server():
    _log_string("Player %s disconnected." % id)
  • 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 custom id property:
    • 300

If 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:

func _ready() -> void:
# Tell our spawner what our custom spawn function is
$MultiplayerSpawner.set_spawn_function(Callable(self, "test_spawn"))
# This is called on the server and on the clients. It just needs to return
# a Node, and Godot will handle adding it to the scene. We get `data` from
# our call to `spawn` below and use that to create and initialize a Player.
func test_spawn(data: Dictionary) -> Node:
var id: int = data["id"]
var player_position: Vector2 = data["position"]
var player: Node = preload ("res://scenes/Player.tscn").instantiate()
player.init(id) = "Player #%s" % id
player.position = player_position
return player
# This would be called on the server. Its job is just to set up `data`,
# which is a Variant type that we happened to make into a Dictionary here.
func add_player(id: int) -> Node:
var player_position := Vector2(randi() % 1000, randi() % 500)
return $MultiplayerSpawner.spawn({"id": id, "position": player_position})

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).

The signals you’ll almost certainly want to handle are:

# This signal is emitted with the newly connected peer's ID on each other
# peer, and on the new peer multiple times, once with each other peer's ID.
# For example, if the server is running and a single client connects, the
# server will have this be hit with the connecting client's ID, and the
# connecting client will have this be hit with the server's ID (1).
# This signal is emitted on every remaining peer when one disconnects.
# These three signals are only emitted on the clients.
# ...when connecting succeeded.
# ...when connecting failed.
# ...when the connection to the server is closed.
  • Checking if you are the server (in a function that can be called on either the server or the client): multiplayer.is_server()

Godot 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 running physics_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.

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.

RPCs 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.

Use .rpc() to invoke the method on every peer and .rpc_id(GODOT_ID) to invoke on a specific one, e.g.:

func _ready():
if multiplayer.is_server():
func print_once_per_client():
print("I will be printed to the console once per each connected client.")

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:

@rpc("any_peer", "call_remote", "reliable")
func client_available_rpc():
print("RPC called on the client. My ID: %s. Remote sender ID: %s" % [multiplayer.get_unique_id(),multiplayer.get_remote_sender_id()])
# This would be connected to some button in the UI, and there would be
# a TextInput named "IDInput" in the scene
func _on_button_pressed():
var num = $IDInput.text.to_int()
# Call the RPC on the other client

Running multiple instances of Godot

Section titled Running multiple instances of Godot

Frequently, you’ll want to test with at least 2 instances of Godot open. It’s very easy to set that up via DebugRun Multiple InstancesRun X Instances: 400

Note 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 ENetMultiplayerPeerWebSocketMultiplayerPeer

Section titled Converting ENetMultiplayerPeer → WebSocketMultiplayerPeer

This is very straightforward

The port probably doesn't HAVE to change, but if you're using Cloudflare Tunnel, it should be 443.
const PORT = 7000
const PORT = 443
Make a different peer wherever you make a peer in your code
var peer =
var peer =
Creating the server has a very slightly different signature
var error = peer.create_server(PORT, MAX_CONNECTIONS)
var error = peer.create_server(PORT)
Creating the client has you specify the address/port all on one line
var error = peer.create_client(address, PORT)
var error = peer.create_client("wss://" + address + ":" + str(PORT))

Pointing Cloudflare Tunnels at a local websocket server

Section titled Pointing Cloudflare Tunnels at a local websocket server

The 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 over ws:// (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
      • Set DEF_PORT to 443
      • In the peer.create_client call, set the protocol to wss://
    • Set up a Cloudflare Tunnel
      • The service should be http://localhost:443
        • This should not be https, although I don’t know why. 😢
      • Note that you shouldn’t try to change the port; Cloudflare uses a proxy for tunneling, and their proxies expect certain ports (reference).
  • 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 through localhost, then either set up your own certificates or change the protocol to ws://.

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/ --path /path/to/MultiplayerPlatformerPrototype --headless or wait for this PR to be present.
  • 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:

func is_dedicated_server():
return OS.has_feature("dedicated_server") or DisplayServer.get_name() == "headless"

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).

To set this up:

  • Go to ProjectExport… and make a new export template that matches the platform where the server will run:
    • 500
  • Click “Resources” and then select Export as dedicated server:
    • 500
  • For the rest of the steps, read the reference link above (Godot was lagging too badly to finish this process)

MultiplayerSynchronizer doesn’t always seem to replicate a value

Section titled MultiplayerSynchronizer doesn’t always seem to replicate a value

I 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.