Bundles several sessions' worth of previously uncommitted work: map categories with Domination/Conquest/King of the Hill mode stubs and a server-list mode filter; a procedurally-drawn character-creation screen replacing the old callsign-only PROFILE overlay; the on-foot groundwork (walkable station hub, ship interior, character controller) plus the RAM currency backend; and today's addition, an in-ship Helldivers-2-style navigation table that QUICK PLAY's queue/connect flow and the Belters/ Military hub travel now live behind, with hub-and-ship return paths and a context-aware pause menu usable both in-match and inside the ship. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.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 aticket_id.GET /matchmaking/queue/status/{ticket_id}— poll this; once matched it returns the assigned server'sserver_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.
RAM (in-game currency)
See overview/onfoot.md for the full design — this is just the backend
piece. Player.ram_kb is a single BigInteger balance, always stored and
transmitted as kilobytes (the smallest denomination); the Godot client
(spacewar/autoload/currency.gd) formats it up into KB/MB/GB/TB for
display, 1000 per step. 100% separate from real-money monetization
(overview/money.md) — this is purely an in-game economy.
POST /matches/report now also credits RAM to every reported player:
ram_payout_participation_kb just for being in the match, plus
ram_payout_per_kill_kb per kill, plus ram_payout_win_bonus_kb if they
won (all three tunable in app/config.py, currently 50/15/200 — placeholder
numbers, not balanced against anything). GET /stats/{callsign} returns the
running total as ram_kb.
There is no spend endpoint yet — nothing in the game can spend RAM until the
ship interior/hubs from overview/onfoot.md exist. Player.ram_kb is a new
column on an existing table, same no-migrations caveat as above — covered by
the same docker compose down -v if you're picking this 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)
- Add/extend a model in
app/models.pyif it needs its own table. - Add request/response shapes to
app/schemas.py. - Add a router file under
app/routers/,include_router()it inapp/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:
callsignis the only player identity, matching the game client's current state (no accounts yet). Real identity arrives with the GodotSteam auth checklist item inoverview/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.