Files
modbison/docs/architecture/networking.md
T
daddy 50c6a31c25 PC3 Phase 2: orchestration under checkpoint control + networking doc
- 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
2026-09-05 22:08:02 +00:00

343 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `MultiplayerPeerENet` is 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
`seq` so 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:
1. Applies the input to its local player immediately.
2. Renders the predicted position.
3. Sends the input to the server (batched, with `seq`).
### 5.2 Reconciliation
- The server sends authoritative `snapshot_state` at 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:
1. Find the last predicted state that matches the server's tick.
2. Compute the **error** between predicted and authoritative position.
3. If error exceeds a threshold, **snap or smoothly interpolate** the local
player to the authoritative position (error correction).
4. 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) + 2` predicted 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 `props` dictionary 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
1. **Connect:** Client opens an ENet connection to the server.
2. **Authenticate / Join:** Client sends `request_join`; server validates and
assigns a `peer_id` and spawns the player entity.
3. **Spawn:** Server broadcasts `spawn_entity` for the new player to all clients.
4. **Play:** Input → prediction → server simulation → snapshot → reconciliation.
5. **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 by `world_id`. Allocates
ports from `GAME_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) reports `players == 0` for `GAME_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, passes `GODOT_BIN`,
`GAME_PROJECT_DIR`, and `GAME_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_id` still 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 `seq` are 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 |