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>
13 KiB
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.mditem 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, seetech.md; for the present file layout, seestructure.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
NetworkManagerautoload (new, alongsideGameConfiginautoload/) that owns theENetMultiplayerPeer, exposeshost_server(port)/join_server(ip, port), and connects tomultiplayer.peer_connected/peer_disconnected/connected_to_server/connection_failed. - Wire
menu/server_browser.gd's_on_join_pressed(currently just setsGameConfigfields and changes scene) to actually callNetworkManager.join_server(...)and only transition toworld.tscnonconnected_to_server. - Add a minimal headless server launch path (
--serverCLI flag or a separate export target) that callshost_server()and loadsworld.tscnwithout a local player. - Smoke test: launch one headless server + two client instances, confirm
peer_connectedfires 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 aDictionary[int, PlayerInfo]keyed bypeer_id, onNetworkManageror a newPlayerRegistryautoload. - 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 setsGameConfigfields directly and emits localteam_selected) to submit the choice via this RPC path instead. - Update anywhere still reading
GameConfig.player_name/player_race/player_ship_pathdirectly 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.tscncurrently has a single hardcoded ship node named "Player" as a direct child. Remove it; replace with an emptyNode2Dcontainer (e.g.Ships) that ships get added to at runtime.- Add a
MultiplayerSpawnerinworld.tscnpointed at theShipscontainer, withship.tscnin its spawnable scene list. - Server-side: on
peer_connected(or once loadout is submitted), instantiateship.tscnfor that peer underShips, setset_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.tscncurrently bakes aCamera2Dinto the scene itself, which breaks with multiple instances. Move the camera out ofship.tscn; inship_movement.gd's_ready(), only create/ activate aCamera2Difis_multiplayer_authority()is true (i.e. this is the local player's own ship). - HUD fix:
ship_movement.gdcurrently pushes health to/root/World/HUD/HealthLabelvia 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_processblock (lines ~45–66, directInput.is_action_pressedcalls) into a small input struct/dictionary ({thrust: bool, rotate: float, firing: bool}) gathered only whenis_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_processruns the actual movement/move_and_slidesimulation 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
MultiplayerSynchronizerper ship replicatingposition,rotation,velocityfrom 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 doesget_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.tscnvia aMultiplayerSpawner(or manual spawn + RPC) under a sharedBulletscontainer inworld.tscn. bullet.gdcurrently self-simulates movement in_processand resolves damage locally viabody_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.gdlines ~104–135) so the actual state mutation only runs wheremultiplayer.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'sMultiplayerSynchronizerfrom 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 loadworld.tscnlocally. Route CASUAL through the sameNetworkManager.join_serverpath once a target server is chosen.- Handle connection failure / timeout UI (currently nothing exists for
this —
connection_failedsignal 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/netemon 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.