diff --git a/CLAUDE.md b/CLAUDE.md index e584d9d..a98c3ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,19 +14,26 @@ | `money.md` | Monetization strategy | | `structure.md` | Project file tree, scene graph, input map | +Matchmaking API (`matchmaking-api/`, separate FastAPI + Postgres service, own +`README.md`) is a second codebase alongside the Godot project — see its +README for how to run/extend it. + ## Completed 1. **Server browser** — player name input, race toggles, server list (Trench Wars 0/32), JOIN button; stores name + race in `GameConfig` autoload 2. **Main menu** — CASUAL / RANKED (disabled) buttons, callsign input, rank badge, QUIT; clears player state on load 3. **In-game pause menu** — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (stub), QUIT TO MENU, QUIT TO DESKTOP -4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match; ship grid with sprites; player spawns with chosen ship after selection +4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match, decided once by the server so every player sees the same pair; ship grid with sprites; player spawns with chosen ship after selection 5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships//`); placeholder `example_ships` removed +6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`) +7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks ## Current Tasks - [ ] Asteroids and environment hazards -- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`) +- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`) — next logical step now that matchmaking works, since `CASUAL_TEAM_SIZE` is set low (1) for solo/duo testing on the assumption bots will pad real matches later - [ ] Sound effects (thrust, shoot, explosion, UI) -- [ ] Multiplayer networking — ENet authoritative server (see `tech.md`) +- [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry - [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) -- [ ] Ranked matchmaking + MMR +- [ ] Ranked matchmaking refinement — MMR-window widening, real account-linked MMR (currently a naive nearest-neighbor sort on a per-callsign stub); GodotSteam auth + VAC still not started +- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost diff --git a/matchmaking-api/.env.example b/matchmaking-api/.env.example new file mode 100644 index 0000000..850701b --- /dev/null +++ b/matchmaking-api/.env.example @@ -0,0 +1,14 @@ +DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking + +# Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5). +CASUAL_TEAM_SIZE=7 +RANKED_TEAM_SIZE=5 + +# Registered on API startup so local matchmaking has somewhere to assign +# players to before real game servers self-register via POST /servers/register. +# This IP is handed straight to game CLIENTS to connect their ENet peer to — +# clients run on the host, not in Docker, so it must be host-reachable +# (127.0.0.1 for local dev), not a docker-internal alias like +# host.docker.internal (that only resolves from inside the api container). +DEV_SEED_SERVER_IP=127.0.0.1 +DEV_SEED_SERVER_PORT=7777 diff --git a/matchmaking-api/.gitignore b/matchmaking-api/.gitignore new file mode 100644 index 0000000..d328f57 --- /dev/null +++ b/matchmaking-api/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.venv/ diff --git a/matchmaking-api/Dockerfile b/matchmaking-api/Dockerfile new file mode 100644 index 0000000..e3f2064 --- /dev/null +++ b/matchmaking-api/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/matchmaking-api/README.md b/matchmaking-api/README.md new file mode 100644 index 0000000..f32914e --- /dev/null +++ b/matchmaking-api/README.md @@ -0,0 +1,81 @@ +# 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, mmr?}` — 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 once enough +players are queued for a mode (`CASUAL_TEAM_SIZE`/`RANKED_TEAM_SIZE` × 2) it +forms a match, splits them into two teams, and assigns the first available +`GameServer` for that mode. + +There's no real game-server pool yet — one dev server is auto-seeded on API +startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to +`127.0.0.1:7777`, i.e. a Godot server run on the host via +`godot4 --headless --path spacewar -- --server`). 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. Real servers should eventually call +`POST /servers/register` on boot and periodically as a heartbeat — that +endpoint exists but nothing calls it yet. + +## Client integration + +The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`): +CASUAL/RANKED 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`. +- **Ranked MMR-window widening / 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. diff --git a/matchmaking-api/app/__init__.py b/matchmaking-api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matchmaking-api/app/config.py b/matchmaking-api/app/config.py new file mode 100644 index 0000000..f5a9c65 --- /dev/null +++ b/matchmaking-api/app/config.py @@ -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() diff --git a/matchmaking-api/app/database.py b/matchmaking-api/app/database.py new file mode 100644 index 0000000..cb2c56b --- /dev/null +++ b/matchmaking-api/app/database.py @@ -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 diff --git a/matchmaking-api/app/main.py b/matchmaking-api/app/main.py new file mode 100644 index 0000000..61efad7 --- /dev/null +++ b/matchmaking-api/app/main.py @@ -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"} diff --git a/matchmaking-api/app/models.py b/matchmaking-api/app/models.py new file mode 100644 index 0000000..715cb5c --- /dev/null +++ b/matchmaking-api/app/models.py @@ -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") diff --git a/matchmaking-api/app/queue_manager.py b/matchmaking-api/app/queue_manager.py new file mode 100644 index 0000000..bae9a9e --- /dev/null +++ b/matchmaking-api/app/queue_manager.py @@ -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() diff --git a/matchmaking-api/app/routers/__init__.py b/matchmaking-api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matchmaking-api/app/routers/matchmaking.py b/matchmaking-api/app/routers/matchmaking.py new file mode 100644 index 0000000..3bb48c7 --- /dev/null +++ b/matchmaking-api/app/routers/matchmaking.py @@ -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, + ) diff --git a/matchmaking-api/app/routers/ranks.py b/matchmaking-api/app/routers/ranks.py new file mode 100644 index 0000000..7aa71ed --- /dev/null +++ b/matchmaking-api/app/routers/ranks.py @@ -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)} diff --git a/matchmaking-api/app/routers/servers.py b/matchmaking-api/app/routers/servers.py new file mode 100644 index 0000000..4d6001d --- /dev/null +++ b/matchmaking-api/app/routers/servers.py @@ -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 + ] diff --git a/matchmaking-api/app/routers/stats.py b/matchmaking-api/app/routers/stats.py new file mode 100644 index 0000000..86a64f7 --- /dev/null +++ b/matchmaking-api/app/routers/stats.py @@ -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) diff --git a/matchmaking-api/app/schemas.py b/matchmaking-api/app/schemas.py new file mode 100644 index 0000000..3c3aa92 --- /dev/null +++ b/matchmaking-api/app/schemas.py @@ -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 diff --git a/matchmaking-api/docker-compose.yml b/matchmaking-api/docker-compose.yml new file mode 100644 index 0000000..7efff5c --- /dev/null +++ b/matchmaking-api/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: matchmaking + POSTGRES_PASSWORD: matchmaking + POSTGRES_DB: matchmaking + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U matchmaking"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + env_file: .env + ports: + - "8100:8000" + depends_on: + postgres: + condition: service_healthy + extra_hosts: + # Lets the container reach a Godot dev server running on the host + # (Linux Docker Engine doesn't resolve host.docker.internal by default). + - "host.docker.internal:host-gateway" + volumes: + - ./app:/app/app + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +volumes: + postgres_data: diff --git a/matchmaking-api/requirements.txt b/matchmaking-api/requirements.txt new file mode 100644 index 0000000..f669fb2 --- /dev/null +++ b/matchmaking-api/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +sqlalchemy==2.0.36 +asyncpg==0.30.0 +pydantic-settings==2.6.1 diff --git a/overview/tech.md b/overview/tech.md index 8399373..c370f29 100644 --- a/overview/tech.md +++ b/overview/tech.md @@ -55,19 +55,30 @@ func send_input(dir: Vector2, firing: bool) -> void: ### Matchmaking Backend -| Component | Tech | -|-----------|------| -| API Server | Go or Node.js | -| Queue / State | Redis | -| Database (accounts, ranks) | PostgreSQL | -| Hosting | VPS (Hetzner / DigitalOcean) or self-hosted | +Built — see `matchmaking-api/README.md` for how to run it. Actual stack +diverged from the original plan below: -**Flow:** -1. Client sends "find match" with MMR + mode -2. Backend queues player, finds lobby within MMR range -3. Lobby full → backend assigns a game server -4. Backend sends client the server IP + session token -5. Client connects directly to game server +| Component | Planned | Actual | +|-----------|---------|--------| +| API Server | Go or Node.js | **FastAPI (Python)** — I/O-bound queue/CRUD logic, not a latency-critical hot path, so dev velocity won over raw throughput | +| Queue / State | Redis | **In-memory in the API process** — no Redis; revisit only if the API needs to run as more than one instance | +| Database (accounts, ranks) | PostgreSQL | PostgreSQL, as planned | +| Hosting | VPS (Hetzner / DigitalOcean) or self-hosted | Not deployed — local Docker Compose only so far | + +**Flow (as built):** +1. Client sends `POST /matchmaking/queue/join` with callsign + mode +2. Backend queues the ticket; a background loop forms a match once enough + players are queued for that mode +3. Match forms → backend assigns the first available registered `GameServer` + for that mode +4. Client polls `GET /matchmaking/queue/status/{ticket}` until `matched`, + then connects directly to the returned server IP/port + +Not yet built: session tokens (server IP/port are handed back unauthenticated +— fine for local dev, not for a real deployment), and a real server pool — +only one dev server is registered right now (auto-seeded on API startup); +`POST /servers/register` exists for real servers to self-register/heartbeat +but nothing calls it yet. ### Steam Integration @@ -79,11 +90,11 @@ func send_input(dir: Vector2, firing: bool) -> void: ## Networking Checklist -- [ ] Godot ENet server/client setup -- [ ] Player input RPC structure -- [ ] Position sync with interpolation -- [ ] Ship class registration per peer -- [ ] Server-authoritative health/death -- [ ] Matchmaking API (queue + lobby assignment) +- [x] Godot ENet server/client setup +- [x] Player input RPC structure +- [x] Position sync with interpolation +- [x] Ship class registration per peer +- [x] Server-authoritative health/death +- [x] Matchmaking API (queue + lobby assignment) — client wired end-to-end; real server pool and ranked MMR-window widening still open, see Current Tasks in `CLAUDE.md` - [ ] GodotSteam auth + VAC - [ ] Lag compensation (basic rewind) diff --git a/spacewar/autoload/game_config.gd b/spacewar/autoload/game_config.gd index b277d3d..8641890 100644 --- a/spacewar/autoload/game_config.gd +++ b/spacewar/autoload/game_config.gd @@ -1,7 +1,5 @@ extends Node -signal team_selected - # Ship movement var ship_thrust: float = 250.0 var ship_max_speed: float = 300.0 @@ -21,12 +19,9 @@ var bullet_damage: int = 40 var ship_bounce_restitution: float = 0.45 var ship_collision_damage_scale: float = 0.064 -# Set before entering a game +# Locally-remembered callsign, pre-fills the name field on the menu. +# Race/ship/speed are per-peer now — see PlayerRegistry. var player_name: String = "" -var player_race: int = 0 -var player_ship_path: String = "" -var player_ship_scale: float = 1.0 -var player_ship_speed_factor: float = 1.0 # Current map's play area, in world coordinates. Set by world.gd on load. var world_bounds: Rect2 = Rect2(0, 0, 1152, 648) diff --git a/spacewar/autoload/matchmaking_client.gd b/spacewar/autoload/matchmaking_client.gd new file mode 100644 index 0000000..ba9ced6 --- /dev/null +++ b/spacewar/autoload/matchmaking_client.gd @@ -0,0 +1,125 @@ +extends Node + +# Talks to the FastAPI matchmaking service (see /matchmaking-api). No client +# library — plain HTTPRequest per call, since traffic here is low-frequency +# (one join, then a poll every ~1.5s) and each request gets its own +# short-lived HTTPRequest node so calls never collide with each other. + +signal search_started +signal match_found(server_ip: String, server_port: int) +signal search_failed(reason: String) + +const POLL_INTERVAL := 1.5 + +var base_url: String = "http://127.0.0.1:8100" + +var _ticket_id: String = "" +var _poll_timer: Timer +var _searching: bool = false + + +func _ready() -> void: + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--matchmaking-api="): + base_url = arg.substr("--matchmaking-api=".length()) + + _poll_timer = Timer.new() + _poll_timer.wait_time = POLL_INTERVAL + _poll_timer.one_shot = false + _poll_timer.timeout.connect(_poll_status) + add_child(_poll_timer) + + +func start_matchmaking(callsign: String, mode: String) -> void: + if _searching: + return + _searching = true + search_started.emit() + + var result := await _request("POST", "/matchmaking/queue/join", { + "callsign": callsign, + "mode": mode, + }) + + if not _searching: + return # cancelled while the join request was in flight + + if result.get("code", 0) != 200: + _fail("Couldn't reach matchmaking service") + return + + _ticket_id = result.body.ticket_id + _poll_timer.start() + + +func cancel_matchmaking() -> void: + if not _searching: + return + _searching = false + _poll_timer.stop() + if _ticket_id != "": + _request("POST", "/matchmaking/queue/leave?ticket_id=" + _ticket_id, null) + _ticket_id = "" + + +func _poll_status() -> void: + if _ticket_id == "": + return + var result := await _request("GET", "/matchmaking/queue/status/" + _ticket_id, null) + + if not _searching: + return # cancelled while the poll was in flight + + if result.get("code", 0) != 200: + _fail("Lost contact with matchmaking service") + return + + var status: String = result.body.status + if status == "matched": + _poll_timer.stop() + _searching = false + var ip: String = result.body.server_ip + var port: int = result.body.server_port + _ticket_id = "" + match_found.emit(ip, port) + elif status == "cancelled": + _poll_timer.stop() + _searching = false + _ticket_id = "" + _fail("Queue ticket was cancelled") + # else "queued": keep polling + + +func _fail(reason: String) -> void: + _poll_timer.stop() + _searching = false + _ticket_id = "" + search_failed.emit(reason) + + +# Returns {"code": int, "body": Dictionary} — "code" is 0 if the request +# itself couldn't even be sent (e.g. malformed URL), not an HTTP status. +func _request(method: String, path: String, body) -> Dictionary: + var http := HTTPRequest.new() + add_child(http) + + var headers := PackedStringArray() + var body_str := "" + if body != null: + headers.append("Content-Type: application/json") + body_str = JSON.stringify(body) + + var http_method := HTTPClient.METHOD_GET if method == "GET" else HTTPClient.METHOD_POST + var err := http.request(base_url + path, headers, http_method, body_str) + if err != OK: + http.queue_free() + return {"code": 0, "body": null} + + var response = await http.request_completed + http.queue_free() + + var response_code: int = response[1] + var response_body: PackedByteArray = response[3] + var text := response_body.get_string_from_utf8() + var parsed = JSON.parse_string(text) if text != "" else null + return {"code": response_code, "body": parsed} diff --git a/spacewar/autoload/matchmaking_client.gd.uid b/spacewar/autoload/matchmaking_client.gd.uid new file mode 100644 index 0000000..56f376f --- /dev/null +++ b/spacewar/autoload/matchmaking_client.gd.uid @@ -0,0 +1 @@ +uid://d26rs8apbu84y diff --git a/spacewar/autoload/network_manager.gd b/spacewar/autoload/network_manager.gd new file mode 100644 index 0000000..b763d06 --- /dev/null +++ b/spacewar/autoload/network_manager.gd @@ -0,0 +1,92 @@ +extends Node + +signal connection_succeeded +signal connection_failed +signal peer_joined(peer_id: int) +signal peer_left(peer_id: int) + +const DEFAULT_PORT := 7777 +const MAX_PLAYERS := 50 # covers 25v25 casual + +var is_server: bool = false +var target_ip: String = "127.0.0.1" + + +func _ready() -> void: + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.connected_to_server.connect(_on_connected_to_server) + multiplayer.connection_failed.connect(_on_connection_failed) + multiplayer.server_disconnected.connect(_on_server_disconnected) + + _apply_cmdline_args() + + +# Dev-only bootstrap: `-- --server` runs this instance as a dedicated headless +# server; `-- --connect=` overrides the default join target for clients. +func _apply_cmdline_args() -> void: + var args := OS.get_cmdline_user_args() + for arg in args: + if arg.begins_with("--connect="): + target_ip = arg.substr("--connect=".length()) + if args.has("--server"): + host_server(DEFAULT_PORT) + await get_tree().process_frame + get_tree().change_scene_to_file("res://world/world.tscn") + + +func host_server(port: int = DEFAULT_PORT) -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(port, MAX_PLAYERS) + if err != OK: + push_error("[NetworkManager] Failed to host on port %d: %s" % [port, err]) + return + multiplayer.multiplayer_peer = peer + is_server = true + print("[NetworkManager] Hosting on port %d" % port) + + +func disconnect_from_game() -> void: + if multiplayer.multiplayer_peer != null: + multiplayer.multiplayer_peer.close() + multiplayer.multiplayer_peer = null + is_server = false + + +func join_server(ip: String, port: int = DEFAULT_PORT) -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client(ip, port) + if err != OK: + push_error("[NetworkManager] Failed to start client toward %s:%d: %s" % [ip, port, err]) + connection_failed.emit() + return + multiplayer.multiplayer_peer = peer + is_server = false + print("[NetworkManager] Connecting to %s:%d..." % [ip, port]) + + +func _on_peer_connected(peer_id: int) -> void: + print("[NetworkManager] Peer connected: %d" % peer_id) + peer_joined.emit(peer_id) + + +func _on_peer_disconnected(peer_id: int) -> void: + print("[NetworkManager] Peer disconnected: %d" % peer_id) + peer_left.emit(peer_id) + + +func _on_connected_to_server() -> void: + print("[NetworkManager] Connected, local peer id: %d" % multiplayer.get_unique_id()) + connection_succeeded.emit() + + +func _on_connection_failed() -> void: + print("[NetworkManager] Connection failed") + multiplayer.multiplayer_peer = null + connection_failed.emit() + + +func _on_server_disconnected() -> void: + print("[NetworkManager] Server disconnected") + multiplayer.multiplayer_peer = null + connection_failed.emit() diff --git a/spacewar/autoload/network_manager.gd.uid b/spacewar/autoload/network_manager.gd.uid new file mode 100644 index 0000000..634067c --- /dev/null +++ b/spacewar/autoload/network_manager.gd.uid @@ -0,0 +1 @@ +uid://cwfpdpotmiial diff --git a/spacewar/autoload/player_registry.gd b/spacewar/autoload/player_registry.gd new file mode 100644 index 0000000..226fd6d --- /dev/null +++ b/spacewar/autoload/player_registry.gd @@ -0,0 +1,72 @@ +extends Node + +# Server-authoritative registry of every connected peer's chosen loadout, +# replicated to all clients so everyone knows everyone's race/ship, not just +# their own. + +signal loadout_updated(peer_id: int) +signal player_removed(peer_id: int) + +var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor} + + +func _ready() -> void: + NetworkManager.peer_left.connect(_on_peer_left) + + +func get_local_id() -> int: + return multiplayer.get_unique_id() + + +func get_info(peer_id: int) -> Dictionary: + return players.get(peer_id, {}) + + +func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: + submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor) + + +# Client -> server (and locally to self via call_local). Server then relays +# the new loadout to everyone else, and backfills the newcomer with everyone +# else's already-known loadout. +@rpc("any_peer", "call_local", "reliable") +func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if sender_id == 0: + sender_id = multiplayer.get_unique_id() + + if not multiplayer.is_server(): + _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) + return + + for peer_id in multiplayer.get_peers(): + if peer_id != sender_id: + _receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) + for peer_id in players: + if peer_id != sender_id: + var info: Dictionary = players[peer_id] + _receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor) + + _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) + + +@rpc("authority", "call_remote", "reliable") +func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: + _store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor) + + +func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: + players[peer_id] = { + "name": player_name, + "race": race, + "ship_path": ship_path, + "ship_scale": ship_scale, + "ship_speed_factor": ship_speed_factor, + } + print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]]) + loadout_updated.emit(peer_id) + + +func _on_peer_left(peer_id: int) -> void: + if players.erase(peer_id): + player_removed.emit(peer_id) diff --git a/spacewar/autoload/player_registry.gd.uid b/spacewar/autoload/player_registry.gd.uid new file mode 100644 index 0000000..258493e --- /dev/null +++ b/spacewar/autoload/player_registry.gd.uid @@ -0,0 +1 @@ +uid://ccta6qxoqmoh8 diff --git a/spacewar/menu/main_menu.gd b/spacewar/menu/main_menu.gd index 41333e7..46e2651 100644 --- a/spacewar/menu/main_menu.gd +++ b/spacewar/menu/main_menu.gd @@ -3,6 +3,8 @@ extends Control var _name_input: LineEdit var _casual_btn: Button var _ranked_btn: Button +var _status_lbl: Label +var _connecting: bool = false const RANK_DATA := [ {"label": "CADET", "color": Color(0.72, 0.48, 0.22)}, @@ -16,9 +18,11 @@ const CURRENT_RANK := 0 # placeholder until backend exists func _ready() -> void: - GameConfig.player_race = 0 - GameConfig.player_ship_path = "" _build_ui() + NetworkManager.connection_succeeded.connect(_on_connected) + NetworkManager.connection_failed.connect(_on_connect_failed) + MatchmakingClient.match_found.connect(_on_match_found) + MatchmakingClient.search_failed.connect(_on_search_failed) func _build_ui() -> void: @@ -134,6 +138,17 @@ func _add_play_section() -> void: _name_input.text_changed.connect(_on_name_changed) add_child(_name_input) + _status_lbl = Label.new() + _status_lbl.text = "" + _status_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X) + _status_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 68.0) + _status_lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP) + _status_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 92.0) + _status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status_lbl.add_theme_font_size_override("font_size", 13) + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + add_child(_status_lbl) + _update_play_buttons() @@ -322,17 +337,49 @@ func _on_name_changed(_text: String) -> void: func _update_play_buttons() -> void: - var ok := not _name_input.text.strip_edges().is_empty() + var ok := not _name_input.text.strip_edges().is_empty() and not _connecting _casual_btn.disabled = not ok _ranked_btn.disabled = true func _on_play_casual() -> void: - GameConfig.player_name = _name_input.text.strip_edges() - get_tree().change_scene_to_file("res://world/world.tscn") + _start_match("casual") func _on_play_ranked() -> void: + _start_match("ranked") + + +func _start_match(mode: String) -> void: + if _connecting: + return GameConfig.player_name = _name_input.text.strip_edges() - GameConfig.player_race = 1 # TODO: race selection screen + _connecting = true + _status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86)) + _status_lbl.text = "Searching for a match..." + _update_play_buttons() + MatchmakingClient.start_matchmaking(GameConfig.player_name, mode) + + +func _on_match_found(server_ip: String, server_port: int) -> void: + _status_lbl.text = "Match found — connecting..." + NetworkManager.join_server(server_ip, server_port) + + +func _on_connected() -> void: get_tree().change_scene_to_file("res://world/world.tscn") + + +func _on_connect_failed() -> void: + _connecting = false + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "Connection failed — is the game server reachable?" + _update_play_buttons() + + +func _on_search_failed(reason: String) -> void: + _connecting = false + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "Matchmaking failed: %s" % reason + _update_play_buttons() + _update_play_buttons() diff --git a/spacewar/menu/pause_menu.gd b/spacewar/menu/pause_menu.gd index b8c118e..74bc7ca 100644 --- a/spacewar/menu/pause_menu.gd +++ b/spacewar/menu/pause_menu.gd @@ -117,6 +117,7 @@ func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: func _on_quit_to_menu() -> void: + NetworkManager.disconnect_from_game() get_tree().change_scene_to_file("res://menu/main_menu.tscn") diff --git a/spacewar/menu/team_select.gd b/spacewar/menu/team_select.gd index 62818cd..0287556 100644 --- a/spacewar/menu/team_select.gd +++ b/spacewar/menu/team_select.gd @@ -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: diff --git a/spacewar/project.godot b/spacewar/project.godot index 91c3fdc..ce4a86a 100644 --- a/spacewar/project.godot +++ b/spacewar/project.godot @@ -18,12 +18,14 @@ config/icon="res://assets/icon.svg" [autoload] GameConfig="*res://autoload/game_config.gd" +NetworkManager="*res://autoload/network_manager.gd" +PlayerRegistry="*res://autoload/player_registry.gd" +MatchmakingClient="*res://autoload/matchmaking_client.gd" [display] -window/size/viewport_width=2560 -window/size/viewport_height=1440 -window/size/mode=3 +window/size/viewport_width=1280 +window/size/viewport_height=800 window/stretch/mode="canvas_items" window/stretch/aspect="expand" diff --git a/spacewar/ships/bullet.gd b/spacewar/ships/bullet.gd index 95781d7..f648652 100644 --- a/spacewar/ships/bullet.gd +++ b/spacewar/ships/bullet.gd @@ -1,19 +1,26 @@ extends Area2D +# Set by World._spawn_bullet() before this node enters the tree. var velocity: Vector2 = Vector2.ZERO -var source: Node = null +var source_peer_id: int = 0 +# Only the server detects hits and despawns bullets — that's replicated to +# every client automatically since bullets are spawned via BulletSpawner. +# Movement itself stays local on every peer (deterministic constant-velocity +# simulation from the replicated spawn state, no further sync needed). func _ready() -> void: - body_entered.connect(_on_body_entered) + if multiplayer.is_server(): + body_entered.connect(_on_body_entered) func _process(delta: float) -> void: global_position += velocity * delta - var bounds = GameConfig.world_bounds.grow(64.0) - if not bounds.has_point(global_position): - queue_free() + if multiplayer.is_server(): + var bounds = GameConfig.world_bounds.grow(64.0) + if not bounds.has_point(global_position): + queue_free() func _on_body_entered(body: Node) -> void: - if body == source: + if body.get("peer_id") == source_peer_id: return if body.has_method("take_damage"): body.take_damage(GameConfig.bullet_damage) diff --git a/spacewar/ships/ship.tscn b/spacewar/ships/ship.tscn index c89f83a..f43cb5b 100644 --- a/spacewar/ships/ship.tscn +++ b/spacewar/ships/ship.tscn @@ -18,7 +18,7 @@ texture = ExtResource("2_epypp") [node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491] [node name="Camera2D" type="Camera2D" parent="."] -current = true +current = false rotating = false position_smoothing_enabled = true position_smoothing_speed = 8.0 diff --git a/spacewar/ships/ship_movement.gd b/spacewar/ships/ship_movement.gd index 1fcc55e..136eda3 100644 --- a/spacewar/ships/ship_movement.gd +++ b/spacewar/ships/ship_movement.gd @@ -1,39 +1,89 @@ extends CharacterBody2D -const BULLET_SCENE = preload("res://ships/bullet.tscn") +# Set by World._spawn_ship() before this node enters the tree. +var peer_id: int = 0 var _fire_cooldown: float = 0.0 var health: int = 0 var _dead: bool = false var _invincible: bool = false var _invincible_timer: float = 0.0 +var _ship_speed_factor: float = 1.0 + +var _is_owner: bool = false +var _fixed_delta: float = 1.0 / 60.0 + +# Owner-side prediction: inputs sent to the server but not yet acknowledged, +# replayed on top of the server's authoritative state on correction. +const MAX_PENDING_INPUTS := 180 +var _input_tick: int = 0 +var _pending_inputs: Array = [] + +# Server-side: queued inputs received from the owning peer, plus the last +# processed one (reused when the queue runs dry, e.g. a dropped packet). +var _remote_inputs: Array = [] +var _last_remote_input: Dictionary = {"up": false, "down": false, "left": false, "right": false, "shoot": false} +var _last_processed_tick: int = 0 + +# Remote (non-owner, non-server) clients only ever interpolate toward the +# last couple of authoritative snapshots broadcast by the server. +var _interp_from_pos: Vector2 +var _interp_from_rot: float = 0.0 +var _interp_to_pos: Vector2 +var _interp_to_rot: float = 0.0 +var _interp_elapsed: float = 0.0 + func _ready() -> void: - if GameConfig.player_ship_path.is_empty(): + _fixed_delta = 1.0 / Engine.physics_ticks_per_second + _is_owner = peer_id == multiplayer.get_unique_id() + + # Only ever claim the camera for the local owner. Whichever Camera2D + # enters the SceneTree first auto-claims the viewport's active camera + # regardless of its own `current` value (Godot's built-in "first camera + # wins" fallback) — with MultiplayerSpawner catch-up replication, that's + # often another peer's ship (e.g. the always-present server-ghost ship + # for peer 1), not ours. Setting `current = true` via property/deferred + # assignment does not reliably dethrone that auto-claimed camera; calling + # make_current() does, since it's the actual API that updates the + # viewport's tracked active camera rather than just the local property. + if _is_owner: + var camera := get_node_or_null("Camera2D") as Camera2D + if camera: + camera.call_deferred("make_current") + + PlayerRegistry.loadout_updated.connect(_on_loadout_updated) + var info := PlayerRegistry.get_info(peer_id) + if info.is_empty(): visible = false set_physics_process(false) - GameConfig.team_selected.connect(_on_team_selected) else: - _init_player() + _init_player(info) -func _on_team_selected() -> void: - _init_player() +func _on_loadout_updated(updated_peer_id: int) -> void: + if updated_peer_id == peer_id: + _init_player(PlayerRegistry.get_info(peer_id)) -func _init_player() -> void: +func _init_player(info: Dictionary) -> void: var sprite := get_node_or_null("Sprite2D") - if sprite and not GameConfig.player_ship_path.is_empty(): - sprite.texture = load(GameConfig.player_ship_path) - sprite.scale = Vector2.ONE * GameConfig.player_ship_scale + var ship_path: String = info.get("ship_path", "") + if sprite and not ship_path.is_empty(): + sprite.texture = load(ship_path) + sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0) + _ship_speed_factor = info.get("ship_speed_factor", 1.0) set_physics_process(true) - _respawn() + if multiplayer.is_server(): + _server_respawn() + func _physics_process(delta: float) -> void: if _dead: return - # 1. INVINCIBILITY BLINK + # INVINCIBILITY BLINK — purely visual, runs once per real tick regardless + # of role (never part of prediction replay). if _invincible: _invincible_timer -= delta modulate.a = 0.3 if int(_invincible_timer * 8) % 2 == 0 else 1.0 @@ -41,37 +91,142 @@ func _physics_process(delta: float) -> void: _invincible = false modulate.a = 1.0 - # 2. ROTATION - if Input.is_action_pressed("move_right"): + if multiplayer.is_server(): + _server_tick(delta) + elif _is_owner: + _owner_tick(delta) + else: + _remote_tick(delta) + + +# ── Owner client: predict locally, send input to server ───────────────────── + +func _owner_tick(delta: float) -> void: + var input := _sample_local_input() + _input_tick += 1 + _simulate_step(delta, input, true) + + _pending_inputs.append({"tick": _input_tick, "input": input}) + if _pending_inputs.size() > MAX_PENDING_INPUTS: + _pending_inputs.pop_front() + submit_input.rpc_id(1, _input_tick, input) + + +@rpc("authority", "call_remote", "unreliable") +func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, shown: bool) -> void: + # Piggyback current health AND visibility on every state broadcast (not + # just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) so a + # peer that connects after another ship already spawned/respawned still + # converges within a tick, instead of being stuck with that ship's + # defaults (health=0, invisible) forever — those one-shot RPCs only ever + # reach peers that were already connected at the moment they fired. + if health != hp: + health = hp + _update_hud() + visible = shown + + if _is_owner: + _reconcile(tick, pos, rot, vel) + else: + _interp_from_pos = global_position + _interp_from_rot = rotation + _interp_to_pos = pos + _interp_to_rot = rot + _interp_elapsed = 0.0 + velocity = vel + + +func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server_vel: Vector2) -> void: + var replay_index := -1 + for i in _pending_inputs.size(): + if _pending_inputs[i].tick == server_tick: + replay_index = i + break + + global_position = server_pos + rotation = server_rot + velocity = server_vel + + if replay_index == -1: + _pending_inputs.clear() + return + + _pending_inputs = _pending_inputs.slice(replay_index + 1) + for entry in _pending_inputs: + _simulate_step(_fixed_delta, entry.input, false) + + +# ── Server: simulate every ship, broadcast authoritative state ────────────── + +func _server_tick(delta: float) -> void: + var input: Dictionary + if _is_owner: + input = _sample_local_input() + else: + if not _remote_inputs.is_empty(): + var entry: Dictionary = _remote_inputs.pop_front() + _last_remote_input = entry.input + _last_processed_tick = entry.tick + input = _last_remote_input + + _simulate_step(delta, input, true) + _server_process_shoot(delta, input) + + _receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, visible) + + +@rpc("any_peer", "call_remote", "unreliable") +func submit_input(tick: int, input: Dictionary) -> void: + if not multiplayer.is_server(): + return + if multiplayer.get_remote_sender_id() != peer_id: + return + _remote_inputs.append({"tick": tick, "input": input}) + if _remote_inputs.size() > MAX_PENDING_INPUTS: + _remote_inputs.pop_front() + + +# ── Remote client: no local simulation, just interpolate toward last snapshot ─ + +func _remote_tick(delta: float) -> void: + _interp_elapsed += delta + var t: float = clamp(_interp_elapsed / _fixed_delta, 0.0, 1.0) + global_position = _interp_from_pos.lerp(_interp_to_pos, t) + rotation = lerp_angle(_interp_from_rot, _interp_to_rot, t) + + +# ── Shared movement step (deterministic — replayed during reconciliation) ─── + +func _sample_local_input() -> Dictionary: + return { + "up": Input.is_action_pressed("move_up"), + "down": Input.is_action_pressed("move_down"), + "left": Input.is_action_pressed("move_left"), + "right": Input.is_action_pressed("move_right"), + "shoot": Input.is_action_pressed("shoot"), + } + + +# apply_side_effects gates anything non-deterministic-safe (e.g. dealing +# damage) so replaying buffered inputs during reconciliation can't double it. +func _simulate_step(delta: float, input: Dictionary, apply_side_effects: bool) -> void: + if input.get("right", false): rotation += GameConfig.ship_rotation_speed * delta - if Input.is_action_pressed("move_left"): + if input.get("left", false): rotation -= GameConfig.ship_rotation_speed * delta - # 3. THRUST - var thrust := GameConfig.ship_thrust * GameConfig.player_ship_speed_factor - var max_speed := GameConfig.ship_max_speed * GameConfig.player_ship_speed_factor - if Input.is_action_pressed("move_up"): + var thrust := GameConfig.ship_thrust * _ship_speed_factor + var max_speed := GameConfig.ship_max_speed * _ship_speed_factor + if input.get("up", false): velocity += Vector2.UP.rotated(rotation) * thrust * delta - if Input.is_action_pressed("move_down"): + if input.get("down", false): velocity += Vector2.DOWN.rotated(rotation) * thrust * delta - velocity = velocity.limit_length(max_speed) var velocity_before_move := velocity move_and_slide() - _handle_environment_collisions(velocity_before_move) + _apply_collision_bounce(velocity_before_move, apply_side_effects) - # 4. SHOOT - _fire_cooldown -= delta - if Input.is_action_pressed("shoot") and _fire_cooldown <= 0.0: - _fire_cooldown = GameConfig.ship_fire_rate - var bullet = BULLET_SCENE.instantiate() - bullet.global_position = global_position + Vector2.UP.rotated(rotation) * 40.0 - bullet.velocity = Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity - bullet.source = self - get_parent().add_child(bullet) - - # 5. WALL COLLISION var bounds = GameConfig.world_bounds if global_position.x < bounds.position.x or global_position.x > bounds.end.x: velocity.x = 0 @@ -80,7 +235,8 @@ func _physics_process(delta: float) -> void: velocity.y = 0 global_position.y = clamp(global_position.y, bounds.position.y, bounds.end.y) -func _handle_environment_collisions(previous_velocity: Vector2) -> void: + +func _apply_collision_bounce(previous_velocity: Vector2, apply_side_effects: bool) -> void: for i in get_slide_collision_count(): var collision := get_slide_collision(i) var collider := collision.get_collider() as Node @@ -95,29 +251,69 @@ func _handle_environment_collisions(previous_velocity: Vector2) -> void: if impact_speed <= 0.0: continue velocity = previous_velocity.bounce(normal) * GameConfig.ship_bounce_restitution - if is_hazard: + if is_hazard and apply_side_effects and multiplayer.is_server(): var damage := int(impact_speed * GameConfig.ship_collision_damage_scale) if damage > 0: take_damage(damage) +# Server-only: decides whether this tick's queued input fires a shot, using +# its own authoritative cooldown (never trust a client's fire rate). Requests +# World spawn the bullet, which replicates it to every peer. +func _server_process_shoot(delta: float, input: Dictionary) -> void: + _fire_cooldown -= delta + if input.get("shoot", false) and _fire_cooldown <= 0.0: + _fire_cooldown = GameConfig.ship_fire_rate + var world := get_node_or_null("/root/World") + if world: + world.request_bullet_spawn( + peer_id, + global_position + Vector2.UP.rotated(rotation) * 40.0, + Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity + ) + + +# ── Health / death — server-authoritative, broadcast to every peer ────────── + func take_damage(amount: int) -> void: + if not multiplayer.is_server(): + return if _invincible or _dead: return health = max(0, health - amount) - _update_hud() + _apply_health.rpc(health) if health == 0: _die() + +@rpc("authority", "call_remote", "reliable") +func _apply_health(new_health: int) -> void: + health = new_health + _update_hud() + + func _die() -> void: + if not multiplayer.is_server(): + return + _apply_death.rpc() + await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout + _server_respawn() + + +@rpc("authority", "call_local", "reliable") +func _apply_death() -> void: _dead = true visible = false velocity = Vector2.ZERO - await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout - _respawn() -func _respawn() -> void: - global_position = _get_spawn_position() + +func _server_respawn() -> void: + _apply_respawn.rpc(_get_spawn_position()) + + +@rpc("authority", "call_local", "reliable") +func _apply_respawn(pos: Vector2) -> void: + global_position = pos velocity = Vector2.ZERO rotation = 0.0 health = GameConfig.ship_max_health @@ -125,6 +321,15 @@ func _respawn() -> void: visible = true _invincible = true _invincible_timer = GameConfig.ship_invincibility_time + + _interp_from_pos = global_position + _interp_to_pos = global_position + _interp_from_rot = rotation + _interp_to_rot = rotation + _interp_elapsed = 0.0 + _pending_inputs.clear() + _remote_inputs.clear() + _update_hud() func _get_spawn_position() -> Vector2: @@ -135,6 +340,8 @@ func _get_spawn_position() -> Vector2: func _update_hud() -> void: + if not _is_owner: + return var label = get_node_or_null("/root/World/HUD/HealthLabel") if label: label.text = "HP: %d / %d" % [health, GameConfig.ship_max_health] diff --git a/spacewar/world/world.gd b/spacewar/world/world.gd index 29d9a12..59f6b25 100644 --- a/spacewar/world/world.gd +++ b/spacewar/world/world.gd @@ -1,8 +1,20 @@ extends Node2D const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn") +const SHIP_SCENE: PackedScene = preload("res://ships/ship.tscn") +const BULLET_SCENE: PackedScene = preload("res://ships/bullet.tscn") @onready var _map_container: Node2D = $MapContainer +@onready var _players: Node2D = $Players +@onready var _spawner: MultiplayerSpawner = $MultiplayerSpawner +@onready var _bullets: Node2D = $Bullets +@onready var _bullet_spawner: MultiplayerSpawner = $BulletSpawner +@onready var _team_select: TeamSelect = $TeamSelect + +# Server-authoritative: the 2 races offered for this match, decided once and +# shared by every player — see TeamSelect._ready(), which either reads this +# directly (server's own instance) or requests it over RPC (remote clients). +var _offered_race_ids: Array = [] func _ready() -> void: @@ -13,6 +25,82 @@ func _ready() -> void: GameConfig.world_bounds = Rect2(background.position, background.size) _build_tile_collisions(map) + _spawner.spawn_function = _spawn_ship + _bullet_spawner.spawn_function = _spawn_bullet + NetworkManager.peer_joined.connect(_on_peer_joined) + NetworkManager.peer_left.connect(_on_peer_left) + + if multiplayer.is_server(): + _spawn_peer(multiplayer.get_unique_id()) + for peer_id in multiplayer.get_peers(): + _spawn_peer(peer_id) + + +# Picks the match's 2 offered races once and caches them, so every caller +# (the server's own TeamSelect and every remote client's request) converges +# on the same pair regardless of call order. +func decide_offered_races() -> Array: + if _offered_race_ids.is_empty(): + var ids := range(1, TeamSelect.RACES.size() + 1) + ids.shuffle() + _offered_race_ids = [ids[0], ids[1]] + return _offered_race_ids + + +@rpc("any_peer", "call_remote", "reliable") +func request_offered_races() -> void: + if not multiplayer.is_server(): + return + var sender_id := multiplayer.get_remote_sender_id() + _deliver_offered_races.rpc_id(sender_id, decide_offered_races()) + + +@rpc("authority", "call_remote", "reliable") +func _deliver_offered_races(race_ids: Array) -> void: + _team_select.offer_races(race_ids) + + +func _spawn_peer(peer_id: int) -> void: + if _players.has_node(str(peer_id)): + return + _spawner.spawn(peer_id) + + +func _spawn_ship(peer_id: int) -> Node: + var ship := SHIP_SCENE.instantiate() + ship.name = str(peer_id) + ship.peer_id = peer_id + return ship + + +# Called by a Ship node running on the server when its authoritative fire +# cooldown allows a shot. Replicated to every peer via the bullet spawner. +func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2) -> void: + if not multiplayer.is_server(): + return + _bullet_spawner.spawn({"peer_id": source_peer_id, "pos": pos, "vel": vel}) + + +func _spawn_bullet(data: Dictionary) -> Node: + var bullet := BULLET_SCENE.instantiate() + bullet.global_position = data.pos + bullet.velocity = data.vel + bullet.source_peer_id = data.peer_id + return bullet + + +func _on_peer_joined(peer_id: int) -> void: + if multiplayer.is_server(): + _spawn_peer(peer_id) + + +func _on_peer_left(peer_id: int) -> void: + if not multiplayer.is_server(): + return + var ship := _players.get_node_or_null(str(peer_id)) + if ship: + ship.queue_free() + # Tiles have no collision shapes of their own, so spawn one StaticBody2D per # occupied cell. Asteroids get round hitboxes to roughly match their sprites. diff --git a/spacewar/world/world.tscn b/spacewar/world/world.tscn index cbf4034..0d1bf14 100644 --- a/spacewar/world/world.tscn +++ b/spacewar/world/world.tscn @@ -1,6 +1,5 @@ [gd_scene format=3 uid="uid://c77s15ns13p6y"] -[ext_resource type="PackedScene" uid="uid://dygtdlkvo6ofl" path="res://ships/ship.tscn" id="1_f3sb7"] [ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"] [ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"] [ext_resource type="Script" path="res://world/world.gd" id="4_world"] @@ -10,7 +9,15 @@ script = ExtResource("4_world") [node name="MapContainer" type="Node2D" parent="."] -[node name="Player" parent="." unique_id=2118863138 instance=ExtResource("1_f3sb7")] +[node name="Players" type="Node2D" parent="."] + +[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."] +spawn_path = NodePath("../Players") + +[node name="Bullets" type="Node2D" parent="."] + +[node name="BulletSpawner" type="MultiplayerSpawner" parent="."] +spawn_path = NodePath("../Bullets") [node name="HUD" type="CanvasLayer" parent="."]