93ccc8c449
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / integration (push) Successful in 20s
CI / ui (push) Has been skipped
CI / deploy (push) Successful in 1m44s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
624 lines
49 KiB
Markdown
624 lines
49 KiB
Markdown
# Scrabble Game — Functional spec
|
||
|
||
Per-domain user stories: what each user-visible operation does. This is the
|
||
starting point for any change request that touches behaviour. The English
|
||
version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for
|
||
the project owner — mirror every point edit in the same patch (translate only
|
||
the changed paragraphs).
|
||
|
||
## Domains
|
||
|
||
### Client app
|
||
The web/app client (Svelte + Vite) realizes these stories. It
|
||
covers signing in (guest or email), the "my games" lobby, starting an
|
||
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
|
||
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
|
||
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
||
read-only profile. It also handles managing friends (including one-time friend
|
||
codes) and blocks, friend-game invitations, editing the profile and binding an
|
||
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
|
||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||
costs nothing when the rack has no legal move. The word-check accepts only the
|
||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||
A public **landing page** at the site root introduces the game, switches language and
|
||
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
|
||
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||
render with arbitrary languages, so detection would make the indexed content
|
||
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
|
||
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
|
||
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
|
||
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
|
||
the published prices always match what is currently on sale — no redeploy to update them.
|
||
|
||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
||
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
|
||
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
|
||
|
||
### First-run onboarding
|
||
The first time a player opens the app it walks them through the interface with a light
|
||
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||
tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the
|
||
overlay is gone for good. Two independent series run. The **lobby** series points at the
|
||
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
|
||
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
|
||
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
|
||
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
|
||
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||
bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The
|
||
advertising banner hides while the overlay is up and returns when it closes.
|
||
|
||
### Identity & sessions
|
||
A player arrives from a platform (Telegram first), via email login, or as an
|
||
ephemeral guest. The gateway validates the credential once and mints a thin
|
||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||
the device safe-area, and — on first contact — seeds the new account's interface
|
||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
|
||
(verified by the gateway), and on first contact seeds the new account's interface language from
|
||
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
||
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
||
"couldn't load" screen applies inside VK.
|
||
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
||
(en/ru), no images — to the address; entering it signs the player in. A new address
|
||
is provisioned on the first request but only becomes a durable account once the code
|
||
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
|
||
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
|
||
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
||
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
|
||
this link: the email carries only the code, entered in the same window, since the link would open
|
||
in a separate browser the app cannot reach.
|
||
Telegram runs a **single bot**: every player uses
|
||
the same bot, and all of its chat and out-of-app notifications are written in the
|
||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||
main one — its only job is to answer `/start` with a short message and a button that opens the
|
||
**main** bot's app, where the player picks their game variant; it is an onboarding entry point that
|
||
touches nothing else. Guests are session-only with restricted features
|
||
(auto-match only; no friends, stats or history); an abandoned guest that never
|
||
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
|
||
keeps a live stream and receives in-app updates in real time — the opponent's move,
|
||
your turn, chat, nudges and an opponent joining a game you are waiting in. Each update lands as the
|
||
event itself, applied in place with no reload, so the board refreshes seamlessly and an invited game
|
||
opens instantly. When the app is **closed**, the chosen
|
||
out-of-app events (your turn, game over, nudge, a found match, an invitation or friend
|
||
request) arrive as a **Telegram notification** instead — unless the player keeps
|
||
notifications in the app only (a profile setting, **on by default**). The "your turn"
|
||
notification names the opponent and recaps their last move — the word and the running score
|
||
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
|
||
notification with your result and the final score (scores read with yours first). If the
|
||
connection drops or the server is rate-limiting, the app does not nag with errors: the header
|
||
shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server
|
||
pause until it is back (a server-data screen still opens, with the spinner, and fills in on
|
||
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||
flashing a red banner each time.
|
||
|
||
### Accounts, linking & merge
|
||
First platform contact auto-provisions a durable account. From the profile a player
|
||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||
who links their first identity becomes a durable account. The "already taken" status
|
||
of an identity is never revealed before the code/sign-in is verified. If the linked
|
||
identity already belongs to another account, the player is shown an explicit,
|
||
**irreversible** confirmation and the two accounts are merged into the one they are
|
||
using (statistics summed, games and friends transferred, duplicates removed) — except
|
||
when a guest links an identity that already has a durable account, where the durable
|
||
account is kept and the guest's games move into it. A merge is blocked only while the
|
||
two accounts share a game still in progress.
|
||
|
||
The profile lists the account's **sign-in methods**. On the web a player can add
|
||
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||
back); inside a Mini App the host platform is already linked, and its own sign-in-method
|
||
row is hidden — the platform the player is currently signed in through cannot be unlinked
|
||
from there, only the other provider's row is shown. Linking a provider that
|
||
already belongs to another account offers the same irreversible **merge** as email
|
||
linking. A linked provider can be **unlinked** — except the last
|
||
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
||
never unlinked; it is changed**: the player enters a new address, confirms a code
|
||
mailed to it, and the account switches to it atomically, freeing the old address. A new
|
||
address that already belongs to another account is refused with a neutral "check the
|
||
address or contact support" — the switch never merges and never reveals the other
|
||
account.
|
||
|
||
A player can **delete their account**. This is a legal-retention removal, not an erasure:
|
||
the account is deactivated and its live surfaces anonymised (opponents see "[Deleted]"), its
|
||
sign-in methods are freed for reuse, and its sessions are revoked — but a dossier (the
|
||
credentials that were linked, the last login, and the player's messages) is retained for
|
||
the operator and purged after two years. Confirming deletion needs a mailed code for an
|
||
account with an email, or a typed phrase otherwise. Active games are forfeited so opponents
|
||
are not stranded; solo games against the AI are removed, while games with a human opponent
|
||
are kept under the anonymised name and the player's chat stays. Reopening the app after
|
||
deletion simply creates a fresh account.
|
||
|
||
### Lobby & matchmaking
|
||
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
||
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
||
games list is ready, so the list never flashes an "empty" state on a slow connection.
|
||
|
||
The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a
|
||
**⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The
|
||
**my games** list groups games into three
|
||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||
opponent-turn and finished games are most-recent first; within the your-turn and
|
||
opponent-turn sections a game with any unread notification floats to the top (the finished
|
||
section keeps its order). It renders as a compact, line-separated list. While the lobby is open and a listed game **becomes your turn or
|
||
finishes**, its status icon **blinks twice** to draw the eye (an opponent's-turn change is
|
||
silent, applied in place). You can **remove a finished game from your own list**:
|
||
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
|
||
The removal is per-account and permanent — the game disappears only from your list and stays
|
||
in the other players' lists, and there is no undo. A finished **AI game (🤖) you left** — by
|
||
resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list
|
||
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
|
||
no other game type is auto-removed. The game types offered on **New Game** are
|
||
limited to the player's chosen **variant preferences** (see *Profile & settings*) —
|
||
Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
|
||
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
|
||
the same name titles the in-game screen. This gates only **starting** a new game you initiate —
|
||
auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays
|
||
existing games of any variant, and being **invited** to a game lets you accept it in any variant.
|
||
|
||
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
|
||
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
|
||
there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a
|
||
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
|
||
and the AI itself never runs out of time). Choosing a **random player** is auto-match
|
||
(always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you
|
||
can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and
|
||
the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are
|
||
unavailable. Another player searching the same variant and rule joins your game; failing that, a robot
|
||
takes the empty seat after **1.5–3 minutes**, so a game always starts — and the New Game screen notes
|
||
you can close the app while you wait and come back later. For Russian games (auto-match or friend
|
||
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
|
||
the simplified **single-word rule** — only the word laid along the player's line must be a
|
||
real word (and it must still cross or extend letters already on the board along that line),
|
||
and any incidental perpendicular words are ignored and not scored — while on is
|
||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||
formed by inviting players from the friend list (an invitation, like a friend code,
|
||
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
|
||
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
|
||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||
expires after seven days.
|
||
|
||
**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** —
|
||
against the AI, in a random match, and by friend invitation — with separate limits for a **guest**
|
||
and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1**
|
||
game against the AI and **1** random match at once and cannot start friend games at all; a signed-in
|
||
account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished
|
||
game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a
|
||
start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game:
|
||
a **guest** is invited to sign in or create an account (to play several games at once), a signed-in
|
||
player is told to finish a current game first. **Accepting an incoming invitation is never blocked** —
|
||
friend games are capped only when you start one. The server enforces every cap regardless of the
|
||
client, and refuses a guest's friend request, friend code or invitation outright.
|
||
|
||
### Playing a game
|
||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
|
||
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
|
||
without choosing a direction — the game infers the play's orientation, so a single
|
||
tile that extends
|
||
an existing word (down a column or across a row) is accepted. A play is validated
|
||
against the game's dictionary at submit time and scored; an unlimited preview shows
|
||
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
|
||
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||
one-word-per-turn game only the main word), or a calm pink when they form none — and an
|
||
**orange badge** on the main word carries the move's
|
||
score. The confirm control is offered only once the move is legal. The preview is
|
||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||
local dictionary is unavailable. The dictionary check tool is
|
||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||
English) to look it up. Hints are governed per game —
|
||
whether they are allowed and how many each player starts with — and draw on a
|
||
personal hint wallet once the per-game allowance is spent. Against the robot
|
||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||
counts toward no hint statistic. The game ends when the
|
||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||
daily away window. In a two-player game a resignation or timeout gives the win to
|
||
the other player and the leaver keeps their score. In a game with three or four
|
||
players the leaver's seat is dropped and the others play on, the game ending when a
|
||
single active player remains; the disposition of the leaver's tiles (returned to
|
||
the bag or removed from play) is chosen when the game is created, and the leaver's
|
||
rack is never shown to the others. A player's **board composition is kept per game**:
|
||
the rack arrangement and the tiles laid but not yet submitted are saved as they compose
|
||
and restored on return (including on another device); a player may **arrange tiles during
|
||
the opponent's turn**, but that draft is position-only — the score preview and submission
|
||
stay available only on the player's own turn.
|
||
|
||
### Robot opponent
|
||
When auto-match finds no human within the wait window (1.5–3 minutes), a robot opponent
|
||
takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
|
||
decides once per game whether to play to win (about 40% of the time, so the human
|
||
wins most games), aims for a close score rather than crushing or throwing the game,
|
||
now and then plays a single move against that plan — a surprise lead or a slack move —
|
||
yet holds to the plan once the bag empties, and plays at a human pace — short thinking
|
||
times for most moves, the occasional long one, and a night-time pause that tracks the
|
||
player's own day; once a game is clearly decided and both sides are only passing, it stops
|
||
dragging it out, answering its forced passes at roughly the player's own pace rather than
|
||
after a long deliberation. It answers a nudge
|
||
within a few minutes and, when the player keeps it waiting, nudges back on a sparse
|
||
schedule — roughly every 9-12 hours, and never during its night-time pause. It
|
||
carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide
|
||
international pool of real names and handles, so the arena feels populated by many different
|
||
players (a Russian game shows mostly Russian names, never East-Asian scripts; an international
|
||
game the full mix); it does not chat, and **silently ignores friend requests** — a request to a
|
||
robot stays pending and expires, exactly like a human who never responds.
|
||
|
||
The same robot also backs the **honest-AI quick game** the player chooses directly (above). There
|
||
it makes no pretence: it is shown as **🤖** everywhere, joins and moves at once (no thinking time
|
||
or night pause), keeps the same strength (it still plays to win only about 40% of the time, with
|
||
the same occasional move against its plan that fades out by the endgame), and chat, nudge and
|
||
add-friend are off. AI games are **practice** — they never count toward a player's
|
||
statistics.
|
||
|
||
### Offline mode
|
||
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||
native apps only). When it cannot reach the
|
||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||
app never touches the network.
|
||
|
||
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
|
||
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
|
||
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
|
||
backend; local games are kept on the device and never sync to the account, so a game can be created and
|
||
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
|
||
while still online, and (once installed, or on the native app) launches from bundled/precached data even
|
||
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
|
||
a short note.
|
||
|
||
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
||
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
||
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
|
||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
|
||
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
|
||
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
|
||
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
|
||
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
|
||
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
|
||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||
so no one can wipe a game the moment they have moved.
|
||
|
||
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||
offline, so you are never interrupted for a hiccup.
|
||
|
||
### Staying up to date
|
||
When a new client version is required, the app does not lock you out. On the **native** app a
|
||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
|
||
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
|
||
required — version exists; you can update or dismiss it, and play continues either way.
|
||
|
||
### Native app (Android)
|
||
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
||
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
|
||
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
|
||
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
|
||
network is available the app quietly establishes a guest in the background and online features (random
|
||
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
|
||
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
|
||
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
|
||
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
|
||
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
|
||
this appears only on an online action, never while you are playing offline.
|
||
|
||
### Social: friends, block, chat, nudge
|
||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||
digits, valid for twelve hours), or send a **request to someone you have played
|
||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||
friendship. In a game, each opponent's score card (while the move history is open) carries two controls:
|
||
an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap
|
||
on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while
|
||
confirming — and while one is confirming the other is hidden so they never overlap. The 🤝
|
||
goes **disabled** while a request is pending or was declined and **disappears** once you are
|
||
friends; both controls **disappear and the opponent's name is struck through** once you have
|
||
blocked them. They update in place the moment the relationship changes and stay correct across
|
||
reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a
|
||
live event; a transport failure rolls the control back to its prior state.
|
||
|
||
Block globally — switch off incoming chat and/or friend requests — or block an **individual
|
||
player**. A per-user block is **one-directional and silent**: you stop receiving everything
|
||
**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker
|
||
never pairs you with them, while they **notice nothing**. Their messages and nudges still send
|
||
by the normal rules but never reach you (anything that would show as unread for you is marked
|
||
read at once), and a friend request or invitation they send you is kept but never surfaces. A
|
||
block **overrides but does not delete** an existing friendship (so you may block a friend, and
|
||
they keep seeing you as one); active games are never interrupted — you can finish them, with
|
||
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
|
||
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
|
||
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
|
||
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
|
||
that names the friend (*Block this player?* / *Remove from friends?*).
|
||
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
|
||
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
|
||
recorded only against that game — the disguise holds, the shared robot is never globally
|
||
blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out
|
||
of opponents). Sending the 🤝 to such a **disguised robot** likewise records the request only
|
||
against that game (pinned to the seat, so it stays *sent* there without touching that robot in
|
||
your other games); the robot **ignores it**, the request never appears in **Settings → Friends**,
|
||
and it drops automatically about a week after the game ends. Per-game chat is for quick reactions: messages are short
|
||
(up to 60 characters) and may not contain links, email addresses or phone numbers,
|
||
even disguised. You may send **one message per turn, on your own turn**; once it is sent
|
||
the field gives way to a short caption until your next turn. Nudge the player whose turn
|
||
is awaited at most once per hour (the
|
||
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
||
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
||
tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no
|
||
chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the
|
||
dictionary closed) the 💬 entry disappears entirely. An unread chat
|
||
entry — a message **or a nudge** from an opponent — raises a small **dot** next to the game
|
||
in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
|
||
softer amber when only nudges are**. Opening the **move history** counts as reading
|
||
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
||
also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once
|
||
the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread.
|
||
|
||
### Profile & settings
|
||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||
optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most
|
||
5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone
|
||
(chosen as a UTC offset, and pre-filled from your device's detected offset when the account
|
||
is first created — so robot games are timed correctly before you ever open this form), the
|
||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||
entry that opens this screen, and your display preferences (theme, board-label style and
|
||
reduce-motion — not the interface language, which follows your account) sync across your
|
||
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
|
||
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
|
||
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||
|
||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||
existing games of any variant.
|
||
|
||
### Feedback
|
||
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
|
||
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
|
||
PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form
|
||
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
|
||
marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||
|
||
### Telegram support chat
|
||
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
|
||
with the support desk's working hours and what to include (a description and, when possible,
|
||
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
|
||
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
|
||
user sees no automatic reply to a forwarded message; an
|
||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||
|
||
### History & statistics
|
||
Finished games are archived in a dictionary-independent form and exportable in
|
||
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||
the final position; the export is offered **only once a game is finished**, and
|
||
never for an honest-AI practice game (a live game's export would leak the move
|
||
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
|
||
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
|
||
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
|
||
is the OS share sheet, which a native-popup callback cannot open, and VK has no
|
||
native chooser.
|
||
|
||
The **image** is rendered on the server (the internal render sidecar runs the same
|
||
drawing module the web client tests; always the light theme): the final board with
|
||
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||
name and final score in the header (🏆 by the winner), then one row per move
|
||
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||
words of a multi-word play ride a smaller second line, non-play moves show as
|
||
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||
endgame rack settlement when there was one (the final scores are authoritative —
|
||
running totals alone do not include it). The footer stamps the site host and the
|
||
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||
stretches the board (never below its minimum) so the image carries no dead space.
|
||
|
||
Delivery is **one signed, short-lived link for both formats on every platform**,
|
||
handed to the best affordance each platform has (each branch verified on-device):
|
||
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
|
||
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
|
||
takes both formats through VK's download into its native share flow, while on the
|
||
**VK Android client** — whose downloader hangs — the image opens in VK's native
|
||
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
|
||
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
|
||
the OS share sheet** (nothing lands in Downloads first); a desktop browser
|
||
downloads the file. The link needs no login to fetch (the platforms' downloaders
|
||
carry none) and is valid for minutes. The single exception is a legacy Telegram
|
||
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
|
||
the old clipboard copy (with the confirming toast) and the image option is not
|
||
offered. Statistics (durable accounts only):
|
||
wins, losses, draws, max points in a game, and max points for a single move (the
|
||
best play, which already includes every word it formed plus the all-tiles bonus). It
|
||
also shows the player's **move count** (their plays — passes and exchanges do not
|
||
count) and their **hint share** (the percentage of those plays where a hint was taken).
|
||
The best move is also broken down **per game variant**, showing the **word itself**
|
||
drawn as game tiles (a wildcard shows its letter but no value) — one row per variant
|
||
the player has played, omitting variants with no plays.
|
||
A game that can no longer be continued — because a rule changed and an earlier
|
||
move would now be illegal — is closed as a **draw** the moment someone opens it,
|
||
never left stuck on an error: the move history shows an impersonal organizer note
|
||
at its end (the game could not be continued), and the same note rides the GCG export.
|
||
|
||
### Administration
|
||
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
|
||
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
|
||
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
|
||
identities, their games) and **games** — summary, seats, the recorded **first-move tile
|
||
draw** (who drew what, deciding who leads), and a simple **step-by-step board replay** of the
|
||
whole game — works the **word-complaint review queue** — resolving each as reject / accept-add / accept-remove — and exposes
|
||
the **dictionary**: the active version new games pin, the resident versions per
|
||
variant, an online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz`
|
||
release archive, preview the words added and removed per variant against the active
|
||
dictionary, then install — which writes the version, loads it and makes it active;
|
||
versions are immutable and games in progress keep their own), and the **pending
|
||
wordlist changes** derived from accepted complaints (which feed the offline rebuild
|
||
and are marked applied after an update). When the Telegram bot channel is configured an operator can also
|
||
**message a user** (by their Telegram identity) or **post to the game channel**.
|
||
State-changing actions are protected by a same-origin check; the console tracks no
|
||
operator identity.
|
||
|
||
The console also surfaces **rate-limit abuse**: a **Throttled** page lists the
|
||
recently throttled users/IPs the gateway reported (an in-memory window — it resets on
|
||
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
|
||
account sustaining rejections past a tunable threshold is flagged automatically —
|
||
the marker is reversible, shown as a badge in the user list and on the user card, and
|
||
**never blocks play**; the operator reviews and clears it from the user card. The
|
||
account flag itself is never a ban. In **production** the same page also lists the
|
||
**active IP bans** the gateway is enforcing: a temporary block of a client IP that
|
||
floods the service past a threshold, or trips a hidden **honeypot** path or a planted
|
||
**honeytoken** — a high-confidence sign of a scanner or hostile bot, never a normal
|
||
player. Each ban shows its reason and expiry with an **Unban** action; bans auto-expire
|
||
and the operator can lift one early. IP bans are a production-only safeguard — the
|
||
shared test environment cannot tell its clients apart, so it does not enforce them.
|
||
|
||
The console also lets an operator **manually block** an account — the hard counterpart to the
|
||
soft high-rate flag. From the user card the operator blocks the account **permanently** or
|
||
**until a date** (presets: 1 day / 3 days / 1 week / 1 month, or a custom date in UTC), optionally
|
||
citing a **reason** chosen from an editable **en+ru picklist** managed on a **Reasons** page; the
|
||
blocked player sees the reason in their own language. Blocking instantly **forfeits** the player's
|
||
active games (each opponent wins, as if the player resigned) and removes them from matchmaking. A
|
||
blocked player — on opening the app or on any action — is shown a terminal screen: *"Your account
|
||
is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, in their timezone),
|
||
plus the reason when one was given, and the app stops all background traffic with the server. A
|
||
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
|
||
at any time (games already lost stay lost).
|
||
|
||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||
take effect for a player already in the chat; one who is not in it is unaffected until they next join.
|
||
|
||
From the user card the operator can also **top up a player's hint wallet**: an additive grant
|
||
(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** —
|
||
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
|
||
over-grant cannot be reversed there.
|
||
|
||
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
|
||
**unread / read / archived** with per-user search, each shown with its sender, source, channel
|
||
(with the bot language — en/ru — for a Telegram message), the sender's interface
|
||
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the
|
||
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
|
||
any attachment. The operator can mark a message read, **reply** to the player (delivered
|
||
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
||
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||
|
||
### Wallet
|
||
|
||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||
VK-iOS the balance is shown but frozen for spending.
|
||
|
||
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||
|
||
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||
|
||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||
**no purchase history** — only the current balances, active benefits and store.
|
||
|
||
### Advertising banner
|
||
|
||
A one-line banner under the nav shows short promotional or operational messages to **free
|
||
players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and
|
||
does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account
|
||
or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an
|
||
operator changes one of those properties, the banner appears or disappears **in place**, without a
|
||
reload.
|
||
|
||
Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a
|
||
percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the
|
||
banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share
|
||
paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is
|
||
written in **both languages** (English + Russian); a player sees the variant for the **bot they play
|
||
through**, regardless of their interface language. The client rotates the eligible campaigns
|
||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||
and a campaign with several messages shows them in turn.
|
||
|
||
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
|
||
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
|
||
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
|
||
well on both; the strip's border is derived from the background automatically. A campaign can also be
|
||
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
|
||
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
|
||
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
|
||
outages), not advertising.
|
||
|
||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
||
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
||
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
||
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
|
||
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
|
||
colours, never urgent).
|