Godot 4 Multiplayer: Explicit Replication with ENet and RPCs
Godot 4’s high-level multiplayer API removes socket boilerplate, but replication is explicit. Learn how ENetMultiplayerPeer, RPCs, and MultiplayerSynchronizer fit together—and where they fall short.
13 Aug 2026, 15:52 UTC

The problem: two screens, one health bar
When two players need to see the same health bar drop at the same moment, networking stops being a background detail and becomes part of the game loop. Writing that synchronization by hand means managing UDP sockets, packet loss, sequence numbers, and reconciliation. Godot 4’s high-level multiplayer stack—ENetMultiplayerPeer, the MultiplayerAPI, and MultiplayerSynchronizer—gives you a working baseline for real-time state sync. It does not, however, replicate your scene automatically. You opt into every synchronized property and every remote call. That explicit contract is the core trade-off: far less boilerplate than raw sockets, but no hidden magic.
What Godot 4 actually gives you
ENetMultiplayerPeer is the transport layer. It wraps UDP with optional reliable-ordered channels, so you do not write ACK logic yourself. You create it, call create_server() or create_client(), and assign it to multiplayer.multiplayer_peer.
Remote procedure calls are methods marked with @rpc. In Godot 4 the annotation takes configuration strings such as "any_peer", "authority", and "reliable". You invoke them directly on the instance—take_damage.rpc(amount)—not by string name.
Continuous state, such as position or health, is handled by the MultiplayerSynchronizer node. You add it as a child of the node you want to replicate, point its root path at that parent, and list the properties to sync. The synchronizer streams those values from the multiplayer authority to remote peers at a configurable interval. This replaces the older puppet system and makes replication declarative rather than implicit.
A minimal server, client, and synchronized player
The bootstrap below runs the same scene on both machines. It checks the user command-line arguments for --server; anything else starts a client. This is a convention, not an engine requirement, so adapt it to your launcher or UI.
# NetworkBootstrap.gd
extends Node
const PORT := 7777
const MAX_CLIENTS := 8
func _ready():
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
if "--server" in OS.get_cmdline_user_args():
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(PORT, MAX_CLIENTS)
if err != OK:
push_error("Failed to start server")
return
multiplayer.multiplayer_peer = peer
print("Server listening on %d" % PORT)
else:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client("127.0.0.1", PORT)
if err != OK:
push_error("Failed to connect")
return
multiplayer.multiplayer_peer = peer
func _on_peer_connected(id: int):
print("Peer connected: %d" % id)
func _on_peer_disconnected(id: int):
print("Peer disconnected: %d" % id)
For the player, use an RPC for discrete events and the synchronizer for continuous state. The server is the multiplayer authority by default, so is_multiplayer_authority() returns true on the server instance and false on clients.
# Player.gd
extends CharacterBody2D
@export var health := 100
@rpc("any_peer", "reliable")
func take_damage(amount: int):
if not is_multiplayer_authority():
return
health -= amount
if health <= 0:
queue_free()
func _physics_process(delta):
if is_multiplayer_authority():
var dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = dir * 200.0
move_and_slide()
Add a MultiplayerSynchronizer node as a child of Player. Set its Root Path to .. so it targets the parent, then add health and position to the replication list in the inspector. When the server changes those values, the synchronizer pushes them to every client. Damage, by contrast, is an event: a client calls take_damage.rpc(10), the server validates authority and applies the change, and the new health value flows back through the synchronizer.
Trade-offs you inherit
- Explicit configuration. If you forget to add a property to the synchronizer, it silently desyncs. There is no automatic
@exportreplication in Godot 4. - UDP is not universal. ENet uses UDP. Some corporate networks and mobile carriers block it, causing connection failures. Godot does not bundle STUN or TURN for ENet; if you need NAT traversal, you must supply it separately or fall back to a relay.
- Bandwidth adds up. Every replicated property and reliable RPC consumes bandwidth. High-frequency transforms on dozens of nodes can saturate a home connection. Lower the synchronizer interval for distant or cosmetic entities.
- Authority is centralized by default. The server owns state unless you call
set_multiplayer_authority()on a node. Client-side prediction and rollback are not built in; you implement them on top of the RPC layer.
Check it before you trust it
- Run one instance with
--serverand a second without it. Confirm the server printsPeer connected: <id>. - On the client, call
take_damage.rpc(10)and verify the server’shealthdrops. Then confirm the client’shealthupdates via the synchronizer. - Open Debugger > Network while playing to inspect RPC counts and synchronizer traffic.
- Test from a network known to restrict UDP to observe the failure mode before you rely on it in production.
Godot 4’s high-level API will not design your netcode for you, but it removes the socket plumbing. Treat replication as an explicit budget—sync only what players must see, send events when they happen, and profile the wire early.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.