Introduction
Hi! This is a short, simple tutorial explaining some techniques to implement a fast-paced multiplayer game in Godot. We'll specifically be talking about Server Reconciliation, Client Prediction, and Interpolation.
Gabriel Gambetta's Fast-Paced Multiplayer articles are an excellent introduction to these topics. Reading that is not a prerequisite, but may be helpful.
This tutorial will not provide robust, production-ready code that you can copy/paste into a game. Instead, it uses the simplest code I could come up with to explore these concepts.
Before we start, you'll want:
- Godot 3.4
- An understanding of Godot's High Level Multiplayer API.
- A tool to simulate delay and packet loss on your local network.
Part 1: Connect
To start, we'll need a client-server setup. Open Godot
, create a Node2D
scene, and attach this script:
Game.gd
extends Node
const MAX_PLAYERS := 2
const ADDRESS := "127.0.0.1"
const PORT := 22354
func panic(msg: String):
push_error(msg)
get_tree().quit()
func _ready():
match OS.get_cmdline_args()[1]:
"host": host()
"join": join()
_: panic("Usage: godot Game.tscn host|join")
func join():
print("Joining %s:%d" % [ADDRESS, PORT])
var peer := NetworkedMultiplayerENet.new()
peer.set_compression_mode(NetworkedMultiplayerENet.COMPRESS_RANGE_CODER)
var err := peer.create_client(ADDRESS, PORT)
if err:
panic("Failed to join: %d" % err)
get_tree().set_network_peer(peer)
peer.connect("server_disconnected", self, "panic", ["Server disconnected"])
peer.connect("connection_failed", self, "panic", ["Connection failed"])
yield(peer, "connection_succeeded")
print("Connected to server")
func host():
print("Hosting on port %d" % PORT)
var peer := NetworkedMultiplayerENet.new()
peer.set_compression_mode(NetworkedMultiplayerENet.COMPRESS_RANGE_CODER)
var err := peer.create_server(PORT, MAX_PLAYERS)
if err != OK:
panic("Failed to host: %d" % err)
get_tree().set_network_peer(peer)
var peer_id: int = yield(peer, "peer_connected")
print("Peer %d connected" % peer_id)
Save your scene as "Game.tscn".
The scene parses the command line arguments, looking for either "host" or "join". Based on that, it will either host or join a game on localhost.
We'll start from the command line so we can run both client and server:
godot Game.tscn host & godot Game.tscn join
We haven't added any visuals, so the window will be blank. However, you should see output on the console like this:
Hosting on port 22354
Joining 127.0.0.1:22354
Connected to server
Peer 518498842 connected
Part 2: Initialize
Let's create a simple script for our player object.
It's a KinematicBody2D
with a sprite and collision shape.
For the sprite, we'll just use the "icon.png" included in a default Godot project.
First, go into Project Settings > Input Map
and add actions for "left", "right", "forward", and "backward".
Bind some keys to these actions like WASD.
Next, add a player script:
Player.gd
extends KinematicBody2D
const SPEED := 512.0
onready var net_id: int = int(name)
onready var is_local := net_id == get_tree().get_network_unique_id()
var sprite := Sprite.new()
var velocity: Vector2
func _ready():
# color the sprite so we can distinguish the host from the guest
sprite.texture = preload("res://icon.png")
sprite.modulate = Color.red if net_id == 1 else Color.blue
add_child(sprite)
var rect := RectangleShape2D.new()
rect.extents = sprite.get_rect().size / 2.0
var col := CollisionShape2D.new()
col.shape = rect
add_child(col)
func step_physics(input: Vector2):
velocity = move_and_slide(input * SPEED)
func _physics_process(delta: float):
if is_local:
var input := Input.get_vector("left", "right", "forward", "backward")
step_physics(input)
Finally, append some logic to our Game script to spawn players when the match starts.
Game.gd
# ... existing code ...
const HOST_SPAWN := Vector2(128, 128)
const GUEST_SPAWN := Vector2(512, 512)
# ... existing code ...
func host():
# ... existing code ...
rpc("spawn", get_tree().get_network_unique_id(), HOST_SPAWN)
rpc("spawn", peer_id, GUEST_SPAWN)
puppetsync func spawn(id: int, pos: Vector2):
var player := KinematicBody2D.new()
player.script = preload("res://Player.gd")
player.name = str(id)
player.position = pos
add_child(player)
print("Spawned %d at %s" % [id, pos])
Let's try running it again:
godot Game.tscn host & godot Game.tscn join
Focus a window and press the movement keys you bound. You should see the local player move around. The remote player won't move at all, because we haven't implemented any synchronization yet.
Part 3: Sync
Now we need to synchronize state between the client and server. It would be easy to let each peer simulate it's own state and send that to other peers. Unfortunately, this makes it too easy to cheat by sending bad data. Instead, peers will send only their inputs to the server. The server will simulate physics using those inputs and send the resulting state back to clients. We call this an "Authoritative Server" model.
Player.gd
# ... existing code ...
var move_input: Vector2
func _physics_process(delta: float):
if is_local:
# gather inputs for the player this peer controls
move_input = Input.get_vector("left", "right", "forward", "backward")
if is_network_master():
# the server simulates physics and sends states to clients
step_physics(move_input)
rpc("update_state", position, velocity)
else:
# clients send inputs to the server
rpc("update_input", move_input)
master func update_input(move: Vector2):
move_input = move
puppet func update_state(pos: Vector2, vel: Vector2):
position = pos
velocity = vel
Run this again, and note that the state of each peer is mirrored between client and server.
Now let's try adding 100ms of latency using a network emulator:
tc qdisc add dev lo root netem delay 100ms
# (or an equivalent command on Windows or Mac)
Note that when you move the host, it moves instantly, but the client lags slightly behind. However, when you move the client, it takes 200ms before the local player starts moving! That's 100ms for input to reach the server, and another 100ms for state to reach the client. This will make a fast paced game like an FPS nearly unplayable.
Part 4: Predict and Reconcile
There's two reasons for the lag on our local client:
- The local player doesn't start moving until it receives updates from the server
- The server applies inputs as if they happened when the RPC arrived, not when the player pressed them
The first issue can be addressed by Prediction. On pressing an input, the local client will send the input to the server (as it does in Part 3), but it will also start simulating movement locally. Since the server and client are running the same simulation, they should arrive at close to the same state.
For the second issue, the server needs to respect that client inputs are pressed in the past. If it receives an input from 100ms ago, it cannot just apply the input right now. It needs to apply the input to a past state, and then re-simulate up to the current time using that new input. This is called Reconciliation. Again, since the client and server are running the same simulation, the server should arrive at close to the same state the client's local prediction did.
This suggests we need a way to communicate "when" inputs or states happened. We'll add a current_frame
counter that measures the frames elapsed since a player spawned.
Player.gd
var current_frame := 0
func _physics_process(delta: float):
if is_local:
# Only advance current_frame for the local player
# For remote players, current_frame is based on the last received packet
current_frame += 1
move_input = Input.get_vector("left", "right", "forward", "backward")
if is_network_master():
# This is the local player on the host
# We have the most recent state and inputs for this player
# We can send an updated state every frame
rpc("update_state", position, velocity, current_frame)
else:
# This is the local player on a guest
# We'll send inputs to the server
rpc("update_input", move_input, current_frame)
# Finally, all local players perform simulation locally
# (we're "predicting" based on the last state and our current input)
step_physics(move_input)
We've rearranged _physics_process
a bit. We increment current_frame
, but only for the local player. That's because the local player is the only one we have the most recent information for. Even on the server, we can't advance a player further than the last input we received. If the server continually runs step_physics
during physics_process
for a remote player, it might later receive inputs that invalidate the state, and would have to send corrected states to all peers. It is safer to only simulate players up to the latest data we have. To quote Gabriel Gambetta's "Fast Paced Multiplayer":
... each player sees itself in the present but sees the other entities in the past
So instead of advancing physics every frame, the server only advances a player when it receives inputs:
Player.gd
master func update_input(move: Vector2, frame: int):
if frame <= current_frame:
push_warning("Old frame %d (latest=%d)" % [frame, current_frame])
return
# re-simulate physics to the given frame using the newly received inputs
var steps := frame - current_frame
for _i in range(steps):
step_physics(move_input)
move_input = move
current_frame = frame
# send client the state corresponding to the input frame it just sent us
rpc("update_state", position, velocity, frame)
If our simulation were perfectly deterministic, this would be enough. Peers could simulate in parallel and their states would stay in sync. Unfortunately, we can't rely on this. Simulations might differ due to floating point implementations, or because the server knows one peer collided with another (but the peers didn't know that because they're running with older data).
For this reason, the client still needs to receive state updates from the server to correct these deviations. If we just apply the state updates directly (i.e. set the position and velocity to whatever the update contains), the client will jump back in time, because the state updates are old by the time they arrive at the client. This is where "prediction" comes in. Upon receiving a state from the server, the client rolls back in time to that point, applies the state, and reapplies all the inputs the local player has pressed since then. Most of the time, our prediction will be correct (or close enough).
This means that local players need to store some number of past inputs. Since we'll be constantly appending new inputs and discarding old inputs, a Ring Buffer is an ideal data structure:
RingBuffer.gd
extends Reference
class_name RingBuffer
var data: Array
var idx := 0
func _init(size: int):
data.resize(size)
func push(v):
idx = (idx + 1) % data.size()
data[idx] = v
func at(offset: int):
assert(offset < data.size())
return data[(idx - offset) % data.size()]
In physics_process
, local players will now record their inputs:
Player.gd
const HISTORY_SIZE := 32
var past_input := RingBuffer.new(HISTORY_SIZE)
func _physics_process(delta: float):
if is_local:
current_frame += 1
move_input = Input.get_vector("left", "right", "forward", "backward")
if is_network_master():
rpc("update_state", position, velocity, current_frame)
else:
past_input.push(move_input) # <-- added this line
rpc("update_input", move_input, current_frame)
# ...
Finally, in update_state
, players will replay inputs, applying them on top of the server's state:
Player.gd
puppet func update_state(pos: Vector2, vel: Vector2, frame: int):
position = pos
velocity = vel
if not is_local:
# For non-local players, we just have to trust the latest snapshot from the server
return
# For the local players, we have inputs the server doesn't have yet
# We can use these to simulate up to the current frame
var offset := current_frame - frame
if offset < 0:
# This should never happen, because the server only sends updates based
# on the frame we attached to update_input
push_warning("Server sent state from %d frames in the future" % offset)
return
if offset >= HISTORY_SIZE:
# If the latency is greater than the product of HISTORY_SIZE and framerate,
# we may have to drop old inputs
push_warning("Server sent state from %d frames in the past" % offset)
offset = HISTORY_SIZE - 1
# re-simulate to current time
for i in range(offset, -1, -1):
step_physics(past_input.at(i))
Try running again with the simulated latency. You should notice that the local player feels much more responsive!
Part 5: Interpolate
I have some bad news: our network simulation so far hasn't been terribly realistic. A 100ms delay (200ms ping) is on the high end for gaming latency, but we're assuming that the latency is constant and packets arrive at regular intervals. Let's try introducing some jitter and packet loss:
tc qdisc change dev lo root netem delay 50ms 20ms loss random 1%
Now our packets take 30-70ms to arrive, and have a 1% chance of getting lost. Try running the simulation again. The local player still feels fine, but the remote player jumps all over the place! This is because the RPCs are not evenly spaced out any more. We might go a few frames without receiving any updates, causing the player to freeze. A few frames later, we receive the updates and the player jumps forward as we re-simulate.
We can make this less jarring by interpolating the position of remote players.
const INTERPOLATION_FACTOR := 3.0
func _ready():
# ...
# non-local entities will interpolate the local position of the mesh
# to the authoritative location of the player to smooth out blips
sprite.set_as_toplevel(true)
sprite.position = position
func _physics_process(delta: float):
sprite.position = lerp(
sprite.position,
position,
1.0 if is_local else delta * INTERPOLATION_FACTOR
)
An easy way to do this is to set the sprite for remote players as toplevel so it doesn't inherit the parent's transform. Then as the body moves, we'll interpolate the sprite to the position of the body. This means that the remote players appear even further in the past, but most of the time this isn't noticeable, and it helps smooth out the apparent motion.
Try running it with the --debug-collisions
flag so you can see the difference between the "real" and interpolated positions:
godot Game.tscn host --debug-collisions & godot Game.tscn join --debug-collisions
The local players should look the same, but remote players should look much smoother now!
A Note on Reliable Transport
You might expect that rpc_unreliable
would be more appropriate for regular state updates. I thought so too, and was pretty surprised that I got better results with rpc
.
Reliable transport introduces more latency, as a peer might not process RPCs for a bit as it waits for a missing packet. However, since our algorithm is already designed to handle latency, usually the extra delay introduced by reliability isn't too noticeable. On the other hand, using rpc_unreliable
might mean the server misses client inputs, causing more jitter as the client and server disagree on the state.
We might be able to do a little better using rpc_unreliable
with our own protocol by sending all un-acked messages in each RPC. However, Enet's built-in reliability protocol works pretty well out-of-the-box for this example.
Conclusion
Hopefully this was a helpful introduction to some techniques you can use to make your multiplayer Godot game feel more responsive and handle lag more gracefully. This just scratches the surface, and there's so much more you can try to improve the experience further!
Keep on learning! Here's some works I found very helpful:
- https://gafferongames.com/categories/networked-physics/
- https://developer.valvesoftware.com/wiki/Prediction
- https://www.gamedev.net/forums/topic/697159-client-side-prediction-and-server-reconciliation/?page=1
- https://www.gdcvault.com/play/1014345/I-Shot-You-First-Networking
- https://link.springer.com/article/10.1007/s00530-012-0271-3