da25eac070
When a guest links an identity (email/TG/VK) that already belongs to a durable account, the guest-primary rule already made the durable account the survivor and switched the session — but the client still showed an irreversible 'merge two accounts?' confirmation, which is nonsense from the user's side (they are simply signing into their account). The confirm step now merges inline for a GUEST initiator and returns the completed merge (the switched token); a durable initiator still gets the explicit confirmation (consolidating two real accounts is consequential). The active-game guard still refuses, surfaced as the clear error.merge_active_game_conflict message rather than swallowed. Also fixes the merged-away device-local games: their human/host seat was recorded under the retired account id, so after the switch the lobby and game header could not identify 'me' and showed every seat as an opponent (the game still played — turn logic is seat-index based). applyLinkResult now re-points local game seats from the retired id to the survivor (repointLocalGameSeats). Docs: FUNCTIONAL.md (+_ru).
647 lines
51 KiB
Markdown
647 lines
51 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 three **legal
|
||
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
|
||
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
|
||
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
|
||
from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in 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. Opening a Mini App link directly in an ordinary browser —
|
||
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
|
||
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
|
||
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
|
||
**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 two accounts are merged into one
|
||
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
|
||
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
|
||
consolidating two real accounts is consequential. A **guest** initiator is not: the
|
||
durable account that owns the identity is kept, and the ephemeral guest — with its games,
|
||
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
|
||
simply signing into their account (the app switches to it with no prompt). A merge is
|
||
blocked only while the two accounts share a game still in progress; a guest is then asked
|
||
to finish that shared game before signing in.
|
||
|
||
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. While offline in an online game the check runs against the game's own
|
||
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||
need the network, are hidden. 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.
|
||
|
||
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||
board as a draft. Nothing
|
||
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||
on-device dictionary.
|
||
|
||
### 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. It also clears the automatic pin Telegram puts on each channel post that is
|
||
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. 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).
|