docs: record the closed root blocker and the new probe/rotation contract

docs/MATCHMAKING.md is the stated source of truth for this design, so it
changes first: the probe challenge endpoint and why probing gates
matching rather than merely improving it, and the key-ID rotation
procedure that makes overlapping-key rotation concrete.

multiplayer-next.md §0's root blocker is closed rather than deleted --
what it was, why it blocked everything, and how it was resolved, since
the reasoning is what a future reader needs. Tasks 7.6, 8.15 and 8.31
updated to what actually remains, which in every case is now external
rather than unbuilt.

TODO.md #14 asked for a join-signing design decision; that decision is
recorded with its rationale. CLAUDE.md no longer says a real deployment
cannot complete a match end to end.
This commit is contained in:
Josh Creek
2026-09-05 11:00:50 +01:00
parent f628ccfd35
commit ccf7d0fbfe
4 changed files with 56 additions and 21 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
## Project overview
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is partially implemented and a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (a real deployment cannot complete a match end to end today), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, and a live Agones cluster), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
+1 -1
View File
@@ -43,7 +43,7 @@ Everything below needs a person — hardware, a design decision, an external acc
Each item is also a GitHub issue (linked inline), labelled `needs:human` plus a
`P0-blocker``P3-low` priority. Close the issue and tick the box together.
- [ ] ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **Decide the join-signing design for the Phase 8 root blocker.** No real deployment can advance a match past `PROCESS_READY` today because nothing calls the (fully built and tested) assignment-publishing path in production — it needs a join-signing key shared between allocator and game server, roster-digest computation, and per-player authorization construction, deliberately flagged rather than built pending this decision. See `multiplayer-next.md` §0 ("the actual current root blocker") and §8.31.
- [x] ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **Join-signing design decided and implemented.** Resolved as HMAC-SHA256 over the canonical claim bytes with a **key ID inside those bytes**: the allocator signs with one named key while allocated servers hold the set of currently-valid keys, so rotation does not invalidate authorisations already issued for in-flight matches. `allocator.Worker` now publishes the signed roster after binding, and `cmd/allocator` refuses to start without key material. Rotation procedure is in `docs/MATCHMAKING.md` §2; see `multiplayer-next.md` §8.31. Nothing human-only remains here — live verification is covered by [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#18](https://github.com/jcreek/CosmicClash/issues/18)) **Phase 4 playtest at ~100 ms RTT** — does the ship/ball feel local, do contact corrections read as bumps or glitches? Every numeric gate is green; this is a feel judgment no metric can answer. `multiplayer-next.md` §0, gate A.
- [ ] ([#19](https://github.com/jcreek/CosmicClash/issues/19)) **Phase 5 3v3 gate** — a full 6-player match start to finish, with a mid-match disconnect and a late joiner. Only verified so far at 1v1 plus a two-bot CI match. `multiplayer-next.md` §0, gate B.
- [ ] ([#20](https://github.com/jcreek/CosmicClash/issues/20)) **Phase 6 external gate** — run the exported Docker server and clients from separate real machines over the internet, then play a full match (controlled test only, since defect C below is still open). `multiplayer-next.md` §0.
+22 -1
View File
@@ -95,6 +95,15 @@ matcher, allocator and game-server pods cannot read it. The signer accepts
only allocator-recorded assignments, audits every signature, and supports
overlapping-key rotation.
Join authorisations carry a key ID naming the key that signed them, and that
ID is part of the signed bytes so it cannot be repointed at a different key.
Allocated servers hold the set of currently-valid keys and select by ID, which
is what makes rotation overlapping rather than breaking: publish the new key
everywhere, move the allocator's active key ID to it, then drop the retired key
once no live match can still reference it. The key set is delivered as a JSON
map of key ID to base64 key, mounted from the same Secret by both the allocator
Deployment and the Fleet.
## 3. Control-plane architecture
Use one repository and shared domain packages, with independently runnable
@@ -169,7 +178,19 @@ The client submits its recent opaque Steam ping location plus nonce-bound
active-probe responses from each regional endpoint; it does not submit the RTT
used for placement. The backend validates a 30-second freshness window and
nonce, then uses the Steam coordinator SDK and probe timings to compute the
regional matrix. A predicted/observed discrepancy over 25 ms or 30% (whichever
regional matrix.
The nonce comes from `POST /v1/probes/{region}/challenge`, which the client
calls before `POST /v1/probes/{region}`. The challenge is single-use and
durable rather than per-process, because any control-plane replica may serve
the answer to a challenge another replica issued. The recorded RTT is the
interval the backend measures between issuing the challenge and receiving the
answer, which is what keeps client-reported latency out of placement entirely.
Probing is a precondition for matching, not an optimisation: a ticket with no
regional RTT evidence is rejected by the matcher outright, so the client
collects evidence before it creates a ticket. Not every region has to answer --
placement uses whichever did -- but a ticket with none is never queued. A predicted/observed discrepancy over 25 ms or 30% (whichever
is larger) in three matches within 24 hours quarantines the account's samples:
it may queue only in regions whose active probe independently remains under
the ceiling until five clean matches clear the quarantine. The matchmaker:
+32 -18
View File
@@ -41,21 +41,32 @@ blocker and is in progress.** It is larger than anything below and adds a
backend service outside the Godot project. Tasks are in §7; the design is in
[`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
**The current root blocker** ([#14](https://github.com/jcreek/CosmicClash/issues/14)): nothing in production ever publishes a
player's signed match assignment. `store.SaveAssignment`/`SaveAssignments`/
`SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster` are
fully built and tested in isolation, but no real code path
(`allocator/worker.go`, `cmd/allocator`) ever calls them — only tests do, by
seeding the table directly rather than exercising the real write path. Since
**The former root blocker** ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **is closed.** Nothing in production
used to publish a player's signed match assignment:
`store.SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster`
were fully built and tested in isolation, but no real code path called them —
only tests did, by seeding the table directly. Since
`AdvanceServerRegistration`'s SQL requires an `assignments` row per
participant before a match can reach `ASSIGNMENT_READY`, **a real deployment
cannot advance any match past `PROCESS_READY`** — no player can ever receive
a real assignment or connect, regardless of how correct every other piece
(including the client-side connect-wiring in task 8.41) is. See task 8.31
for the full detail. Closing it needs new security-relevant design (a
join-signing key shared between allocator and game server, roster-digest
computation, per-player authorisation construction) — flagged rather than
built, at the user's explicit direction, pending a decision on that design.
participant before a match can reach `ASSIGNMENT_READY`, a real deployment
could not advance any match past `PROCESS_READY`.
`allocator.Worker.RunOnce` now builds one signed join authorisation per
durable participant and publishes the roster after binding the allocation, and
`cmd/allocator` refuses to start without key material rather than stranding
every match silently. The signing-key design that was pending a decision is
settled: HMAC-SHA256 over the canonical claim bytes, with a **key ID** in
those bytes so allocated servers can hold the set of currently-valid keys and
rotation does not invalidate authorisations already issued for in-flight
matches. See `docs/MATCHMAKING.md` §2 for the rotation procedure.
Two further blockers of the same shape were found and closed alongside it:
regional RTT probing had no nonce-issuing endpoint (so no client-created
ticket could ever be selected — the matcher requires non-empty RTT evidence),
and the Kubernetes base deployed a control-plane image nothing built while
building a matcher image nothing deployed. What remains for a live deployment
is external: a Steamworks App ID and publisher key ([#15](https://github.com/jcreek/CosmicClash/issues/15)),
custom GodotSteam builds ([#16](https://github.com/jcreek/CosmicClash/issues/16)),
and a real cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)).
### Blocking sign-off — the work exists, the verification does not
@@ -141,7 +152,7 @@ retrofitting one.
| 7.3 `[D:7.2]` `[P]` | Server-browser UI and `ISteamMatchmakingServers` adapter | Unimplemented until real Steam SDK/API access is available; ENet direct-IP remains the supported browser-free path meanwhile |
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello``BeginAuthSession`, Steam identity in the roster, persistent ban list | Real GodotSteam auth integration, server-side VAC state, durable ban storage remain. **Fixes known defect C** for direct/community servers once landed |
| 7.5 `[D:7.2]` `[P]` | `SteamBootstrap` gating (stock builds keep ENet, explicit Steam selection fails closed) | Custom Steam client/server export smoke remains blocked on pinned GodotSteam binaries |
| 7.6 `[D:7.4]` | Backend `AuthCoordinator`, session persistence, `ControlPlaneClient.login_steam()` | Real Steam `BeginAuthSession`/`EndAuthSession` adapter, login UI, live PostgreSQL/session integration remain |
| 7.6 `[D:7.4]` | Backend `AuthCoordinator`, session persistence, `ControlPlaneClient.login_steam()`, real `ISteamUserAuth/AuthenticateUserTicket` adapter (`server/steam`), client web-API ticket acquisition, sign-in before matchmaking | Needs a real App ID and publisher key ([#15](https://github.com/jcreek/CosmicClash/issues/15)) and a custom GodotSteam build ([#16](https://github.com/jcreek/CosmicClash/issues/16)) to exercise live; sign-in is config-gated and returns 503 until both are set |
| 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Not started |
| 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Not started; depends on 7.6 and 7.7 |
@@ -154,7 +165,10 @@ the largest architectural departure in the project's history; read the
design doc before picking up any task below. The local control-plane,
durable-store, allocated-server, and verification paths are substantially
implemented; every row below lists only what's still open, not what's
built. **The critical path is task 8.31 — see §0's root blocker.**
built. **Task 8.31, formerly the critical path, is done — see §0.** What now
gates a live deployment is external: an App ID ([#15](https://github.com/jcreek/CosmicClash/issues/15)),
GodotSteam builds ([#16](https://github.com/jcreek/CosmicClash/issues/16)), and a
cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)).
**Hard dependency on 7.6 and 7.8.** The local allocated path binds slot
reclaim to a control-plane-signed player identity and locks its team/slot
@@ -196,7 +210,7 @@ are done; everything below is what's left on the tasks still open.
| # | Task | Remaining |
|---|---|---|
| 8.14 `[D:8.4,8.5,8.8]` | Queue policy (ownership, heartbeat/expiry, candidate projection) | Live Redis failover-under-load and worker integration remain |
| 8.15 `[D:7.8,8.3]` | Probe validation (RTT, nonce/freshness/region, quarantine) | Steam coordinator, regional probe adapters, multi-region probe population remain |
| 8.15 `[D:7.8,8.3]` | Probe validation (RTT, nonce/freshness/region, quarantine), `POST /v1/probes/{region}/challenge`, durable single-use nonces, client probe collection before queueing, candidate-index refresh after probe | Steam coordinator ping-location source remains (a placeholder blob is sent without a Steam runtime); multi-region endpoint deployment remains |
| 8.16 `[D:8.14,8.15]` | Candidate/team formation, matcher worker | Long-running worker integration remains |
| 8.17 `[D:8.14,8.16]` | Proposal policy (response window, cooldowns, offender/innocent split) | Live PostgreSQL execution and allocation integration remain |
| 8.18 `[D:8.5,8.14,8.17]` | Store layer (serializable retries, claim SQL, atomic promotion) | Allocation runtime integration remains |
@@ -217,7 +231,7 @@ are done; everything below is what's left on the tasks still open.
| 8.28 `[D:8.6,8.27]` | Process-ready/Agones-Ready separation, control-plane registration | Remaining gates are live Agones annotation/shutdown behavior and production cluster readiness — see task 8.49 |
| 8.29 `[D:8.26,8.27]` | Dynamic port/SDR env propagation | Real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT, multi-match fixture remain |
| 8.30 `[D:8.18,8.26,8.28,8.29]` | Allocation leasing, compatibility validation | Full unknown-outcome cluster recovery and signed roster metadata remain |
| **8.31** `[D:8.9,8.30]` | Signed assignment/roster persistence, player recovery | **This is the actual root blocker of the whole allocation-to-connect pipeline (see §0).** `store.SaveAssignment`/`SaveAssignments`/`SaveVerifiedAssignmentRoster` and `allocator.Service.PublishRoster` are built and tested but never called from `allocator/worker.go`, `cmd/allocator`, or anywhere else in production — only tests seed the table directly. A real match cannot advance past `PROCESS_READY`. Closing it needs new security-relevant design: a join-signing key shared between the allocator (to sign) and the game server (`fleet.yaml` already mounts one for verification via `--join-authorisations-key-file`, but no control-plane binary has a matching signing flag), roster-digest computation, and per-player `domain.JoinAuthorisation` construction via the already-built `domain.SignJoinAuthorisationHMAC`. Flagged rather than fixed at the user's explicit direction, pending a decision on that design |
| **8.31** `[D:8.9,8.30]` | Signed assignment/roster persistence, player recovery | **Done — this was the root blocker of the allocation-to-connect pipeline.** `allocator.Worker.RunOnce` now builds one signed join authorisation per durable participant and calls `PublishRoster` after binding; `cmd/allocator` takes `--join-authorisations-key-file`/`--join-authorisations-key-id` and refuses to start without them. The signing design is settled: HMAC-SHA256 over the canonical claim bytes with a key ID inside them, so servers hold a key *set* and rotation does not invalidate in-flight matches. The provider endpoint is now persisted on the allocation so a worker crashing between allocating and publishing can retry. Verified by an integration test that drives the real worker through the supervisor's own roster read path without seeding `assignments`. Live Agones verification remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) |
| 8.32 `[D:8.2,8.26,8.30]` | FleetAutoscaler baseline, Ready buffer | Regional on-demand node pools/failure domains, pre-pull rollout, warm-allocation p95/p99, N+1 certification remain |
| 8.33 `[D:8.26,8.32]` | Fleet scheduling, zone spread | Regional node pools, forced node-loss testing, measured N+1 headroom remain |
| 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready/assignment-ready, p99 CPU/RSS/network, node cap with 30% headroom | Not started |