Files
scrabble-game/docs/ARCHITECTURE.md
T
Ilia Denisov caefc8f579
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
feat(game): official first-move tile draw + admin step-by-step replay
Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
2026-06-20 08:47:18 +02:00

1054 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](FUNCTIONAL.md); the staged build order lives in
[`../PLAN.md`](../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`](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.
```mermaid
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 (`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).
- **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
(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_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`](../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
`MoveRecord`s 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).
- **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 bag `seed`, 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 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 **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 (**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 **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_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** (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_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 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's `BlockedWith` set);
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. A request sent in-game to an **auto-match opponent who is secretly a
pooled robot** is recorded instead in a separate **`robot_friend_requests`** table, keyed on
the requester + game + seat with the seen name snapshotted (`RequestInGame`): the shared robot
account is never put in `friendships` (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 directional `blockExists` check
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 a `user_blocked` /
`user_unblocked` notification to the blocker only (§10). Blocking an auto-match opponent who
is secretly a pooled robot is recorded instead in a separate **`robot_blocks`** table, keyed on
the blocker + game + seat with the seen name snapshotted (`BlockInGame`): the shared robot
account is never put in `blocks` (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_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. 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 only `unread_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 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), `game_setup_draws` (the first-move draw record, §6),
`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. The **first-move draw**
(§6) is recorded in `game_setup_draws` when the game opens; the synthetic opponent's rows
carry a NULL `account_id` until 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 `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.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. **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 **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 **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 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) 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.yaml``docker 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.