Files
client/matchmaking-api/README.md
T
anekdotin 7b44b2f2dc Add real server pool, race roster overhaul, and ship energy system
Server browsing & matchmaking:
- HL2-style main menu (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT)
  replaces the old CASUAL/RANKED tiles; RANKED removed end-to-end
  (menu, matchmaking-api's Mode.ranked queue path, MMR matching) until
  ranked is real.
- New menu/server_select.gd lists real servers from the matchmaking
  API's GET /servers and connects directly, retiring the old
  unreachable menu/server_browser.gd.
- Real game-server pool: NetworkManager.host_server() self-registers on
  boot and heartbeats every 8s with live player counts; API computes
  available/full status from player_count, and a background sweep marks
  any server offline once its heartbeat goes stale (catches a crashed
  server that never deregistered).
- Server-select rows get a live UDP ping probe (query port = game port
  + 10000) instead of trusting stale DB numbers; post-connect HUD shows
  live RTT off ENet's own peer stats.

Race roster overhaul:
- Swapped Terran/Mechanos/Vorg for the pivoted roster — Apex Dynamics,
  Inner Sphere Navy, Outer Rim Collective — each with a 3-ship
  Fighter/Gunner/Tank lineup, art cropped from concept sheets with
  background removal + orientation fixes per sheet.
- Live headcount + roster + "TEAM FULL" lock on the race-select screen,
  shared between the initial pre-spawn pick and the pause menu's live
  SELECT TEAM swap.
- Bot personalities (bots/bot_personality.gd): aggression/caution/
  accuracy/reaction/awareness traits rolled per bot instead of one
  fixed AI profile.

HUD additions:
- Player list (roster, teammates white/enemies yellow, bots flagged),
  kill feed, minimap, and explosion VFX on death.
- Health and energy now render as bars (hud/stat_bar.gd) instead of
  text in the top-left HUD.

Ship energy system:
- Per-role max energy (Fighter 100 / Gunner 150 / Tank 250), 75 energy
  per shot, flat regen (100 per 2.5s), fully server-authoritative and
  piggybacked on the existing per-tick state broadcast alongside health.
- New blue "mirrored" bar top-middle of the screen (hud/energy_bar.gd)
  whose fill drains from both edges toward the center instead of
  left-to-right.

Ship handling tuning:
- Turn rate reduced (4.0 -> 1.0 rad/s) so a quick tap no longer
  over-rotates; holding past 0.15s ramps to double speed (2.0 rad/s) for
  fast full turns, gated the same way damage already is so replay during
  reconciliation can't double-count the hold timer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:56:33 -04:00

5.4 KiB

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.

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.