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

13 KiB
Raw Blame History

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.