Architecture
A single sbt module. The entry point is dicechess.play.Main (an IOApp.Simple serving Ember
on 0.0.0.0:8080), which reads the environment and wires up the optional subsystems —
persistence, analytics ingest, the ladder scheduler, the rating batch, webhook push, and
retention. Each is opt-in; see Configuration.
Package map
Section titled “Package map”Everything lives under src/main/scala/dicechess/play/.
| Package | Owns |
|---|---|
core/ |
Domain types with no dependencies: Protocol, Identity (Seat / Principal), GameId, Seek, BotEvent |
dice/ |
DiceSource — server-only CSPRNG dice with commit-reveal fairness |
game/ |
GameRoom (the actor-style room), EngineOps (the only engine wrapper), PlayerConnection |
server/ |
http4s routes and the services behind them — the largest package |
store/ |
GameStore / PgGameStore (doobie + Flyway), GameArchive, Retention |
rating/ |
Glicko2, RatingBatch, and the offline strength report behind mise run ladder:report: Sprt, BradleyTerry, AnchoredStrength, StrengthReport |
ingest/ |
PlaysiteIngest + IngestDeliverer — the transactional outbox to analytics |
wire/ |
Codecs.scala — the Circe codecs that are the client wire contract |
How a move travels
Section titled “How a move travels”flowchart TD
C["Client<br/>(browser or bot)"] -->|"intent: move / challenge / seek"| R["server/ routes<br/>PlayRoutes · BotRoutes · LobbyRoutes"]
R --> REG["GameRegistry"]
REG --> ROOM["game/GameRoom<br/>single writer fiber"]
ROOM -->|"validate"| ENG["game/EngineOps<br/>→ dicechess-rules"]
ROOM -->|"roll"| DICE["dice/DiceSource<br/>CSPRNG + commit-reveal"]
ROOM -->|"tryOffer, non-blocking"| SUB["Per-subscriber queues<br/>WebSocket · ndjson · webhook"]
ROOM -->|"snapshot"| STORE["store/PgGameStore<br/>jsonb snapshot"]
STORE -->|"same transaction, on game end"| OUT["ingest/ outbox"]
OUT -->|"HTTP POST, retried"| AN["dicechess-analytics"]
STORE --> RES["game_results projection"]
RES --> RB["rating/RatingBatch<br/>Glicko-2 + training estimate"]
The shape to hold on to: the room is the only writer of game state, and everything downstream of it — subscribers, snapshots, the outbox — is fed without ever letting a slow consumer block play. That rule is spelled out in Concurrency Doctrine.
Cross-repository contracts
Section titled “Cross-repository contracts”play-api sits in the middle of four contracts. Changing either side of one without the other
is the most common way to break the platform.
- Consumes
com.fortemate:dicechess-rules, a JVM artifact from Maven Central with the version pinned inbuild.sbt. It is the single source of truth for the rules — legality is never reimplemented here. Legal moves ship on the wire as a prefix tree of UCI micro-moves. The server validates and enumerates; it never searches, so the heavierdicechess-enginecoordinate (bots, evaluators, optional ONNX runtime) stays inTestscope for bot opponents. - Publishes the client wire protocol in
wire/Codecs.scala, consumed by thedicechess-playSvelteKit front end. Both sides must be verified together. - Publishes the analytics ingest payload built in
ingest/PlaysiteIngest.scala— posted to the analytics service’s/api/gameswithsource=playsite, idempotent, first writer wins. - Publishes the public Bot API, documented at bots.fortemate.com. Its
machine-readable contracts (
openapi.yaml,asyncapi.yaml) live in that site’spublic/directory and are rendered into the reference at build time, so the spec cannot drift silently from the docs.
HTTP surface
Section titled “HTTP surface”Routes are grouped by audience rather than by resource:
- Operational —
GET /health,GET /version. - Human game surface —
POST /games,GET /games/{id},GET /games/{id}/ws?token=…. The token grants the seat; redeeming it is also when the seat learns who is sitting in it (#285), from the session or a?guest=uuid. - Public discovery —
GET /games,GET /leaderboard,GET /bots/{team}/{name}, plus the history endpoints. - Showcase table —
GET /showcaseandPOST /showcase/claim(ADR-005, #46), mounted only withSHOWCASE_ENABLED=true. The homepage’s singleton table:GET /showcaseis the public read:status(unavailable,open,live,finishing), the featured bot, the fixed5+3,nextHumanColorwhen open (and, while a game is on, the colour offered next),currentGameand a spectatorwsUrlwhen live or finishing, and a coarsereason(disabled,maintenance,bot_unavailable) when unavailable. It isCache-Control: no-store, no-cache, must-revalidatewith a weakETag(If-None-Matchanswers304), needs no authentication, and never carries a seat token, a webhook detail, a private identity or infrastructure state. Colours use the existingSidewire form (White/Black).POST /showcase/claimis the atomic first claim. The actor is the session account (thenX-DiceChess-CSRF: 1and anOriginon thePLAY_CORS_ORIGINSallow-list are both required; a deployment without an allow-list refuses the session path, so visitors claim as guests there) or a stableguestIdin the body. The body is capped at 4 KiB.Idempotency-Key(a UUID) is mandatory;clientEntropyis an optional dice seed. The winner gets200withoutcome: "claimed",seat,seatTokenand a relativewsUrl, underCache-Control: no-store, private; every other caller gets200withoutcome: "spectating", areason(already_claimed/game_ended), thegameIdand aspectatorWsUrl— and no credential. Problems are RFC 7807 (application/problem+json) with acode:400missing_idempotency_key/invalid_idempotency_key/guest_required/invalid_guest_id/malformed_request,403csrf_origin_rejected,409idempotency_conflict(same key, different body),413request_too_large,415malformed_request,429rate_limited(per IP and per actor,Retry-After),503showcase_unavailable(Retry-After). WebSocket URLs are relative references against the API origin; the server does not guess its public hostname. The authoritative contract is ADR-005 (#44); the machine-readable one isopenapi.yaml.
- Bot API — everything under
/bot/…: identity, challenges, seeks, gameplay, streams, webhooks, ladder. - Account control —
/me/…uses the liveaccess_tokensession and current ownership;/admin/…additionally requires the account id inPLAY_ADMINS. The parallel staged webhook roots shareSessionWebhookRoutesandWebhookManagement, so only authorization differs — status mapping, CAS, redaction, verification, and audit cannot drift between them. They mount only behindWEBHOOK_SESSION_MANAGEMENT_ENABLEDplus persistence, session verification, and an explicit origin allow-list; the legacy/bot/webhook…contract remains separate. The accepted security/state-machine contract is ADR-004; this page and Concurrency carry everything a contributor needs from it.
The complete, authoritative reference for the bot-facing routes is the Bot API site, not this page.
The accepted but still disabled stake-doubling state machine is specified separately in
ADR-0019. Its doubling webhook capability remains reserved;
the ADR and fixtures do not enable a route, registration, wallet, or production game.
Staged webhook control plane
Section titled “Staged webhook control plane”The staged path deliberately separates browser authorization, durable credential state, and outbound networking:
SessionWebhookRoutesauthenticates the live owner/admin session and enforces exact Origin, CSRF, content type, and strongIf-Matchrequirements.WebhookManagementvalidates the typed transition, performs a DB-authoritative authority and revision preflight, then consumes the shared verification budget and coordinates the lease around verification-v2 without exposing stored candidate material. The store repeats authority and CAS checks at mutation/commit time; unauthorized or stale requests spend no budget and cause no DNS or outbound HTTP.WebhookManagementStore/PgGameStoreown the authoritative revision, registration generation, setup/tombstone lifecycle, admin-authority heartbeat and audit transaction. Security deadlines use the PostgreSQL clock, and activation attempts are reserved when the lease is acquired.WebhookTransportresolves and validates the candidate once, connects to that validated public IP, and retains the hostname for HTTP Host and TLS SNI. The ordinary delivery path reuses this DNS-pinned primitive and checksregistration_idagain before a response can enterGameRoom.
Main mounts the routes and supervises the 5-second admin-generation heartbeat only when the
complete staged configuration is present. A generation is live for 20 seconds; overlapping admin
allow-list generations fail closed until the old one ages out. Deployment ordering is part of the
architecture, not an operations afterthought: follow the
staged webhook rollout runbook,
including the old-writer/worker drain, DNS-pinning proof, compatible runtime v2 dual-key release,
and flag-off rollback.
Singleton showcase table
Section titled “Singleton showcase table”The homepage (fortemate.com, route /) exposes exactly one permanent public Dice Chess table facing
a configurable featured bot (initially rpi3/hunter-book) at casual/unrated 5+3. When open, the first
visitor to claim it atomically starts one game; all other visitors spectate that same game. The existing
play site remains unchanged at /play.
The table moves through four public states:
stateDiagram-v2
[*] --> unavailable
unavailable --> open: Bot ready + DB healthy + no active game
unavailable --> live: Resumed active game
unavailable --> finishing: Resumed terminal game
open --> live: Atomic first claim committed
open --> unavailable: Bot down / DB outage
live --> finishing: Game ends (mate, flag, resign, abort)
live --> unavailable: Persistence failure (fail-closed)
finishing --> open: Terminal persistence committed (4 fences satisfied)
finishing --> unavailable: Terminal persistence exhausted
unavailable: The table cannot accept claims. Set when PostgreSQL is unconfigured or unreachable, the featured bot’s webhook is unresponsive, or startup reconciliation detects ambiguous state.open: The table is idle, advertising the configured featured bot,5+3, and the server-assigned next human color. Exactly 1 bot capacity slot is reserved.live: One active game against the featured bot is in progress. The winning claimant holds the human seat’s credential (seatToken), which appears only in the winningPOST /showcase/claimresponse body underCache-Control: no-store, private; spectators observe via the ordinary spectator WebSocket.GET /showcaseand spectator outcomes (outcome: "spectating") never include a credential.finishing: The game has ended but the table has not yet been released — the window between the room’s committed terminal transaction and the coordinator clearing the table’s current game. A claim in this window spectates. The table cannot beopenbefore the archive row exists (GameRoom.resultfires only after the terminal commit) and the reserved seat has been released.
The coordinator (#46)
Section titled “The coordinator (#46)”server/ShowcaseTable is the table: one process-local actor holding the phase and a mutex every claim
runs under, so “first visitor wins” is decided by lock order — exactly one claimant can find the table
open. It remembers nothing across a restart; the next human colour, the current game and the claim
idempotency records live in PostgreSQL behind store/ShowcaseStore (tables showcase_table and
showcase_claims, V6), and ShowcaseTable.reconcile rebuilds the phase from them before the port
opens (Main runs it inside the resource that mounts the routes). Reconciliation reads
PgGameStore.activeShowcaseGameIds: none → the table may open (after repairing a stale current-game
pointer left by a crash between the terminal commit and the clear — and only once that repair has
committed; a store that will not take it keeps the table unavailable); exactly one → the resumed room is
adopted as live (and the colour advanced if the claim transaction never got to); several → split-brain,
unavailable with an operator alert. A live showcase game the registry failed to resume, a store that is
not PostgreSQL, or a bot that fails its readiness probe all fail closed the same way.
A winning claim does, in order: AdmissionGuard.admitAndCreate under the showcase purpose (the reserved
seat; the room’s creation snapshot commits fail-closed inside it), then one store transaction that advances
the colour, points showcase_table.current_game_id at the room and writes the claim record — fenced on the
colour the human was seated on and on no game being current — and only then the live phase and the
credential. A failed creation advances nothing. A failed commit aborts the room it just created (a
technical abort, archived as such) and fails the table closed rather than guess which side is right. The
winner’s clientEntropy, when given, is submitted to the room as its dice seed.
Completion is inherited from the room: GameRoom.result completes only once the terminal transaction is
durable, the coordinator then waits for the registry to deregister the game (which releases the reserved
seat), clears current_game_id, and reconciles again — a bot probe, then open or unavailable. A
duplicate completion finds nothing to clear and reconciles to the same phase. A winner that never opens its
socket within the claim grace (30 s) is resigned by the coordinator, through the same command a dropped
connection already uses. While the table is not live, a readiness loop (every 15 s) re-runs reconciliation
so a bot that stops answering closes the table before anyone claims a dead one, and a bot or database that
recovers reopens it without a restart. Main.showcaseReadiness is the probe: the featured bot must be a
registered identity, webhook delivery must be enabled, and its webhook must answer the unsigned
verification echo within 5 s.
Fail-closed persistence and recovery rules (#47)
Section titled “Fail-closed persistence and recovery rules (#47)”A showcase room runs under Durability.Required; every other room keeps the availability-first
Durability.BestEffort it always had (a failed snapshot write is logged and the game plays on in
memory). GameRegistry picks the mode from the game’s origin, at creation and again on resume,
and refuses to create a showcase room at all over a store that does not claim durability
(GameStore.durable — the in-memory store does not). Under the required mode:
- Nothing is published before it is committed. The room’s writer fiber persists a version
first and only then updates its state and broadcasts. Turn acknowledgement (
submitTurn’s verdict), spectator WebSocket events and the bot webhook’syourTurnare all downstream of that broadcast, so none of them can carry an uncommitted version. The creation snapshot — withorigin = 'showcase'— is fail-closed in every mode and is what a claim’s success answer depends on. - A failed write halts the room and is retried. Forward progress stops (no command is
processed while the write is retried), the mover’s clock is credited for the stall, and every
attempt is reported through
PersistenceTelemetry— in production one stderr line per event naming the game and version, saying whether the room is retrying, has given up, or is back. Intermediate writes retry a bounded number of times (four, ~3 s of backoff, each attempt under the store’s own save timeout); the ending retries without bound, backing off to one attempt every 30 s. - An exhausted intermediate write is a technical abort from the last durable version. The
unsaved move or roll is discarded — a subscriber never saw it — and the room ends with
termination = abortedbuilt from the last committed state. That abort is itself a terminal write and follows the terminal policy. - Completion follows the commit.
GameRoom.result— and with it the registry’s deregistration, the admission release and the coordinator’s completion hook — fires only once the terminal transaction (final snapshot,game_results, immutablegame_archive, outbox payload) has committed. Duplicate deregistration is a no-op, so a repeated completion cannot reopen the table early. - Stalled sessions are released, not left hanging. Once a write has been failing for longer
than the stall grace (15 s), the room drops its subscribers so their WebSockets close; the room
keeps retrying,
GameRoom.persistenceStalledreadstruefor the coordinator to answerunavailable, and a client that reconnects sees the last durable state. - Restart resumes from the last durable version. There is no partially finalised durable
state to reconcile: the terminal write is one transaction, so a game is either still
activeingames(resumed byGameRegistry.resume, under the required mode again) or fully ended with its archive row. An in-memory ending that never committed before a crash is therefore not an ending — the game resumes at its last committed version, its clocks restart, and it ends again by play, durably.
The full specification is codified in ADR-005 (#44); concurrency and capacity invariants are detailed in Concurrency.