The public Dice Chess play site (play.jc.id.lv) — anonymous, no sign-up, and signing in is optional: it only adds a Glicko-2 rating shared with the bots, a place on the leaderboard, and history that follows you across devices. Two ways to play:
/practice— against our bots, fully in the browser: the Scala.js engine runs client-side in a Web Worker, so a game needs no server at all. Finished games are recorded todicechess-analyticsas theplaysitesource./lobby+/live/[id]— against another human, served by the siblingdicechess-play-api. That server is authoritative: it owns the dice (provably fair), the clocks, and move legality; this client only applies versioned events and rolls back optimistic moves.
Around them: the game hub (/play — every way to start a game, also rendered on the landing
page), a bot catalog (/bots), the rating ladder (/leaderboard), local + server game
history with replays (/games, /replay/[id]), and a profile (/me) — guest by default, with an
optional account for a rating.
Public repo, AGPL-3.0 — external contributors sign the CLA (
CLA.md). Decisions and roadmap live in the privatedicechess-docswiki under Play Site; ADRs are referenced by number (ADR-0002 client authority, ADR-0007 server authority, ADR-0014 bot catalog).
SvelteKit 2 · Svelte 5 (runes) · Tailwind 4 · adapter-static (SPA, ssr=false) · PWA
(@vite-pwa/sveltekit). Game rules come from one place — the @rabestro/dicechess-engine
(Scala.js) artifact, pinned in package.json. Theme system (7 themes) is shared with
dicechess-analytics-ui.
export NODE_AUTH_TOKEN=ghp_xxx # PAT with read:packages — see Configuration
mise run setup # npm install
mise run hook:install # register the lefthook Git hooks (once per clone)
mise run dev # vite dev → http://localhost:5173
mise run check # eslint + prettier --check + svelte-check
mise run test # vitest run
mise run compile # vite build → dist/Only the @rabestro scope resolves from GitHub Packages (see .npmrc), and that needs
NODE_AUTH_TOKEN even though the packages are public — everything else, chessground included,
comes from the public npm registry. Failure signature: npm install dies with 401 Unauthorized
on @rabestro/dicechess-engine.
Run npm ci right after every git pull: a stale node_modules makes local Prettier disagree
with CI and produce phantom formatting drift.
To work on live play, run dicechess-play-api locally and point VITE_PLAY_API_URL at it in
.env.local.
| Variable | When | Effect |
|---|---|---|
NODE_AUTH_TOKEN |
install | GitHub PAT with read:packages, for the @rabestro scope |
VITE_PLAY_API_URL |
build (client) | Base URL of play-api. Empty = the /live routes are disabled AND recording is off (games stay in IndexedDB) |
VITE_* values are baked into the bundle at build time — the site is Direct-Uploaded to
Cloudflare Pages, so Pages dashboard variables never reach vite build. Changing one means
a rebuild and redeploy; in CI they come from repo variables.
src/
├── routes/ SPA pages (ssr/prerender disabled in +layout.ts)
│ ├── +layout.svelte themed shell: nav, theme switcher, toasts, zen chrome
│ ├── +page.svelte landing — marketing hero over the game hub
│ ├── rules/ · ru/rules/ prerendered rules pages, EN + RU (#254) — the only routes with
│ │ ssr/prerender re-enabled, so crawlers get real HTML + OG tags
│ │ (src/hooks.server.ts strips app.html's default-head block there)
│ ├── play/ game hub: every way to start a game (#217)
│ ├── practice/ vs-bot game (client-authoritative; engine in a Web Worker)
│ ├── lobby/ seek list + live-board wall (polls play-api)
│ ├── live/ · live/[id]/ friend-link entry · server-authoritative live board (WebSocket)
│ ├── games/ · games/[id]/ game history (local + play-api's own lobby/live games), filters +
│ │ head-to-head view (#151), "Show more" pagination (#150) · replay
│ ├── replay/[id]/ public replay for a server-recorded game (play-api GET
│ │ /games/{id}/history, #163) — engine-walked per-turn positions,
│ │ board-flip toggle, provably-fair commit/seed section
│ ├── leaderboard/ bot rating ladder (play-api GET /leaderboard)
│ ├── bots/ human-play bot catalog (play-api GET /lobby/bots, ADR-0014)
│ │ └── [team]/[name]/ bot profile — rating, ladder W-D-L, recent games (#152 Tier 1;
│ │ Tier 2/3 — human record, head-to-head vs models, rating history —
│ │ are a separate, not-yet-agreed design)
│ └── me/ guest profile + restore code; W-D-L on this device + in the lobby
│ ├── bots/ signed-in author's owned-bot surface: claim with one-time Bearer
│ │ token, ladder/catalog/capacity, rotate token, release (#242)
│ └── admin/bots/ administrator-only full registry: ladder/catalog/description and
│ one-time token recovery; ownership is display-only (#243)
├── components/ shared UI
│ ├── Board.svelte thin chessground wrapper driven by either game store
│ ├── GameHub the ways-to-start-a-game cards shared by the landing and /play
│ ├── lib/Chessground.svelte
│ ├── PlayerStrip · DicePanel · MoveHistory · GameEndModal · BotBadge · PawnPromotionSelector
│ ├── GameHistoryCard · LiveGameHistoryCard · BotProfileGameCard · WdlBar · WdlCounts
│ │ WdlSummaryCard · MiniBoard · TimeControlPicker · ThemeMenu
│ │ ToastContainer
│ ├── CategoryRatings one rating per speed (bullet/blitz/rapid, #258) — shared by both
│ │ public profiles; an unplayed speed renders as an explicit dash
│ ├── AuthMenu header identity slot — Sign in / nickname badge; renders nothing
│ │ while loading or when play-api is unreachable (anonymous-first)
│ ├── GamesFilterBar /games's source/result pills + opponent search+chip (#151)
│ ├── BotCatalogCard · BotTimeControlPicker · BotChallengePanel — the /bots page's card
│ │ (click → wake → config → start)
│ ├── BotRematchButton one-click rematch of the bot game that just finished, on the live
│ │ board's end-of-game surfaces (same wake → start handshake, #215)
│ └── RatingDeltaLine a finished rated game's rating change on both end-of-game surfaces —
│ says "updating…" while play-api's batch has not applied it yet
├── lib/
│ ├── auth/ authApi — play-api /auth/* client (ADR-0017): session is an HttpOnly
│ │ cookie, so the SPA holds no token; login is a full-page navigation
│ ├── authStore.svelte.ts identity: guest by default, account once signed in (status/nickname/
│ │ externalId; `user:<uuid>` signed in, `guest:<uuid>` otherwise)
│ ├── playWithBot/ bot-play core: store, engine worker, dice/history, opening book
│ ├── rules/ seo.ts — shared origins for the prerendered rules pages' absolute
│ │ canonical/OG URLs (#254); sitemap.xml/app.html hardcode the same
│ ├── live/ live-play client: liveGameStore, liveClient (WS + reconnect),
│ │ liveApi/lobbyApi/historyApi/ratingApi (REST), ratingDelta (a finished
│ │ game's own rating change: poll state, and rounding for display),
│ │ liveTypes (play-api wire mirror),
│ │ turnReplay (engine-driven per-turn walk, shared by liveGameStore and
│ │ reconstructServerHistory, #163),
│ │ dfen/board/clock/seat/timeControl/playerLabel helpers;
│ │ ratingCategory (hand-mirror of play-api's RatingCategory rule:
│ │ bullet/blitz/rapid from estimated duration, #258),
│ │ replayLink — when a finished game has an archived replay and the
│ │ shareable (token-free) URL for it (#216)
│ ├── leaderboard/ leaderboardApi — rating-ladder + bot-profile read client (play-api
│ │ wire mirror; GET /bots/{team}/{name}, #152)
│ ├── catalog/ catalogApi — bot-catalog read/wake/play-bot client (play-api wire mirror);
│ │ botChallenge — starting a game + how a failure reads, shared by the
│ │ challenge panel and the rematch button; lastBotGame — the setup of the
│ │ bot game this browser started, so a finished board can replay it (#215)
│ ├── bots/ ownerApi + myBotsStore — credentialed `/me/bots` transport and
│ │ account-scoped state; only claim receives a pasted Bearer token (#242);
│ │ adminApi + adminBotsStore — credentialed `/admin/bots` inventory and
│ │ operations; rotated tokens stay component-local (#243)
│ ├── games/ gamesApi — GET /players/{guestId}/games (vs/result/before filters +
│ │ hasMore, #173) + /opponents client (play-api wire mirror) + the
│ │ signed-in account's union, GET /me/opponents, credentialed (#226);
│ │ gamesFilters — /games's ?vs=/?result=/?source= URL state (VsFilter's
│ │ local/lobby namespaces, local-game filtering, opponent search
│ │ options, head-to-head totals) (#151)
│ ├── ingest/ finished-game recording via play-api POST /ingest/games
│ │ ├── types.ts GameIngestWire contract (verbatim copy — see the file head)
│ │ ├── guestIdentity.ts per-browser guest:<uuidv7> + restore code
│ │ ├── mapper.ts LocalGameRecord → GameIngestWire (UUIDv5 id, dice decode)
│ │ ├── ingestClient.ts POST to play-api /ingest/games (token never in browser)
│ │ └── outbox.ts flush pending games → play-api
│ ├── history/ move-history reconstruction for replays: reconstructHistoryMap
│ │ (local IndexedDB games) · reconstructServerHistory (play-api's
│ │ per-turn archive → the same historyMap shape, #163)
│ ├── stats/ playerRecord (local W-D-L) · lobbyRecord (play-api opponents
│ │ aggregate + /me's "In the lobby" label/link helpers, head-to-head
│ │ lookup by ?vs=)
│ ├── stores/ singleton rune stores (themeStore 7 themes · localGamesStore ·
│ │ playerGamesStore + myGamesStore (paginated, keyset `before` cursor,
│ │ #150; guest record vs the signed-in union — /games picks by auth
│ │ status, #229) ·
│ │ playerOpponentsStore + myOpponentsStore, the guest record vs the
│ │ signed-in union — /me picks by auth status (#226) · chromeStore) +
│ │ gameHistoryMerge (local +
│ │ play-api games → one newest-first /games list) · gameHistoryPagination
│ │ (render-cap + live-fetch-boundary logic over the merge, #150)
│ ├── utils/ getPieceImage (piece sprite paths) · logger (DEV-gated console)
│ ├── localGamesDB.ts IndexedDB via idb (sync_status: pending → synced | quarantined)
│ ├── timings.ts presentation pacing shared by BOTH game surfaces — never fork per surface
│ ├── staleBundleRecovery.ts one-shot reload when a mid-session deploy breaks a lazy chunk import
│ ├── appAssetFallback.ts honest-404 logic for functions/_app/ (below) — pure, tested here
│ ├── boardStore.ts the structural interface Board.svelte consumes, satisfied by both stores
│ ├── lastMove.ts last-move highlight keys · types.ts shared history/board types
│ └── bots.ts · gameOutcome.ts · sound.ts · preferencesStore · toastStore · botStatsStore
│ · authStore (guest stub)
└── utils/ fenUtils · formatters
Sibling to src/, at the repo root (Cloudflare Pages Functions look for functions/ there, not inside dist/):
functions/
└── _app/[[path]].ts Pages Function, scoped by its file-based route to /_app/*:
turns the SPA fallback (200 + index.html) into an honest 404
for a hashed asset a later deploy has already deleted (#220,
#223) — logic in src/lib/appAssetFallback.ts, wiring only here
Finished games are saved to IndexedDB (localGamesDB, sync_status: 'pending'), then
flushOutbox() maps each to GameIngestWire and POSTs it to play-api's
/ingest/games (same VITE_PLAY_API_URL base as live play). play-api validates the
payload structurally, queues it durably, and relays it to dicechess-analytics
server-side with its own Bearer token and retry/backoff. The browser never holds
INGEST_TOKEN. (This replaced the standalone Koyeb gateway of ADR-0005.)
Identity: source='playsite'; human = guest:<uuidv7> (per-browser), bot =
bot:<algorithm> (shared with the extension, disambiguated by source); game id =
UUIDv5('playsite/game/<uuid>').
A 400/422 from play-api is permanent: ingestClient classifies it as rejected, the outbox
quarantines the record and never retries it. Acceptance is asynchronous — the authoritative
engine-replay validation happens later in analytics, and a replay rejection parks the report
on the server rather than reaching this client. Games only reachable through the live surface
are recorded by play-api itself, not from here.
Every push to main builds in GitHub Actions and Direct-Uploads dist/ to Cloudflare Pages
(.github/workflows/deploy.yaml) — so never commit to main. Building in Actions is what
keeps the packages PAT out of Cloudflare. Releases are a manual Ops: Release dispatch that bumps
a git tag; package.json stays at 0.0.0.
- Outbox retry backoff — a
rejected/errorrecord is left pending and retried on the next flush with no delay (thequarantinedstate for 422 rejects is already implemented).
AGPL-3.0 (inherited from the Dice Chess engine; the public site distributes the engine
bundle). See LICENSE.