- go.work (Go 1.26.3) with backend module; deps added incrementally (gin+zap only) - backend: /healthz + /readyz, env config, graceful shutdown - docs: ARCHITECTURE, FUNCTIONAL (+ru mirror), TESTING - PLAN.md (stage tracker + per-stage open details) and CLAUDE.md (per-stage workflow) - .gitea go-unit CI (gofmt/vet/build/test)
12 KiB
Scrabble Game — Architecture
Source of truth for the platform architecture, transport, security model and
cross-service contracts. User-visible behaviour per domain lives in
FUNCTIONAL.md; 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(planned) — the only public ingress. Performs anti-abuse (rate limiting), authenticates the player against the originating platform (or an email/guest session), resolves the internaluser_id, and forwards authenticated traffic tobackendwith anX-User-IDheader. Hosts an admin surface behind HTTP Basic Auth. Bridges live events frombackendto the client.backend— internal-only service that owns every domain concern: identity/sessions, accounts and linking, lobby and matchmaking, the game runtime, the robot opponent, chat, notifications, statistics, history, and administration. Embeds thescrabble-solverengine as a library, in-process — there is no per-game container. The only network consumer ofbackendisgateway(plus platform side-services over an internal API).ui(planned) — pure-HTML5 client (plain Svelte + Vite, static build). Talks tobackendonly throughgateway. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.platform/<name>(planned) — per-platform side-services (Telegram bot first): deep-link invites and platform-native push notifications. They talk tobackendover an internal API.
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]]
Telegram[Telegram bot side-service] -- internal API --> Backend
The MVP runs gateway and backend as single-instance processes inside a
trusted network. No Redis is planned (anti-replay crypto was deliberately
dropped). Horizontal scaling is explicit future work.
2. Transport
- client ↔ gateway: Connect-RPC + FlatBuffers over HTTP/2 cleartext
(
h2c). Binary payloads, server-streaming for the in-app live channel, first-class JS clients (@connectrpc/connect-web+ theflatbuffersnpm package). The contract is kept minimal. - gateway ↔ backend (sync): plain HTTP REST/JSON. The gateway injects
X-User-IDfor authenticated requests;backendnever 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 — the platform's
signed launch data (e.g. Telegram
initDataHMAC), an email-code login, or a guest bootstrap — then mints a thin opaque server session token (session_id). - The client holds
session_idin memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login). - The gateway caches
session → user_idand injectsX-User-ID. Sessions are revocable. Session records live inbackend. - Guest = ephemeral web session (no platform, no email): session-only, nothing persisted; restricted to auto-match, with no friends and no stats/history. Platform users are auto-provisioned durable accounts.
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. - Linking is initiated from an authenticated profile: choose a platform → complete that platform's web-auth confirm → attach the identity to the current account.
- Merge: if the identity being linked already has its own account with history, the two accounts are merged into the current one (A is primary): statistics are summed, games and friends are transferred, duplicates are de-duplicated, the secondary account is retired. High blast-radius; an isolated, well-tested stage.
5. Game engine integration (scrabble-solver)
backend embeds the solver library (see CLAUDE.md for the
exact public API and constraints). Key points:
- Variants at launch: English Scrabble, Russian Scrabble, Эрудит —
rules.English(),rules.RussianScrabble(),rules.Erudit(). - Dictionaries are committed DAWGs loaded with
dawg.Load; held in memory and addressed by(variant, dict_version). - Dictionary versioning — pin per game. A game records the
dict_versionit started on and finishes on that version; new games use the latest. Multiple versions may be resident at once. An admin reload endpoint (planned) adds a new version; delivery is the DAWG file in the image / a mounted volume. - Move generation/validation/scoring use
Solver.GenerateMoves(ranked),Solver.ValidatePlay,Solver.ScorePlay; board mutation usesscrabble.Apply. Tile bag follows theselfplay.Bagpattern.
6. Game rules
- Word legality: validate-at-submit. An illegal play is rejected by
Solver.ValidatePlay; there is no challenge phase. - End of game: the bag is empty and a player empties their rack, or 6 consecutive scoreless turns (passes/exchanges). A move that is not made within the 24-hour turn timeout becomes an automatic resignation.
- Players: auto-match is always 2 players; friend games are 2–4 players.
backendowns turn order and the bag for any player count. - Hint: one per game; reveals the top-1 ranked move (
GenerateMoves[0]). - Word-check tool: unlimited dictionary lookups; each result offers a complaint that lands in an admin review queue (admin side planned).
7. Robot opponent
Substitutes for a human in 2-player auto-match when the pool yields no human within 10 seconds. Designed to be 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%). Adaptive difficulty is post-MVP. - Margin targeting: each turn it picks from
GenerateMovesa move that keeps the resulting lead (when playing to win) or deficit (when playing to lose) small (≈ 1–20 points), rather than always the maximum. - Timing: per-move delay sampled from a right-skewed distribution (short delays frequent), clamped to [2, 90] minutes; sleeps 00:00–07:00 in the opponent's profile timezone (fallback UTC); on a daytime nudge after 60 minutes idle it replies within 2–10 minutes; it proactively nudges the human after 12 hours idle.
- Blocks friend requests and direct messages; uses a human-like name pool.
8. Lobby & social
- Matchmaking (detail planned): a FIFO pool keyed by
(variant, language); 10 s with no human match → substitute the robot. - Friends: add by friend list, internal ID, or platform deep-link.
- Block settings independently suppress in-game chat and friend requests.
- Chat: per-game, persisted, length-limited, suppressed by the block setting.
- Nudge: a player may nudge the opponent whose turn is awaited once per hour; the opponent receives a platform-native notification.
- Profile:
preferred_language(en/ru), display name, linked platform accounts, email (confirm-code binding), timezone (drives robot sleep; default from platform/locale, user-editable), block toggles.
9. Persistence
- Single Postgres database, schema
backend;backendis the only writer. pgx pool; queries via go-jet (introduced when the first real query lands); migrations embedded and applied withpressly/goose/v3at startup (planned). - Active game state is stored structurally with the
dict_versionpinned. - Statistics (computed on finish): wins, losses, max points in a game, max points for a single word.
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 log
persists only decoded concrete values: letters as text, coordinates, blank
flag, action kind (play / pass / exchange / resign / timeout), acting player,
per-move score and running total, timestamp. The board for visual replay is
reconstructed by applying placements onto an empty grid; no dictionary is
needed because 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. GCG export is derived from the same
rows and is likewise self-contained (we ship our own writer; the solver exposes
no public GCG writer).
10. Notifications
Two channels: platform-native push (out-of-app, via the platform side-service — your-turn, nudge) and the in-app live stream (chat, opponent-moved, while the app is open). Backend emits notification intents; delivery fans out to the appropriate channel.
11. Observability
- Structured logging with
go.uber.org/zap(JSON). OpenTelemetry traces and metrics, with a Prometheus pull endpoint where configured (introduced with the first real workload). - Per-request server-side timing via middleware from day one. A client-measured RTT piggybacked on the next request is a later enhancement, not MVP.
- Unauthenticated
GET /healthz(liveness) andGET /readyz(readiness).
12. Security boundaries
| Concern | Enforced by |
|---|---|
| Public rate limiting / anti-abuse | gateway |
| Platform credential validation, session minting | gateway |
Session → user_id resolution, X-User-ID injection |
gateway |
| Authorisation, ownership, state transitions | backend (X-User-ID is the sole identity input) |
| Admin authentication | gateway Basic Auth → backend admin endpoints |
| backend ↔ gateway trust | the network (only gateway may reach backend) |
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.
13. Deployment (informational)
Single public origin, path-routed: the UI, the gateway public surface and the
admin surface share one host that terminates TLS. MVP runs one gateway, one
backend, one Postgres. Docker/compose environments are introduced when there
is something to deploy.
14. CI & branches
- Trunk is
master; feature work happens onfeature/*branches merged via PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily lands onmaster). .gitea/workflows/holds the CI.go-unit.yamlruns gofmt/vet/build/test on Go changes; more workflows (ui-test, integration, deploy) are added with the components they cover.- After any push, the run is watched to green before a stage is declared done
(
python3 ~/.claude/bin/gitea-ci-watch.py).