Files
client/overview/networking_implementation_plan.md
T
anekdotin be5c41f82f Add match structure (countdown, timer, scoreboard, banner, persistent stats) and capital-ship fleet polish
Match phases (autoload/match_manager.gd, world/game_modes/):
- New server-authoritative MatchManager loops PRE_MATCH (30s countdown,
  ships held invisible/uncollidable, flagships creep into formation) ->
  IN_PROGRESS (active GameMode's clock runs) -> POST_MATCH (winner
  banner, result reported to matchmaking-api) -> back to a fresh
  PRE_MATCH forever, matching the always-on server-pool model instead
  of kicking players to the menu at match end.
- Win-condition logic lives in a new GameMode abstraction (game_mode.gd
  base + team_deathmatch_mode.gd, the only mode so far) so a future
  mode is a new subclass plus one factory branch, no timer/scoreboard/
  banner code changes needed.
- Three new HUD pieces: match_timer.gd (countdown/clock), scoreboard.gd
  (hold-Tab two-team panel), match_banner.gd (winner banner).
- New MatchStats autoload tracks per-match kills/deaths by peer_id
  (including bots), hooked into kill_feed_manager's existing
  report_kill() call site.

Persistent stats (matchmaking-api/):
- Player gains kills/deaths/hours_played columns; new
  POST /matches/report endpoint (server-only, called once at
  POST_MATCH) upserts each real player's totals by callsign via a
  shared app/crud.py helper also used by the matchmaking-queue join
  path. GET /stats/{callsign} returns the new fields alongside mmr/
  wins/losses.

Capital-ship fleet polish (world/flagship.gd, world/world.gd,
ships/ship_movement.gd, autoload/game_config.gd):
- Flagship formations are now a clean vertical line (no per-ship
  position/rotation jitter) so play_creep_in()'s rigid-group tween
  reads as one disciplined fleet arriving together, rising from
  directly below (not a random compass direction) over the full 30s
  countdown.
- Fixed a real bug where the creep-in tween only ever played on the
  server -- MatchManager._run_pre_match() called straight into World,
  server-only code a remote client's own process never runs, leaving
  their flagships static all match. World now triggers it off
  MatchManager.phase_changed instead, which fires identically on every
  peer.
- Fixed a second bug (only reachable on a fresh server boot's very
  first spawn): a phase==PRE_MATCH check that's true even before the
  match loop has genuinely started that phase for the first time fired
  play_creep_in() with a bogus zero-duration tween, corrupting the
  target the real 30s tween read moments later -- flagships would
  settle 4000 units off from their intended formation slot and fire
  from there instead. Guarded on get_remaining_seconds() > 0 too.
- The held ship's camera now actively tracks its own team's flagship
  centroid every tick during the countdown (position_smoothing
  disabled for the hold, since it fights a manually-driven target and
  was the reason the fleet read as invisible) instead of sitting fixed
  and wide-angle; local offset/zoom/smoothing are explicitly reset on
  release so control handback doesn't inherit a stale camera transform.
- _apply_pre_match_hold()/_apply_respawn() now set _dead/
  _held_for_pre_match inside the RPC itself, not just in the
  server-only caller -- those flags never reached remote clients
  before, so WASD wasn't actually blocked for them during the hold.
- Ships now launch from a narrow point directly beneath their own
  flagship formation instead of a full-circle scatter around the spawn
  marker (which could land a spawn behind/inside a hull). Bumped
  flagship_defense_radius so it still comfortably reaches a player who
  flies a straight line to the enemy side without correcting for that
  new offset.

ISN gets a Stealth Corvette hull mixed into its flagship formation
(assets/images/ships/isn/), banner art renamed off opaque UUID
filenames to isn_banner.jpeg/orc_banner.jpeg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:40:15 -04:00

273 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
> **Status: complete.** This was the ordered checklist that took Spacewar from
> zero networking code to the real ENet authoritative-server implementation —
> all 10 phases below were executed (see `CLAUDE.md` item 6 and the many items
> after it that build on top of this foundation). Kept as a historical record
> of the approach and ordering; the checkboxes are left unchecked as
> originally written rather than retroactively edited. For current
> architecture, see `tech.md`; for the present file layout, see `structure.md`.
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.
**Original starting state:** zero networking code existed 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 was ever made. Everything downstream assumed 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.