Record the execution platform (kind vk|telegram|direct + device subtype ios|android|web) on each session, captured at creation and carried gateway->backend as a trusted X-Platform header, so the upcoming store-compliance gate has an unforgeable execution context. - backend.sessions gains nullable platform_kind/platform_subtype columns (migration 00011, CHECK-constrained, jet regenerated); session.Platform captures them at mint, resolve returns them, middleware exposes platform(c). kind is derived from the establish endpoint, never a client field; the account-merge session mint inherits the caller's platform. - gateway derives the platform (VK subtype from the signed vk_platform via vkauth, Telegram/direct best-effort from the client) and injects X-Platform on every authenticated backend call through the request context. - ui submits a best-effort device subtype on the telegram/guest/email login requests (new FBS subtype field); VK is server-derived from the signed params. - an unattributed session is untrusted (view-only); VK/TG self-heal on the next cold-start re-mint, direct/email on re-login. Signal plumbing only, no user-visible change; X-Platform is inert until the gate consumes it.
119 KiB
Scrabble Game — Architecture
Source of truth for the platform architecture, transport, security model and
cross-service contracts. User-visible behaviour per domain lives in
FUNCTIONAL.md. This document always describes the current
design, not the history of how it was reached.
1. Overview
Three executables plus per-platform side-services:
gateway— the only public ingress (modulescrabble/gateway). Performs anti-abuse (rate limiting), authenticates the player against the originating platform (or an email/guest session), resolves the internaluser_id, and forwards authenticated traffic tobackendwith anX-User-IDheader. Serves the backend's admin console at/_gmon its public listener behind HTTP Basic Auth. Bridges live events frombackendto the client. The shared wire contracts (the push proto and the FlatBuffers edge payloads) live inscrabble/pkg, imported by bothgatewayandbackend.backend— internal-only service that owns every domain concern: identity/sessions, accounts and linking, lobby and matchmaking, the game runtime, the robot opponent, chat, notifications, statistics, history, and administration. Embeds thescrabble-solverengine as a library, in-process — there is no per-game container. The only network consumer ofbackendisgateway(plus platform side-services over an internal API).ui— pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build; no SvelteKit). Talks tobackendonly throughgatewayover Connect-RPC + FlatBuffers, with the edge TS bindings generated from the sameedge.protoandscrabble.fbsand committed underui/src/gen/. The client covers auth, "my games", auto-match, the board (play/pass/exchange/ resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile view, plus the social/account/history surfaces. There is no board on the wire — the client reconstructs the 15×15 board by replaying the move journal (§9.1) and renders board, tiles, premium squares and effects as pure CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag or tap; a CSS-token theme is light/dark and Telegram-themeParams-ready; navigation is a hash router and the session token is held in memory + IndexedDB. A build-flagged in-memory mock transport (pnpm start) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), a one-line advertising banner under the nav (server-driven campaigns shown to eligible free users, a weighted fair rotation — §10), and a client board-style setting (bonus-label mode). The visual/interaction design system is documented inUI_DESIGN.md. Inside the Telegram Mini App the client additionally tracks Telegram's live theme switch (themeChanged), fits the full device safe-area insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes Telegram's native Settings button into the in-app settings, and syncs the device-independent display preferences (theme, reduce-motion, board labels — not the interface language) across the user's Telegram devices via CloudStorage.platform/telegram— the Telegram side-service (modulescrabble/platform/telegram), split into two binaries that share the bot token (one bot, one optional game channel, §3):- the validator (
cmd/validator) verifies Mini App initData and Login Widget data by HMAC (the bot token is the secret) and never reaches the Bot API, so it runs on the main host with no VPN. The gateway calls its gRPC API (pkg/proto/telegram/v1) over the trusted internal network during Telegram auth, so game login is independent of Telegram reachability (§10). - the bot (
cmd/bot) runs the Bot API long-poll (Mini App launch +/startdeep-links) andsendMessage, the only component reaching the Telegram Bot API. It holds no inbound port: it dials the gateway over a reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send commands the gateway pushes (out-of-app push, operator broadcasts), so its egress lives on a host with native Telegram access off the main host — a VPN sidecar in the test contour, a separate host in prod (§12). Its delivery commands are platform-agnostic (keyed by the identityexternal_id), so a future VK/MAX bot reuses them; only initData validation is Telegram-specific.
- the validator (
flowchart LR
Client((Client / webview)) -- Connect-RPC + FlatBuffers (h2c) --> Gateway
Gateway -- REST/JSON, X-User-ID --> Backend
Backend -- gRPC server-stream (live events) --> Gateway
Gateway -- in-app stream --> Client
Backend -- pgx --> Postgres[(Postgres)]
Backend -. embeds .- Solver[[scrabble-solver library]]
Gateway -- gRPC (validate initData) --> Validator[Telegram validator]
Bot[Telegram bot] -. dials, reverse mTLS bot-link .-> Gateway
Gateway -- send commands (out-of-app push, broadcasts) --> Bot
Backend -. operator broadcasts (gRPC relay) .-> Gateway
Bot -- Bot API --> TgCloud((Telegram))
The MVP runs gateway and backend as single-instance processes inside a
trusted network. No Redis is planned (anti-replay crypto was deliberately
dropped). Horizontal scaling is explicit future work.
2. Transport
- client ↔ gateway: Connect-RPC + FlatBuffers over HTTP/2 cleartext
(
h2c). Binary payloads, server-streaming for the in-app live channel, first-class JS clients (@connectrpc/connect-web+ theflatbuffersnpm package). The contract is kept minimal: a singleGatewayservice (defined ingateway/proto/edge/v1) withExecute(message_type, payload, request_id)for unary operations andSubscribefor the live stream. The proto envelope is a thin carrier; the real request/response and event bodies are FlatBuffers tables (pkg/fbs, thescrabblefbnamespace) inside thepayloadbytes, which the gateway transcodes to and from the backend's JSON. The session token rides in theAuthorization: Bearerheader (there is no per-request signing, §3); auth operations are unauthenticated and return the minted token. A unary operation's domain outcome rides back inExecuteResponse.result_code(HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) surface as Connect error codes. The client treats a connectivity edge failure as state, not a per-call toast: a transportunavailableor arate_limitedflips a globalonlinesignal that drives a header "Connecting…" spinner and softly disables proactive actions, and the transport auto-retries with capped exponential backoff — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but only read-only ops onunavailable(a mutation is never blindly re-sent, to avoid double-applying one whose response was lost — its button is disabled while offline and the player re-issues it on reconnect). A reachability watcher (a lightweightprofile.getprobe) clears the signal when no other traffic is in flight; the liveSubscribestream's drop/recovery feeds the same signal. Edge hardening: every request body on the public listener is capped atGATEWAY_MAX_BODY_BYTES(default 1 MiB — far above any legitimate payload), both at the HTTP layer (http.MaxBytesReader) and as the Connect per-message read limit, so an oversizedExecuteis refused (resource_exhausted) without buffering. The h2c server carries explicit sizing:MaxConcurrentStreams250 (the x/net default made visible — a real client holds oneSubscribestream plus a few unary calls) and a 3-minute connectionIdleTimeout(a liveSubscribestream keeps its connection active, so only abandoned connections are reaped); thehttp.Serversets onlyReadHeaderTimeout(10 s) — Read/WriteTimeout would kill the stream. - Alphabet on the wire: live play exchanges alphabet indices, not
concrete letters. The rack (
StateView.rack), theSubmitPlay/Evaluatetiles, theExchangetiles and theCheckWordword areubyteindices into the variant's alphabet (a blank is the sentinel index 255). The client is alphabet-agnostic: on a per-variant cache miss it setsStateRequest.include_alphabet, and the backend embeds the variant's(index, letter, value)table (engine.AlphabetTable, derived from the solver ruleset — no dictionary) for display; the client caches it by variant and renders the rack and the blank chooser from it. The backend maps index↔letter at its REST edge, so the gateway forwards indices verbatim (it holds no alphabet table) and the engine's letter-based domain API — shared with the robot — is unchanged. The table is pinned by the solver version, so it cannot drift from the running backend. The move journal, history and GCG are unaffected (they stay decoded concrete characters, §9.1). - gateway ↔ backend (sync): plain HTTP REST/JSON. The gateway injects
X-User-ID(and the session's trustedX-Platform, §3) for authenticated requests;backendnever re-derives identity or platform from the body. Because every sync call targets the one backend host, the gateway's REST client widens its keep-alive pool well past the stdlib default of 2 idle connections per host; otherwise the per-request connection churn exhausts ephemeral ports and burns gateway CPU under load (see../loadtest/REPORT.md). - backend → gateway (live): a single gRPC server-stream carries live events (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the client's in-app stream while the app is open. Out-of-app delivery uses platform-native push via the platform side-service.
3. Authentication & sessions
Platform-native, deliberately simple: no Ed25519 client keys, no per-request signing, no anti-replay crypto (these were considered and dropped — players arrive from a platform rather than completing a mandatory registration).
- The gateway validates the originating credential once — Telegram
initData(delegated to the validator'sValidateInitDataRPC, which holds the bot token — the HMAC secret — so it never reaches the gateway), a VK Mini App launch (verified in-process bygateway/internal/vkauth: HMAC-SHA256 over the signedvk_*params under the VK app's protected keyGATEWAY_VK_APP_SECRET, base64url — a pure offline check, as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest bootstrap — then mints a thin opaque server session token (session_id). First Telegram/VK contact seeds the new account's language (Telegram'slanguage_code/ VK'svk_language) and display name (§4; VK omits the name from the signed params, so the client reads it viaVKWebAppGetUserInfoas an unsigned, cosmetic seed). The validator runs on the main host and never reaches the Bot API, so login does not depend on Telegram or the remote bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram'sauth_date), so freshness is not enforced — the minted session is the short-lived credential, and a replay only re-authenticates the samevk_user_id. - Single bot. The platform side-service runs one bot (one token + one optional
game channel), split into a home validator and a remote bot that share the
token.
ValidateInitData(the validator) validatesinitDataagainst that single token, rejects a bot user (the signedis_botflag), and returns only the Telegram user identity — there is no per-bot "service language" and no supported-languages set on the wire. The bot's chat messages and out-of-app push are rendered in the recipient's interface language (preferred_language, en/ru), not in any bot-scoped language, and the friend-invite share link (and its caption) point at that one bot. First Telegram contact seeds the new account'spreferred_languagefrom the launchlanguage_code(§4), but the interface language follows the device — the system guess, or an explicit Settings choice saved locally — and the bot never dictates the UI.preferred_languageis then reconciled to the active interface locale on every session adopt (not only on a Settings change; a no-op for guests and when already equal), so the server-rendered language surfaces — this push and the ad banner — always match the UI rather than stranding a user who never opened Settings on the creation-time seed. - Variant preferences (New Game gating). Which variants a player may be matched into is a
per-user profile setting —
variant_preferences, a set ofengine.Variantlabels (scrabble_en,scrabble_ru,erudit_ru) edited on the Settings/Profile screen. New accounts default to Erudit only (a DB column default); at least one variant must stay selected. The picker is ordered Erudit-first everywhere. The preference gates the New Game picker on every create path the player initiates — auto-match, vs-AI and a friend invitation the player creates — and the backend enforces it on those paths (a chosen variant outside the caller's preferences is rejected with HTTP 400). An invited friend may still accept an invitation in any variant (accepting is never gated), and opening or playing existing games of any variant is unrestricted. This replaces the former login-language variant gating; it is a per-account product affordance plus a server-side create-path check, distinct frompreferred_language(the interface language) and from a game's variant language. - The client holds
session_idin memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login). - The gateway caches
session → user_idand injectsX-User-ID. Session records live inbackend, which stores only a SHA-256 hash of the opaque token (never the plaintext), keeps a warmed in-memory cache for fast resolution, and treats sessions as revoke-only — they have no TTL and live until explicitly revoked (status→revoked). A revoke can target one token or, on an account merge (§4), every session of the retired account (RevokeAllForAccount, which also evicts them from the warm cache). - Trusted execution platform. Each session also records a
platform— akind(vk/telegram/direct) plus a devicesubtype(ios/android/web) — captured at creation, so a store-compliance gate has an unforgeable execution context (docs/PAYMENTS.md§8).kindis derived server-side from the validated establish path (the VK launch, the Telegram initData, or a web-native session), never a client field; thesubtypeis trusted only for VK (it rides inside the signedvk_platformparameter) and is client-reported best-effort for telegram/direct. VK and Telegram re-mint (and so re-validate and re-capture) on every cold start; a direct session captures it once. The gateway injects the resolved platform asX-Platform(<kind>/<subtype>) alongsideX-User-ID, sourced from the session and never the request body; an absent or unrecorded platform — a session predating the feature, or one the gateway could not attribute — is untrusted, treated as view-only (no spends). Consistent with the revoke-only model, platform freshness carries no separate TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a reused direct/email session) on re-login. - Guest = ephemeral web session (no platform, no email). A guest is backed by
a durable
accountsrow flaggedis_guestand carrying no identity — the row is a technical necessity (thesessionsandgame_playersforeign keys require one, the same way the robot pool is durable), not a profile: no friends, statistics or history are kept for it, and it is restricted to auto-match. A background guest reaper deletes an abandoned guest — flaggedis_guest, holding no game seat, older thanBACKEND_GUEST_RETENTION— on aBACKEND_GUEST_REAP_INTERVALsweep, so transient guest rows do not accumulate. Platform and email users are auto-provisioned durable accounts with an identity.
Decision (2026-06-20) — single bot, preference-based variant gating. The former two-bot model (one bot per service language, with
accounts.service_language, asupported_languagesset on theSessionwire and game-language push routing) was collapsed into one unified bot: it renders chat and out-of-app push in the recipient's interface language (preferred_language), with no per-bot routing. New Game variant gating moved off the login language onto a per-user profile settingvariant_preferences(default Erudit only, server-enforced on the caller's create paths; an invited friend may still accept any variant, and a Telegram promo deep-link seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validatedstart_param). The per-bot env vars andGATEWAY_DEFAULT_SUPPORTED_LANGUAGESwere removed; the wire droppedservice_language/supported_languagesand the pushlanguagerouting field, and gainedvariant_preferenceson Profile/UpdateProfile.
4. Accounts, identities, linking & merge
- One internal account may carry several platform identities
(
telegram,vk, …) plus an optional email identity. First contact from a platform auto-provisions a durable account bound to that platform identity. Concretely, platform and email identities share oneidentitiestable keyed by a unique(kind, external_id); email is an identity withkind=emailand aconfirmedflag. A synthetickind='robot'identity backs each pooled robot opponent (§7). The email confirm-code flow binds an email to the authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute TTL, ≤ 5 attempts) is sent as a branded HTML + plain-text email through aMailerseam (go-mail over the shared relay — TLS chosen by port, implicit on 465 and STARTTLS otherwise, or forced by config for a non-standard port; the server certificate validated against the system roots, no client certificate; a development log mailer when no relay is configured) and, once verified, attaches a confirmed email identity. Sends are throttled per recipient (a 60-second cooldown and a five-per-hour cap). Links in the email are built from a configured public base URL, never a request Host header (anti-injection). The email also carries a one-tap confirm deeplink (/app/#/confirm/<token>, an opaque 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the browser that opens it (magic-link), a link confirms the identity and emits anotifyprofile-refresh to the in-app session, a change switches the account's email in place, and a would-be merge is deferred to the interactive flow. A login requested from an installed PWA omits the deeplink entirely — its email carries the code only, typed in the same window, since the link would open in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never sent to the server), so a plain link prefetch cannot reach it, and the manual six-digit code is the fallback if an aggressive scanner runs the page. An email-login account is created flaggedis_guestand stays reapable until the code is confirmed — so an abandoned, never-confirmed login frees its reserved address — with confirming clearing the flag. Accounts and identities use application-generated UUIDv7 primary keys. A service flagpaid_account(lifetime one-time payment; no purchase flow yet) is carried on the account and ORed on a merge. - Linking is initiated from an authenticated profile and proves
control of the identity before attaching it: email through the confirm-code
flow, Telegram through the web Login Widget (validated by the validator,
HMAC under
SHA-256(bot_token)— distinct from Mini App initData; the gateway passes the trustedexternal_idto the backend, as forauth.telegram), and VK through VK ID web login (gateway/internal/vkid): the browser runs the VK ID raw OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no@vkid/sdk— and the gateway completes the confidential code exchange server-side under the VK "Web" app's protected key (a distinct VK app from the Mini App) to obtain the trustedexternal_id. A browser has no signed launch parameters, so unlike the VK Mini App (offline HMAC, §12) this makes an outbound call toid.vk.com, and it is web-only — a full-page redirect would strand a native Mini App webview. The request step always sends/accepts the proof (no pre-send "already taken" signal, so a probe cannot enumerate registered addresses); a required merge is revealed only after the proof is verified and is performed behind an explicit, irreversible confirmation (for VK, whose authorization code is single-use, the merge re-authorizes for a fresh code). A free identity is simply attached (and a guest is promoted to durable, clearingis_guest). - Unlink detaches a platform identity (
telegram/vk) from the profile. The backend refuses removing the last identity (ErrLastIdentity), so an account never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one method remains. Email is never unlinked — it is changed. - Change email mails a confirm-code (
purpose=change) to the new address on the authenticated account and, on confirm (code or one-tap deeplink), atomically replaces the account's email identity with the new one, freeing the old address. A new address already confirmed by another account is refused without disclosure (a neutral "check the address or contact support") and never merged — the anti- enumeration check is only reachable by someone who controls the new mailbox. - Deletion is legal retention, not erasure (
internal/accountdelete). The account row survives as a tombstone (accounts.deleted_at) — its chat/complaint foreign keys have no cascade, so a hard delete is impossible.AnonymizeAndTombstonejournals every live identity into an append-onlyretained_identitieslog (the legal dossier) then removes it, freeing the(kind, external_id)for a new account to reuse; it scrubs the livedisplay_name→[Deleted](an unspoofable sentinel — the editable-name rule forbids brackets) while snapshotting the real name intodeleted_display_name, anonymises the game-seat snapshots, and drops the account's own friendships / blocks / invitations / friend-codes / drafts / pending-codes. Chat, feedback and complaints are kept. The orchestration a layer up resigns the account's active games (so opponents are not stranded), drops its all-robot games (no human opponent; children cascade), and revokes its sessions. Every credential detachment — unlink, email change, delete and a merge collision — writes aretained_identitiesrow (reason), so the dossier keeps the full timeline. (A merge that would otherwise leave the survivor with two identities of one kind — e.g. each account held a confirmed email — keeps the primary's and journals the secondary's withreason=mergebefore dropping it.) Step-up is a mailed code (purpose=delete, no deeplink — a stray click must not delete;ConfirmByTokenrefuses a delete token) for an email account, else a typed phrase.last_login_at/last_login_ipare stamped on the cold-load profile fetch (throttled to once an hour). A two-year TTL reaper (from the event) purges the whole dossier — the journal, plus a deleted account's feedback thread and dossier PII — while the chat and the tombstone row stay. - Merge retires the account that owns the linked identity into the current
account, in a single transaction (
internal/accountmerge): statistics summed (counters incl. moves/hints added, max points kept, and the per-variant best moves merged keeping the higher-scoring play), the hint wallet summed,paid_accountORed, identities repointed, games / chat / complaints transferred, friends and blocks de-duplicated (friendships keep the strongest status accepted>pending>declined), pending invitations/codes dropped, and the secondary kept as an audit tombstone (accounts.merged_into/merged_at) so a shared finished game's no-cascade foreign keys stay valid — its seat there is left untouched. A merge is refused only when the two share an active game. The current account is the primary, except when the initiator is a guest and the linked identity already has a durable owner: then the durable account wins, the guest's active games move into it, the guest is retired, and a fresh session is minted for the durable account (the client switches to it). The secondary's sessions are revoked (§3). High blast-radius; isolated and well-tested.
5. Game engine integration (scrabble-solver)
backend embeds the solver library in-process behind internal/engine, the
only package that imports scrabble-solver (see CLAUDE.md for
the solver's public API and constraints). The engine is a self-contained rules
library — no persistence, transport or scheduling; the game domain drives it.
Key points:
- Variants at launch: English Scrabble, Russian Scrabble, Эрудит
(
engine.Variant, mapping torules.English()/RussianScrabble()/Erudit()). Эрудит's specifics (non-doubling centre,ёwith no tiles, 3 blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine treats every variant uniformly. - Dictionaries are committed DAWGs loaded with
dawg.Loadfrom the directoryBACKEND_DICT_DIR;backendloads theengine.Registryat startup as a hard dependency (like migrations), so a missing dictionary fails the boot. The registry holds dictionaries in memory addressed by(variant, dict_version), tracking the latest version per variant, and answers the word-check tool throughRegistry.Lookup. - Dictionary versioning — pin per game, update through the console. A game
records the
dict_versionit started on and finishes on it; new games pin the active version, the single source of truth persisted in thedictionary_statesingleton and restored on boot, so an operator's choice survives a restart. Multiple versions are resident at once.BACKEND_DICT_DIRis a writable named volume seeded from the image on first boot (its nonroot ownership is inherited from the image, so the runtime can write it); the flat directory is the seed version, labelledBACKEND_DICT_VERSION— set from the build'sDICT_VERSION, so the resident label equals the release tag — and each uploaded version lives in aBACKEND_DICT_DIR/<version>/subdirectory. The admin console updates a dictionary online (internal/dictadmin): an operator uploads thescrabble-dawg-vX.Y.Z.tar.gzrelease archive, previews the per-variant words added/removed against the active dictionary (engine.DiffWordsenumerates both DAWGs and decodes only the differences), and confirms — the backend extracts the archive into the version subdirectory (hardened against path-traversal, symlink and decompression-bomb attacks), loads it viaRegistry.LoadAvailable, and makes it the active version. Versions are immutable (re-uploading a resident tag is rejected), so in-flight games keep their pinned version while new games use the new one. A restart re-loads every resident version viaengine.OpenWithVersions(the flat seed plus each subdirectory, skipping the.staging/upload area) and restores the active pointer fromdictionary_state. The volume preserves uploaded versions across redeploys; once seeded it is not re-seeded, so after bootstrap dictionary changes go through the console rather than a rebuild. Because the flat DAWGs carry no embedded version,OpenWithVersionsrecords the version the flat directory was first seeded at in a.seed_versionmarker on the volume and treats that marker as authoritative (the seed-drift guard): on an already-seeded volume a laterBACKEND_DICT_VERSIONis ignored, so a bumped build seed cannot relabel the already-seeded bytes — which would otherwise silently serve the wrong dictionary and void games pinned to the prior label. A running contour therefore moves to a new release through the console (the prior version stays resident, so its games keep replaying), andDICT_VERSIONis the seed for a fresh volume only: bumping it on a live contour is a harmless no-op that takes effect on the next fresh volume. Set it per contour from the deploy'sTEST_/PROD_DICT_VERSION. (The dictionaries ship as a versioned release artifact from thescrabble-dictionaryrepo; the build'sDICT_VERSIONselects only the seed.) - Move generation/validation/scoring use
Solver.GenerateMoves(ranked),Solver.ValidatePlayandSolver.ScorePlay; board mutation usesscrabble.Apply. The engine adds its own deterministic, seeded tile bag that can return tiles (an exchange needs this; the solver's self-play bag cannot). engine.Gameis the in-memory match state and the pure rules engine: it deals racks, applies legal plays / passes / exchanges / resignations, refills from the bag, keeps the scores and whose turn it is, and detects the end of the game — empty bag with an empty rack, or six consecutive scoreless turns, applying the end-game rack-value adjustment, or a resignation. On a resignation the resigner keeps their accumulated score (no rack adjustment) and never wins: the win goes to the highest score among the remaining seats, unconditionally the other player in a two-player game. A player may resign on the opponent's turn (a forfeit is not a turn-scoped move):engine.ResignSeat(seat)resigns that player's own seat whoever is to move, and the game domain skips the turn check for resign. The engine exposes a decoded, solver-free API (SubmitPlay/SubmitExchange/EvaluatePlay/HintView/Hand) sointernal/gamedrives it without importing the solver. A play's orientation (H/V) is inferred from the placed tiles and the board, not supplied by the caller: two or more tiles fix it by the line they share; a lone tile takes the axis along which it abuts existing tiles (the longer word winning, horizontal on a tie), so a single tile extending an existing word vertically is accepted. Journal replay instead trusts the stored direction (SubmitPlayDir, §9.1) to reproduce a committed game exactly rather than re-deriving it.- The game domain (
internal/game) owns everything the engine does not — persistence, turn scheduling, the configurable turn timeout / auto-resign, the hint budget, word-check complaints, history and GCG — and is the engine's only consumer. Timeout auto-resign reusesengine.Resign, recording the move as a timeout, so it inherits the resignation win/loss. - History is dictionary-independent (§9.1): the engine emits decoded
MoveRecords and reconstructs the board from them withengine.ReplayBoard(alphabet only, no dictionary). - The client mirrors this validation path for an instant move preview (the
local eval,
ui/src/lib/dict): the dawg reader and the validate/score/direction slice are ported to TypeScript, byte-for-byte against this engine and pinned by theconformanceCI job. Composing a move is scored on-device with no round trip. It is an advisory accelerator only —submit_playre-validates on the server, so a wrong or spoofed local result cannot corrupt a game. The pinned per-game dictionary blob is fetched once through a session-gatedGET /dict/{variant}/{version}edge route (immutable; cached in IndexedDB best-effort) and reused across sessions; any miss, storage eviction or a bad-connection breaker falls back to the networkevaluate. The warm-up overlay isdocs/UI_DESIGN.md.
6. Game rules
- Word legality: validate-at-submit. An illegal play is rejected by
Solver.ValidatePlay; there is no challenge phase. - Multiple words per turn (Russian games). Russian variants carry a per-game
single-word rule, chosen on New Game (default off = single word; on = standard
Scrabble). Off, only the main word along the play direction is validated and scored —
perpendicular cross-words are ignored, including in robot move generation and the
unlimited move preview; on, every cross-word must be a real word and is scored. The main
word must still run through an existing tile along its own line to connect: a play that
forms no word along the direction it is laid — touching the board only perpendicular to
itself — is illegal even though its cross-word is never checked, and for a single tile that
abuts the board on both axes the engine plays the higher-scoring legal orientation. The
single-word rule is therefore not a superset of the standard rule: it forbids parallel
plays the standard rule allows and admits in-line plays whose cross-words are invalid. The
engine threads it as
scrabble.PlayOptions{IgnoreCrossWords}(solverv1.1.1); the first-move centre rule is unaffected. The "Russian-only" limit is a UI affordance: the backend and engine are variant-agnostic about the flag, and English games always send it on (standard). For auto-match the rule is part of the matchmaking key, so only players who chose the same rule are paired (the rule field rides every create/enqueue request, so matchmaking stays one uniform path). - End of game: the bag is empty and a player empties their rack, or
6 consecutive scoreless turns (passes/exchanges), or a resignation, or
a missed turn. The per-game turn timeout is chosen at creation
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h); a turn not made within it
becomes an automatic resignation, applied by a background sweeper. The sweeper
honours each player's away window — a daily local-time sleep interval on the
account (default 00:00–07:00, midnight-cross aware) — so a player is never
timed out while asleep. A game whose journal can no longer be replayed — a
committed move made illegal by a later rule change — is instead closed as a
draw (
aborted) on the next open, never left unopenable (§9.1). - First move (who goes first): decided by the official draw — each seated player
draws one tile and the tile closest to "A" leads (a blank supersedes all letters);
players tied for the best tile re-draw until a single leader remains. Each draw uses
fresh entropy (
crypto/rand), not the deterministic bagseed, so the draw is genuinely random and its record — not a seed — is the only account of the outcome. The leader takes seat 0 (which moves first), the rest keeping their seating order, so the engine and journal replay are unchanged (seat 0 always leads). A directly-seated game (friend/AI) draws at creation. Auto-match draws when the game opens, with the not-yet-arrived opponent as a synthetic placeholder (uuid.Nil, whose draw rows are back-filled to the real opponent on join), so the opener's seat is fixed up front and they may make their opening move while waiting with no later reseating. The draw is recorded with the game (game_setup_draws, §9) and surfaced only in the admin console's step-by-step game replay — not shown to players today; it is kept for future tournaments, where it becomes a manual, per-tile external call. It is modelled as a discrete "player N draws a tile" step so that API is a thin driver over the same component. - Players: auto-match is always 2 players; friend games are 2–4 players.
backendowns turn order and the bag for any player count. A resignation or timeout in a two-player game ends it with the other player winning. In a game with three or more seats a resignation or timeout drops that seat and the rest play on — the engine skips the resigned seat in the turn rotation and excludes it from the win, finishing the game (the sole survivor wins) only once one active seat remains, or by the ordinary end conditions among the active seats. A per-game drop-out tile disposition, chosen at creation (dropout_tiles:removefrom play — the default — orreturnto the bag), governs the leaver's rack, which is never revealed to the remaining players; it is recorded for deterministic journal replay. (Two-player games end on the first drop-out, so the disposition does not affect them.) - Hint: governed by two per-game settings — whether hints are allowed and the
starting per-player allowance — plus a per-account hint wallet
(
hint_balance, spent after the allowance; top-ups are a later feature). A hint reveals the top-1 ranked move (GenerateMoves[0]). The lobby/tournament caller picks the per-game defaults (e.g. one in casual random games, none in tournaments). The client lays the hinted tiles onto the board as a pending placement and leaves the commit to the player. When the rack has no legal move the service spends nothing and returnsErrNoHintAvailable— surfaced as the distinct result codeno_hint_available(separate fromhint_unavailable) so the UI can say "no options" rather than "no hints left". The hint count shown to the player is the per-game allowance remaining plus the global wallet; because the wallet is global,game.state/game.hintcarry it as a separatewallet_balancefield beside the combinedhints_remaining, so the client derives the per-game allowance (hints_remaining - wallet_balance, which it may cache per game) and reads the wallet live from the profile — otherwise a wallet hint spent in one game would leave a stale, too-high count cached on every other game. - Word-check tool: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a complaint (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in the admin
review queue. An operator resolves it (
open → resolved) with a disposition — reject, accept-add or accept-remove; the accepted ones form a derived pending-changes list that feeds the offline dictionary rebuild and is marked applied once the rebuilt version is installed through the console (§5, §12).
7. Robot opponent
Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
open game's empty opponent slot when no human has joined within the wait window (§8).
It lives in internal/robot and plays as an ordinary seated account through the game
service, so only internal/engine imports the solver. In the random/auto-match path it is
designed to be indistinguishable from a person.
The same robot serves two quick-game modes, chosen by the player on New Game and recorded
on the game as games.vs_ai: the random path above (disguised as a person) and an
honest-AI path the player knowingly picks (shown as 🤖). The mode is a per-game flag,
never derived from the opponent account, so the disguised path is never revealed. In an
honest-AI game the robot keeps its per-game strength (playToWin) and margin band but moves
at once — no sampled delay, no sleep window, no proactive nudge; chat and nudge are disabled,
the opponent is shown as 🤖 everywhere, and the game records no statistics for either seat
(practice, like a guest game). The fast reply is event-driven: committing a move (or
creating the game) triggers robot.DriveGame immediately via the game service's after-commit /
after-create hook (game.Service.SetAITrigger, a func value so the game package never imports
the robot package), with the periodic driver as the fallback. There is no short move
timeout; instead the game is created with turn_timeout_secs = AIInactivityTimeout (7
days) and the existing turn-timeout sweeper resigns the overdue seat — since the robot moves
at once, only the human is ever on the clock, so the per-turn timeout doubles as the
"abandoned after 7 days of inactivity → loss" rule with no new column or sweeper. An AI
game emits no your_turn (the instant reply makes it redundant; opponent_moved
still advances the UI), its GCG export labels the robot seat "AI", and the
games-started / -abandoned metrics carry a vs_ai attribute so AI and human games
chart separately (the admin /games list and game card also show the AI flag). A finished
honest-AI game the player left — end_reason resign or timeout — is also dropped from
that player's own finished lobby list by game.Service.ListForLobby (a lobby-only filter over
ListForAccount); the admin console and the account-merge count keep the full set, and a
normally finished AI game stays. The filter keys on the game's end reason, not on which seat
left, so it extends to any player should the robot ever resign.
The robot keeps no per-game state: every choice is derived deterministically
from the game's bag seed (a restart-stable FNV-1a mix), so a background driver
(robot.Service.Run, mirroring the turn-timeout sweeper) recomputes the same
behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a kind='robot' identity (§4), keyed
robot-<lang>-<index> and provisioned at startup with chat blocked but friend
requests open — a request to a robot is accepted as pending and expires unanswered
(the robot never responds), mirroring a human who ignores it; the chat
block backs the human-like names (there is no DM surface; chat is per-game).
Per-game names. A seated player's display name is snapshotted on the seat
(game_players.display_name) when the seat is taken — a human's then-current name, or a
disguised robot's freshly composed name — so the name an opponent sees is frozen for the life
of the game (a later rename never rewrites past games) and a small pool of accounts presents
as an ever-changing crowd. A reader falls back to the account's current name when a seat
carries no snapshot (a pre-migration row). Each durable robot account still seeds a stable
fallback name (32 composed per language), but the disguised reaper stamps a fresh per-game
name drawn from a wide composed corpus: Western first/surname pools per locale (English,
German, Spanish, Italian, French, Portuguese) in one of three forms (first only / first +
surname initial / first + full surname), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles (a stem, an optional ./_, an optional trailing
number). Selection is variant-aware: a Russian game (Russian Scrabble or Эрудит) draws a
Cyrillic name or handle with at most ~20% Latin analogue and never a CJK script; an
English game draws the full international corpus. Every composed name stays within the
editable display-name format (account.ValidateDisplayName) — which now admits a trailing
run of up to five digits (so "Player2007"-style handles are valid for humans too) — so the
disguised robot stays indistinguishable from a person.
- Balance: at game start it decides once whether to play to win, with
P(play-to-win) ≈ 0.40(so the human wins ≈ 60%), derived from the seed. Adaptive difficulty is post-MVP. - Margin targeting: each turn it picks from the ranked candidates
(
engine.Candidates) the move whose resulting lead (playing to win) or deficit (playing to lose) is closest to a small band (1–30 points), rather than always the maximum; with no legal play it exchanges a full rack when the bag can refill it, else passes. On ≈20% of moves through the opening and midgame it deviates — playing that single move toward the opposite band (a winning robot eases off, a losing one surges ahead), so the chosen strategy may not pan out, which favours the human; the deviation chance tapers linearly to 0 over the last 14 tiles in the bag and is 0 once the bag is empty, so the endgame follows the per-game intent strictly. It is deterministic from the seed (mix(seed,"deviate",moveCount)), a per-move wobble that leaves the per-game play-to-win intent (and the admin card) unchanged. - Timing: the per-move delay is move-number-aware — a right-skewed sample (exponent k=4, short delays frequent) from a band that interpolates from [3, 10] min at the first move to [10, 90] min by ~28 moves, so openings are quick and the endgame can run long, clamped to [1, 90] minutes; it sleeps 00:00–07:00 anchored to the opponent's profile timezone with a per-game drift of ±3 h (fallback UTC), so its night overlaps the human's rather than running anti-phase; on a daytime nudge it replies near the move's lower band; it proactively nudges the idle human on a sparse, randomized schedule — every nudge waits a uniform random 9-12 h (the first measured from the turn start, each later one from the previous nudge), so a neglected turn gets only a handful of widely-spaced reminders. A nudge that would fall inside the sleep window is skipped and fires at the first scan after wake.
- Dead-endgame timing: once the two most recent moves are both passes, the board and the robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game think time and answers on a shortened schedule scaled to the human's own last (pass) think time — a uniform sample in [0.8, 1.5]× of it, clamped to [30 s, 8 min] and taken as a min with the normal delay, so it never slows down. A slow human collapses to the 8-min cap (a decided game is not dragged out); a fast human is tracked, with the floor keeping the robot from passing suspiciously instantly. The anchor (the gap between the last two journal entries) reads the move journal only — no schema change — stays deterministic from the seed, and still defers to the sleep window.
- Observability: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
robot_games_finished_totalOTel counter plus a per-finish log give a live view. The admin game card surfaces each robot seat's per-game play-to-win intent (from the seed) and, on the robot's turn, its deterministic next-move ETA (the normal-schedule upper bound — a dead-endgame pass may land sooner).
8. Lobby & social
- Matchmaking: a quick game offers two opponents on New Game — an honest AI (the
default) or a random human (§7). The AI choice (
vs_aionPOST /lobby/enqueue) takes theMatchmaker.StartVsAIpath, which picks a pooled robot and creates a game already seated and active (vs_ai, random seat order); it never enters the open pool, so the reaper below never touches it. The random choice drops the player straight into a real game and lets them wait inside it:Enqueue(POST /lobby/enqueue) opens a game seating the caller with an empty opponent seat (statusopen, §9), or — when another player is already waiting for the samevariantand per-turn rule — seats the caller into that open game and starts it; which seat the caller takes is randomised for first-move fairness, and a re-enqueue while already waiting opens another game (or joins a different player's) rather than returning the caller's own, so choosing "random opponent" again always starts a new search (bounded by the simultaneous quick-game cap, §9). Matchmaking state is therefore the open games in the database (not an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a player is never paired into a game whose waiting opponent they have a per-user block with, in either direction (the enqueue excludes the caller'sBlockedWithset); concurrent enqueues for one bucket are serialised by a transaction-scoped advisory lock so two callers pair rather than each opening a game. A background reaper seats a pooled robot (§7) in any open game whose wait window — a fixed 90 s plus a random 0–90 s (so 90–180 s total) — has elapsed, guaranteeing every game gets an opponent. When a human or a robot takes the seat, the waiting starter receives an opponent-joined notification (§10) that fills the opponent card and re-enables resign and chat in place — the starter never leaves the game. While a game isopenthe starter may move on their turn, but resign, chat and nudge are refused (no opponent yet) and the lobby and opponent card show a "searching for opponent" placeholder. - Simultaneous-game cap: a player may hold at most
game.MaxActiveQuickGames(10) active quick games.game.Service.CountActiveQuickGamescounts the games seating the account in statusactiveoropenwithout a linkedgame_invitationsrow — friend games are excluded, and hidden games still occupy a slot, so it is a dedicated count rather than a filter over the lobby list. The backend gate (Server.ensureUnderGameLimit) refuses both new-game entry points at the cap —POST /lobby/enqueueandPOST /invitations— with 409game_limit_reached; accepting an invitation (POST /invitations/:id/accept) is never gated, so friend games are capped only at initiation. The lobby learns the state from a booleanat_game_limitcarried on thegames.listresponse — the lobby already re-fetches that on entry and on every game event, so the flag needs no separate request or per-event payload; while it is set the client disables New Game and shows a notice. - Friends: two add paths over one
friendshipstable. A one-time code the to-be-added player issues (afriend_codesrow: 6-digit numeric, SHA-256-hashed, 12 h TTL, one live code per issuer, single-use, redeem rate-limited) is redeemed by the other player to become friends immediately. It is shared as a Telegramstartappdeep-link to the single bot (with a matching caption), redeemed by the recipient's Mini App on launch; a spent or expired code is not surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the bot, since the shared link outlives the single-use code. Alternatively a request → accept is sent to someone you share a game with (active or finished); the recipient may accept, ignore (the pending row lazily expires after 30 days and may be re-sent), or decline — a decline is remembered (status='declined') and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still deletes the row. A request sent in-game to an auto-match opponent who is secretly a pooled robot is recorded instead in a separaterobot_friend_requeststable, keyed on the requester + game + seat with the seen name snapshotted (RequestInGame): the shared robot account is never put infriendships(so it is not befriended/awaited under its other per-game names and the in-game 🤝 stays pinned to that seat as sent), the robot never accepts, the row is not surfaced in Settings → Friends, and a background reaper deletes it once its game has been finished for 7 days. (Discovery by friend list or platform deep-link is future work.) - Block: two independent global account toggles (
block_chat,block_friend_requests) plus a per-user block list. A per-user block is asymmetric and non-destructive: the blocker stops receiving everything from the blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never pairs them — while the blocked user notices nothing. Their sends still persist by the normal rules but are never delivered or surfaced to the blocker (a directionalblockExistscheck drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read), and applying a block also marks read any unread the blocked user had left for the blocker. It overrides but does not delete a friendship (an unblock cleanly restores it), so a pair may be friends and blocked at once; the admin user card shows the full truth (blocks, blocked-by, friends) regardless of this suppression. Block/unblock emit auser_blocked/user_unblockednotification to the blocker only (§10). Blocking an auto-match opponent who is secretly a pooled robot is recorded instead in a separaterobot_blockstable, keyed on the blocker + game + seat with the seen name snapshotted (BlockInGame): the shared robot account is never put inblocks(so the matchmaker keeps it free and it is not blocked under its other per-game names), while the blocked list and the in-game card still show it by joining that table; an unblock deletes the row. - Friend games: formed by invitation → accept (an
game_invitationsrecord with one row per invitee). The 2–4 player game starts once every invitee accepts; any decline cancels the invitation, and a pending invitation expires after 7 days (enforced lazily on access). - Chat: per-game, persisted (kept with the game's archive), ≤ 60 runes, and validated on input — links, email addresses and phone numbers (including lightly obfuscated forms) are rejected, since the chat is for quick reactions, not contact exchange. Chat is allowed only on the sender's own turn and at most once per turn (the turn boundary is the move-driven turn start; the opponent's-turn control is the nudge); the backend enforces both and the client mirrors them by hiding the field. Each message stores the sender's IP (forwarded by the gateway) for moderation. A sender who has disabled chat cannot post, and messages from a blocked sender are hidden from the viewer. The operator console has a Messages section that lists posted messages (nudges excluded) newest-first with the sender's resolved name, source (guest / robot / oldest identity kind), IP and game, searchable by sender name / external-id glob masks and pinnable to one game or sender (linked from the game and user cards). It also offers an unread-only filter and a read/unread column, and each message has a detail card with the per-seat read breakdown (sender / read / unread).
- Nudge: folded into the chat as a
nudgemessage kind. The player awaiting the opponent may nudge once per hour per game; it is not allowed on one's own turn. The platform-native delivery runs through the gateway and the platform side-service. - Read receipts: each
chat_messagesrow carries anunread_seatsbitmask — a set bit per recipient seat that has not yet read it (the sender's own bit is never set). A text message seeds the bits of every seated recipient; a nudge seeds only the awaited player's. A disguised robot opponent's bit is never set — it never opens the chat, so a message to it is born read (it would otherwise linger unread forever); a nudge to a robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player opens the move history or the chat (POST /games/:id/chat/read, which the client sends only when it holds unread, so a history open is not a constant backend call), and a nudge additionally clears when its recipient answers by moving (the move path calls a wiredNudgeClearer); and every nudge in a game is marked read when that game finishes — on any completion path (a closing move, a resignation, a forfeit or a turn-timeout, all funnelling through the sharedcommit), since the nudge badge is stale once the game is over (a wiredNudgeExpirer; chat messages stay unread and this expiry records no read latency). The mask is inverted so "anything unread" is a plainunread_seats <> 0, which the per-viewerunread_chatgame-view flag (seeding the lobby and in-game unread dot), the admin unread filter and the unread gauge all use. A second per-viewer flag,unread_messages(unread_seats <> 0 AND kind = 'message'), reports whether any unread entry is a real message rather than only a nudge, so the dot is coloured: the regular danger colour when a message is unread, a softer amber when only nudges are. Both flags share the REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge event raises onlyunread_chat, a message event raises both). The lobby additionally floats games with any unread entry to the top of the your-turn and opponent-turn sections (the finished section keeps its activity order). On each player-driven clear the publish-to-read latency is recorded — the completion expiry records none (it is not a read); the read time itself is not retained. - Profile:
preferred_language(en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), timezone, the daily away window, the variant preferences (variant_preferences, the matchable-variant set that gates New Game — §3, defaulting to Erudit only, at least one enforced) and the block toggles — all editable throughaccount.UpdateProfile, which validates them: a display name is Unicode letters joined by single/./_separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a fixed±HH:MMUTC offset (or a legacy IANA name) resolved byaccount.ResolveZonefor the sweeper and the robot's sleep (a fixed offset trades DST for a simple picker), and is seeded at account creation from the client's detected offset — sent on the Telegram / guest / email first-contact request — so the robot's sleep and the away-window sweeper are anchored to the player's real zone from the first game rather than theUTCdefault (an undetected or malformed offset keeps the default); the away window is at most 12 h (midnight-wrap aware). Linked platform accounts and merge are covered in §4.
9. Persistence
- Single Postgres database, schema
backend;backendis the only writer. The "pgx pool" is adatabase/sqlhandle backed by the pgx stdlib driver and instrumented with otelsql; type-safe queries use go-jet (code generated intointernal/postgres/jetand committed, regenerated bycmd/jetgen). Migrations are embedded SQL applied withpressly/goose/v3at startup. Primary keys are application-generated UUIDv7. - Tables:
accounts(durable internal accounts, carrying the away-window columnsaway_start/away_end, the hint wallethint_balance(spent after a game's per-seat allowance; an operator tops it up with an additive, raise-only grant from the admin console), theis_guestflag for ephemeral guest rows, thenotifications_in_app_onlyout-of-app push toggle, thepaid_accountservice flag and the merge-tombstone columnsmerged_into/merged_at),identities(platform/email/robot identities, unique(kind, external_id), thekindadmittingrobot),sessions(revoke-only opaque-token hashes), the game tablesgames(carrying thedropout_tilesdisposition column),game_players,game_moves(the move journal),game_setup_draws(the first-move draw record, §6),complaints,account_statsandaccount_best_move, and the social/lobby tablesfriendships(the request/accept graph, its status admittingdeclined),blocks(per-user blocks),chat_messages(per-game chat and nudges, carrying the per-messageunread_seatsread bitmask),email_confirmations(pending confirm-codes),game_invitations/game_invitation_invitees(friend-game invitations),friend_codes(one-time add-a-friend codes),game_drafts(a player's in-progress rack order + board composition per game) andgame_hidden((account_id, game_id)rows that drop a finished game from one account's own lobby list, leaving it visible to the other players — finished-only and irreversible by design, so there is no un-hide). Auto-match has no separate store: a game awaiting an opponent is an ordinarygamesrow with statusopenand a single seatedgame_playersrow (the empty opponent seat is a nullaccount_id, filled when a human or robot joins), plus anopen_deadline_atstamp the reaper scans for robot substitution. The first-move draw (§6) is recorded ingame_setup_drawswhen the game opens; the synthetic opponent's rows carry a NULLaccount_iduntil a real opponent joins and back-fills them. The record is dictionary-independent — a decoded letter, a blank flag and the numeric draw rank — like the journal (§9.1), so it never depends on a dictionary or the solver's encoding. - Active games are event-sourced. A game is a
gamesrow (pinnedvariant/dict_version, bagseed, the per-game settings, and a denormalised turn cursor) plus an append-only, decoded move journal (game_moves); the live position is anengine.Gameheld in an in-memory cache (≈24 h idle TTL) and rebuilt by replaying the journal on a miss, which the seeded bag makes exact. Each game is serialised by a per-game lock; a persistence failure evicts the live game so the next access rebuilds from the journal.game_playersrecords each seat's account (null for an open game's still-empty opponent seat), running score, hints used and winner flag. - Statistics (
account_stats, recomputed on each finish for durable non-guest accounts only — the finish-time recompute skips anyis_guestseat): wins, losses, draws, max points in a game, and max points for a single move (which already folds in every word the move formed plus the all-tiles bonus); plus two summed counters —moves(the player's plays, i.e. tile placements; passes and exchanges do not count) andhints_used(every hint taken, allowance + wallet) — from which the screen derives the hint share = hints_used / moves. A tie increments draws only; a resignation or timeout is a loss for the acting player. A companion tableaccount_best_move(keyed by account and variant) keeps the highest-scoring single play per variant with the word itself: its main word as an ordered JSON array of tiles (letter, tile value, blank flag — value 0 for a blank), so the statistics screen renders it as game tiles without the variant's alphabet table. Blank flags are taken from every blank ever placed in the game (equivalent to reading the final board, since a placed tile never moves). It is replaced only by a strictly higher-scoring play, written in the same finish transaction, and skipped for guest/honest-AI games exactly likeaccount_stats. It is filled forward only — plays finished before the table existed are not back-populated (the aggregatemax_word_pointsstill covers them numerically).
9.1 History invariant (must hold forever)
Archived games must replay independently of any dictionary and of the
solver's internal encoding — at least visually. Therefore the move journal
persists only decoded concrete values: action kind (play / pass / exchange /
resign / timeout), acting player, per-move score and running total, timestamp,
and — in a per-move JSON payload — the acting player's rack before the move (with
? for a blank), and for a play its direction, main-word anchor, placed tiles
(letter as text, coordinate, blank flag) and the words formed; for an exchange,
the swapped tiles. This is exactly what is needed both to replay the game
through the engine (a cache miss; replay trusts the stored direction rather than
re-deriving it, so the rebuild matches the committed game) and to render history or emit GCG without a
dictionary: the board for visual replay is reconstructed by applying placements
onto an empty grid, since moves were validated at play time and scores are
stored. variant and dict_version are kept as metadata only (audit,
complaint review), never as a replay dependency. Engine replay re-validates each
move, so a committed move that later becomes illegal under a tightened rule
(e.g. the single-word connectivity rule) would make the rebuild fail; rather than
leave the game unopenable, the next open closes it gracefully as a draw
(engine.EndAborted → end_reason='aborted', all seats marked non-winners),
preserves the journal intact, and surfaces an impersonal organizer note at the
end of the history and in the GCG export (a free-text #note). GCG export is derived from
the same rows and is likewise self-contained — we ship our own writer (the solver
exposes none): the standard Poslfit dialect (UTF-8, #player/#lexicon
pragmas, 8G/H8 coordinates, lower-case blanks, . pass-throughs, -TILES
exchanges), plus #note lines for resignations and timeouts, which the standard
does not cover. Export is offered only on a finished game (game.ErrGameActive
otherwise), so an in-progress journal is never leaked mid-play.
Export delivery — the signed download URL. Both export artifacts — the .gcg text
and the rendered PNG of the final position — travel one uniform route on every
platform: the client calls the authenticated game.export_url op, the backend mints a
relative, HMAC-signed, short-lived path
(/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>, 10-minute TTL,
BACKEND_EXPORT_SIGN_KEY), and the client resolves it against its own origin (no
service ever needs to know the public host) and hands it to the best affordance
each platform has: Telegram Android/desktop downloadFile (Bot API 8.0; the
chooser there is the native showPopup — activation-free bridge calls end to end),
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
the user activation a popup callback lacks), VK iOS VKWebAppDownloadFile for both
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
iframe). The gateway serves the bytes via
http.ServeContent (Content-Length + Range/206 — Android's system DownloadManager
hangs on chunked bodies of unknown length). The GET is the gateway's
only unauthenticated data route (/dl/* — in the caddy @gateway matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its /api/v1/public group in constant
time, every failure a uniform 404 — is the whole grant, and minting requires an
authenticated caller on a finished game. The PNG is rasterized on demand by the
renderer sidecar (internal-only Node + skia-canvas running the same
ui/src/lib/gameimage.ts the web project unit-tests — one renderer, no drift;
renderer/README.md); the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the client's date locale, IANA time zone and UI-localized
non-play labels ride the signed URL so the server render matches the player's
presentation. Nothing is stored: the artifact is re-derived from the immutable
finished journal on each GET. The only degraded platform is a legacy Telegram client
predating downloadFile, where the GCG falls back to the old clipboard copy and the
image option is not offered.
The alphabet-on-the-wire transport does not touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
game still replays with the variant's rules.Alphabet alone, independent of any dictionary.
10. Notifications
Two channels: the in-app live stream and
platform-native push (out-of-app, via the platform side-service).
The backend emits notification intents through an in-process hub
(internal/notify, a Publisher seam installed on the game, social and lobby
services); a single backend→gateway gRPC server-stream (Push.Subscribe,
pkg/proto/push/v1) carries every event, and the gateway fans them out by
user_id to each client's Connect Subscribe stream while the app is open. The
catalog is your-turn and opponent-moved (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to every seat,
including the mover, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), chat-message and nudge
(from the social service), opponent-joined (from the matchmaker, §8), and notify
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation, invitation-update or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create (invitation) or any later change to it (invitation-update: an
updated response, a decline, a cancel, or its game start — to every participant)). game-over is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout — and your-turn is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
and a recipient-first running score line (e.g. 120:95:80, the reader's score first).
The in-app stream is a delta channel so the client renders from the event
without a follow-up game.state: opponent-moved carries the committed move plus the post-move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the move count — idempotent (a re-delivered or own-move
echo is a no-op) and gap-safe (a missed move falls back to a game.state + game.history
refetch); your-turn carries that move count as a consistency check; the game-started
notify carries the recipient's full initial StateView so opening a freshly started game is
instant, and opponent-joined carries the waiting starter's refreshed StateView so the
opponent card and the resign/chat controls update in place; game-over carries the final summary; the lobby notify sub-kinds
carry the changed account / invitation, so the client patches its lobby lists in place: invitation
and invitation-update carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The user-blocked / user-unblocked sub-kinds
confirm a per-user block change to the blocker only (never the blocked user), carrying the other
account, so the blocker's open game screens re-derive the block / add-friend controls and the struck
name in place across sessions. The move-commit response (submit_play / pass /
exchange / resign) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby
preloads the player's ongoing games — each one's game.state, game.history and saved
draft — into that same per-game cache, so opening one from the lobby is instant and a saved
composition paints already on the board (no rack→board step). The notify package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
verbatim. Auto-match needs no match poll — Enqueue returns the game the player enters
synchronously, and an opponent later taking the open seat arrives as the in-app opponent-joined
event. Unlike a move, that event has no follow-up delta to trigger the move-count gap recovery, so
the waiting game screen recovers a missed join itself: it polls game.state while the stream is
down, refetches once on stream reconnect, and resyncs on a foreground regain that did not drop the
stream (covering an event shed from a full hub buffer while suspended), so a join missed during an
outage or while the app was backgrounded still resolves the open game in place. For the lobby notification badge (incoming friend requests + open
invitations) the client re-polls on the notify event and on lobby open / focus, covering a push
missed while the app was hidden. Out-of-app platform push is a fallback
the gateway routes from the same firehose: for an event whose recipient has no
live in-app stream it resolves the backend /internal/push-target (their Telegram
external_id, the recipient's interface language (preferred_language) as the render
language, and the notifications_in_app_only flag). It then pushes a deliver command over
the bot-link to the remote bot — fire-and-forget, best-effort (dropped, with a
metric, when no bot is connected) — only when the recipient has a Telegram identity and has
not confined notifications to the app, so the two channels never duplicate. The bot renders a
localized message with a Mini App deep-link button in that language; there is no per-bot
routing. The out-of-app set is
your-turn, game-over, nudge and the invitation (a new invitation) / friend-request notify sub-kinds;
the bot renders the message and skips the rest — so in-app-only sub-kinds like
invitation-update (a response/withdrawal lobby sync) and user-blocked/-unblocked (a
block-state sync to the blocker) never become a platform push. Operator broadcasts
(SendToUser / SendToGameChannel, §10 admin) render in an operator-chosen language in
the console; the backend calls them on the gateway's bot-link relay, which forwards them
to the bot and awaits its delivery ack (so the console still reports delivered/not). Beyond
messages the same bot-link carries a chat-gate control path — a ChatGate command sets a user's
write access in the moderated discussion chat and the bot's unary ResolveChatEligibility resolves a
joiner's eligibility (neither renders a message; see Moderated discussion chat below). An optional
standalone promo bot runs in the bot container (TELEGRAM_PROMO_BOT_TOKEN): a second bot
answering /start with a URL button into the main bot's Mini App (?startapp, since a web_app
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
A separate advertising-banner channel feeds the client's one-line strip (UI_DESIGN.md),
server-driven by internal/ads. An operator manages campaigns (each one placement order) in
the admin console (/_gm/banners): a campaign has a show weight (integer percent 1..100), an
optional validity window, an enabled flag and one or more bilingual messages (en + ru,
both mandatory, minimal markdown). A single perpetual default campaign fills the unsold
remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is
!paid_account && hint_balance == 0 && !has(no_banner) (the no_banner account role suppresses
it unconditionally); guests qualify. The eligible viewer's banner block rides the profile.get
response (the one bootstrap every client fetches on open, authed or guest — no separate request,
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
interface language (preferred_language) and
computes the active set — window-filtered campaigns, the default's effective weight
(max(0, 100 − Σ active timed weights), dropped at 0), GCD-reduced. The client rotates that set
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
cycle), round-robining a campaign's messages within its slots; the global display timings (hold,
edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set
(/_gm/banner-settings, clamped) and ride the same block. When an operator changes a viewer's
eligibility inputs (grants hints, grants/revokes no_banner; a future payment flow sets
paid_account), the backend emits a notify banner sub-kind (a payload-free re-poll signal),
and the open client re-fetches profile.get to show or hide the banner in place. Operator content
edits take effect on the next profile.get (open/reconnect/foreground), not mid-session. A
non-default campaign may also carry an optional colour override (background, text, link) in two
sets — one for every theme, one for the dark theme only — plus an urgent flag. The colours ride
the same block (six optional strings appended to each BannerCampaign on the wire — trailing,
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
the strip's border from the background in JS (no CSS color-mix, for the old-WebView floor).
Urgent is resolved entirely server-side, with no wire field: while any enabled, in-window urgent
campaign exists, computeActiveSet returns only the urgent campaigns (preempting the timed set and
the default remainder) and bannerFor skips the eligibility gate for that feed, so an urgent
notice reaches every viewer — paid, hint-holding or no_banner included. There is no instant
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
viewer's next profile.get. The default campaign stays plain (no colours, never urgent), enforced by
both the service and a DB CHECK. The same
mechanism carries a profile sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
replay, so a backgrounded Mini App — suspended while the user is in their mail app tapping the
link — misses the event; while an add-email confirmation is pending the client therefore also
polls profile.get (and on foreground regain) as a fallback until the address lands.
A single
app.loadbootstrap aggregator (collapsingprofile.get+ lobby + badge fetches into one round-trip) was considered and deferred: client↔gateway is HTTP/2 (h2c), so the bootstrap RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead, not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence (edge_request_duration); if ever built, as a gateway-side fan-out/merge that keeps the per-domain backend handlers intact.
11. Observability
- Structured logging with
go.uber.org/zap(JSON). OpenTelemetry tracer and meter providers are wired in all services (backend, gateway, the Telegram validator and bot) through a sharedpkg/telemetrybootstrap, env-gated per service by{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTERwith a default ofnone(so no collector is required locally or in CI).stdoutis available for debugging;otlp(gRPC, endpoint from the standardOTEL_EXPORTER_OTLP_*environment) exports to a collector. The Postgres pool is instrumented with otelsql andotelgrpctraces the backend↔gateway push stream and the gateway↔validator and bot-link calls; the gateway also exportsbotlink_connected_botsandbotlink_commands_total(by result) for the bot-link. The OTLP Collector (OTLP/gRPC → Prometheus metrics + Tempo traces), Prometheus (15d), Tempo (72h) and Grafana (provisioned datasources + dashboards, behind the caddy/_gm/grafanaBasic-Auth) are stood up with the deploy (deploy/); the default exporter staysnone, so CI needs no collector. The collector also runs adocker_statsreceiver (per-container CPU/memory/network read from the Docker API and exported through its Prometheus endpoint), and the contour runs postgres_exporter (connections, cache-hit ratio, transactions, db size, scraped directly by Prometheus); both are surfaced on the Scrabble — Resources Grafana dashboard, which captures the stress-run resource profile. (docker_statsreplaced cAdvisor, which on the contour host resolved only the root cgroup — a separate-XFS/var/lib/docker.) - Alerting. Grafana emails infra alerts through the shared relay (its own SMTP on the
STARTTLS port) to
SERVICE_EMAIL, from provisioned rules (deploy/grafana/provisioning/alerting/): a scrape-target down, the gateway's internal-error rate and p99 latency (edge_request_*), host memory/disk/CPU (node_exporter), Postgres connection saturation (postgres_exporter), and TLS certificate expiry < 20 days — a Caddy ACME-renewal-failure signal from ablackbox_exporterprobe of the edge caddy (probe_ssl_earliest_cert_expiry). Every rule isnoDataState=OK, so an absent metric never false-alerts — notably the cert probe, which has no TLS target on the HTTP-only contour caddy. Separately, the backend's admin-alert worker (internal/adminalert, started frommain) emails the operator (ADMIN_EMAIL, from a distinctSMTP_RELAY_ADMIN_FROM) when new player feedback or word complaints arrive, coalescing a burst into one digest per interval; both recipients may be several comma-separated addresses. The digest carries no admin-console link — an admin URL must never travel in an email, where a mail provider could cache or index it; the operator opens the console directly. Both paths are inert unless configured. - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics, recorded through the meter and invisible
until an exporter is configured: histograms
game_replay_duration(journal rebuild on a cache miss),game_move_validate_durationandgame_move_duration(a seat's think time per committed move, attributed byvariantand aphaseof opening/middle/endgame; it aggregates all seats including robots, whose synthetic timing dominates the tail, so per-human analysis lives in the admin console, below); countersgames_started_total,games_abandoned_total(a turn-timeout seat drop),chat_messages_total(kind= message/nudge) androbot_games_finished_total; a histogramchat_read_duration(chat publish-to-read latency bykind); observable gaugesgame_cache_activeandchat_unread_messages(chat entries withunread_seats <> 0, both chat metrics surfaced on the Scrabble — Messages dashboard); the gatewayedge_request_duration(the UI-perceived roundtrip, bymessage_type/result); and Go runtime/heap metrics. Game-scoped metrics carry avariantattribute (scrabble_en/scrabble_ru/erudit_ru). - Per-user move-time analytics are offline, derived in the admin
console from the move journal (
game_moves.created_atdeltas, the first move from the game's creation), not Prometheus labels (which anaccount_idwould explode): the user list shows each account's min/avg/max think time, and the user-detail page draws a zero-JS inline-SVG chart of min/mean/max by the player's move number. - User metrics: a backend counter
accounts_created_total(kind= telegram/email/guest; robots are a provisioned pool, not users, and are excluded) and a gateway in-memory observable gaugeactive_users(window= 24h/7d) — distinct accounts that performed an authenticated edge action in the window. The gauge is single-process by design (single-instance MVP, §10): it is correct for one gateway, resets on restart, and is a live operational figure, not a billing count. - Local-preview adoption (client-reported): three gateway counters measure uptake of
the local move-preview accelerator (§5), fed by a small, best-effort client beacon
(
POST /metrics/local-eval, session-gated) that batches counter deltas and posts them on a 60 s timer and when the app is backgrounded — never on the gameplay path (the in-app counters are plain in-memory increments; only the periodic flush touches the network, fire-and-forget).local_eval_cold_start_totalcounts app cold starts (the adoption denominator);local_eval_dict_load_total(result= fetched/cache_hit/miss) splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that failed or timed out;local_eval_preview_total(path= local/network) is the share of previews computed on-device versus the networkevaluatefallback — the backend load shed. It answers the owner's adoption question — how often the app opens versus how often it fills IndexedDB — and is surfaced on the Scrabble — Users dashboard. Lossy by design (a dropped beacon just retries its batch on the next flush); it is telemetry, never a game input. - Unsupported-engine screens (client-reported): the ES5 boot guard in
index.html(§13) shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white screen — when the engine lacks an unpolyfillable essential (BigInt, the 64-bit FlatBuffers decode; orProxy, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is Chrome 67. It then fires one fire-and-forget beacon (POST /telemetry/unsupported— unauthenticated, since the client never booted, but per-IP public-limited and body-capped), deduped inlocalStorageby app version + reason + Chromium so a user reopening the app is one report. The gateway folds it intounsupported_engine_total(reason= no_bigint/no_proxy/boot_error/other;chromium= the major version reduced to a bounded range so a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced on the Scrabble — Users dashboard beside app opens. - Rate-limit observability: every limiter rejection increments the gateway
counter
gateway_rate_limited_total(class= user/public/email/admin — aggregate only, honouring the no-per-user-label discipline above) and logs one Debug line; a gateway reporter drains the per-key rejection tracker every 30 s, emits one Warn summary per throttled key and posts the report to the backend (POST /api/v1/internal/ratelimit/report, network-trusted likesessions/resolve). The backend'sratewatchkeeps a bounded in-memory episode window (single-instance, resets on restart, likeactive_users) surfaced on the admin console's Throttled page next to the flagged-account review queue, and applies the conservative auto-flag: an account sustainingBACKEND_HIGHRATE_FLAG_THRESHOLDrejected calls (default 1000) withinBACKEND_HIGHRATE_FLAG_WINDOW(default 10 min) gets the soft, reversibleaccounts.flagged_high_rate_atmarker — set once, shown in the user list/detail, cleared by the operator, never an automatic ban and never a request gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection rate by class. - Temporary IP ban (prod-only): with
GATEWAY_ABUSE_BAN_ENABLEDset, the gateway enforces a fail2ban-style block keyed by client IP, fed by three signals: an IP that sustainsGATEWAY_ABUSE_BAN_THRESHOLDrate-limiter rejections withinGATEWAY_ABUSE_BAN_WINDOW(the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern, never the ban's), a honeypot decoy-path hit, and a honeytoken (a planted bearer no real client holds,GATEWAY_HONEYTOKEN). A banned IP is refused with 429 by an edge middleware (abuseGuard) before any work — covering the Connect edge, the live stream and the static SPA/landing the per-op limiter never gated. A rejection ban lastsGATEWAY_ABUSE_BAN_DURATION; a tripwire/honeytoken hit is near-zero-false-positive and earns a longer fixed ban (1 h / 24 h). The ban is in-memory, single-instance and resets on restart, likeratewatch; each ban incrementsgateway_abuse_banned_total(reason= rejections/tripwire/honeytoken). The decoy paths live only in the contour caddy, which tags them withX-Scrabble-Honeypot(stripping any client-supplied value) and routes them to the gateway. It is off by default and only enabled in prod: the ban keys by real client IP, which the shared-NAT test contour does not expose (every client arrives as one address), so a ban there would be self-inflicted — the honeypot/honeytoken still log in the contour, only the ban action is gated. Operators see the active bans and lift them on the admin console's Throttled page; the gateway syncs its active set to the backend every 30 s (POST /api/v1/internal/bans/sync, network-trusted like the rejection report) and applies the operator unbans the response returns, so a manual unban takes effect within the sync interval. - Unauthenticated
GET /healthz(liveness) andGET /readyz(readiness — the database answers a bounded ping and the session cache is warmed). - The backend serves a second listener — a gRPC server
(
BACKEND_GRPC_ADDR, default:9090) for the live-event push stream to the gateway — alongside the HTTP listener; both start together and stop on signal.
12. Security boundaries
| Concern | Enforced by |
|---|---|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of GATEWAY_MAX_BODY_BYTES; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a temporary IP ban (GATEWAY_ABUSE_BAN_ENABLED) blocks an IP that sustains rejections or trips a honeypot decoy path / honeytoken, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram validator; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also rejects a bot principal (the signed is_bot flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) |
Session → user_id + trusted platform resolution, X-User-ID / X-Platform injection |
gateway (platform sourced from the session, never the request body — §3) |
| Authorisation, ownership, state transitions | backend (X-User-ID is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a feedback_banned account from submitting; the gateway also rejects a guest's feedback.submit (the Op.NonGuest flag + is_guest from session resolve) with guest_forbidden before any backend call; attachments are served nosniff with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on /_gm/*, forwarded verbatim to the backend's server-rendered admin console (and, in the deployed contour, routing /_gm/grafana/* to Grafana). In the deploy the caddy owns this gate (§13); a local non-caddy run uses the gateway's own GATEWAY_ADMIN_* proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a same-origin check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ validator trust | the network (only gateway may reach backend; the validator and the gateway's admin bot-link relay serve unauthenticated gRPC on the trusted internal segment) |
| remote bot ↔ gateway (bot-link) | mutual TLS: a private CA signs the gateway server cert and the bot client cert, and each verifies the other. The bot dials out (no inbound port, no static IP), so the channel is guarded solely by mTLS — the bot client key is as sensitive as the token (§13) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend network segment defeats backend authentication. Mitigated by network isolation; mutual auth is a future hardening step. The bot-link is the exception — it already uses mutual TLS, because it is the one inter-service link that leaves the trusted segment (the remote bot lives off the main host).
Manual account block (suspension). Beyond the soft, reversible high-rate flag (§11, never a
gate), an operator can hard-block an account from the admin console — permanently or until a
date, with an optional reason chosen from an editable en+ru picklist. A block is a row in
account_suspensions (the chosen reason's text is snapshotted, so editing or deleting a
picklist entry never changes what an already-blocked player is shown); it is named suspension
to stay distinct from the peer-to-peer blocks table. Enforcement is a backend middleware gate
after X-User-ID: every /api/v1/user/* route except the block-status probe refuses a blocked
account with HTTP 403 + code account_blocked, which threads through the gateway unchanged as
the Execute result_code, so the UI detects the block from any call and replaces every screen
with a terminal "blocked" screen, stopping all push/poll. The one exempt route,
GET /api/v1/user/block-status, returns the expiry and the reason resolved to the account's
language so the blocked client can render the message. Sessions are not revoked on block (a
revoked token would fail session resolution at the gateway before the gate, sending the UI to
login instead of the blocked screen). A block instantly forfeits every active game the player
is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels
their open matchmaking games; a temporary block lapses automatically once its expiry passes (no
sweeper for the gate — it recomputes against now). No operator identity is recorded (shared
Basic-Auth).
Moderated discussion chat. A channel's linked discussion group is gated by the Telegram bot
(TELEGRAM_CHAT_ID). The group allows sending by default and the bot only restricts: Telegram
intersects the chat default with each user's permission, so a per-user grant can never exceed a
deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write
while they are registered and neither admin-suspended nor holding the chat-only chat_muted role
(eligible = registered AND NOT suspended AND NOT chat_muted — the game suspension dominates); the bot
mutes an ineligible member and un-mutes an eligible one it had muted, leaving an already-allowed
eligible member untouched (it acts only when the current state differs, so it is idempotent and never
loops on its own change). A single backend resolver behind POST /api/v1/internal/chat-access answers
both directions: the bot's ResolveChatEligibility on a chat_member event (over the mTLS bot-link),
and a chat_access_changed event — emitted on a block/unblock, a chat_muted grant/revoke, a first
Telegram registration, or a temporary block lapsing (a dedicated account.SuspensionSweeper, since no
request fires then) — drives a ChatGate command the gateway pushes to the bot. The bot applies it
only to a member currently in the chat (a per-user getChatMember probe, since bots cannot list
members); the signal is idempotent and is never an in-app or out-of-app message. chat_muted is an
account_roles entry (an operator toggle in the console), so it needs no schema change. The bot
must be an administrator in the group with the restrict-members right and chat_member in its
allowed updates.
Short numeric codes (email confirm-codes and friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the friend-code redeem is authenticated, so it rides the per-user limit (300 / min) and is further bounded by the code's 12 h TTL, single use, and one live code per issuer (which caps the valid-code population). Brute-forcing a 6-digit friend code within these limits is an accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable); a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.
Client source exposure. The production UI build ships no sourcemaps
(vite.config.ts gates build.sourcemap off when mode === 'production'), so the gateway
and landing images serve only minified JS and the full TypeScript/Svelte source is not
recoverable from a served .map at the edge. Dev and the mock e2e build (vite build --mode mock) keep maps for debugging. This is surface reduction, not secrecy — the single
minified bundle still carries all platform code paths and is reversible with effort.
13. Deployment (informational)
Single public origin, path-routed. The Vite build has two entries: a lightweight
landing page and the game SPA. The gateway embeds the SPA build
(go:embed, baked in by a node stage in gateway/Dockerfile) and serves it at
/app/ (web), /telegram/ (the Telegram Mini App; on that path without sign-in data
— no initData — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and /vk/ (the VK Mini App; the client reads the signed vk_* launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
gateway's / 308-redirects to /app/. The landing ships in its own static container: the
landing target of gateway/Dockerfile (caddy:2-alpine + the same Vite build,
deploy/landing/Caddyfile) serves it at /, so stray public traffic is absorbed by
static file serving and never reaches the Go edge. The landing shell carries the site's
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
origin, so the test contour canonicalises to production instead of being indexed as a
separate site; the SPA shell is noindex, and the landing always boots in Russian (no
browser-language detection — crawlers render with arbitrary languages). The favicon set,
og-image.png and robots.txt ship unhashed from ui/public/ (generated by
assets/icons/, same tile design as the VK loader). On the web (/app/) the SPA is an installable PWA:
manifest.webmanifest ships unhashed from ui/public/, while the service worker (sw.js) is
built from ui/src/sw.ts by vite-plugin-pwa (injectManifest strategy) — the client registers it
web-only, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
precaches the app shell and the hashed assets (Workbox) so an installed PWA cold-launches with
no network. Shell navigations are network-first — the fresh no-cache shell is fetched from the
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
cache-first, and the hash router resolves the route client-side. This navigation route is registered
before the precache route on purpose: Workbox matches in registration order, and the precache
route (its directoryIndex is index.html) would otherwise shadow it and serve the shell
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in offline mode (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an Offline chip and confines play to on-device vs_ai games. The offline lobby lists only those
device-local games (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's with friends becomes the entry to local pass-and-play (hotseat).
A hotseat game is a device-local 2-4 human game built through the same engine (hotseat +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A mandatory host
(referee) PIN gates the roster at creation and, in-game, the referee overrides — skip the
current turn, exclude a player (a forced seat resign, resignSeat) or terminate the game
(deleted, no winner/loser); each seat may also carry its own optional PIN that withholds its rack
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
around freely and lock a seat only against a distrusted tablemate. The unlock is per turn
(re-locks on advance). PINs are a social lock, not cryptography — a salted SHA-256 kept only in
the device record (lib/pin); a 4-digit PIN is trivially brute-forceable and the racks live in
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
not vs_ai: hints (the freed control becomes the host button) and chat/self-resign are gone, and
its lobby card — active and finished — is master-PIN-gated to delete (so the last mover cannot
instantly wipe it); a naturally finished game is saved with its result like any local game. A vs_ai
hint (online and offline alike) is unlimited and wallet-free but idle-gated
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The source reports the
seconds left (hint_unlock_left_seconds on the game view) — computed by the backend from the
server clock online (which also enforces the gate, returning hint_locked for an early request),
and by the offline source from the device clock (persisted, capped at the window). The client
anchors a MONOTONIC countdown (performance.now(), lib/hints) to that seconds-left when it lands
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the Profile advertises the current dictionary
version per variant (dict_versions,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) background-preloads those dictionaries
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a poor-connection notice in
the ad-banner slot. Flipping the Settings toggle to offline runs that same cache-first fetch
bounded by a ~5 s UI wait (raceOfflineReady + the lazy dict/offlineready): it enters offline only
once every enabled variant is ready, otherwise it stays online with a needs internet note while the
fetch finishes in the background (the next flip is then instant). A cold launch already in offline
mode boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — bootstrap skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app auto-detects connectivity: the
reactive flag carries an auto bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's Switch) persists. At cold start navigator.onLine === false enters offline for the session; otherwise a single bounded reachability probe (a profile.get,
~3 s) decides — success boots online, a timeout on an eligible install raises a No connection dialog
(Switch = sticky offline, Wait for network = boot online with the reachability watcher retrying).
Mid-session the window online/offline events drive it — offline auto-enters, online
re-verifies reachability before returning — backed by a navigator.onLine poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real transport kill switch
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the .webmanifest MIME type
in-process (the distroless image has no /etc/mime.types). Hash-named /assets/* are served
immutable (a relaunch is a cache hit, not a re-download); the HTML shells are
no-cache so a new deploy is picked up — both containers apply the same caching. An
in-compose caddy is the contour's edge: it owns a single /_gm Basic-Auth and
routes /_gm/grafana/* to Grafana (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of /_gm/* to the backend-rendered
admin console; /app/, /telegram/, /vk/ and the Connect path go to the gateway; the
catch-all — notably the landing at / — goes to the landing container. The
Telegram validator runs as a separate container with no public ingress,
answering only internal gRPC (HMAC, no Telegram egress). The Telegram bot holds
no inbound port either: it dials the gateway's bot-link (mTLS) and egresses to
Telegram — through a VPN sidecar in the test contour, from a separate host in prod.
The gateway exposes the bot-link on a dedicated mTLS gRPC listener
(GATEWAY_BOTLINK_ADDR, internal-only in the test contour, published in prod) plus a
plaintext relay (GATEWAY_BOTLINK_RELAY_ADDR) the backend admin console calls.
The full contour (deploy/docker-compose.yml) runs one gateway, one backend,
one Postgres, the static landing, the Telegram validator and bot (+ the bot's VPN
sidecar — the bot+vpn pair is gated to a telegram-local compose profile so the prod
main host can omit them) and the observability stack —
OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces), a node_exporter
for host CPU/memory (the prod main host's OOM signal), and Grafana
with provisioned datasources and dashboards. All services export OTLP to the
collector; the bot shares the VPN sidecar's netns, so its AWG_CONF must not
carry a DNS= directive (that would hijack resolv.conf and stop it resolving
otelcol / gateway; without it the netns uses Docker's resolver, which resolves
otelcol, gateway and api.telegram.org). Inter-service traffic uses a private internal
network (project-scoped DNS); only caddy joins the shared external edge network
(alias scrabble).
Two contours, two secret/variable prefixes (TEST_ / PROD_):
- Test: auto-deploys on a PR into — or a push to —
development(.gitea/workflows/ci.yaml→docker compose up -d --buildon the Gitea runner host, thenGET /+GET /app/probes through caddy — the landing container and the gateway). The host caddy terminates TLS and forwards the domain toscrabble:80, so the in-compose caddy serves plain HTTP (CADDY_SITE_ADDRESS=:80). The in-compose caddy trusts X-Forwarded-For from private-range upstreams (trusted_proxies private_ranges), so the real client IP — used for chat-moderation logging and the gateway's per-IP rate limiting — survives the host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the real peer, so the single config is correct and spoof-safe in both contours. The bot-link mTLS material (a private CA + gateway/bot leaves, CN=gateway) is generated bydeploy/gen-certs.shbeforecompose up; the bot keeps its VPN sidecar for Telegram egress and dials the gateway by its internal name, so the bot-link stays on the internal network. - Prod: a manual rollout —
.gitea/workflows/prod-deploy.yaml,workflow_dispatchonly (frommaster,confirm=deploy), run afterdevelopment → masteris merged green. It builds and pushes the images to the registry (docker.iliadenisov.ru), then deploys over SSH onto two hosts provisioned bydeploy/ansible/(docker, a non-sudodeployservice account holding a dedicated CI key, key-only sshd, default-deny ufw, fail2ban): the main host runs the full stack (docker-compose.yml+docker-compose.prod.yml), the bot host runs only the bot (docker-compose.bot.yml, no VPN — native Bot API egress, telemetry off). There is no host caddy, so the contour caddy terminates TLS —CADDY_SITE_ADDRESSis the domain and caddy does its own ACME. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the compose maps only TCP and ufw opens 443/tcp), so the edge emitsAlt-Svc: clearto keep clients on h2/h1 rather than stall on a dead QUIC path — seeEDGE_HTTP3.md. The gateway publishes the bot-link:9443; the remote bot dials it over mTLS (certs fromPROD_BOTLINK_*, ServerNamegateway, so TLS validation is independent of the public dial address), holds no inbound port, and login is unaffected if that host or the link is down.deploy/prod-deploy.shrolls the main stack one service at a time in dependency order (postgres → backend → gateway → landing → validator → caddy), health-checking after each; any failure rolls the whole stack back to the previous image tag. A schema migration adds a maintenance window: the backend (the sole writer) is stopped for a consistentpg_dumpbefore the new backend migrates forward — image rollback stays DB-safe under the expand-contract migration rule, and the dump is kept for a manual restore. The workflow runs four visible jobs (build → deploy-main → deploy-bot → verify). Releases are git tagsvX.Y.Z; the version is stamped into the image tag, every binary (-ldflags→pkg/version→ theservice.versiontelemetry attribute) and the SPA About screen. A separate manualprod-rollbackworkflow re-deploys any prior release tag (blank input = the previous deployed version, tracked on the host) over the same rolling, health-gated path — image-only, no DB migration. The main host is intentionally launch-sized (2 vCPU / 1.9 GiB): the prod overlay trims the baseline limits (GOMAXPROCS=2, smaller caps, 7d Prometheus retention) and a node_exporter feeds host-memory metrics to Grafana so it can be resized reactively as players arrive.GATEWAY_ABUSE_BAN_ENABLED=truein prod (the per-IP ban is meaningful only with real client IPs). Thevpn+botpair is gated to atelegram-localcompose profile the test contour activates; the prod main host omits it.
14. CI & branches
- Two long-lived branches:
developmentis the integration trunk andmasterthe production trunk;feature/*branches are cut fromdevelopmentand PR back into it (the genesis commit necessarily landed onmaster). A commit to afeature/*branch triggers nothing. - A single
.gitea/workflows/ci.yaml(Gitea has no cross-workflowneeds) runs the suite on a PR intodevelopment/masterand on a push todevelopment. Itsunit(gofmt/vet/build/unit-test),integration(Postgres-backedintegrationtag, testcontainerspostgres:17-alpine, Ryuk off, serial) andui(check/unit/build/bundle-budget/e2e) jobs are path-conditional (achangesjob filters by changed paths), and an always-runninggatejob aggregates them (passing when each succeeded or was skipped) and is the single branch-protection required check (CI / gate), so a path-skipped job never blocks a merge. - A gated
deployjob auto-rolls the test contour on a PR into — or a push to —development(it generates the bot-link certs, thendocker compose up -d --buildon the runner host), then probes the gateway (GET /) and the Telegram validator's and bot's liveness (viadocker inspect: running, not restarting, stable restart count, with a VPN-handshake grace period, since neither has public ingress and a crash-loop is otherwise invisible). A PR intomasteris test-only; the prod deploy is the manual workflow. Secrets/variables are prefixedTEST_/PROD_per contour. - The engine consumes
scrabble-solveras a published, versioned module (gitea.iliadenisov.ru/developer/scrabble-solver, pinned inbackend/go.mod); both Go workflows setGOPRIVATE=gitea.iliadenisov.ru/*so go fetches it directly from this Gitea (no public proxy/checksum DB, no sibling clone). The dictionaries ship as a release artifact from thescrabble-dictionaryrepo; the workflows downloadscrabble-dawg-<DICT_VERSION>.tar.gzand point the engine tests at it viaBACKEND_DICT_DIR. - After any push, the run is watched to green before a stage is declared done
(
python3 ~/.claude/bin/gitea-ci-watch.py).
15. User feedback & account roles
Players reach the operators through a Feedback screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
feedback_messages; the sender's IP (gateway-forwarded, as for chat), the submitting
channel (telegram/ios/android/web, client-reported and validated), the client app version
(__APP_VERSION__, the build a report was sent from), the client's detected UTC offset at
submit (browser_tz, ±HH:MM) and a snapshot of the sender's interface language are recorded. The domain is internal/feedback (store + service), modelled on the admin
chat-moderation surface.
Anti-spam. A player with an unreviewed message (read_at IS NULL) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
rate limit — there is no separate per-user feedback limiter.
Operator review happens in the server-rendered console (/_gm/feedback): an
unread / read / archived queue with per-user search (the /users glob masks), a detail card
(user content rendered as auto-escaped html/template text; it shows the channel, interface
language and app version, and the filed time in three zones — UTC, the browser offset detected at
submit, and the sender's saved profile zone, each N/A when not known), and the read /
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
detail does not.
The attachment is served from /_gm/feedback/:id/attachment with X-Content-Type-Options: nosniff: images inline (loaded only via <img>, which never executes — a renamed non-image is
inert), everything else as an application/octet-stream download. The UI gates the attachment by
file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list
plus the ≤1,000,000-byte size cap as the trust boundary; file content is not inspected. The
1,000,000-byte cap keeps the whole feedback.submit request under the gateway's 1 MiB edge body
cap (§12) without weakening it.
Reply delivery. The operator's reply lives on the message row and is shown back on the
feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied
message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and
is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with
the friend-request count — signals an undelivered reply; it rides the existing NotificationEvent
with a new admin_reply sub-kind (no new push schema) plus an authoritative poll on lobby load.
Account roles. account_roles (account_id, role) is the project's first per-account role
table — the reusable replacement for per-feature boolean flags. The first role, feedback_banned,
blocks only feedback submission (unlike a suspension, the whole-account block of §12). It is
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
/users console card. Roles are validated against a known set in Go, so adding one needs no
migration.
Telegram support relay. Separate from the in-app Feedback above, the bot offers a direct
support channel for users who message it on Telegram. Any message other than /start is relayed
into a private forum supergroup (TELEGRAM_SUPPORT_CHAT_ID): a user's first message opens a
dedicated forum topic whose first message is an info card (the name is a tappable profile
mention via a text_mention entity — which also keeps a name beginning with / from being read as
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
button; every message is then
copied into that topic (copyMessage, so any content — text, media, voice, files — carries over).
Any administrator of the support chat who writes in a user's topic has their message copied
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info
card; a topic the operators delete is reopened on the next message. State (user→topic map, block
list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database
and cannot reach Postgres. The relay is bot-local: it touches neither the backend, the gateway,
nor feedback_messages. The bot must be an administrator in the forum group with the manage-topics
and delete-messages rights.
Decision (2026-06-23) — bot-local support relay over forum topics. The direct Telegram support channel lives entirely in the bot (no backend, no bot-link command), because the bot host has no database. One forum topic per user (not a reply-to-header thread) gives native per-user separation and survives message deletion; the topic id, not a fragile header message, anchors the mapping. Operators are the support chat's administrators (no separate owner id); messages relay both ways with
copyMessage. State persists as JSON on a dedicated volume.