Online Hide and Seek — Syncing Game State Over WebSockets
The multiplayer started as a joke
"Wouldn't it be funny if you could play this online?" I said to nobody, at midnight, already halfway through writing the WebSocket code. Three days later it worked. Sort of. Players teleported around, paint colors arrived before lizards existed, and the clock drifted by 10 seconds between host and clients. But the core idea was sound.
The architecture
The backend is a generic WebSocket relay — the same AWS-hosted server I use for all my multiplayer games. It handles rooms, roster management, and message routing. It knows nothing about game logic. All the smarts live in the Godot client.
extends Node
## Net — WebSocket client for the obstudio game backend (autoload).
## Same protocol the 2D remake proved out: join/start/end room routes +
## everything gameplay-side on the generic relay route, namespaced {g:"mc3"}.
signal state_msg(m: Dictionary)
signal relay_msg(from_id: String, data: Dictionary)
signal net_error(text: String)
signal closed()
var ws: WebSocketPeer = null
var my_id := ""
var connected := falseThree signals handle all incoming traffic: state_msg for roster/status broadcasts, relay_msg for game-specific messages, and net_error for problems. The relay route is generic — you send any dictionary, the server broadcasts it to the room with your ID attached. The g: "mc3" field namespaces it so different games can share the same server.
Host-authoritative design
The host is simply roster[0] — the first player who joined the room. The host controls:
- Round configuration (which map, who's the seeker, phase timers)
- Phase transitions (hide → seek → results)
- Clock sync (authoritative timer)
- Round results (points, who survived)
am_host = roster.size() > 0 and String(roster[0].get("id", "")) == Net.my_idThere's one exception: the seeker is catch-authoritative for tags. When the seeker tags someone, they send a tag message and the victim is caught immediately. The host doesn't validate it. This means a cheating seeker could tag people they didn't actually hit — but in a casual browser game, that trade-off is fine. The alternative (host-validated raycasts) would require syncing camera angles to the server, adding 50-100ms of latency to every tag attempt. Not worth it.
Position updates
Positions are throttled to ~8 updates per second (every 125ms):
_pos_acc += delta
if _pos_acc > 0.125:
_pos_acc = 0.0
Net.relay({"t": "p",
"p": [snappedf(me.global_position.x, 0.01),
snappedf(me.global_position.y, 0.01),
snappedf(me.global_position.z, 0.01)],
"r": snappedf(me.rotation.y, 0.01),
"k": poses.get(me.pose, 0),
"m": 1 if me.moved_recently(0.3) else 0})Notice the snappedf(..., 0.01) — positions are snapped to centimeter precision. This keeps the JSON payload small without visible loss. The "m" field encodes whether the player moved recently (as a 0/1 int instead of a bool, because JSON bools are larger). On the receiving end, puppets lerp toward the target position:
Role.PUPPET:
global_position = global_position.lerp(_tgt_pos, minf(1.0, delta * 10.0))
rotation.y = lerp_angle(rotation.y, _tgt_yaw, minf(1.0, delta * 10.0))The lerp factor of delta * 10.0 gives about 100ms of smoothing. At 8 updates per second, that's just barely enough to hide the network jitter without making movement look floaty. I tried higher values (smoother but laggy) and lower values (snappier but jerky). 10.0 was the sweet spot.
Paint sync
Paint doesn't change every frame — only when the player actively paints a body part. So it's event-driven with throttling:
if paint_dirty:
_paint_acc += delta
if _paint_acc > 0.25:
_paint_acc = 0.0
_send_paint()The paint message sends all five part colors at once. Sending deltas ("just the head changed") would save bandwidth but adds complexity for almost no benefit — the entire color dictionary is about 80 bytes.
Clock sync
The host periodically broadcasts the authoritative timer:
if am_host and GS.phase == GS.Phase.SEEK:
_clock_acc += delta
if _clock_acc > 10.0:
_clock_acc = 0.0
Net.relay({"t": "clock", "left": time_left})Every 10 seconds, the host tells everyone "there are X seconds left." Clients snap to this value. Ten seconds is infrequent enough to not spam the network, but frequent enough to correct drift before anyone notices. The first version synced every second — that worked but was wasteful. The clock only drifts by maybe 200ms over 10 seconds on typical connections. Nobody can tell the difference.
The pending_paints race condition
This one bit me hard. During round setup, the host sends a cfg message telling everyone to spawn lizards. But paint messages from fast players might arrive before the lizard exists on slower clients. Solution:
var pending_paints := {} # id -> colors (paint may arrive before cfg spawn)Paint that arrives for a not-yet-spawned ID goes into pending_paints. When the lizard finally spawns, the pending paint is applied:
func _spawn(id: String, disp_name: String, role: int, pos: Vector3) -> Lizard:
var l := Lizard.new()
# ...
lizards[id] = l
if pending_paints.has(id):
l.apply_colors(pending_paints[id])
pending_paints.erase(id)
return lClassic message-ordering problem. I didn't anticipate it until testers on slow connections reported invisible hiders (white lizards because paint never arrived). The fix is three lines but finding the bug took an hour of staring at logs.
Late joiners
If someone joins mid-match, they get the current roster state from the server. The game shows them a "Starting… you'll join the next round" message. They can watch but don't participate until the next round begins. This was easier than trying to reconstruct mid-round game state (paint positions, caught status, clock position) for a new player. Sometimes the lazy solution is the correct one.
What I'd improve
Prediction. Right now, if there's a network hiccup, other players freeze for a frame then snap to their new position. Client-side prediction (extrapolating from the last known velocity) would smooth this out. I haven't implemented it because the game is slow-paced enough that occasional jitter doesn't ruin anything — you're hiding, not playing Quake. But it would be the next thing I'd add if I wanted to polish the feel.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.