Files
client/overview/networking_implementation_plan.md
T
anekdotin b55453d948 first commit
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:16:29 -04:00

265 lines
13 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.
# Networking Implementation Plan
Step-by-step breakdown for taking Spacewar from single-player-only to working
multiplayer. `tech.md` describes the target architecture; this doc is the
ordered checklist for getting there from the current codebase.
**Current state (as of this doc):** zero networking code exists anywhere in
the project. The server browser's JOIN button and the main menu's CASUAL
button just set local `GameConfig` fields and load `world.tscn` — no
connection is ever made. Everything downstream assumes exactly one player.
**Guiding principle:** get two peers moving/shooting/dying correctly over
ENet *first*, with ugly/no prediction. Polish feel (prediction,
reconciliation, lag comp) only after the authoritative loop is proven
correct. Don't build matchmaking/Steam/galaxy-war until basic peer-to-peer
combat works.
---
## Phase 0 — Decisions to make before writing code
- [ ] Pick topology for early dev: dedicated headless server instance vs.
one client acting as host. (Recommend: always run a dedicated server,
even locally — avoids a host-migration refactor later, and matches
the authoritative-server model in `tech.md`.)
- [ ] Decide the connection target for now: hardcoded `127.0.0.1` / LAN IP
is fine until Phase 8. Don't build matchmaking yet.
- [ ] Confirm max casual match size (25v25 per `overview.md`) — this affects
how much you can get away with naive replication before needing
interest management / area-of-interest culling.
---
## Phase 1 — ENet bootstrap & connect flow
**Goal:** two Godot instances can connect over ENet and see each other join/leave.
- [ ] Add a `NetworkManager` autoload (new, alongside `GameConfig` in
`autoload/`) that owns the `ENetMultiplayerPeer`, exposes
`host_server(port)` / `join_server(ip, port)`, and connects to
`multiplayer.peer_connected` / `peer_disconnected` / `connected_to_server`
/ `connection_failed`.
- [ ] Wire `menu/server_browser.gd`'s `_on_join_pressed` (currently just sets
`GameConfig` fields and changes scene) to actually call
`NetworkManager.join_server(...)` and only transition to `world.tscn`
on `connected_to_server`.
- [ ] Add a minimal headless server launch path (`--server` CLI flag or a
separate export target) that calls `host_server()` and loads
`world.tscn` without a local player.
- [ ] Smoke test: launch one headless server + two client instances, confirm
`peer_connected` fires on the server for both and each client sees the
other's peer_id.
---
## Phase 2 — Per-peer player state
**Goal:** replace the single-player assumption in `GameConfig` with a real
per-peer registry.
- [ ] Split `autoload/game_config.gd`: keep shared/match-wide constants
(thrust, speed, fire rate, `world_bounds`, etc.) as-is, but move the
per-player fields (`player_name`, `player_race`, `player_ship_path`,
`player_ship_scale`, `player_ship_speed_factor`) out of flat globals
into a `Dictionary[int, PlayerInfo]` keyed by `peer_id`, on
`NetworkManager` or a new `PlayerRegistry` autoload.
- [ ] On connect, client sends its chosen name/race/ship to the server via
RPC (`@rpc("any_peer", "call_local", "reliable") submit_loadout(...)`);
server stores it in the registry and relays to all peers so everyone
knows everyone's loadout.
- [ ] Update `menu/team_select.gd` (currently sets `GameConfig` fields
directly and emits local `team_selected`) to submit the choice via
this RPC path instead.
- [ ] Update anywhere still reading `GameConfig.player_name` /
`player_race` / `player_ship_path` directly to read from the local
peer's entry in the registry instead.
---
## Phase 3 — Spawning multiple ships
**Goal:** N ships exist in `world.tscn`, one per connected peer, each owned
by the right client.
- [ ] `world.tscn` currently has a single hardcoded ship node named "Player"
as a direct child. Remove it; replace with an empty `Node2D`
container (e.g. `Ships`) that ships get added to at runtime.
- [ ] Add a `MultiplayerSpawner` in `world.tscn` pointed at the `Ships`
container, with `ship.tscn` in its spawnable scene list.
- [ ] Server-side: on `peer_connected` (or once loadout is submitted),
instantiate `ship.tscn` for that peer under `Ships`, set
`set_multiplayer_authority(peer_id)` on the ship root, name the node
by peer_id (e.g. `str(peer_id)`) so it replicates deterministically.
- [ ] On `peer_disconnected`, despawn that peer's ship and remove it from
the registry.
- [ ] **Camera2D fix:** `ship.tscn` currently bakes a `Camera2D` into the
scene itself, which breaks with multiple instances. Move the camera
out of `ship.tscn`; in `ship_movement.gd`'s `_ready()`, only create/
activate a `Camera2D` if `is_multiplayer_authority()` is true (i.e.
this is the local player's own ship).
- [ ] **HUD fix:** `ship_movement.gd` currently pushes health to
`/root/World/HUD/HealthLabel` via a hardcoded absolute path. Gate this
the same way — only the locally-authoritative ship should update the
local HUD.
---
## Phase 4 — Split input from simulation
**Goal:** stop reading `Input.is_action_pressed()` inside the shared
simulation code; every ship's movement should be driven by whoever is
authoritative for it (server), fed by input coming from the owning client.
- [ ] In `ship_movement.gd`, extract the current `_physics_process` block
(lines ~4566, direct `Input.is_action_pressed` calls) into a small
input struct/dictionary (`{thrust: bool, rotate: float, firing: bool}`)
gathered only when `is_multiplayer_authority()` on the *client* side.
- [ ] Add `@rpc("any_peer", "call_remote", "unreliable") send_input(input)`
on the ship: client calls it every physics tick with its local input;
server receives it, validates the sender is this ship's owning peer,
and stores it as "current input" for that ship.
- [ ] Server's `_physics_process` runs the actual movement/`move_and_slide`
simulation using the last-received input for every ship it owns
authority over (the server owns authority over all ships in the
dedicated-server model).
- [ ] Add a `MultiplayerSynchronizer` per ship replicating `position`,
`rotation`, `velocity` from server → clients.
- [ ] Get this working *without* prediction first: local ship will feel
laggy (input → server → back). That's expected at this stage — fixed
in Phase 7.
---
## Phase 5 — Shooting / bullets over the network
**Goal:** bullets are server-simulated and replicated, not spawned locally
by each client.
- [ ] `ship_movement.gd`'s fire logic (~line 72) currently does
`get_parent().add_child(bullet)` directly on whichever peer runs it.
Change so firing is just another bit in the input struct from Phase 4;
server decides when a shot is actually fired (respecting fire-rate
cooldown server-side, not trusting client timing).
- [ ] Server instantiates `bullet.tscn` via a `MultiplayerSpawner` (or
manual spawn + RPC) under a shared `Bullets` container in
`world.tscn`.
- [ ] `bullet.gd` currently self-simulates movement in `_process` and
resolves damage locally via `body_entered`. Keep bullet *movement*
client-side-predicted for visual smoothness if desired, but damage
resolution (`take_damage()` call, ~lines 1520) must only happen on
the server's copy of the bullet.
- [ ] Despawn bullets server-side when out of `world_bounds`; replicate
despawn to clients.
---
## Phase 6 — Server-authoritative health / death / respawn
**Goal:** no client can kill, heal, or respawn anything except by asking the
server.
- [ ] Move `take_damage` / `_die` / `_respawn` (`ship_movement.gd` lines
~104135) so the actual state mutation only runs where
`multiplayer.is_server()` is true. Clients only ever display the
replicated result.
- [ ] Add an authority check at the top of `take_damage`: reject calls that
didn't originate from the server (bullets are already server-spawned
after Phase 5, so this mostly falls out naturally — but double check
nothing client-side can still call it directly).
- [ ] Replicate `health`, `is_dead` (or similar) via the ship's
`MultiplayerSynchronizer` from Phase 4 so HUD and visuals update on
all clients.
- [ ] Respawn: server decides timing/position and re-broadcasts spawn
state; don't let respawn timers run independently on each client.
---
## Phase 7 — Client-side prediction & reconciliation
**Goal:** local ship feels responsive despite server round-trip; remote
ships move smoothly despite update-rate gaps.
- [ ] Local client: predict own ship's movement immediately on input
(re-run the same movement function locally that the server runs),
rather than waiting for the server echo.
- [ ] Server periodically sends authoritative position/velocity/tick back to
the owning client; client reconciles by snapping/blending toward it
if prediction drifted (basic version: hard snap if error exceeds a
threshold; polish later with smoothing).
- [ ] Remote ships (not locally owned): interpolate between the last two
received network states instead of snapping on every update.
- [ ] This is the highest-skill, most iterative phase — budget real time for
tuning "feel," not just correctness.
---
## Phase 8 — Real server browser / connect flow
**Goal:** menus do what they currently only pretend to do.
- [ ] `menu/server_browser.gd`'s server list is a single hardcoded
"Trench Wars 0/32" entry. Replace with either: (a) a small manual
"enter IP" field for direct-connect testing, or (b) if a lightweight
server-list service exists by this point, query it.
- [ ] `menu/main_menu.gd`'s CASUAL/RANKED buttons currently skip networking
entirely and load `world.tscn` locally. Route CASUAL through the same
`NetworkManager.join_server` path once a target server is chosen.
- [ ] Handle connection failure / timeout UI (currently nothing exists for
this — `connection_failed` signal has no handler anywhere).
---
## Phase 9 — Testing & hardening
- [ ] Test with 2 clients, then push toward the real casual target (25v25)
to find where naive full-replication breaks down (bandwidth, spawn
storms). Consider interest management / relevance culling only if
needed at that scale — don't build it preemptively.
- [ ] Artificially add latency/packet loss locally (Godot has debug tools
for this, or use `tc`/`netem` on Linux) and verify prediction/
reconciliation still feels acceptable.
- [ ] Verify a client can't cheat: send garbage/rapid-fire input via a
modified client and confirm the server-side rate limits / bounds
checks (added in Phases 46) actually hold.
---
## Phase 10 — Deferred / parallelizable (not blocking core multiplayer)
These don't block getting ship-vs-ship combat working over the network and
can happen in parallel or after Phases 19:
- [ ] Matchmaking backend (queue, MMR, lobby assignment) — see `tech.md`'s
Go/Node + Redis + Postgres sketch.
- [ ] GodotSteam integration (auth, VAC, lobbies).
- [ ] Lag compensation (server-side rewind for hit validation) — only
matters once hit-detection precision is actually being contested;
skip until basic damage registration is proven reliable.
- [ ] Bot fill for casual matches (`bots.md`) — depends on Phases 36 being
done, since bots need to be simulate-able the same way real players'
ships are.
- [ ] Galaxy war meta / sector control — orthogonal system, layer on top
once match-level multiplayer is solid.
---
## Effort summary
| Phase | Relative effort | Notes |
|---|---|---|
| 1. ENet bootstrap | Small | 12 days |
| 2. Per-peer state | SmallMedium | Mechanical, touches every menu script |
| 3. Spawning | Medium | Camera/HUD ownership bugs are the sharp edges |
| 4. Input/sim split | Medium | The core refactor of `ship_movement.gd` |
| 5. Bullets | SmallMedium | Mostly follows the pattern from Phase 4 |
| 6. Authoritative health | Medium | Mostly enforcement of what Phase 4/5 set up |
| 7. Prediction/reconciliation | MediumLarge | Iterative feel-tuning, not just correctness |
| 8. Real menus | Small | UI wiring once NetworkManager exists |
| 9. Testing/hardening | Medium | Scales with target match size (25v25) |
| 10. Deferred systems | Large, but parallelizable | Doesn't block core multiplayer |
**Bare working version (Phases 16, no prediction polish):** roughly 12
weeks of focused work. **Feeling good at 25v25 (through Phase 9):** the long
pole — budget significantly more for iteration on Phase 7 in particular.