Files
scrabble-game/docs/ARCHITECTURE.md
T
Ilia Denisov 6d1d8030e3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
feat(stats): per-game hints used + lifetime moves and hint share
Store the hints a player used in each game, and add two lifetime tiles —
Moves and Hint share — to the statistics screen.

- per-game: game_players.hints_used now counts EVERY hint (allowance + wallet),
  not just the free allowance, so it is the seat's true total hints used this
  game. The allowance decision (used < HintsPerPlayer) and the hint badge values
  (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does
  NOT regress; only the admin "hints used" column, which silently under-counted
  wallet hints, becomes accurate.
- account_stats gains two summed counters: moves (the player's plays — passes and
  exchanges excluded) and hints_used (every hint). Computed at game finish in
  buildStats over the same non-guest, non-honest-AI games as the rest of the stats.
- wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model;
  regen.
- ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active
  locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate.
- docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN.

Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats
(moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
2026-06-17 23:44:53 +02:00

74 KiB
Raw Blame History

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; the staged build order lives in ../PLAN.md. This document always describes the current design, not the history of how it was reached. Sections describing not-yet-implemented components are marked (planned).

1. Overview

Three executables plus per-platform side-services:

  • gateway — the only public ingress (module scrabble/gateway). Performs anti-abuse (rate limiting), authenticates the player against the originating platform (or an email/guest session), resolves the internal user_id, and forwards authenticated traffic to backend with an X-User-ID header. Serves the backend's admin console at /_gm on its public listener behind HTTP Basic Auth. Bridges live events from backend to the client. The shared wire contracts (the push proto and the FlatBuffers edge payloads) live in scrabble/pkg, imported by both gateway and backend.
  • 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 the scrabble-solver engine as a library, in-process — there is no per-game container. The only network consumer of backend is gateway (plus platform side-services over an internal API).
  • ui — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build; no SvelteKit). Talks to backend only through gateway over Connect-RPC + FlatBuffers, with the edge TS bindings generated from the same edge.proto and scrabble.fbs and committed under ui/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 in UI_DESIGN.md.
  • platform/telegram — the Telegram side-service (the "connector", module scrabble/platform/telegram). It is the only component holding the bot tokens — one bot per service language (en/ru), each its own token + game channel, the same Telegram user id spanning both (§3). It runs a Bot API long-poll loop per bot (Mini App launch + /start deep-links) and serves a gRPC API (pkg/proto/telegram/v1) that gateway (Mini App initData validation and out-of-app push) and backend (operator broadcasts) call over the trusted internal network. Its generic delivery methods are platform-agnostic (keyed by the identity external_id), so a future VK/MAX connector reuses them; only initData validation is Telegram-specific. It runs in its own container, egressing to Telegram through a VPN sidecar.
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, out-of-app push) --> Telegram[Telegram connector]
  Backend -. operator broadcasts (gRPC) .-> Telegram
  Telegram -- Bot API (via VPN sidecar) --> 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 + the flatbuffers npm package). The contract is kept minimal: a single Gateway service (defined in gateway/proto/edge/v1) with Execute(message_type, payload, request_id) for unary operations and Subscribe for the live stream. The proto envelope is a thin carrier; the real request/response and event bodies are FlatBuffers tables (pkg/fbs, the scrabblefb namespace) inside the payload bytes, which the gateway transcodes to and from the backend's JSON. The session token rides in the Authorization: Bearer header (there is no per-request signing, §3); auth operations are unauthenticated and return the minted token. A unary operation's domain outcome rides back in ExecuteResponse.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 transport unavailable or a rate_limited flips a global online signal 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 on unavailable (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 lightweight profile.get probe) clears the signal when no other traffic is in flight; the live Subscribe stream's drop/recovery feeds the same signal. Edge hardening: every request body on the public listener is capped at GATEWAY_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 oversized Execute is refused (resource_exhausted) without buffering. The h2c server carries explicit sizing: MaxConcurrentStreams 250 (the x/net default made visible — a real client holds one Subscribe stream plus a few unary calls) and a 3-minute connection IdleTimeout (a live Subscribe stream keeps its connection active, so only abandoned connections are reaped); the http.Server sets only ReadHeaderTimeout (10 s) — Read/WriteTimeout would kill the stream.
  • Alphabet on the wire: live play exchanges alphabet indices, not concrete letters. The rack (StateView.rack), the SubmitPlay/Evaluate tiles, the Exchange tiles and the CheckWord word are ubyte indices into the variant's alphabet (a blank is the sentinel index 255). The client is alphabet-agnostic: on a per-variant cache miss it sets StateRequest.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 for authenticated requests; backend never re-derives identity from the body.
  • 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 connector's ValidateInitData RPC, which holds the bot token — the HMAC secret — so it never reaches the gateway), an email-code login, or a guest bootstrap — then mints a thin opaque server session token (session_id). First Telegram contact seeds the new account's language (from the launch language_code) and display name (§4).
  • Service language & variant gating. The connector hosts one bot per service language (en/ru), each its own token + game channel; the same Telegram user id spans both. ValidateInitData tries each token in turn and returns the validating bot's service language and its supported-languages set. The set rides the Session (FlatBuffers, session-scoped, not persisted): the UI offers only the variants those languages support on New Game (en → English; ru → Russian
    • Эрудит). Starting a new game is the only gated action — opening and playing existing games of any language is unrestricted, and the backend does not enforce the gate (it is a product affordance, not a trust boundary). The service language is persisted per account (accounts.service_language, updated on every Telegram login — last-login-wins) and routes the user's out-of-app push back through the right bot (§10) — except a game event, which routes by the game's own language (its variant → en/ru), so a game's notification always comes from the game's bot rather than the recipient's latest login bot. It also rides the Session wire to the client, which uses it to build the friend-invite share link (and its caption) for the same bot the player is in. The service language is distinct from preferred_language (the interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the gateway's default set (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants by default).
  • The client holds session_id in memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login).
  • The gateway caches session → user_id and injects X-User-ID. Session records live in backend, 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 (statusrevoked). 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).
  • Guest = ephemeral web session (no platform, no email). A guest is backed by a durable accounts row flagged is_guest and carrying no identity — the row is a technical necessity (the sessions and game_players foreign 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 — flagged is_guest, holding no game seat, older than BACKEND_GUEST_RETENTION — on a BACKEND_GUEST_REAP_INTERVAL sweep, so transient guest rows do not accumulate. Platform and email users are auto-provisioned durable accounts with an identity.

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 one identities table keyed by a unique (kind, external_id); email is an identity with kind=email and a confirmed flag. A synthetic kind='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 through a Mailer seam (an SMTP relay, or a development log mailer when none is configured) and, once verified, attaches a confirmed email identity. Accounts and identities use application-generated UUIDv7 primary keys. A service flag paid_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 connector, HMAC under SHA-256(bot_token) — distinct from Mini App initData; the gateway passes the trusted external_id to the backend, as for auth.telegram). 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. A free identity is simply attached (and a guest is promoted to durable, clearing is_guest).
  • Merge retires the account that owns the linked identity into the current account, in a single transaction (internal/accountmerge): statistics summed (max points kept), the hint wallet summed, paid_account ORed, 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 to rules.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.Load from the directory BACKEND_DICT_DIR; backend loads the engine.Registry at 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 through Registry.Lookup.
  • Dictionary versioning — pin per game, update through the console. A game records the dict_version it started on and finishes on it; new games pin the active version, the single source of truth persisted in the dictionary_state singleton and restored on boot, so an operator's choice survives a restart. Multiple versions are resident at once. BACKEND_DICT_DIR is 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, labelled BACKEND_DICT_VERSION — set from the build's DICT_VERSION, so the resident label equals the release tag — and each uploaded version lives in a BACKEND_DICT_DIR/<version>/ subdirectory. The admin console updates a dictionary online (internal/dictadmin): an operator uploads the scrabble-dawg-vX.Y.Z.tar.gz release archive, previews the per-variant words added/removed against the active dictionary (engine.DiffWords enumerates 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 via Registry.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 via engine.OpenWithVersions (the flat seed plus each subdirectory, skipping the .staging/ upload area) and restores the active pointer from dictionary_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. (The dictionaries ship as a versioned release artifact from the scrabble-dictionary repo; the build's DICT_VERSION selects only the seed.)
  • Move generation/validation/scoring use Solver.GenerateMoves (ranked), Solver.ValidatePlay and Solver.ScorePlay; board mutation uses scrabble.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.Game is 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) so internal/game drives 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 reuses engine.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 with engine.ReplayBoard (alphabet only, no dictionary).

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} (solver v1.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:0007: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).
  • Players: auto-match is always 2 players; friend games are 24 players. backend owns 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: remove from play — the default — or return to 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 returns ErrNoHintAvailable — surfaced as the distinct result code no_hint_available (separate from hint_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.hint carry it as a separate wallet_balance field beside the combined hints_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 leftend_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 (130 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:0007: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 lengthening, randomized schedule — the first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long wait gets a handful of increasingly-spaced nudges rather than an hourly stream.
  • Observability: robot accounts accrue ordinary statistics (§9) — the authoritative balance metric (target ≈ 40% robot wins) — and a robot_games_finished_total OTel 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.

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_ai on POST /lobby/enqueue) takes the Matchmaker.StartVsAI path, 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 (status open, §9), or — when another player is already waiting for the same variant and 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 returns the caller's own still-open game (idempotent). Matchmaking state is therefore the open games in the database (not an in-memory pool), so it survives a restart and stays anonymous (no block check); 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 090 s (so 90180 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 is open the 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.CountActiveQuickGames counts the games seating the account in status active or open without a linked game_invitations row — 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/enqueue and POST /invitations — with 409 game_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 boolean at_game_limit carried on the games.list response — 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 friendships table. A one-time code the to-be-added player issues (a friend_codes row: 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 Telegram startapp deep-link to the issuer's own bot (by service language, 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 right 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; blocking someone severs an existing friendship. (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 applied mutually: it hides the pair's chat from each other and refuses friend requests and game invitations between them.
  • Friend games: formed by invitation → accept (an game_invitations record with one row per invitee). The 24 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 nudge message 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_messages row carries an unread_seats bitmask — 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 wired NudgeClearer). The mask is inverted so "anything unread" is a plain unread_seats <> 0, which the per-viewer unread_chat game-view flag (seeding the lobby and in-game unread dot), the admin unread filter and the unread gauge all use. On each clear the publish-to-read latency is recorded; the read time itself is not retained.
  • Profile: preferred_language (en/ru, edited in Settings), display name, email (confirm-code binding, see §4), timezone, the daily away window and the block toggles — all editable through account.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:MM UTC offset (or a legacy IANA name) resolved by account.ResolveZone for the sweeper and the robot's sleep (a fixed offset trades DST for a simple picker); 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; backend is the only writer. The "pgx pool" is a database/sql handle backed by the pgx stdlib driver and instrumented with otelsql; type-safe queries use go-jet (code generated into internal/postgres/jet and committed, regenerated by cmd/jetgen). Migrations are embedded SQL applied with pressly/goose/v3 at startup. Primary keys are application-generated UUIDv7.
  • Tables: accounts (durable internal accounts, carrying the away-window columns away_start/away_end, the hint wallet hint_balance (spent after a game's per-seat allowance; an operator tops it up with an additive, raise-only grant from the admin console), the is_guest flag for ephemeral guest rows, the notifications_in_app_only out-of-app push toggle, the paid_account service flag and the merge-tombstone columns merged_into/merged_at), identities (platform/email/robot identities, unique (kind, external_id), the kind admitting robot), sessions (revoke-only opaque-token hashes), the game tables games (carrying the dropout_tiles disposition column), game_players, game_moves (the move journal), complaints, account_stats and account_best_move, and the social/lobby tables friendships (the request/accept graph, its status admitting declined), blocks (per-user blocks), chat_messages (per-game chat and nudges, carrying the per-message unread_seats read 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) and game_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 ordinary games row with status open and a single seated game_players row (the empty opponent seat is a null account_id, filled when a human or robot joins), plus an open_deadline_at stamp the reaper scans for robot substitution.
  • Active games are event-sourced. A game is a games row (pinned variant/dict_version, bag seed, the per-game settings, and a denormalised turn cursor) plus an append-only, decoded move journal (game_moves); the live position is an engine.Game held 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_players records 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 any is_guest seat): 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) and hints_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 table account_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 like account_stats. It is filled forward only — plays finished before the table existed are not back-populated (the aggregate max_word_points still 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.EndAbortedend_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. GCG export is offered only on a finished game (game.ErrGameActive otherwise), so an in-progress journal is never leaked mid-play; the client shares the .gcg file via the Web Share API where available, else downloads it.

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 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 service language — the bot they last signed in through, falling back to the interface language — and the notifications_in_app_only flag). A game event, however, carries the game's own language on the push, and the gateway routes by that instead of the service language — so a game's notification always comes from the game's bot, not the recipient's latest-login bot. It then asks the Telegram connector to deliver a localized message with a Mini App deep-link button — only when the recipient has a Telegram identity and has not confined notifications to the app, so the two channels never duplicate. The connector routes by that language to the matching bot and renders the message in it. The out-of-app set is your-turn, game-over, nudge and the invitation (a new invitation) / friend-request notify sub-kinds; the connector renders the message and skips the rest — so the in-app-only invitation-update (a response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts (SendToUser / SendToGameChannel, §10 admin) instead pick the bot by an operator-chosen language in the console, unrelated to the recipient's login. 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 service language (the bot they signed in through, falling back to the interface 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 single app.load bootstrap aggregator (collapsing profile.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 three services (backend, gateway, the Telegram connector) through a shared pkg/telemetry bootstrap, env-gated per service by {BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER with a default of none (so no collector is required locally or in CI). stdout is available for debugging; otlp (gRPC, endpoint from the standard OTEL_EXPORTER_OTLP_* environment) exports to a collector. The Postgres pool is instrumented with otelsql and otelgrpc traces the backend↔gateway push stream and the gateway↔connector calls. The OTLP Collector (OTLP/gRPC → Prometheus metrics + Tempo traces), Prometheus (15d), Tempo (72h) and Grafana (provisioned datasources + dashboards, behind the caddy /_gm/grafana Basic-Auth) are stood up with the deploy (deploy/); the default exporter stays none, so CI needs no collector. The collector also runs a docker_stats receiver (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_stats replaced cAdvisor, which on the contour host resolved only the root cgroup — a separate-XFS /var/lib/docker.)
  • 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_duration and game_move_duration (a seat's think time per committed move, attributed by variant and a phase of 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); counters games_started_total, games_abandoned_total (a turn-timeout seat drop), chat_messages_total (kind = message/nudge) and robot_games_finished_total; a histogram chat_read_duration (chat publish-to-read latency by kind); observable gauges game_cache_active and chat_unread_messages (chat entries with unread_seats <> 0, both chat metrics surfaced on the Scrabble — Messages dashboard); the gateway edge_request_duration (the UI-perceived roundtrip, by message_type/result); and Go runtime/heap metrics. Game-scoped metrics carry a variant attribute (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_at deltas, the first move from the game's creation), not Prometheus labels (which an account_id would 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 gauge active_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.
  • 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 like sessions/resolve). The backend's ratewatch keeps a bounded in-memory episode window (single-instance, resets on restart, like active_users) surfaced on the admin console's Throttled page next to the flagged-account review queue, and applies the conservative auto-flag: an account sustaining BACKEND_HIGHRATE_FLAG_THRESHOLD rejected calls (default 1000) within BACKEND_HIGHRATE_FLAG_WINDOW (default 10 min) gets the soft, reversible accounts.flagged_high_rate_at marker — 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.
  • Unauthenticated GET /healthz (liveness) and GET /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)
Telegram initData validation (bot-token HMAC) the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector
Session minting; email-code / guest validation gateway (with backend)
Session → user_id resolution, X-User-ID injection gateway
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 ↔ connector trust the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment)

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.

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 — the gate recomputes against now). No operator identity is recorded (shared Basic-Auth).

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.

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) and /telegram/ (the Telegram Mini App; outside Telegram that path redirects to the root — the client-side guard); 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. 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/ and the Connect path go to the gateway; the catch-all — notably the landing at / — goes to the landing container. The Telegram connector runs as a separate container with no public ingress — it long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.

The full contour (deploy/docker-compose.yml) runs one gateway, one backend, one Postgres, the static landing, the connector (+ its VPN sidecar) and the observability stack — OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana with provisioned datasources and dashboards. All three services export OTLP to the collector; the connector 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; without it the netns uses Docker's resolver, which resolves both otelcol 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.yamldocker compose up -d --build on the Gitea runner host, then GET / + GET /app/ probes through caddy — the landing container and the gateway). The host caddy terminates TLS and forwards the domain to scrabble: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.
  • Prod: a manual SSH deploy after development → master. There is no host caddy, so the contour ships its own caddy terminating TLS — set CADDY_SITE_ADDRESS to the domain and the caddy does its own ACME.

14. CI & branches

  • Two long-lived branches: development is the integration trunk and master the production trunk; feature/* branches are cut from development and PR back into it (the genesis commit necessarily landed on master). A commit to a feature/* branch triggers nothing.
  • A single .gitea/workflows/ci.yaml (Gitea has no cross-workflow needs) runs the suite on a PR into development/master and on a push to development. Its unit (gofmt/vet/build/unit-test), integration (Postgres-backed integration tag, testcontainers postgres:17-alpine, Ryuk off, serial) and ui (check/unit/build/bundle-budget/e2e) jobs are path-conditional (a changes job filters by changed paths), and an always-running gate job 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 deploy job auto-rolls the test contour on a PR into — or a push to — development (docker compose up -d --build on the runner host), then probes the gateway (GET /) and the Telegram connector's liveness (via docker inspect: running, not restarting, stable restart count, with a VPN-handshake grace period, since the connector has no public ingress and a crash-loop is otherwise invisible). A PR into master is test-only; the prod deploy is the manual workflow. Secrets/variables are prefixed TEST_/PROD_ per contour.
  • The engine consumes scrabble-solver as a published, versioned module (gitea.iliadenisov.ru/developer/scrabble-solver, pinned in backend/go.mod); both Go workflows set GOPRIVATE=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 the scrabble-dictionary repo; the workflows download scrabble-dawg-<DICT_VERSION>.tar.gz and point the engine tests at it via BACKEND_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) and the submitting channel (telegram/ios/android/web, client-reported and validated) 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), 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.