Architecture
High-level flow
- Player registers and creates a character in the portal.
- Portal issues a JWT containing
sub(user id),character_id, and role. - Godot client connects to the game server over ENet/UDP.
- Client calls
Auth(token). - Game server validates JWT, loads the character from PostgreSQL, spawns a
CharacterBody3D. - Client sends movement input; server simulates and broadcasts world state.
- Admin actions are queued in
server_commandsand polled by the game server.
ENet transport
The game server uses Godot's ENetMultiplayerPeer:
- Server:
peer.CreateServer(port, maxClients) - Client:
peer.CreateClient(host, port)
RPCs use Godot's high-level multiplayer API. Client-to-server calls use RpcId(1, ...).
Authority model
- The server is authoritative for 3D movement.
- Clients send normalized movement axes, yaw, jump, sprint, and an input sequence number.
- The server applies gravity, jumping, acceleration, and
MoveAndSlide. - Clients predict the same simulation locally and correct toward the server.
- Clients interpolate remote entities from received snapshots.
Movement constants are shared between the two sides and enforced by
scripts/check_rpc.py. A one-sided change does not fail loudly — it produces
rubber-banding, unreachable speeds, or jumps of the wrong height.
Ground
The ground is Terrain3D on both sides, loaded from byte-identical map_data in
the repository. There is no separate box collider: a flat box disagreed with the
terrain wherever the terrain sloped, which showed up as horizontal speed
diverging between client and server on flat-ground input alone.
Movement reconciliation
The client runs the same movement code as the server and corrects toward the server's position, with two ideas that are easy to get wrong.
Deadzones are sized by transport lag, not by a constant. A snapshot is
~half a round trip old, so at 5 m/s and 126 ms RTT the client is legitimately
1.5 m from where the server last saw it. The horizontal allowance is
deadzone + speed x staleness / correction_rate, which places it just above
the equilibrium the correction loop settles at. Staleness is measured from the
input sequence number the server echoes back, not assumed.
Correction applies to the residual, not the raw gap. Slope travel produces a vertical difference between the two positions that is a fact about the world, not a disagreement between two simulations. The client reads the same heightmap the server does and subtracts that component, so the vertical deadzone is applied to what is left. Raising the threshold instead would also have silenced the jitter, but by accepting real vertical drift as well.
Correction is capped per frame, not just fractioned. A fixed fraction of the gap is the right shape while the gap is small and the wrong one once it is not. Sprinting at 8 m/s moves the body ~0.133 m per frame, and the gap measured when correction fires on a slope averages 0.91 m — so a 25% pull was ~0.23 m, larger than a frame of running. Each correction was a discrete hitch rather than a correction, arriving about twice a second on sloped ground.
Capping the step (MAX_CORRECT_STEP) keeps the exponential behaviour for small
gaps and bounds the discontinuity when the gap is large; big gaps then converge
over several frames instead of one. The trade is that the gap stays above the
threshold longer, so correction fires more often — measured 17 → 51 per 8 s
on the worst edge case, each roughly six times smaller. If a build ever
converges less smoothly with the cap on, that is the mechanism to revisit, not
the deadzone.
This was only identifiable by turning correction off (F1): with it on, the symptom looked like transport lag, and every measurement of divergence, floor agreement and gravity was normal. The defect was never in whether correction ran, only in how far it moved the body at once.
But on slopes the root cause was above reconciliation entirely. FloorMaxAngle
was 45° while the terrain's actual faces reached 58.5°, so is_on_floor()
returned false on standing ground and correction spent its time correcting a
body that was never wrong. Raising the limit to 60° removed most of the slope
jitter outright — on the worst measured edge, airborne samples went 224 → 0 and
floor disagreements 94 → 5. See Movement for the
measurement.
Known and accepted: a stop leaves a small standing offset. This is the one reconciliation behaviour that is currently not fixed, recorded here because the symptom is easy to misattribute when it comes back.
The deadzone is deadzone + speed × staleness / rate. The speed term happens to
also cover about one tick of prediction lag, which is what makes it work while
moving. At a stop speed → 0, so the entire term vanishes — including the tick
component — even though the gap being closed was created at sprint. The
threshold collapses from ~1.2 m to 0.12 m in a handful of frames while the
actual gap is ~0.14 m, so correction fires by about 2 cm.
Measured on terrain, medians over runs of a 2 s sprint followed by key release:
| correction steps per stop | 4 |
| size of each | 3–4 cm (MAX_CORRECT_STEP binds) |
| total pulled | 16 cm |
| peak gap | 0.34 m |
| residual, left permanently | 0.107 m |
One frame of natural movement is 8.3 cm walking and 13.3 cm sprinting, so this is a 3–4 cm tug sustained over ~4 frames — down from a 0.75 m gap when the problem was first reported.
The part that matters more than the tug is the residual: once the gap falls under the deadzone, nothing corrects it again, so every stop leaves the player ~10 cm from where the server thinks they are, permanently. That is a positional desync rather than a visible artefact, and it compounds with anything else that reads authoritative position.
A decaying hold on recent speed does not fix it. That is the textbook answer and it was measured rather than assumed — replaying recorded stop trajectories offline under alternative thresholds:
| decay | ticks of hold | fires | total pulled |
|---|---|---|---|
| shipped | — | 8 | 0.319 m |
| 0.99 | 2.0 | 5 | 0.200 m |
The most aggressive setting removes about a third of the correction and eliminates none of it. It was tried before this measurement and reverted; the data above says that was the right call, so it has not been reinstated.
Revisit this if the tug becomes perceptible, if any system starts depending on client and server position agreeing more tightly than ~10 cm, or if the residual is shown to compound over a session. The trade to be aware of: the lurch is the visible cost and the standing offset is the invisible one, and suppressing the first by widening the deadzone mostly moves cost into the second. Fixing it properly means removing the one-tick input lag between the client applying a frame and the server seeing it, not widening a threshold.
Correction is skipped when the client and server disagree about being airborne. Walking off a cliff, the client steps off first and the server follows a round trip later; correcting toward that snapshot drags a grounded player off the ledge they are standing on. The server reports its floor state with each snapshot so the client can tell this apart from genuine drift.
Press F1 in the example client to toggle correction. Turning it off is how
jitter caused by correction is distinguished from jitter caused by physics.
For measuring all of this, see Deployment → Running a movement measurement.
Persistence
Character saves occur:
- on disconnect
- every 15 seconds for connected players
- chat messages immediately
Command queue
Admin panel writes rows to server_commands. The game server polls every second and acknowledges them.
Supported commands in Phase 1:
broadcastkicksetStateshutdown
Heartbeats
The game server updates server_status every 5 seconds with:
- online player count
- server state
- heartbeat timestamp
- max players