b55453d948
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
265 lines
13 KiB
Markdown
265 lines
13 KiB
Markdown
# 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 ~45–66, 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 15–20) 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
|
||
~104–135) 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 4–6) 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 1–9:
|
||
|
||
- [ ] 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 3–6 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 | 1–2 days |
|
||
| 2. Per-peer state | Small–Medium | 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 | Small–Medium | Mostly follows the pattern from Phase 4 |
|
||
| 6. Authoritative health | Medium | Mostly enforcement of what Phase 4/5 set up |
|
||
| 7. Prediction/reconciliation | Medium–Large | 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 1–6, no prediction polish):** roughly 1–2
|
||
weeks of focused work. **Feeling good at 25v25 (through Phase 9):** the long
|
||
pole — budget significantly more for iteration on Phase 7 in particular.
|