Server-Authoritative Multiplayer in Godot, Single Player Included
Four network roles, one spawn function and a bot that plays the game, all running in single player too.
The decision: single player is a local server #
The human set the multiplayer direction early: Valheim-style hosting. Players host for friends from the game or run a dedicated server. Characters live on each player's PC and can join any world, and the world lives with whoever hosts it. Up to 16 players.
The key engineering choice followed from that. Single player is just a local server with one player. No separate offline code path exists that could drift apart from the online one. If something works alone, it runs the same server code it would run online. The cost is that every gameplay system has to be written with the network in mind from the start. The multiplayer commit arrived on day two, after combat, skills, loot, maps and saving existed, and it changed 33 files. After that, every new system was born networked.
Four roles per actor #
Every Actor (players, monsters, Shadows, companions) has a network role:
enum NetRole { LOCAL, OWNER, PROXY, PUPPET }
- LOCAL: simulated here. Everything in single player, and monsters on the server.
- OWNER: a client's own player. It moves locally so input feels instant; everything else goes to the server.
- PROXY: the server's copy of a client's player. Combat, damage and loot happen here.
- PUPPET: a client's view of anyone else. It only shows what the server says.
CLAUDE.md states the rule: "Gameplay code runs only where the role says so; check net_role /
multiplayer.is_server() rather than assuming." Movement is client-authoritative but sanity-checked by
the server. Combat, damage, loot, items and progression are decided by the server.
One way to create things #
Every dynamic entity (players, monsters, Shadows, training dummies, loot, books) is created through one function on the server:
## Server: creates an entity (replicated to clients on its map). `data` needs
## "kind", "map" and "pos" ([x, z], map-local) plus kind-specific fields.
func spawn(data: Dictionary) -> Node:
var d := data.duplicate(true)
d["name"] = "E%d" % _next_id
_next_id += 1
return spawner.spawn(d)
That dictionary goes through Godot's MultiplayerSpawner, and World._build_entity builds the same
node from it on every machine. The rule in CLAUDE.md is short: "Never add_child gameplay entities
directly (tests excepted)." Spawning from a plain dictionary has a nice side effect: anything that can
be spawned can also be described in a map file, and the dungeon and proximity spawning code just builds
those dictionaries.
State versus events #
We split what goes over the wire into two kinds:
State is replicated with MultiplayerSynchronizer. The list is short on purpose:
const ACTOR_SYNC := [["net_pos", true], ["net_facing", true], ["net_time", true], ["hp", false], ["max_hp", false], ["is_dead", false]]
The boolean says whether the property goes out every tick or only when it changes. Players add a few more fields (map, mana, level, title, weapon look).
Events are things that happen: a swing, a hit, a death, a skill effect, a Shadow wave. These go
through Actor.net_event(kind, args), which sends an RPC from the server to every peer that can see
this actor:
func net_event(kind: String, args: Array = [], only_peer: int = 0, skip_peer: int = 0) -> void:
if not is_networked() or not multiplayer.is_server() or map == null or map.world() == null:
return
for peer: int in map.world().peers_seeing(self): # incl. peers across a joined border
if peer == 1 or peer == skip_peer or (only_peer != 0 and peer != only_peer) or not can_reach(peer):
continue
_rpc_event.rpc_id(peer, kind, args)
can_reach is there because of a bug the network bots found on the first day: an RPC about an entity
whose spawn message hasn't arrived yet fails on the client. Now an entity has to be at least 300 ms old
on that peer's map before events about it are sent.
The UI never changes anything #
On a client, the inventory window can't move an item. It can only ask:
func request(action: String, args: Array = []) -> void:
if net_role == NetRole.OWNER:
_rpc_request.rpc_id(1, action, args)
else:
perform_action(action, args)
perform_action runs on the server and starts with the comment "Arguments come from the network, so
everything is validated here." It checks indices, ownership, levels, gold and room in the bag, then
applies the action. The owner gets a fresh snapshot of its character back. In single player, request
just calls perform_action directly. It's the same code with no network hop.
Trading shows the pattern at its strictest. Both players put items and gold up, both lock, both accept. Then the server checks that everything is still there and fits, and swaps it all at once. Any change unlocks both sides.
Who sees what: interest management #
At first, visibility was per map: a peer received everything on its player's map and nothing else. That was fine for 480 m maps. When Map 1 grew to 2.6 x 3 km, streaming every monster on the map to every player stopped making sense. Now the synchronizers carry a visibility filter:
func _peer_sees(peer: int, node: Node) -> bool:
var player: Actor = players_by_peer.get(peer)
if not is_instance_valid(player) or player.map == null:
return false
var node_map := str(node.get_meta(&"map_id", ""))
if player.map.map_id == node_map:
if node is Actor and players_by_peer.values().has(node):
return true # other players: always (party, duels)
return not node is Node3D or (node as Node3D).global_position.distance_to(player.global_position) <= INTEREST_RANGE
Other players on your map are always visible (for party frames and duels). Everything else is visible
within INTEREST_RANGE, 160 m. Maps without players sleep entirely: their monsters don't think.
Smooth movement from jittery packets #
Remote players and monsters used to stutter on real connections. Packets arrive bunched up, especially
over Wi-Fi. The fix is classic snapshot interpolation in NetSmoother: every sample carries the sender's
timestamp, and we show the actor where it was 100 ms ago on the sender's clock, interpolating between
the two samples around that moment.
## How far in the past we show things. Must cover a couple of send intervals plus jitter.
const DELAY_MSEC := 100.0
## Past the newest sample we keep moving along the last velocity for at most this long.
const MAX_EXTRAPOLATE_MSEC := 80.0
## A jump this big between two samples is a teleport (portal, respawn, dash): no gliding.
const SNAP_DISTANCE := 6.0
The clock offset follows the fastest packets down immediately and drifts up slowly, so jitter doesn't shake it. Players sync at 30 Hz, everything else at 20 Hz. A unit test feeds the smoother bunched packet arrivals and checks that playback stays even.
One design consequence: hit-stop (the brief freeze when the combo finisher connects) is single-player only. Freezing the server would freeze everyone.
Dedicated servers #
A dedicated server is the same game started with --server, or exported as its own build. It reads a
server.cfg (name, world, port, password, max players, autosave interval, message of the day) and
creates one with comments on first start. A console takes status, save, say, kick, event and
stop. A STOP file in the save folder shuts it down cleanly, which is handy for service managers.
Linux got the same treatment from the start. Exports for Windows and Linux, game and server, exist as
presets. The first Linux run under WSL found a real problem: a blocked stdin read deadlocked process exit
on Linux. The fix was --no-console for when nothing reads stdin, plus a watchdog.HOSTING.md has a systemd unit example. A later fix made World.shutdown free entities before the spawner on every quit
path, because the exported Linux server had logged one error per entity on exit.
Steam lobbies #
For playing with friends, the game uses the GodotSteam GDExtension: friends-only lobbies, overlay invites, "Join Game" from the friends list, and a Join <friend> button on the start screen when a friend is hosting. Dedicated servers stay on ENet.1 During development the game uses Steam's test app 480.
The important constraint: everything must work with Steam off. Tests, headless runs and dedicated servers never touch it. The Steam test itself needs two humans and two Steam accounts, so it's a written checklist in HOSTING.md. The human ran it with a friend the day after the feature landed, and it passed.
The bots #
Automated tests of a networked game are worth more than any amount of reasoning about it.
tests/net_test.sh starts a headless host and a headless client, both driven by tests/net_bot.gd:
Automated player for network tests (main.gd adds it with --bot). Takes over our own player: walks to the nearest enemy, fights it, picks up loot, spends an attribute point through a server request, and prints what it saw.
Over time the bots learned to form a party, accept a quest, receive a world event, enter the dungeon together and hire a companion. The script then starts a passworded dedicated server, checks that a wrong password is refused and the right one plays, and that the world gets saved. At the end it prints each side's report and the number of engine errors.
The first bot run found four bugs before any human connected: synchronizer visibility, RPCs sent before spawns arrived, cyclic preloads, and the spawner on a reused local player node. A later one found that owners lost their own player after a map transfer, because the visibility meta wasn't updated.
What doesn't go over the network #
The newer systems taught a second lesson: the cheapest replicated state is the state you don't replicate. A lot of the world is presentation that every machine can compute for itself from data it already has:
- Time of day.
WorldClockreads the wall clock (a 36-minute day), so every player sees the same sun without a single packet. The one catch is that a player's PC clock can be wrong, so on join the server sends its own wall clock and clients keep the difference (server_skew). Everything timed by the clock (the sky, the caravan merchant's schedule, townsfolk routines) then matches the server. - Townsfolk and animals. The 66 townsfolk walk their daily routines and the cats, dogs and goats wander on schedules that are all derived from the same clock and a seeded hash, so everyone sees them about where everyone else does. There is no spawn, no sync and no collision. The one exception is petting a cat or dog: that is a server request, and the server checks it loosely against where the animal's schedule says it should be (a "leash" of 18 m), because nothing else may depend on where a presentation-only actor happens to stand.
- Dungeon devices (levers, gates, rising lava, bells, light beams) are gameplay, so the server runs them. But they're built on every machine from the map data, and only their state goes out: one reliable message when something changes, plus a refresh every second. Moving parts like light beams are drawn from a shared clock, so clients don't need a stream of positions.
The rule of thumb that came out of it: if it's derived from data and a clock, compute it everywhere; if a player's choice can change it, the server owns it.
Takeaways #
- Make single player a local server if you want multiplayer at all. One code path is easier to keep right than two.
- Separate state from events. Replicate a few properties; send everything that happens as an event to the peers who can see it.
- The UI asks, the server decides.
request→perform_action→ snapshot. It also makes cheating through edited clients much harder. - Write a bot. It finds the race conditions you'd otherwise find with a friend on a Friday night.
What's next #
The bots are one part of a larger safety net. The next post looks at the whole test setup: about 13,800 headless checks, screenshot scenarios, and how an AI developer checks its own visuals.