- pm/checkpoint_ops.py (framework): playtest backend now launches with GAME_SERVERS_ENABLED + GODOT_BIN + GAME_PROJECT_DIR + GAME_SERVER_HOST so playtesters actually share a world. - docs/architecture/networking.md 7a/7b: the orchestration + client-play design as built. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
15 KiB
Multiplayer Networking Architecture
Status: Proposed Owner: Platform team Related: Server authority + client prediction
1. Overview
This document specifies the multiplayer networking architecture for agp-game, a Godot-based game built by autonomous agents. The architecture follows a server-authoritative model with client-side prediction and reconciliation, which is the industry standard for fast-paced, competitive multiplayer games.
The goals of this architecture are:
- Fairness & anti-cheat: The server is the single source of truth for all gameplay state. Clients never directly mutate world state.
- Responsiveness: Client-side prediction hides network latency so local input feels immediate.
- Determinism: The server runs a fixed-timestep simulation so that state is reproducible and testable.
- Scalability: A clear separation between transport, simulation, and replication layers allows the server to be tuned independently.
2. Transport Choice: ENet over WebSocket
2.1 Decision
We choose ENet as the primary transport for gameplay traffic.
2.2 Justification
| Criterion | ENet | WebSocket (TCP) |
|---|---|---|
| Reliability model | Reliable + unreliable channels | Reliable only (TCP) |
| Latency | Low; unreliable packets skip retransmit | Head-of-line blocking on loss |
| Packet ordering | Per-channel ordering | Global strict ordering |
| Congestion control | Lightweight, game-oriented | TCP-style (slow start, backoff) |
| Godot support | First-class (ENetConnection, MultiplayerPeerENet) |
Supported but TCP-based |
| Use case fit | Real-time action game | Chat, lobby, HTTP-style messaging |
Rationale:
- Movement & state replication are latency-sensitive and tolerate occasional packet loss. ENet's unreliable channel lets us send position snapshots at high frequency without head-of-line blocking.
- RPCs that must be delivered (e.g., interactions, spawns) use ENet's reliable channel, giving us exactly the semantics we need without TCP's retransmission stalls.
- Godot's
MultiplayerPeerENetis mature and integrates directly with the high-level multiplayer API (MultiplayerAPI,rpc()).
2.3 WebSocket as a Secondary Transport
WebSocket remains available as a secondary transport for:
- Lobby / matchmaking messaging (low-frequency, must-be-reliable).
- Browser builds where raw UDP/ENet is unavailable.
- Admin / telemetry channels.
The transport layer is abstracted behind an interface so gameplay code does not depend on the underlying protocol.
3. Server-Side Game Loop
3.1 Tick Rate
- Simulation tick rate: 30 Hz (33.33 ms per tick).
- Replication / snapshot rate: 30 Hz, aligned with the simulation tick.
Justification:
- 30 Hz is sufficient for smooth movement and interaction in a top-down / 2D action game while keeping CPU and bandwidth costs predictable.
- A fixed timestep (rather than delta-time) makes the simulation deterministic and testable.
- The tick rate is a configuration constant so it can be raised (e.g., to 60 Hz) for competitive modes without architectural changes.
3.2 Authority Model
- Server is authoritative for:
- Player position, velocity, and physics state.
- Health, damage, and death.
- Item pickups, inventory, and world-object state.
- Spawning and despawning of entities.
- Game rules, scoring, and win/lose conditions.
- Client is authoritative for:
- Its own input (which keys/buttons are pressed).
- Local camera and UI.
- Cosmetic / non-gameplay presentation.
3.3 Server Loop Pseudocode
while server_running:
process_network_inbound() # read client inputs & RPCs
for _ in range(ticks_to_advance):
simulate_tick() # fixed 30 Hz step
broadcast_snapshots() # replicate authoritative state
process_network_outbound() # flush reliable/unreliable channels
The server accumulates real elapsed time and advances the simulation in fixed
TICK_DURATION increments, decoupling simulation from frame rate.
4. RPC Call Patterns
RPCs are grouped by direction and reliability. All gameplay RPCs are routed
through the high-level MultiplayerAPI with explicit rpc_id targeting.
4.1 Client → Server (Input & Requests)
| RPC | Channel | Payload | Notes |
|---|---|---|---|
request_move_input |
unreliable | { seq, input_state, timestamp } |
Batched per tick; drives prediction |
request_interact |
reliable | { target_id } |
Open door, pick up item, use object |
request_attack |
reliable | { target_id, aim_dir } |
Combat action |
request_join |
reliable | { player_name, profile } |
Session setup |
request_respawn |
reliable | {} |
After death |
Design notes:
- Input is batched per client tick and sent with a monotonically increasing
seqso the server can detect gaps and the client can reconcile. - Interactions are reliable because they are discrete, must-happen events (opening a door, picking up an item). Losing them would corrupt world state.
4.2 Server → Client (State & Events)
| RPC | Channel | Payload | Notes |
|---|---|---|---|
snapshot_state |
unreliable | { tick, entities[], player_states[] } |
Periodic authoritative state |
spawn_entity |
reliable | { entity_id, type, transform, props } |
New entity enters world |
despawn_entity |
reliable | { entity_id, reason } |
Entity removed |
interact_result |
reliable | { request_seq, success, result } |
Acknowledges interaction |
damage_event |
reliable | { target_id, amount, source_id } |
Combat feedback |
game_event |
reliable | { type, payload } |
Score, win, round start/end |
4.3 Broadcast vs. Targeted
- Broadcast (all clients):
snapshot_state,spawn_entity,despawn_entity,game_event. - Targeted (single client):
interact_result,damage_event(to affected players), and any player-private data.
5. Client-Side Prediction & Reconciliation
5.1 Prediction
- The client runs a local copy of the movement simulation using the same physics constants as the server.
- When the player provides input, the client:
- Applies the input to its local player immediately.
- Renders the predicted position.
- Sends the input to the server (batched, with
seq).
5.2 Reconciliation
- The server sends authoritative
snapshot_stateat 30 Hz containing the server's tick number and authoritative player positions. - The client keeps a ring buffer of predicted states keyed by input
seq. - On receiving a snapshot:
- Find the last predicted state that matches the server's tick.
- Compute the error between predicted and authoritative position.
- If error exceeds a threshold, snap or smoothly interpolate the local player to the authoritative position (error correction).
- Re-simulate any inputs that occurred after the server's tick (rollback).
5.3 Interpolation of Remote Entities
- Local player: predicted + reconciled (see above).
- Remote players / entities: rendered with interpolation delay of
2 × TICK_DURATION(≈ 66 ms) to smooth out snapshot jitter. - The client buffers the last N snapshots and interpolates between them.
5.4 Prediction Buffer Sizing
- Buffer size is adaptive based on measured RTT.
- Default: hold up to
ceil(RTT / TICK_DURATION) + 2predicted states.
6. Entity & World State Replication
6.1 Entity Model
Every networked entity has:
- A globally unique
entity_id(server-assigned). - An
EntityType(player, enemy, item, projectile, door, ...). - A
Transform(position, rotation). - A typed
propsdictionary of gameplay state (health, state machine, etc.).
6.2 Snapshot Format
snapshot_state:
tick: uint32
entities:
- entity_id: uint32
type: uint8
transform: { x, y, rot }
props: { ... } # delta-encoded where possible
player_states:
- peer_id: int
entity_id: uint32
health: int
state: uint8
6.3 Delta Compression
- Only changed entity fields are sent per snapshot (dirty-flag per entity).
- Static or unchanged entities are omitted from the snapshot.
- This keeps bandwidth proportional to activity, not world size.
7. Connection & Session Lifecycle
- Connect: Client opens an ENet connection to the server.
- Authenticate / Join: Client sends
request_join; server validates and assigns apeer_idand spawns the player entity. - Spawn: Server broadcasts
spawn_entityfor the new player to all clients. - Play: Input → prediction → server simulation → snapshot → reconciliation.
- Disconnect: Server removes the player entity, broadcasts
despawn_entity, and cleans up session state.
7a. Server Orchestration (implemented, PC3 Phase 2)
One dedicated server process per world, spawned on demand.
client backend (single worker) host
│ POST /api/worlds/{id}/join (bearer JWT) │
│ ───────────────────────────▶ GameServerSupervisor │
│ ensure_server(world_id): │
│ if none running for world ─── spawn:
│ godot --headless
│ res://scenes/server.tscn --
│ --port <p> --world <id>
│ --status-file <path>
│ ◀─────────────────────────── { host, port, world_id } │
│ │
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
- Supervisor (
backend/app/services/game_servers.py): in-process singleton, so the backend MUST run single-worker. Keys servers byworld_id. Allocates ports fromGAME_SERVER_PORT_MIN..MAX. A background reaper kills a world's server once its status file ({players, tick, updated}, rewritten by the server ~1×/s) reportsplayers == 0forGAME_SERVER_IDLE_TIMEOUT_SECONDS. No crash-restart — the next join respawns. - Enable flag:
GAME_SERVERS_ENABLED(default off). The checkpoint/playtest launcher (pm/checkpoint_ops.py) turns it on, passesGODOT_BIN,GAME_PROJECT_DIR, andGAME_SERVER_HOST(the LAN IP baked into the.exe). A backend with it off returns 503 from/join, and the client falls back to local single-player. - Per-world content: all worlds currently load the same test level (M2/M7
work).
world_idstill scopes a distinct process + session.
7b. Client play path
world_list.gd → POST /join → Session.begin(world_id, host, port) →
load game.tscn. game.gd sees Session.active and connects a
NetworkClient:
- Local player keeps predicting with its own collision-aware
CharacterBody3D(player.gd) — that is the prediction. Its input frame (player.net_input) is forwarded to the server every physics tick. - Correction backstop: if the local position drifts from the server's by
more than
CORRECTION_THRESHOLD(1.5 m — generous; catches teleport / a wall walked through / real desync, not prediction noise) it snaps. - Remote players: rendered from snapshots, position lerped. Appearance sync is a follow-up — remotes currently show as labelled capsules; the server does not yet replicate avatar loadouts.
- Disconnect → status message → back to the world list.
The NetworkClient's own flat-plane prediction + input-replay reconciliation
(§5) is exercised by smoke_test_network_client.gd; the collision-aware game
path above uses the lighter send_input() + drift-snap instead.
8. Security & Anti-Cheat
- No client-authoritative gameplay values. Clients only send inputs and requests; the server computes all outcomes.
- Input validation: Server clamps movement speed, rejects out-of-bounds positions, and validates interaction targets.
- Rate limiting: Per-peer input and RPC rate limits prevent flooding.
- Sequence validation: Gaps or replays in input
seqare detected and rejected.
9. Testing & Determinism
- The simulation is a pure function of (state, inputs, tick) so it can be unit-tested headlessly.
- A replay system records inputs and can re-simulate to verify server and client produce identical results.
- Network code is tested with a mock transport that can inject latency, jitter, and packet loss.
10. Open Questions / Future Work
- Interest management / spatial partitioning: For large worlds, restrict snapshots to entities near each client (AOI culling).
- Server-side physics: Decide whether to use Godot's physics server on the host or a custom deterministic physics step.
- Dedicated server vs. listen server: The architecture supports both; a dedicated headless server is the target for production.
- WebSocket fallback: Implement the transport abstraction for browser builds.
11. Summary
| Concern | Decision |
|---|---|
| Transport | ENet (primary), WebSocket (secondary) |
| Tick rate | 30 Hz fixed timestep |
| Authority | Server-authoritative; client sends input only |
| Prediction | Client-side prediction + reconciliation |
| Remote interpolation | 2-tick interpolation delay |
| Replication | Delta-compressed snapshots at 30 Hz |
| Interaction RPCs | Reliable channel |
| Movement input | Unreliable channel, batched with sequence |