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
View File
+14
View File
@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
casual_team_size: int = 7
ranked_team_size: int = 5
dev_seed_server_ip: str = "127.0.0.1"
dev_seed_server_port: int = 7777
settings = Settings()
+19
View File
@@ -0,0 +1,19 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session
+58
View File
@@ -0,0 +1,58 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import select
from app.config import settings
from app.database import Base, async_session, engine
from app.models import GameServer, Mode, ServerStatus
from app.queue_manager import queue_manager
from app.routers import matchmaking, ranks, servers, stats
async def _seed_dev_server() -> None:
async with async_session() as db:
for mode in (Mode.casual, Mode.ranked):
existing = (
await db.execute(
select(GameServer).where(
GameServer.ip == settings.dev_seed_server_ip,
GameServer.port == settings.dev_seed_server_port,
GameServer.mode == mode,
)
)
).scalars().first()
if existing is None:
db.add(
GameServer(
ip=settings.dev_seed_server_ip,
port=settings.dev_seed_server_port,
mode=mode,
status=ServerStatus.available,
)
)
await db.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _seed_dev_server()
matching_task = asyncio.create_task(queue_manager.run_matching_loop())
yield
matching_task.cancel()
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
app.include_router(matchmaking.router)
app.include_router(stats.router)
app.include_router(ranks.router)
app.include_router(servers.router)
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
+67
View File
@@ -0,0 +1,67 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Mode(str, enum.Enum):
casual = "casual"
ranked = "ranked"
class ServerStatus(str, enum.Enum):
available = "available"
full = "full"
offline = "offline"
# No account/auth system yet (GodotSteam auth is still on the networking
# checklist), so callsign is the identity key for now — same as the game
# client itself, which only has a free-text name field today.
class Player(Base):
__tablename__ = "players"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
callsign: Mapped[str] = mapped_column(String(32), unique=True, index=True)
mmr: Mapped[int] = mapped_column(Integer, default=1000)
wins: Mapped[int] = mapped_column(Integer, default=0)
losses: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class GameServer(Base):
__tablename__ = "game_servers"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
ip: Mapped[str] = mapped_column(String(255))
port: Mapped[int] = mapped_column(Integer)
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available)
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Match(Base):
__tablename__ = "matches"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
server_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("game_servers.id"))
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
players: Mapped[list["MatchPlayer"]] = relationship(back_populates="match")
class MatchPlayer(Base):
__tablename__ = "match_players"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
match_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("matches.id"))
player_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("players.id"))
team: Mapped[int] = mapped_column(Integer) # 0 or 1
match: Mapped["Match"] = relationship(back_populates="players")
+106
View File
@@ -0,0 +1,106 @@
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import async_session
from app.models import GameServer, Match, MatchPlayer, Mode, ServerStatus
@dataclass
class Ticket:
ticket_id: uuid.UUID
player_id: uuid.UUID
callsign: str
mode: Mode
mmr: int
queued_at: datetime = field(default_factory=datetime.utcnow)
status: str = "queued" # queued | matched | cancelled
server_ip: str | None = None
server_port: int | None = None
team: int | None = None
class QueueManager:
"""In-memory matchmaking queue. No Redis: a single API process holds all
ticket state, which is fine at this scale and simpler to run — see
memory/spacewar_networking_plan for why Redis was deferred rather than
included from the start.
"""
def __init__(self) -> None:
self._tickets: dict[uuid.UUID, Ticket] = {}
self._lock = asyncio.Lock()
async def join(self, player_id: uuid.UUID, callsign: str, mode: Mode, mmr: int) -> Ticket:
ticket = Ticket(ticket_id=uuid.uuid4(), player_id=player_id, callsign=callsign, mode=mode, mmr=mmr)
async with self._lock:
self._tickets[ticket.ticket_id] = ticket
return ticket
async def leave(self, ticket_id: uuid.UUID) -> bool:
async with self._lock:
ticket = self._tickets.get(ticket_id)
if ticket is None or ticket.status != "queued":
return False
ticket.status = "cancelled"
return True
def get(self, ticket_id: uuid.UUID) -> Ticket | None:
return self._tickets.get(ticket_id)
def _waiting(self, mode: Mode) -> list[Ticket]:
tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"]
if mode == Mode.ranked:
# Simple MMR-sorted grouping. No widening-window-by-wait-time yet
# (real ranked matchmaking will want that) — nearest-neighbor by
# mmr is a reasonable placeholder until ranked queue volume
# exists to tune against.
tickets.sort(key=lambda t: t.mmr)
else:
tickets.sort(key=lambda t: t.queued_at)
return tickets
async def _try_form_match(self, mode: Mode, team_size: int, db: AsyncSession) -> None:
required = team_size * 2
async with self._lock:
waiting = self._waiting(mode)
if len(waiting) < required:
return
group = waiting[:required]
server = (
await db.execute(
select(GameServer).where(GameServer.mode == mode, GameServer.status == ServerStatus.available)
)
).scalars().first()
if server is None:
return # no server free this cycle; leave everyone queued and retry next tick
match = Match(mode=mode, server_id=server.id)
db.add(match)
await db.flush()
for i, ticket in enumerate(group):
team = i % 2
db.add(MatchPlayer(match_id=match.id, player_id=ticket.player_id, team=team))
ticket.status = "matched"
ticket.server_ip = server.ip
ticket.server_port = server.port
ticket.team = team
await db.commit()
async def run_matching_loop(self) -> None:
while True:
await asyncio.sleep(2)
async with async_session() as db:
await self._try_form_match(Mode.casual, settings.casual_team_size, db)
await self._try_form_match(Mode.ranked, settings.ranked_team_size, db)
queue_manager = QueueManager()
@@ -0,0 +1,53 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Mode, Player
from app.queue_manager import queue_manager
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
player = Player(callsign=callsign)
db.add(player)
await db.commit()
await db.refresh(player)
return player
@router.post("/queue/join", response_model=QueueJoinResponse)
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
player = await _get_or_create_player(db, req.callsign)
mmr = req.mmr if req.mode == Mode.ranked and req.mmr is not None else player.mmr
ticket = await queue_manager.join(player.id, player.callsign, req.mode, mmr)
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
@router.post("/queue/leave")
async def leave_queue(ticket_id: uuid.UUID) -> dict:
ok = await queue_manager.leave(ticket_id)
if not ok:
raise HTTPException(status_code=404, detail="Ticket not found or already resolved")
return {"status": "cancelled"}
@router.get("/queue/status/{ticket_id}", response_model=QueueStatusResponse)
async def queue_status(ticket_id: uuid.UUID) -> QueueStatusResponse:
ticket = queue_manager.get(ticket_id)
if ticket is None:
raise HTTPException(status_code=404, detail="Ticket not found")
return QueueStatusResponse(
ticket_id=ticket.ticket_id,
status=ticket.status,
server_ip=ticket.server_ip,
server_port=ticket.server_port,
team=ticket.team,
)
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Player
router = APIRouter(prefix="/ranks", tags=["ranks"])
# Mirrors RANK_DATA in spacewar/menu/main_menu.gd — keep the tier names (and
# ideally thresholds) in sync if either side changes.
_TIERS: list[tuple[int, str]] = [
(0, "CADET"),
(1000, "PILOT"),
(1400, "ACE"),
(1800, "COMMANDER"),
(2200, "ADMIRAL"),
(2600, "LEGEND"),
]
def _tier_for(mmr: int) -> str:
tier = _TIERS[0][1]
for threshold, name in _TIERS:
if mmr >= threshold:
tier = name
return tier
@router.get("/{callsign}")
async def get_rank(callsign: str, db: AsyncSession = Depends(get_db)) -> dict:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
raise HTTPException(status_code=404, detail="Player not found")
return {"callsign": player.callsign, "mmr": player.mmr, "rank": _tier_for(player.mmr)}
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import GameServer, ServerStatus
from app.schemas import ServerRegisterRequest
router = APIRouter(prefix="/servers", tags=["servers"])
# Real game servers should call this on boot and periodically (heartbeat) once
# NetworkManager grows that integration. Until then, a dev server is seeded
# on API startup (see main.py) from DEV_SEED_SERVER_IP/PORT so matchmaking is
# testable end-to-end without it.
@router.post("/register")
async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict:
existing = (
await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode))
).scalars().first()
if existing:
existing.status = ServerStatus.available
existing.last_heartbeat = datetime.utcnow()
else:
db.add(GameServer(ip=req.ip, port=req.port, mode=req.mode, status=ServerStatus.available))
await db.commit()
return {"status": "registered"}
@router.get("")
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
servers = (await db.execute(select(GameServer))).scalars().all()
return [
{
"ip": s.ip,
"port": s.port,
"mode": s.mode,
"status": s.status,
"last_heartbeat": s.last_heartbeat,
}
for s in servers
]
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Player
from app.schemas import PlayerStatsResponse
router = APIRouter(prefix="/stats", tags=["stats"])
@router.get("/{callsign}", response_model=PlayerStatsResponse)
async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> PlayerStatsResponse:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
+37
View File
@@ -0,0 +1,37 @@
import uuid
from pydantic import BaseModel
from app.models import Mode
class QueueJoinRequest(BaseModel):
callsign: str
mode: Mode
mmr: int | None = None # ignored for casual; defaults to the player's stored mmr for ranked
class QueueJoinResponse(BaseModel):
ticket_id: uuid.UUID
status: str
class QueueStatusResponse(BaseModel):
ticket_id: uuid.UUID
status: str
server_ip: str | None = None
server_port: int | None = None
team: int | None = None
class ServerRegisterRequest(BaseModel):
ip: str
port: int
mode: Mode
class PlayerStatsResponse(BaseModel):
callsign: str
mmr: int
wins: int
losses: int