Add ENet multiplayer (movement/combat sync) and matchmaking API

Networked play, end to end: connect, get matched, spawn, fight, respawn.

Godot side:
- Replace the single hardcoded Player with per-peer networked ships
  (MultiplayerSpawner + custom spawn_function), driven by client-side
  prediction with server reconciliation for movement.
- Server-authoritative combat: bullets, health, death/respawn all decided
  by the server and broadcast to every peer over RPC.
- Fix a Camera2D bug where Godot auto-promotes the first camera to ever
  enter the scene tree as the active one regardless of its own `current`
  value — with MultiplayerSpawner catch-up replication that was almost
  never the local player's own ship. `make_current()` on the owner's
  camera fixes it; property assignment doesn't reliably override the
  auto-claim.
- Fix two "late joiner never learns already-established state" gaps
  (health, visibility) by folding both into the existing per-tick
  position broadcast instead of relying on one-shot RPCs that only reach
  peers already connected when they fire.
- Make the 2 offered factions in team select match for every player in
  a match: the server decides once and clients either read it directly
  (server's own instance) or request it over RPC, rather than each
  client rolling its own random pair.
- New MatchmakingClient autoload wires the main menu's CASUAL/RANKED
  buttons to the matchmaking API instead of connecting directly.

New matchmaking-api/ (FastAPI + Postgres, Docker Compose):
- Queue/match/stats/ranks endpoints. In-memory matchmaking queue in a
  single API process — no Redis until there's an actual reason to shard
  the queue across instances.
- Background loop forms a match once enough players are queued for a
  mode, assigns the first available registered game server.
- No real server pool yet: one dev server is auto-seeded from
  DEV_SEED_SERVER_IP/PORT; POST /servers/register exists for real
  servers to self-register later but nothing calls it yet.

Docs: CLAUDE.md and overview/tech.md updated to match — networking
checklist mostly checked off, matchmaking backend section rewritten to
describe what was actually built vs. the original Go/Redis plan, and
Current Tasks reordered around what's actually left (bot fill, real
server pool, ranked/MMR refinement, lag compensation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 22:37:59 -04:00
parent b55453d948
commit edcda92812
36 changed files with 1384 additions and 98 deletions
+34 -11
View File
@@ -1,4 +1,5 @@
extends CanvasLayer
class_name TeamSelect
# Movement speed factor by role, shared across all 3 races so e.g. every
# race's Interceptor moves at the same speed as every other race's.
@@ -116,21 +117,41 @@ const ROW_IMG := 95.0
var _root: Control
var _content: Control
var _loading_lbl: Label
var _offered: Array = []
var _chosen_race: Dictionary = {}
func _ready() -> void:
layer = 20
_offered = _pick_two_races()
_build_root()
# The 2 offered races must be the same for every player in the match, not
# independently randomized per client — the server decides once (see
# World.decide_offered_races()) and every client either asks for it
# (real remote peers) or reads it directly (the server's own instance,
# no round-trip needed since it's the same process).
var world := get_node("/root/World")
if multiplayer.is_server():
offer_races(world.decide_offered_races())
else:
world.request_offered_races.rpc_id(1)
# Called once this client knows which 2 races (by id) the match is offering.
func offer_races(race_ids: Array) -> void:
if _loading_lbl:
_loading_lbl.queue_free()
_loading_lbl = null
_offered = race_ids.map(func(id): return _race_by_id(id))
_show_race_selection()
func _pick_two_races() -> Array:
var pool := RACES.duplicate()
pool.shuffle()
return [pool[0], pool[1]]
func _race_by_id(id: int) -> Dictionary:
for race in RACES:
if race.id == id:
return race
return RACES[0]
func _build_root() -> void:
@@ -144,6 +165,9 @@ func _build_root() -> void:
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_root.add_child(overlay)
_loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75),
0, 380, 1280, 420, HORIZONTAL_ALIGNMENT_CENTER)
func _swap_content() -> Control:
if _content:
@@ -179,7 +203,6 @@ func _show_race_selection() -> void:
func _on_race_chosen(race: Dictionary) -> void:
_chosen_race = race
GameConfig.player_race = race.id
_show_ship_selection()
@@ -203,10 +226,9 @@ func _show_ship_selection() -> void:
func _on_ship_chosen(ship: Dictionary) -> void:
GameConfig.player_ship_path = ship.path
GameConfig.player_ship_scale = ship.scale
GameConfig.player_ship_speed_factor = ship.speed_factor
GameConfig.team_selected.emit()
PlayerRegistry.submit_local_loadout(
GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor
)
queue_free()
@@ -346,7 +368,7 @@ func _make_ship_row(ship: Dictionary, accent: Color) -> Button:
func _label(parent: Control, text: String, font_size: int, color: Color,
x1: float, y1: float, x2: float, y2: float,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> void:
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = align
@@ -355,6 +377,7 @@ func _label(parent: Control, text: String, font_size: int, color: Color,
lbl.add_theme_color_override("font_color", color)
_place(lbl, x1, y1, x2, y2)
parent.add_child(lbl)
return lbl
func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void: