internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
17 KiB
Scrabble Game — implementation plan
Living plan and stage tracker. Each stage is implemented in its own session;
the rules for starting and finishing a stage are in CLAUDE.md.
The architecture/decision record is docs/ARCHITECTURE.md;
behaviour is docs/FUNCTIONAL.md. When a stage produces a
decision, bake it back here and into the affected docs/code in the same PR.
Context
Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
executables — gateway, backend, ui — plus per-platform side-services.
Deliberately simpler than the sibling ../galaxy-game (idea donor, not a
template). The ../scrabble-solver engine is embedded in-process as a library.
Locked decisions (recap — full record in docs/ARCHITECTURE.md)
Stack: go.work monorepo, modules scrabble/<name>, Go 1.26.x, backend
gin+pgx+Postgres(schema backend)+goose+zap+OTel (deps added when first used).
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + X-User-ID
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic.
Dictionary: pin per game. History: structured + GCG export, dictionary-
independent (see ARCHITECTURE §9.1).
Stage tracker
| # | Stage | Status |
|---|---|---|
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | done |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | done |
| 2 | Engine package over scrabble-solver | done |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | done |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo |
| 5 | Robot opponent | todo |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo |
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
| 8 | Telegram integration (bot side-service, deep-link, push) | todo |
| 9 | Admin & dictionary ops (complaint review, version reload) | todo |
| 10 | Account linking & merge | todo |
| 11 | Polish (observability, perf with evidence, deploy) | todo |
Scaffolding is incremental: go.work lists only existing modules; each stage
adds the modules it needs.
Stages
Each stage: read this plan + relevant docs, interview the owner on the open details below, implement within scope, then update plan/docs/code and get CI green before marking done.
Stage 0 — Scaffolding (done)
Scope: go.work (Go 1.26.3, use ./backend); minimal runnable backend
(gin, zap, /healthz, /readyz, env config); docs skeleton; PLAN.md;
CLAUDE.md; .gitea/workflows/go-unit.yaml; README; .gitignore.
Acceptance: go build ./backend/... + go vet + gofmt clean +
go test ./backend/... green; CI green on push.
Stage 1 — Backend foundation
Scope: config/server route groups (/api/v1/{public,user,internal,admin},
probes), Postgres (pgx) + embedded goose migrations + schema backend,
telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts +
platform identities.
Open details: Postgres version + DSN/search_path convention; jet vs
sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque
random length, TTL, revocation); account/identity table shape; whether the
admin bootstrap lands here or in Stage 9.
Stage 2 — Engine package
Scope: backend/internal/engine over scrabble-solver — versioned DAWG
load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the
dictionary-independent game-state model + decode helpers. Add
replace scrabble-solver => ../scrabble-solver to go.work here and solve the
CI sibling-checkout (clone gitea.iliadenisov.ru/.../scrabble-solver).
Open details: how CI obtains the solver (clone sibling vs publish/tag the
solver module); in-memory game-state representation; how blanks and exchanges
are modelled; Эрудит specifics to verify against the solver.
Stage 3 — Game domain
Scope: create/join, turn order, submit play/pass/exchange/resign, validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint, word-check + complaint capture, structured history + GCG writer, stats on finish. Open details: GCG dialect details (blanks, exchanges, notation); exact stats edge cases; turn-timeout scheduler mechanism (cron vs per-game timer); complaint payload shape.
Stage 4 — Lobby & social
Scope: matchmaking pool, friends, block, per-game chat, profile + email confirm-code, nudge. Open details: pool fairness/keying confirmation; deep-link format per platform; chat length limit + retention; friend-request lifecycle; email-code provider (SMTP relay choice).
Stage 5 — Robot opponent
Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min timing + sleep + nudge logic, friend/DM blocking, name pool. Open details: exact delay distribution + parameters; margin band; name pool source; how the scheduler drives robot moves; metrics for tuning balance.
Stage 6 — Gateway edge
Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session →
X-User-ID, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers
transcoding, in-app push stream bridging backend push gRPC stream, email +
ephemeral-guest paths.
Open details: FlatBuffers schema layout + message_type catalog; rate-limit
classes/limits; admin surface routing; session cache shape at the gateway.
Stage 7 — UI
Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat; hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if available); Capacitor-ready structure. Open details: detailed game-board UX (deferred by the owner to this stage); client routing; offline/refresh behaviour; design system / theming.
Stage 8 — Telegram integration
Scope: bot side-service, deep-link invites, platform push (your-turn / nudge), Mini App launch/auth; backend↔platform internal API. Open details: bot framework/library; deep-link scheme; push message templates; internal API contract; Mini App hosting/origin.
Stage 9 — Admin & dictionary ops
Scope: admin endpoints (users, games, complaint review queue, dictionary versions + reload), complaint→dictionary update pipeline. Open details: whether a server-rendered console is wanted or JSON-only; the dictionary rebuild/deploy pipeline; complaint resolution workflow.
Stage 10 — Account linking & merge
Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends, dedupe). High blast-radius — focused regression tests. Open details: conflict resolution (active games on both, duplicate friends, display-name collisions); irreversibility/audit; confirm-flow per platform.
Stage 11 — Polish
Scope: observability dashboards, evidence-based performance work, prod build/deploy. Open details: deployment target/host; dashboards; load expectations.
Refinements logged during implementation
- Stage 0: solver
replacedeferred to Stage 2 (nothing imports it yet; adding the path now would break CI, which checks out only this repo). Docker / compose deferred to a stage that has something to deploy. Trunk ismaster(owner preference);feature/*+ PR from Stage 1; the genesis commit lands onmasterby necessity. - Stage 1 (interview + implementation):
- Query layer: go-jet over
database/sql(pgx stdlib) + otelsql; acmd/jetgentool regenerates the committed code from a throwaway container. Postgres 17 pinned for jetgen, tests and prod. - Sessions: opaque token stored only as a SHA-256 hash (kept as hex
text, notbytea— avoids jet bytea-literal friction), revoke-only (no TTL); revocation-audit table deferred. Backend keeps a warmed write-through session cache that gates/readyz. - Data model: UUIDv7 PKs; one unified
identitiestable (kind ∈ telegram|email, widen tovk/maxlater); no soft-delete / actor-audit columns yet. - HTTP surface: service/store/cache layer only.
/api/v1/{public,user, internal,admin}groups +X-User-IDmiddleware are scaffolding (exposed viaServergroup accessors); the session/account REST handlers land with the gateway in Stage 6. Admin bootstrap deferred to Stage 9. - Telemetry: providers + request-timing middleware + otelsql; exporters
none(default) /stdout; OTLP + dashboards deferred to Stage 11. - Tests/CI: integration tests behind the
integrationbuild tag inbackend/internal/inttest+ newintegration.yaml(testcontainers, Ryuk off, serial), firing on push and PR. Backend now hard-depends on Postgres at boot (migrations at startup) — a deliberate contract change from Stage 0, documented in both READMEs. All code stays in the existingbackendmodule underinternal/(+cmd/jetgen);go.workuntouched.
- Query layer: go-jet over
- Stage 2 (interview + implementation):
- Scope:
internal/engineis a self-contained library (registry, bag,Gamestate machine, decode/replay). Noconfig/main/serverwiring this stage — there is no consumer yet; wiring lands in Stage 3, mirroring Stage 1's deferred handlers. - Pure rules engine (interview): the engine owns the in-memory
Game, pure transitions (play/pass/exchange/resign + draw) and end-condition detection, including the standard end-game rack-adjustment scoring — a deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and GCG. - Solver wiring:
replace scrabble-solver => ../scrabble-solveringo.work;backend/go.modrequiresscrabble-solver(placeholder version, redirected by the replace) andgithub.com/iliadenisov/dafsadirectly (fordawg.Load). CI clones the public solver repo at master HEAD anonymously into../scrabble-solver(no token); both Go workflows gained the step (the engine's untagged tests run under the integration workflow too) and setBACKEND_DICT_DIR. - Dictionaries: registry loads the committed DAWGs from a directory
parameter;
dict_versionis an explicit string label; the latest version per variant is tracked. Smoke tests validate a known word per variant (English/Russian/Эрудит). Эрудит is handled uniformly — every real difference is already inrules.Erudit(); the move.go "single orientation per turn" note needs no special code (any single play is one-directional). - Bag/blanks/exchange: own deterministic
Bag(Draw + Return) becauseselfplay.Bagcannot return tiles; exchange is legal only when the bag holds at least a rack and draws replacements before returning the swapped tiles. A blank isPlacement{Blank:true}carrying its designated letter; the history keeps the concrete letter plus a blank flag (decoded viaAlphabet.Character/Decode).ReplayBoardreusesscrabble.Apply, so nointernal/encodingdependency. - Deviation from the approved plan:
docs/FUNCTIONAL.md(+_ru) was left unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game dictionary and dictionary-independent-history user stories already live in Stages 3–4, so a "light touch" here would have duplicated or pre-empted them.
- Scope:
- Stage 3 (interview + implementation):
- Scope, as in Stages 1–2: domain service/store layer + engine wiring, no
HTTP (
internal/game). The gateway↔backend REST surface lands in Stage 6; the only active driver this stage is a background turn-timeout sweeper started frommain. The robot (Stage 5) will consume the same service API. - Persistence = event-sourcing + warm cache (interview): durable state is
the
gamesrow plus an append-only decoded move journal (game_moves); the live position is anengine.Gamekept in an in-memory cache with a ~24h idle TTL and rebuilt by replaying the journal on a miss (the seeded bag makes replay exact). Each game is serialised by a per-game mutex; a persistence failure evicts the live game so the next access rebuilds. §9 reworded from "stored structurally" to this model. - Resign/timeout split (interview): 2-player resign/timeout only this stage
(the other player wins); multiplayer drop-out-and-continue + resigned-tiles
disposition deferred to Stage 4. Per-game turn-timeout duration setting
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user away window
(
accounts.away_start/away_end, default 00:00–07:00 local, honoured by the sweeper with midnight-cross handling) added now; profile editing of the away window is Stage 4 and the robot's sleep (Stage 5) reuses it. - Engine
Resignfix (interview, ininternal/engine): the resigner keeps their accumulated score (no end-game rack adjustment) and never wins;winnerexcludes the resigner, so a two-player resign/timeout gives the win to the other player regardless of score. Timeout reusesResign, so the game domain needs no winner override. - Additive engine domain API:
Direction,Game.SubmitPlay/SubmitExchange/ EvaluatePlay/HintView/Hand,MoveRecord.{Dir,MainRow,MainCol},Registry.Lookup,ParseVariant— sointernal/gamenever importsscrabble-solver(keeps the §5 single-importer invariant). - Create = atomic with seats (interview):
Createseats all accounts and starts; lobby seat-filling is Stage 4. Sweeper = periodic goroutine (interview; default 60 s,BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL). - Hint = settings + wallet (interview): per-game
hints_allowed+hints_per_player, plus a profile walletaccounts.hint_balance(spent after the allowance; purchases later). Category defaults (random 1 / tournament 0 / friendly 1-or-0) are the caller's job (lobby/tournaments). - Stats (interview):
account_statswithdrawsadded beyond §9's wins/losses;max_word_points= best single move score; ties draw, resign/timeout is a loss, guests get no stats. - Complaint (interview): full payload with
game_id; word-check is scoped to the game's pinned(variant, dict_version). Stage 9 owns the resolution lifecycle, so thestatuscolumn carries no value CHECK yet. - GCG (interview): standard Poslfit dialect (UTF-8,
#player/#lexiconpragmas,8G/H8coordinates, lower-case blanks,.pass-throughs,-TILESexchange) plus#notelines for resign/timeout; derived from the journal, so dictionary-independent. - Engine wiring + config:
mainloads the registry (engine.Open, a hard boot dependency like migrations) and starts the sweeper. New config:BACKEND_DICT_DIR(required),BACKEND_DICT_VERSION(defaultv1),BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL(60 s),BACKEND_GAME_CACHE_TTL(24 h). No CI change — both Go workflows already clone the solver sibling and exportBACKEND_DICT_DIR.accountsgainedaway_start/away_end/hint_balanceand theaccountpackage gainedSpendHint(it owns its table).
- Scope, as in Stages 1–2: domain service/store layer + engine wiring, no
HTTP (
Deferred TODOs (cross-stage)
- TODO-1 — publish & version the solver. Once
scrabble-solveris stable, give it a real module URL and switchbackendto a versioned dependency, dropping thego.workreplace and the CI clone. Removes the floatingmasterdependency accepted for now (Stage 2 interview). - TODO-2 — split the solver into engine vs dictionary generator + versioned
dictionary artifacts. Owner's idea, with the caveats agreed at the Stage 2
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
different lifecycles and shrink the runtime dependency surface), but the
generator must pin the same
dafsa/alphabetversions and alphabet definitions as the runtime engine or the on-disk format / letter indexing drifts and silently corrupts validation. For delivery prefer Git LFS or an artifact store (Gitea releases / OCI artifact / object storage) over a raw git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull is a deploy-time way to populate the directory, not the runtime dynamic-reload mechanism (Stage 9) — keep theBACKEND_DICT_DIRdirectory as the runtime contract: a new.dawgappears in it and is loaded withdawg.Load.