Files
client/matchmaking-api
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
..

Spacewar Matchmaking API

FastAPI service for matchmaking, stats, and ranks (chat planned — see below). One process, one Postgres database, no Redis: matchmaking tickets live in-memory in the API process, which is enough at this scale — add Redis later only if the API needs to run as more than one instance.

Run it

cp .env.example .env   # already done for local dev; edit if needed
docker compose up -d

API is at http://localhost:8100, interactive docs at /docs. Postgres is exposed on host port 5433 (mapped from its usual 5432, to avoid clashing with any other local Postgres) for local inspection.

docker compose down          # stop
docker compose down -v       # stop + wipe the postgres volume

Code in app/ is bind-mounted with --reload, so edits take effect without rebuilding the image. Rebuild (docker compose up -d --build) only when requirements.txt changes.

How matchmaking works right now

  • POST /matchmaking/queue/join {callsign, mode} — creates the player row if it doesn't exist yet, returns a ticket_id.
  • GET /matchmaking/queue/status/{ticket_id} — poll this; once matched it returns the assigned server's server_ip/server_port/team.
  • POST /matchmaking/queue/leave?ticket_id=... — cancel a queued ticket.

A background loop (app/queue_manager.py) runs every 2s and forms a match once enough players are queued for casual, then splits them into two teams and assigns the first available GameServer. Casual has bot fill (spacewar/bots/bot_manager.gd pads both teams up to GameConfig.bot_min_team_size in-game), so 1 queued player is enough to form a match — CASUAL_TEAM_SIZE just caps how many real players can group into one match, it no longer gates formation. Ranked mode has been removed entirely (was never implemented past matchmaking-queue scaffolding) — the Mode enum only has casual now.

Real servers self-register: spacewar/autoload/network_manager.gd's host_server() calls POST /servers/register once on boot and then every HEARTBEAT_INTERVAL (8s) as a heartbeat, reporting live player count. The endpoint upserts on (ip, port, mode) — an unrecognized combo creates a row, a recognized one just refreshes it — and now also computes status from player_count vs max_players (full once at capacity) instead of always writing available. A background loop (sweep_stale_servers in app/routers/servers.py, run every 5s from main.py's lifespan) marks a server offline once its last_heartbeat is older than server_stale_seconds (20s default) — catches a server that crashed or was kill -9'd instead of shutting down cleanly, so it doesn't sit in the list looking joinable forever.

Three demo GameServer rows are still auto-seeded on API startup (_seed_demo_servers in main.py) at 127.0.0.1:7777/7778/7779 with player_count 40/20/10 out of max_players 50, so menu/server_select.gd has something to list even with no real server running — and since registration is a plain upsert, running a real Godot server on one of those same (ip, port) pairs (godot4 --headless --path spacewar -- --server) just takes that row over with live data, no special-casing needed. A demo row with nothing real backing it goes stale and flips to offline within server_stale_seconds of API startup, same as any other server. --server-ip= (new network_manager.gd cmdline arg) controls what IP a hosted server advertises — defaults to loopback for local dev. That IP is handed straight to game clients to connect to, so it must be reachable from wherever the client runs, not the API container — host.docker.internal would resolve inside the api container but not on the client's machine, which is why this isn't a docker-internal address (same constraint DEV_SEED_SERVER_IP has). player_count/max_players from GET /servers are just this DB-stored value; menu/server_select.gd also queries each server directly over UDP (network_manager.gd's ping responder) for a live, pre-connect number and RTT, overriding the DB value in the UI once that probe answers.

Match results & persistent stats

POST /matches/report {server_ip, server_port, mode, winner_team, players} — called once by the hosting game server (never a client) when a match ends, see spacewar/autoload/match_manager.gd's _report_match_result() (fired from its MatchManager phase state machine's POST_MATCH transition, driven by overview/map1.md's 7-minute-match design). players is a list of {callsign, team, kills, deaths, is_winner, seconds_played} — one entry per human player present at match end (bots, negative peer_id in the Godot client, never report). For each player this upserts their Player row (app/crud.py's get_or_create_player, same helper /matchmaking/queue/join uses) and accumulates kills/deaths/hours_played (seconds_played / 3600) plus wins/losses (a draw — winner_team: null — touches neither). If the reporting (server_ip, server_port, mode) matches a registered GameServer, a Match/MatchPlayer row is also written — independent of any Match row the matchmaking queue already created when the match was formed (queue_manager.py), so a queued match can end up with two Match rows (one per "formed"/"ended"); nothing currently reads these tables, so this hasn't been reconciled. No auth on this endpoint, same posture as /servers/register.

GET /stats/{callsign} now also returns kills/deaths/hours_played alongside the existing mmr/wins/losses.

Schema changes have no migration path (this project has no migrations tooling — see below): Player.kills/deaths/hours_played are new columns on an existing table, so Base.metadata.create_all() on API boot will NOT add them to a database that already has a players table from before this change. Run docker compose down -v once to pick them up on an existing local dev database.

Client integration

The Godot client is wired up (spacewar/autoload/matchmaking_client.gd): QUICK PLAY on the main menu calls POST /matchmaking/queue/join, polls GET /matchmaking/queue/status/{ticket_id} every 1.5s, and once matched connects NetworkManager directly to the returned server_ip/server_port. Point it at a non-default API with godot4 ... -- --matchmaking-api=http://host:port.

Adding a new domain (stats/ranks did this; chat will too)

  1. Add/extend a model in app/models.py if it needs its own table.
  2. Add request/response shapes to app/schemas.py.
  3. Add a router file under app/routers/, include_router() it in app/main.py.

Chat isn't scaffolded yet (no data model or requirements decided), but it fits the same shape — likely a WebSocket router using FastAPI's native support, in this same service.

Not set up yet, on purpose

  • Migrations: tables are created via Base.metadata.create_all() on startup. Fine while the schema is still moving; switch to Alembic before this holds real data.
  • Auth: callsign is the only player identity, matching the game client's current state (no accounts yet). Real identity arrives with the GodotSteam auth checklist item in overview/tech.md.
  • Bot-fill timeouts: current matching is a simple threshold (enough players queued → form a match). Refine once there's real queue volume to tune against.