# 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`](CLAUDE.md). The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); behaviour is [`docs/FUNCTIONAL.md`](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/`, 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) | **done** | | 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | | 9 | Telegram integration (bot side-service, deep-link, push) | **done** | | 10 | Admin & dictionary ops (complaint review, version reload) | **done** | | 11 | Account linking & merge | **done** | | 12 | Observability & performance (telemetry, metrics, guest GC) | **done** | | 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** | | 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** | | 15 | Dual Telegram bots & language-gated variants | **done** | | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** | | 17 | Test-contour verification & defect fixes | **done** | | 18 | Prod contour deploy (SSH export/import, manual after merge) | 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 10. ### 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. #### Suggested layouts (lobby + game screen) User note: > Detailed interview about UI/UX is **strongly** required. > Too much to discuss. ```text ┌────────────────────┐ │ Display_Name =│- Profile ├────────────────────┤- Settings │ Invitations │- About │ - list │ ├────────────────────┤ │ Active games │ │ - list │ ├────────────────────┤ │ Finished games │ │ - list │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├────────────────────┤ │ ┌───┐ ┌───┐ ┌───┐│ │ New │ Stats Tourn│ │ └───┘ └───┘ └───┘│ └────────────────────┘ ┌────────────────────┐ Lobby│◄ ==│- History ├────────────────────┤- Chat │You Ann Kaya Rick│- Check word │136 700 179 39│- Drop game ├────────────────────┤ │ │ │ │ │ │ │ c │ │ words │ │ o │ │ s │ │ s │ │ │ │ │ ├──┬──┬──┬──┬──┬──┬──┤ ┌──┐ │A │Q │Z │* │N │I │W │◄│ │MakeMove/Reset ├──┴──┴──┴──┴──┴──┴──┤ └──┘ │ ┌───┐ ┌───┐ ┌───┐ │ │ Draw│ Skip│ Shfl│ │ │ └───┘ └───┘ └───┘ │ └────────────────────┘ ``` ### Stage 8 — UI: social, account & history surfaces Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching backend/gateway operations as each screen needs them (the Stage 6 vertical-slice pattern): friends (request/accept/decline/list), per-user blocks, friend-game invitations (create 2–4 player, accept/decline, invitations list), profile **editing** (`account.UpdateProfile` + the email confirm-code binding UI), the statistics screen, and the history viewer with GCG export/download. Open details: friends/invitations UX; stats presentation; history/GCG viewer + download mechanics; any new validation the profile-editing forms need. ### Stage 9 — 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 10 — 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 11 — 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 12 — Observability & performance Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector** (providers + `otelgrpc` on the gRPC hops) for parity with the backend; add domain/operational **metrics** close to the business (game replay/validate timings, started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go runtime metrics); discharge **TODO-3** (abandoned-guest GC). The OTLP collector and dashboards are stood up with the deploy (Stage 15); the default exporter stays `none`, so CI needs no collector. Performance is operational-metric instrumentation, not speculative optimisation (the standing "evidence first" rule — no measured hotspot yet). Open details: exporter default and whether a collector is stood up now; the metric set and its attributes; the guest-reaper trigger given revoke-only sessions. ### Stage 13 — Alphabet on the wire (TODO-4) Scope: make the UI **alphabet-agnostic**. On game-screen load the client receives the variant's alphabet table `(letter, index, value)` for **display only**, caches it in memory by variant (a request flag gates whether the table is included, so it is not resent on every state poll); live play then exchanges **letter indices** both ways, and **word-check** sends indices, constraining input to the variant's alphabet. The engine already works in alphabet-index bytes, so the wire does *less* decoding in live play; the durable journal / history / GCG stay decoded concrete characters (the §9.1 dictionary-independent invariant is untouched). The alphabet comes from the **solver's rules** (not the DAWG), so the wire table is pinned by the solver version. **Index-drift caveat:** the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the wire table must agree, or letter indexing silently corrupts. Blast radius: `pkg/fbs` (a new Alphabet table; index fields in `StateView`/rack and in `SubmitPlay`/`Exchange`/`check_word`) → backend DTO encode/decode → UI `codec.ts`/`premiums.ts` → board/rack render, the move/exchange/word-check senders, the mock transport and the Vitest tests. Open details: the fbs shape and `include_alphabet` flag placement; whether to keep concrete-letter fields during the transition; whether tile exchange moves fully to indices; the premiums.ts parity-test rework. ### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2) Re-scoped from the original "CI & deploy": that was several sessions of work, so the deploy + observability + the two-bots idea were split into **Stages 15–18** below and this stage took only the dependency/artifact split that everything else builds on. Scope: publish `scrabble-solver` as a versioned Gitea module and split the dictionary build into a new `scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume both — discharging **TODO-1** and **TODO-2**. - **TODO-1 — solver published.** `scrabble-solver` renamed to module `gitea.iliadenisov.ru/developer/scrabble-solver`, tagged **v1.0.0**; `wordlist`/`dictdawg` de-internalised to public packages (the dict repo imports them); `cmd/builddict`/`dictprep`/the `dictionaries` submodule moved out; `internal/dict` repointed at the committed `dawg/*.dawg` fixtures. `backend/go.mod` pins `v1.0.0`; the `go.work` replace and the CI sibling-clone are gone; `GOPRIVATE=gitea.iliadenisov.ru/*` makes go fetch it directly (no public proxy/checksum DB). - **TODO-2 — dictionary artifacts.** New repo `developer/scrabble-dictionary` holds the word-list sources + `cmd/builddict` and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0**, so they are byte-identical to the solver's fixtures (no index drift). Released as `scrabble-dawg-vX.Y.Z.tar.gz` (flat, one semver per set); the Go workflows download it and point `BACKEND_DICT_DIR` at it. The runtime contract is unchanged (additive `BACKEND_DICT_DIR//`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is safe to retire once no active game pins it). ### Stage 15 — Dual Telegram bots & language-gated variants *(done)* Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a **set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice. Built: the connector hosts **two bots in one container** (one per service language, 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`** + **`supported_languages`** set. The set rides the `Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend invite); existing games of any language are unrestricted and the backend does not enforce. The service language is persisted (`accounts.service_language`, migration `00010`, written on every login — last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`) pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`. ### Stage 16 — Deploy infra & test contour *(done)* Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend + gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image), SPA served at both `/` (web) and `/telegram/` (Mini App), the §13 single-origin model; prod UI build vars (`VITE_TELEGRAM_BOT_ID`, `VITE_TELEGRAM_LINK`, `VITE_GATEWAY_URL`) as image build-args; a root `deploy/docker-compose.yml` (backend + gateway + Postgres + connector + VPN sidecar + the **full observability stack** — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on the external `edge` network behind the host caddy (VPN sidecar only for the connector); the backend image pulls the DAWG release artifact (Stage 14). **The test contour deploys automatically on push to a feature branch** (`docker compose up -d --build` on the local host where the gitea runner lives), with a post-deploy probe (`GET /` on the gateway). Test-contour secrets use the **`TEST_`** prefix (see Stage 16). Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go build` works without a UI build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one; collector/Tempo/Prometheus retention. ### Stage 17 — Test-contour verification & defect fixes *(done)* Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the "does it actually work in the contour" pass before prod. Bring up the `development` deploy, then verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test environment) and the Mini App launches/authenticates; a game can be created and played through (web + Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push works. Fix the defects found and harden where the run exposes gaps — notably a CI **connector liveness check** (the deploy probe only hits the gateway today, so a crash-looping connector is invisible — that is how the Stage 16 test-env miss went unnoticed) and **path-conditional CI** (skip the jobs whose code did not change, behind a single always-running gate job so branch-protection required checks stay satisfiable — a skipped required check otherwise blocks the merge). Open details (interview at start): the verification checklist + pass bar; which discovered defects are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before it settles). #### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*) The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss), discussed where they were forks, and resolved in one session with tests where practical. The per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the raw list is kept here as the record of what the first contour run surfaced. - /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/ Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth. - нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход, скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?), а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров. - регистрация пользователя из телеграм (как и других коннекторов): пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле. если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр), язык в зависимости от внешнего коннектора. - game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход". Думаю, опечатка - "не" лишняя, проверь на всех языках. - если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная", т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает на самих стилях) - возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема. когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка. Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним. - не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios, то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби. интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли? если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого? нужен твой анализ и совет. - надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно. На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону. А то я сейчас поиграл, роботы на первых ходах по 15 минут думают. Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры. Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя активного игрока. Этот пункт требует тщательного обсуждения, пожалуй. - при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п. - при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна. - а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой), чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое? ну и для собственного дефолтного стиля тоже надо выбрать соответствующие. - Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем". Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся. Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry. - при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка. - В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён было не больше 20%. - Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации. - Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы остальные игроки были как бы "утоплены" внутрь. - В игре клик/тач по плашке с именами игроков открывает/закрывает историю. - В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо. - В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить) - При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей. - Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится. ### Stage 18 — Prod contour deploy Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import** (`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master` (the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified: the `environments` API 404s), so a flat prefixed namespace is the convention. Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same unprefixed compose vars. **No host caddy on prod**, so the contour's own caddy terminates TLS — set `CADDY_SITE_ADDRESS` to the prod domain so caddy does its own ACME (the Caddyfile is already parameterised for this; the test contour leaves it `:80` behind the host caddy). Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a provided cert) at the contour caddy; prod VPN; rollback. ## Refinements logged during implementation - **Stage 0**: solver `replace` deferred 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 is `master` (owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on `master` by necessity. - **Stage 1** (interview + implementation): - Query layer: **go-jet** over `database/sql` (pgx stdlib) + otelsql; a `cmd/jetgen` tool 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`, not `bytea` — 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 `identities` table (`kind ∈ telegram|email`, widen to `vk`/`max` later); no soft-delete / actor-audit columns yet. - HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user, internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via `Server` group accessors); the session/account REST handlers land with the gateway in **Stage 6**. Admin bootstrap deferred to **Stage 10**. - Telemetry: providers + request-timing middleware + otelsql; exporters `none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 12**. - Tests/CI: integration tests behind the `integration` build tag in `backend/internal/inttest` + new `integration.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 existing `backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched. - **Stage 2** (interview + implementation): - Scope: `internal/engine` is a self-contained **library** (registry, bag, `Game` state machine, decode/replay). No `config`/`main`/`server` wiring 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-solver` in `go.work`; `backend/go.mod` requires `scrabble-solver` (placeholder version, redirected by the replace) and `github.com/iliadenisov/dafsa` directly (for `dawg.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 set `BACKEND_DICT_DIR`. - **Dictionaries**: registry loads the committed DAWGs from a directory parameter; `dict_version` is 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 in `rules.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) because `selfplay.Bag` cannot return tiles; exchange is legal only when the bag holds at least a rack and draws replacements before returning the swapped tiles. A blank is `Placement{Blank:true}` carrying its designated letter; the history keeps the concrete letter plus a blank flag (decoded via `Alphabet.Character` / `Decode`). `ReplayBoard` reuses `scrabble.Apply`, so no `internal/encoding` dependency. - **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. - **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 from `main`. The robot (Stage 5) will consume the same service API. - **Persistence = event-sourcing + warm cache** (interview): durable state is the `games` row plus an append-only decoded move journal (`game_moves`); the live position is an `engine.Game` kept 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 `Resign` fix** (interview, in `internal/engine`): the resigner keeps their accumulated score (no end-game rack adjustment) and never wins; `winner` excludes the resigner, so a two-player resign/timeout gives the win to the other player regardless of score. Timeout reuses `Resign`, 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` — so `internal/game` never imports `scrabble-solver` (keeps the §5 single-importer invariant). - **Create = atomic with seats** (interview): `Create` seats 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 wallet `accounts.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_stats` with **`draws`** added 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 10 owns the resolution lifecycle, so the `status` column carries no value CHECK yet. - **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchange) plus `#note` lines for resign/timeout; derived from the journal, so dictionary-independent. - **Engine wiring + config**: `main` loads the registry (`engine.Open`, a hard boot dependency like migrations) and starts the sweeper. New config: `BACKEND_DICT_DIR` (required), `BACKEND_DICT_VERSION` (default `v1`), `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 export `BACKEND_DICT_DIR`. `accounts` gained `away_start`/`away_end`/`hint_balance` and the `account` package gained `SpendHint` (it owns its table). - **Stage 4** (interview + implementation): - Scope, as in Stages 1–3: **domain service/store layer, no HTTP** — REST/stream is Stage 6. Chat and nudges are **persisted** now; live delivery (push / in-app stream) is Stage 6/8. New packages `internal/social` (friends, blocks, chat+nudge) and `internal/lobby` (matchmaking + invitations); profile editing and the email confirm-code extend `internal/account`. The services have no active driver this stage, so `main` builds them and hands them to the server, which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for the Stage 6 handlers. - **Friends** (interview): request → accept on a single `friendships` table; decline/cancel delete the pending row; **blocking severs** any friendship. - **Blocks** (interview): the existing global toggles **plus** a per-user `blocks` table; block effects are **mutual** (a block either way suppresses chat visibility and prevents requests/invitations between the pair). - **Friend games** (interview): invitation → accept; the game starts only when **all** invitees accept, any decline cancels it, and a pending invitation **lazily expires after 7 days** (checked on access — no new sweeper). - **Chat** (interview): ≤ **60 runes**, stored with the game forever, the sender **IP** kept for moderation (as `text`, following Stage 1's no-`bytea` precedent; the gateway forwards it in Stage 6), input **content-filtered** (links/emails/phone numbers incl. obfuscated forms) via `mvdan.cc/xurls/v2` plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the one new dependency. **Nudge is a chat message** (`kind='nudge'`), rate-limited to once per hour per game per sender. - **Matchmaking** (interview): an **in-memory** FIFO pool keyed by **variant** only (variant fixes the board language), pairing two humans (seat order randomised). The 10 s wait and **robot substitution are deferred to Stage 5**. The pool does **not** consult blocks (auto-match is anonymous) — a deliberate simplification of the plan's optional block-skip that also avoids a DB call under the pool lock. - **Email confirm-code** (interview): 6-digit code, 15-min TTL, ≤ 5 attempts, stored as a **SHA-256 hash**; a `Mailer` seam with an SMTP relay (`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the current account; an email already confirmed by another account → `ErrEmailTaken` (**merge is Stage 11**); email-as-login is Stage 6 and reuses this mechanism. - **Multi-player drop-out** (interview; discharges the Stage 3 deferral): the engine's `Resign` now drops a seat and the rest **play on** while ≥ 2 are active, finishing (last-survivor wins) when one remains; `winner` excludes all resigned seats. A per-game **`dropout_tiles`** setting (`remove` default | `return`) governs the leaver's rack, which is **never revealed** to the others. Timeout reuses `Resign`, so a multi-player timeout drops one seat and play continues; `game.commit`/`timeoutGame` were already keyed on `g.Over()`, so they only needed the setting threaded through create/replay. - **Build/deps**: `go mod tidy` is not run — the bare-path `scrabble-solver` replace lives only in `go.work`, so `tidy`/`go get` cannot resolve it; the `xurls` dependency was added with `go mod edit -require` + `go mod download`, its checksums recorded in the committed **`go.work.sum`**. No CI workflow change (both Go workflows already clone the solver sibling and export `BACKEND_DICT_DIR`). - **Stage 5** (interview + implementation): - Scope, as in Stages 1–4: **domain layer, no HTTP** — the robot consumes the public game API as an ordinary seated player (`internal/robot`), so only `internal/engine` still imports the solver. New: `engine.Candidates()` (decoded ranked plays) and a thin `game.Service.Candidates` + `RobotTurns` read. - **Account model** (interview): a pool of **durable accounts**, each a single `identities` row `kind='robot'` (migration `00004` widens the kind CHECK — a CHECK-only change, no jetgen). A curated ~16-name pool in code; `EnsurePool` provisions them idempotently at boot (a hard dependency, like the registry) with `block_chat`/`block_friend_requests` set, which is **all** the friend/DM blocking needs (no special-casing). - **Driver + state** (interview): a background sweeper goroutine (`robot.Service.Run`/`Drive`, mirroring the timeout sweeper); **every per-game and per-turn choice is derived deterministically from the game `seed`** (FNV-1a mix, restart-stable — not `hash/maphash`), so the robot keeps **no extra state**. `playToWin = mix(seed,"win")%100 < 40`; per-turn `delay`; sleep `drift`. - **Timing** (interview): per-move delay `2 + 88·u^k` minutes, `u~U(0,1)`, **k≈3.5 → median ~10 min**, clamped to [2,90]. A daytime nudge on the robot's turn pulls the move into a 2–10 min reply window; the robot proactively nudges after **12 h** idle on the human's turn (reusing `social.Nudge`'s once-per-hour guard; `social.LastNudgeAt` added to detect the human's nudge). - **Sleep** (interview — resolves the §7-vs-`account.go` mismatch): the robot sleeps 00:00–07:00 in the **opponent's timezone shifted by a per-game drift ∈ [−3,+3]h** (so its night overlaps the human's rather than running anti-phase), computed on the fly per game — **no profile mutation, no concurrency cap**. The `account.go` away-window comment was corrected accordingly. - **Margin** (interview): pick the candidate whose resulting margin (own+move−opp) is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose, tie-broken toward the conservative edge; no legal play → exchange the full rack when the bag can refill it, else pass. - **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`) substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`), `NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human pairing **or** substitution — through a new `Poll` + results map; production delivery is a **match-found notification** (session/in-app push + side-service), Stage 6/8 — noted in §10. - **Metrics** (interview, 1+2): robots are durable accounts, so `account_stats` is the authoritative, complete balance ground-truth (target ~40% robot wins); an OTel counter (`robot_games_finished_total`, exporter `none` today) and a structured log cover robot-finished games for live observation. - **Config**: `BACKEND_ROBOT_DRIVE_INTERVAL` (30 s), `BACKEND_LOBBY_ROBOT_WAIT` (10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows already clone the solver sibling and export `BACKEND_DICT_DIR`). - **Stage 6** (interview + implementation): - **Scope = framework + vertical slice** (interview): the *whole* edge mechanism is built and the backend's REST surface + the live-event seam are opened for the first time, but only a representative slice of operations is wired end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/ `auth.email.login`), `profile.get`, `game.submit_play`/`game.state`, `lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin passthrough. The remaining domain operations reuse the identical transcode pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/ resign, hint, evaluate, word-check/complaint, history, my-games list, chat list/nudge) in **Stage 7**; the social/account ops (friends, blocks, invitations, profile editing, stats, GCG export) in **Stage 8**. - **Wire contracts in a new shared `scrabble/pkg` module** (interview): the backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads (`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated Go, imported by both backend and gateway. The Connect envelope proto lives in `gateway/proto/edge/v1`. Codegen is dev-time (`buf generate` with **local** plugins + `flatc`, driven by per-module `Makefile`s, mirroring `cmd/jetgen`); CI only builds the committed output. `pkg` and `gateway` are bare-path modules like `scrabble-solver`, so `go.work` carries `use ./pkg`, `use ./gateway` and a `replace scrabble/pkg v0.0.0 => ./pkg` (the no-dot path is not VCS-fetchable); deps were added with `go mod edit` + `go work sync` (the established no-tidy pattern). `flatc` is pinned to **23.5.26** to match the `flatbuffers` Go runtime. - **Guest = durable account + `is_guest`** (interview): migration `00005` adds `accounts.is_guest`; a guest is a durable row with **no identity** (so the `sessions`/`game_players` foreign keys hold) that is **excluded from statistics** (the finish-time recompute skips guest seats) and from friends/history. The earlier "guests never reach this table" comments and §3/§9 were softened to "no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3). - **Push = in-process `Publisher` + backend gRPC listener** (interview): a new `internal/notify` hub (a `Publisher` interface defaulting to `Nop`, installed on `game`/`social`/`lobby` via `SetNotifier` during boot — additive, existing tests unchanged) is drained by a new backend gRPC server (`internal/pushgrpc`, `BACKEND_GRPC_ADDR` default `:9090`) serving `Push.Subscribe`. Emission lives in `game.commit` (so robot-driver and timeout-sweep moves emit `your_turn`/ `opponent_moved` too — the background sources a handler-only design would miss), `social` (`chat_message`/`nudge`) and the matchmaker (`match_found`). Event payloads are FlatBuffers-encoded **in the backend** (it imports `pkg/fbs`); the gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are **deferred** (single-instance MVP). - **Edge envelope = minimal, token in header** (interview): the `Gateway` Connect service is `Execute(message_type, payload, request_id)` + `Subscribe`; the session token rides in `Authorization: Bearer`; auth ops are unauthenticated and return the token in the FlatBuffers `Session`. Domain outcomes ride back in the `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) are Connect error codes. No Ed25519/signing (the galaxy donor's crypto stack was dropped, per §3). - **Admin = gateway validates Basic-Auth** (interview): the gateway checks `GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend `/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 10. - **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min, burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min, burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket (`golang.org/x/time/rate`) with a lazy stale-bucket sweep. - **Email-as-login** (discharges the Stage 4 deferral): `account.EmailService` gained `RequestLoginCode`/`LoginWithCode`, reusing the confirm-code mechanism but provisioning-or-finding the account by email identity (it does **not** refuse an already-confirmed address — that is the returning user). - **CI**: both Go workflows gained `gateway/**` (and `pkg/**` where backend depends on it) path filters and now build/vet/test `./backend/... ./pkg/... ./gateway/...` (unit) — integration stays `./backend/...` (the only module with tagged tests). The solver clone + `BACKEND_DICT_DIR` steps are unchanged. - **Stage 7** (interview + implementation): - **Scope = playable slice** (interview): the *whole* UI shell plus the core play loop end-to-end; the social/account/history surfaces were split out into a new **Stage 8** and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11, Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6 "as the UI needs them" pattern): the new gateway/transcode + backend-REST ops `games.list`, `game.{pass,exchange,resign,hint,evaluate,check_word,complaint, history}`, `chat.{list,nudge}`. The only new domain code is `game.ListForAccount` (the "my games" query) and seat **`display_name`** resolution (server DTO layer); `SeatView` gained a trailing `display_name`. Friends/blocks/invitations, profile-editing, stats and the history/GCG viewer are Stage 8. - **Stack** (interview): plain **Svelte 5 (runes) + TypeScript + Vite**, no SvelteKit; `@connectrpc/connect-web` + the `flatbuffers` runtime, with the edge TS bindings generated from the **same** `edge.proto` (`protoc-gen-es`) and `scrabble.fbs` (`flatc --ts`) and **committed** under `ui/src/gen/` (dev-time codegen, like `cmd/jetgen` / `pkg/Makefile`; CI builds the committed output). - **No board on the wire** (discovered): `StateView` carries no grid, so the client **replays the decoded move journal** (`game.history`, newly wired) onto an empty board; premium squares + tile values are a client-side map **ported from `scrabble-solver/rules/rules.go`** with a Vitest parity test. - **Board UX** (interview): full-width, borderless; tiles placed by **Pointer-Events drag or tap** (no HTML5 DnD — it has no touch support); a contextual **MakeMove** control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile recall by tapping a pending tile; a **two-state zoom** (15↔9 cells) on touch only (auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser. All board/tiles/effects are **pure HTML5/CSS + Unicode** — no image/font/SVG asset. - **Theming** (interview): own **CSS custom-property tokens**, light/dark via `prefers-color-scheme`, **Telegram-themeParams-ready** (a runtime hook can override the tokens; the SDK is wired in the Telegram stage). **Navigation** (interview): dependency-free **hash router**; session token in memory + **IndexedDB**, re-resolved on reload (reopen Subscribe, refetch the open game); stream reconnect on focus. **i18n** en/ru is a hand-rolled typed catalog (compile-time key parity + a test). - **Mock transport** (owner request): a build-flagged in-memory fake (`VITE_MOCK`, `pnpm start`) drives lobby → active game → board with no backend, tree-shaken out of production; it is the same fixture the Playwright smoke uses. - **Tests/CI** (interview): **Vitest** units (board replay, placement machine, premium parity, i18n parity, FlatBuffers codec) + a **Playwright** smoke against the mock; a new **`ui-test.yaml`** workflow (type-check, unit, build with a **bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount` integration test and gateway transcode tests for the new ops were added. - **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content pinned to the bottom) + a one-line **announcement banner** (client-side mock rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); board **zoom reworked** to a width-based zoom in a fixed viewport (real native scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings **bonus-label style** (beginner/ classic/none); **hint lays its tiles on the board** (no spend when no move — a new `no_hint_available` result code); the history opens as an in-place **slide-down** (not a modal); word-check is alphabet/length-limited, cached and throttled. Design details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md). - **Stage 8** (interview + implementation): - **Scope = vertical slice continued**: the social/account/history operations were opened end-to-end (UI → gateway transcode → backend REST → existing domain services). The only new backend logic is `lobby.ListInvitations`, `account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`), the friend-code mechanism, and the friendships `declined`-status change. - **Friends — two add paths** (interview, a deliberate plan change): **one-time friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL, SHA-256-hashed like email codes, single active per issuer, single-use, redeem rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a shared game — active or finished). An explicit **decline is permanent** (blocks re-send), an **ignored request lazily expires after 30 days** and may be re-sent, and a **code from the same person bypasses a prior decline**. This **supersedes Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still deletes; decline now sets `status='declined'`). Migration **00006** widens `friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID or name search — discovery is codes + befriend-an-opponent. - **Badges = poll + push** (interview): a new generic **`notify`** push event (`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/ game_started) drives the lobby hamburger + "Friends" badge; emitted on friend- request and invitation create and on the invitation's game start. The client polls incoming requests + open invitations on lobby open and on focus (a missed push while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred (single-instance MVP, §10). - **Language single-control** (interview): the Settings language control writes through to the durable account's `preferred_language` (`profile.update`); guests keep only the client preference. Seeding the language from the platform/client on first provider login is a **Stage 9** forward-note. - **Guests = durable-only** (interview): friends/blocks/invitations/statistics and history management are durable-account-only; a guest sees a sign-in prompt. Binding an email to an existing guest (account linking) stays **Stage 11**. - **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client exports via the **Web Share API** where available, else a **Blob download** (`game-.gcg`). Capacitor-native file save lands with the native wrapper. - **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen from the lobby menu; Invitations is a lobby section + a "play with friends" mode in New game; Stats is a lobby tab-bar button; profile editing is on Profile; history + GCG stay in the game. - **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email- bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in `pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}` and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow change (the Go and UI workflows already cover the new code). - **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the lobby notification badge fixed (it had inherited the hamburger-bar style) and made a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit validation on both UI and backend** — display-name format (letters + single `␠`/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone` parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid capped at **12 h** (wrap-aware), email format — with Save disabled and invalid fields red-bordered while any field is invalid; language stays in Settings; in a game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab bar) instead of hiding it. Two **iPhone-simulator** passes then made the chat and modals keyboard-aware (`dvh` plus a `visualViewport` listener that sizes the modal backdrop to the area above the keyboard), reserved the rack height so a finished footer does not collapse, and compacted the play-with-friends form (a searchable bounded-scroll friend list, a pinned invite, and an explicit, **required game type** — a smart default is TODO-6). On the owner's call, **every profile / new-game picker is a native `