Skip to main content

Architecture

High-level flow​

  1. Player registers and creates a character in the portal.
  2. Portal issues a JWT containing sub (user id), character_id, and role.
  3. Godot client connects to the game server over ENet/UDP.
  4. Client calls Auth(token).
  5. Game server validates JWT, loads the character from PostgreSQL, spawns a CharacterBody3D.
  6. Client sends movement input; server simulates and broadcasts world state.
  7. Admin actions are queued in server_commands and 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 stop4
size of each3–4 cm (MAX_CORRECT_STEP binds)
total pulled16 cm
peak gap0.34 m
residual, left permanently0.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:

decayticks of holdfirestotal pulled
shipped—80.319 m
0.992.050.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:

  • broadcast
  • kick
  • setState
  • shutdown

Heartbeats​

The game server updates server_status every 5 seconds with:

  • online player count
  • server state
  • heartbeat timestamp
  • max players