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
+11 -4
View File
@@ -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/<race>/`); 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
+14
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
.venv/
+10
View File
@@ -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"]
+81
View File
@@ -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.
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
+35
View File
@@ -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:
+5
View File
@@ -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
+29 -18
View File
@@ -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)
+2 -7
View File
@@ -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)
+125
View File
@@ -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}
@@ -0,0 +1 @@
uid://d26rs8apbu84y
+92
View File
@@ -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=<ip>` 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()
+1
View File
@@ -0,0 +1 @@
uid://cwfpdpotmiial
+72
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ccta6qxoqmoh8
+53 -6
View File
@@ -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()
+1
View File
@@ -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")
+34 -11
View File
@@ -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:
+5 -3
View File
@@ -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"
+9 -2
View File
@@ -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:
if multiplayer.is_server():
body_entered.connect(_on_body_entered)
func _process(delta: float) -> void:
global_position += velocity * delta
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)
+1 -1
View File
@@ -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
+247 -40
View File
@@ -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]
+88
View File
@@ -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.
+9 -2
View File
@@ -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="."]