Game runs
Версия клиента и диагностика
Runtime передаёт X-RetentionPlay-Client-Version (r +12hex выпуска) с командой. Это диагностический header: он не входит в8192-байтовый payload, fingerprint или решение сервера. Произвольные значения сервер не журналирует. Новый runtime выбирает5% экземпляров и отправляет до3 transport/error samples за время жизни через POST /v1/client-telemetry, с таймаутом1,5секунды, без повторов и ожидания игрой. Закрытие окна отменяет отправку. Разрешены только build_version, kind(network/http), status; нет URL, token, IP, player или игрового payload. Сервер ограничивает256байт,3/мин на токен и300/мин на проект. Legacy Match3 передаёт legacy-v1 в header, reporter в его сборку не включён. Телеметрия не является игровой командой или источником истины для прохождения/награды.
The server-authoritative path for games built on the game-run engine (match3_v1 and later), as opposed to the events path (POST /v1/events). The game client sends one semantic command at a time; the server computes the board, goals, score, outcome, level progression and rewards. There is no client-reported score to trust.
For a project that has a game set for Play (play_mechanic), the server also picks the game and the level — the client only states an intent. Lobby describes the read that tells the client what Play will start.
Match3: версии 1 и 2
Клиент Match3 объявляет supported_game_versions: [1, 2] и отображает обе версии правил. В дорожке выпуска 1 — 15 позиций: позиция 1 остаётся level-001@1 с game_version: 1; позиции 2–15 используют game_version: 2. Идентификаторы score_intro и rocket_intro сохранены, их новые определения имеют version: 2. Начатые партии с прежними версиями уровней доигрываются по закреплённым правилам. Отсутствующее supported_game_versions означает [1]: попытка начать позицию v2 возвращает 409 client_outdated и не создаёт новую партию.
Вид v1 содержит одиночную цель view.goal. Вид v2 содержит schema: 2 и массив view.goals; победа требует выполнения всех целей. Цель collect передаёт targets и progress, score — target и current, clear — blocker, required и current. Пример фрагмента вида v2:
{
"schema": 2,
"goals": [
{ "type": "clear", "blocker": "chain", "required": 6, "current": 2 },
{ "type": "collect", "targets": { "flower": 10 }, "progress": { "flower": 8 } }
]
}У clear считаются клетки с блокером, а не его отдельные слои: цепь L2 занимает одну клетку и увеличивает current только после снятия последнего слоя. Число оставшихся клеток равно max(required − current, 0). В поле v2 фишка может иметь lock и ice со значениями 1 или 2; дыра передаётся как { "hole": true }. Жест по закреплённой фишке клиент не отправляет. Поддельная команда по цепи или дыре отклоняется сервером без расхода хода; причина находится в swap_rejected.data.reason (locked или hole).
В новом выпуске оболочки поле также доступно с клавиатуры: Tab входит в сетку, стрелки перемещают фокус, Enter или пробел выбирает клетку. Выбор соседней клетки создаёт ту же команду swap, что и жест указателем. Стрелки пропускают дыры без перехода через край; закреплённая клетка описывается, но не отправляет ход. Отдельное кольцо показывает фокус на холсте. Во время запроса, паузы, обучения или конечного результата игровой ввод закрыт. Эти элементы не меняют серверные правила и не добавляются в старый клиент без оболочки.
Звёзды рассчитывает сервер только при победе: view.stars, terminal.data.summary.stars и game_won.data.stars. Клиент не выводит их из очков, числа ходов или локального прогресса. У старых уровней schema1 звёзд нет; у активной или проигранной партии поле звёзд отсутствует.
Карточка лобби получает от сервера preview.moves, preview.goals, а у новых уровней также preview.tier и, при введении элемента, preview.new_element. Превью не содержит поля, подсказки хода или seed. Подробности цикла уровней — в разделе прогресса.
Parcel Pilot server
The server contains parcel_pilot_v1@1 replay rules and levels 1–10. Its template remains hidden and accepts free_play only. The lazy browser client and WASM CSP support are included, with client_ready=true in the passport. Template activation remains a separate acceptance gate. The level track cycles from position 6 after level 10. Progress and records come from server results; this version grants no material rewards.
launch accepts an empty payload and records server time. finish accepts codec version 1, an inclusive end_tick, base64url LEB128 tap differences, timing, client build pp1, and a score/hash/checkpoint claim. Geometry is returned in pixels, with vx_q8, ay_q8 and perfect_band_q8 in Q24.8. The seed and controller proof stay on the server. A native replay determines the outcome and corrects divergent claims; physical replay does not prove that a human supplied the inputs.
Limits are 30 starts and 60 commands per minute per game, a 4096-byte payload, 2048 tap characters, at most 1440 taps, eight pauses and 90 seconds of total pause time. Invalid tape returns accepted:false / invalid_tape and leaves the flight active. Repeating a command ID returns its stored response; changing its body returns 409 idempotency_conflict.
A ready run expires after 30 minutes as abandoned(expired). A flight is not resumable and expires at launch plus max_ticks/60 + 300 seconds as lost(interrupted); rejected commands do not extend that deadline. Replay signals are stored with the command result. Strong fast/slow timing signals apply only to wins and reach the reward gate before commit. Parcel remains limited to free_play; later reward policy needs its separate plan gates.
The browser runs the same WASM kernel for animation and provisional scores. Only the server response determines the result and progression. A trusted first input sends launch; flight starts after its response and a minimum 400 ms. Hiding the page, the pause menu and WebGL recovery pause simulation; resuming adds a 1.5-second countdown to the pause budget.
Before sending finish, the client stores its full body and command ID in session storage scoped to project, player and run, without the access token. Reload resends that same command and displays a quiet server result. An active flight without a saved finish cannot resume: it closes as interrupted and the shell starts a fresh attempt. Storage failure permits normal sending but cannot guarantee recovery after reload. Leaving a flight requires confirmation and sends an empty finish(quit). The shared API transport handles retries and token renewal; client code creates no separate network queue.
Memory slice A
The server contains the memory_v1 slice A rules and levels 1–10. Migration 057 enables its template (selectable=true) for free_play. Selecting it still requires a ready server descriptor and a real shell release containing the supported Memory client. The client is included as a lazy memory_v1 module in the shell release, with client_ready=true in its passport. Adding a client module alone does not make the template selectable in the admin panel.
Changing a project's mechanic through the admin API does not reload an already open lobby. A stale Play request receives 409 mechanic_changed with the current play card; the shell retries once with a new start_id. A second change returns to the lobby without a third start. An existing Match3 run keeps its own rules and can continue after switching to Memory; the active-run limit applies separately to each mechanic.
The client displays server responses using vector card faces. It may animate a card to its edge before a response, but never predicts its face or a match. One additional flip can be buffered; it is sent with the next server sequence. Transient failures retry the same command body up to three times, with a four-second timeout per attempt. Arrow keys move through the card grid; Enter or Space flips a card. The menu includes sound and optional unique symbols. Reduced motion removes shaking, particles and waves. A continued terminal run shows the server layout and a quiet result; refreshing the current lobby keeps the normal lobby entry flow.
Memory accepts flip with {"card": <integer>}. The first card costs no move; the second costs one attempt, including a mismatch. Matching pairs award server-calculated combo points and unlock adjacent L1 locks. Only open and matched positions appear in view.faces. A lost run additionally contains the complete layout, including after reload. Abandoned or expired runs do not reveal hidden cards. Its inactivity limit is 24 hours; the common service closes the run as abandoned / expired.
The private server state accumulates an oracle-detection statistic from blind attempts. Windowed strong signals and promotional rewards are a later gate; the hidden free-play template does not claim that acceptance.
Session token
Reward wheel R1 uses separate spin endpoints and an atomic committed outcome. Its runs share the authenticated reward-status endpoint.
Game runs use the same session token as the events path — identified players get one from POST /v1/server/session-token (see core concepts); anonymous play is limited to free_play.
The calls below are made by the game client inside the widget iframe, which is served from the API origin. State-changing calls (POST) must carry Origin equal to the API origin, otherwise they get 403 origin_forbidden.
Starting a run
POST /v1/game-runs
| Field | Type | Meaning |
|---|---|---|
start_id | UUID, required | Retry key — a fresh UUID per start attempt |
replace_active | bool, default false | Close the active run of this game as abandoned (end_reason: "replaced") and start a new one |
intent | "next" (default) or "replay" | next: continue the active run of the project's game, or start the next level. replay: request an opened level by its number |
level_number | integer ≥ 1 or null | Required with replay. With next only null is accepted; a number is 400 validation_error |
expected_mechanic | string, optional | The game your client currently shows. If the project's game is different: 409 mechanic_changed, no run is created |
supported_game_versions | 1–16 integers, optional | Rule versions your client can draw; missing means [1]. If the chosen level needs another version: 409 client_outdated, no run is created |
Older clients may keep sending only {"start_id": "…"}.
START_ID=$(python3 -c 'import uuid; print(uuid.uuid4())')
curl --fail-with-body -sS -X POST "$API_BASE/v1/game-runs" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Origin: $API_BASE" \
-H "Content-Type: application/json" \
-d "{\"start_id\":\"$START_ID\",\"intent\":\"next\",\"expected_mechanic\":\"match3_v1\",\"supported_game_versions\":[1,2]}"The response is the run view plus resumed:
{
"end_reason": null,
"expires_at": "2026-10-31T12:00:00.123Z",
"game_id": "match3_v1",
"game_version": 1,
"is_replay": false,
"level": {
"best_score": 1840,
"id": "level-001",
"number": 1,
"version": 1
},
"mechanic": "match3_v1",
"resumable": true,
"resumed": true,
"run_id": "00000000-0000-0000-0000-000000000001",
"sequence": 0,
"server_time": "2026-10-01T12:00:00.123Z",
"started_at": "2026-10-01T12:00:00.123Z",
"status": "active",
"view": {
"moves_remaining": 18
}
}- The server picks the game and the level. With
next, the request does not select a level;expected_mechanicguards against a stale selection. Withreplay,level_numberselects an opened level. resumed: true— the player already had an active run of the project's game, and that run is returned instead of a new one. Its level may differ from the one your client shows; two tabs share the same run.replace_active: truecloses that run asabandonedwithend_reason: "replaced"and starts a new one.intent: "replay"accepts a completed level or the next opened level; a higher level is409 level_locked. While the game has an active run andreplace_activeisfalse, the answer is409 active_run_existswith that run'srun_idandlevel_numbernext toerror. A completed-level replay (is_replay: true) never advances progress or creates a promo reward. An opened, unfinished level setsis_replay: falseand behaves as an ordinary start.
Retrying a start
- Repeating a start with the same
start_id,intentandlevel_numberreturns the run that thisstart_idcreated and does not spend the start rate limit — safe after a dropped connection or a5xx. - The same
start_idwith differentintentorlevel_number, or with anexpected_mechanicother than that run's game, is409 idempotency_conflict. - A start that answered
resumed: truecreated nothing and does not keep itsstart_id: repeating it is a new start and counts against the limit. - A start that got
429 rate_limitedcreated nothing either. Wait before starting again; the response has noRetry-Afterheader.
Submitting commands
COMMAND_ID=$(python3 -c 'import uuid; print(uuid.uuid4())')
curl --fail-with-body -sS -X POST "$API_BASE/v1/game-runs/$RUN_ID/commands" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Origin: $API_BASE" \
-H "Content-Type: application/json" \
-d "{\"command_id\":\"$COMMAND_ID\",\"sequence\":0,\"action\":\"swap\",\"payload\":{\"from\":{\"row\":0,\"column\":0},\"to\":{\"row\":0,\"column\":1}}}"command_idis the retry key: repeating a command with the samecommand_idand body returns the stored response, including itsprogressionblock. A move is never applied twice.- The run's own game handles its commands and reads, even if the project switched games after the start.
payloadsize is limited per game (match3_v1: 512 bytes) under a hard 8 KiB cap; larger is413 validation_error.- Rate limits are per player and per game:
match3_v1allows 10 starts and 120 commands per minute; above that the call gets429 rate_limited. - A move the rules reject is not an HTTP error: the answer is
200withaccepted: falseand arejection_code. 409 sequence_conflictmeans the command was built on a stale state. The currentsequenceandvieware next toerror, so the client can resynchronize without another read:
{
"error": {
"code": "sequence_conflict",
"message": "unexpected sequence"
},
"sequence": 4,
"view": {
"moves_remaining": 14
}
}Restoring a run
GET /v1/game-runs/{run_id} returns the run view (60 requests per minute per player):
| Field | Meaning |
|---|---|
run_id, game_id, mechanic | The run and its game (mechanic equals game_id) |
game_version, level.id, level.version | Rule and level versions the run is pinned to |
level.number | Level number on the game's track; null on the legacy template path |
level.cycle_round | Сохранённый сервером круг именно этой партии (начиная с 2). Поле отсутствует у обычной партии и исторической записи без метки круга |
level.best_score | The player's best score on this level; null before the first result |
status, end_reason | See lifecycle |
sequence | Number of the next expected command |
is_replay | Повтор пройденного уровня или партия циклического блока. Промо-награды не выдаются; обычный повтор не продвигает дорожку, победа в цикле увеличивает только cycle_wins |
expires_at | When an active run expires; null for a finished run |
resumable | Whether the run can be continued after a page reload |
started_at, server_time | Use server_time to correct the client clock |
view | Game-specific safe state |
The run's seed and hidden state are never returned.
Run lifecycle and end_reason
status | end_reason | When |
|---|---|---|
active | null | The run is in progress |
won | completed | The game's goal was reached |
lost | out_of_moves | match3_v1 ran out of moves |
abandoned | replaced | A start with replace_active: true replaced it |
abandoned | expired | The run passed expires_at |
lost may also carry crashed, timeout, quit or interrupted; match3_v1 does not produce them.
An active run expires a game-specific time after its last command (match3_v1: 30 days). An expired run is closed as abandoned / expired, with no reward, on the next read or command, or by a background job; a command to it gets 409 run_not_active.
Level progression
У проекта с play_mechanic запрос intent: "next" начинает первый ещё не пройденный уровень либо продолжает активную партию. Прогресс хранится на сервере отдельно для проекта, игрока и механики; смена игры проекта его не сбрасывает.
The project's current game selects new starts. Existing runs keep their saved game_id, rules version and state: send commands for that run's own mechanic even after an owner changes the project. A successful read alone does not mean a new run was created or that a reward was granted.
For Parcel Pilot, a ready run can be continued before launch. A flying run cannot reconstruct its unsent input tape after a page reload; the client closes it as interrupted and opens a new attempt. Leaving a running flight through its confirmation menu sends a terminal finish with reason quit. These terminal paths differ from leaving Match3 or Memory for the lobby with an active resumable run.
У Match3 после победы на позиции 15 начинается цикл позиций 11–15: after_last: {"mode":"cycle","from_position":11}, track_version: 1. Первая циклическая партия имеет is_replay: true и level.cycle_round: 2. Победа увеличивает cycle_wins в транзакции хода; повтор той же команды, поражение и новая попытка после поражения счётчик не увеличивают. После пяти побед следующий старт возвращается на позицию 11 с кругом 3; highest_completed остаётся 15, промо-награды в цикле не создаются. Если дорожка расширится, новая непройденная позиция получает приоритет перед циклом.
level.cycle_round берётся из сохранённой партии и остаётся прежним при восстановлении, даже если прогресс уже изменился в другой вкладке. Миграция 058 оставляет исторические записи без метки круга; клиент не вычисляет её по текущему лобби. Номер уровня и круг в интерфейсе могут выглядеть как «Уровень 13 · круг 2».
The final command response (status won or lost) of a run with a level number carries progression:
{
"best_score": 1840,
"cycle_round": null,
"first_completion": true,
"is_replay": false,
"level_number": 1,
"levels_total": 15,
"new_best": false,
"next_level_number": 2,
"track_complete": false
}| Field | Meaning |
|---|---|
level_number | The level that was played |
first_completion | This win completed the level for the first time; false after a loss |
is_replay | The run was a replay, as in the run's is_replay |
next_level_number | The level the next intent: "next" will start; null after a loss |
levels_total | Number of positions on the track |
track_complete | All positions are completed |
cycle_round | Круг следующего уровня в цикле; null, если следующая позиция не циклическая. Это отличается от level.cycle_round, закреплённого в текущей партии |
best_score | The level's best score including this run |
new_best | This run strictly beat the previous best; false for the first result on the level |
progression is stored with the response: a retried command returns it unchanged. The game_won and game_lost facts carry level_number and first_completion in data. On the legacy template path (play_mechanic empty) there is no track: level.number is null and there is no progression.
Reward status
GET /v1/game-runs/{run_id}/reward tells the player what happened to the reward for a run:
{
"label_key": "reward.status.pending",
"status": "pending",
"updated_at": "2026-10-01T12:00:00.123Z"
}status | Meaning | label_key |
|---|---|---|
none | No claim: free_play, no matching campaign, a cap or cooldown | reward.status.none |
pending | The reward is being sent | reward.status.pending |
checking | Reserved, granted after a check | reward.status.checking |
confirmed | Your webhook endpoint accepted the delivery | reward.status.confirmed |
delayed | Delivery is delayed; the outcome is not known yet | reward.status.delayed |
unavailable | The reward could not be granted | reward.status.unavailable |
- Only the run's owner can read it; it keeps working after the project switches games. Reasons, risk scores and hold deadlines are never returned.
- Several claims of one run (several campaigns) are folded into one status:
pending>checking>delayed>confirmed>unavailable;updated_atis the latest change,nullfornone. - Responses are
Cache-Control: no-store; the limit is 30 requests per minute. Suggested polling: 1, 2, 4 and 8 seconds after the result screen opens, then every 15 seconds while the status ispendingorchecking, for at most 60 seconds. - The result screen must not wait for this status. Only the signed webhook confirms a grant.
- Wins rewarded before claims recorded their run report
none.
Rewarding a win
A confirmed win (status: "won" and a game_won fact) can create a reward claim when the project's game_mode is promo_rewards and an active campaign has the trigger type game_won. Optional trigger_params filters:
| Key | Type | Effect |
|---|---|---|
mechanic | string | Only wins in this game. Without it the campaign fires for every game; campaigns created before this filter existed were set to match3_v1 |
level_number | integer > 0 | Only wins on this level |
min_level_number | integer > 0 | Only wins on this level or higher |
first_completion_only | bool | Only the first win of each level |
An unknown key or a wrong type is rejected when the campaign is saved (400 validation_error).
- A replay (
is_replay) and a run in a repeating round of the track never create a claim — even withoutfirst_completion_only. After the last level of amatch3_v1track, further runs replay that level and earn no claim. - One claim per player and campaign by default (
max_per_user1). Withmax_per_userabove 1 — one claim per won run; withfirst_completion_only— one per level. - The claim is created inside the transaction that confirms the win; a retried winning command lands on the same claim. From there it passes the same value gate as every other reward: eligible immediately, or held/reviewed under the configured fraud policy; a strong mechanic signal has a review floor even in
observe. Your webhook receives it once released, with the run'srun_id,mechanicandlevel_number.
Errors
Error bodies are {"error": {"code", "message", "request_id"}}; some 409 bodies carry extra fields next to error.
| Code | HTTP | When | What to do |
|---|---|---|---|
validation_error | 400 | Invalid start body or command shape; legacy project whose template is not a game run | Fix the request |
validation_error | 404 | The session's project does not exist | Issue a new session |
validation_error | 413 | HTTP body exceeds 2097152 bytes, payload exceeds the game's limit, or the stored command envelope exceeds 8192 bytes | Fix the client |
unauthorized, invalid_token | 401 | Missing, invalid or expired session token | Get a fresh token |
origin_forbidden | 403 | POST not sent from the API origin | Call from the widget iframe |
anonymous_rewards_forbidden | 403 | Anonymous session outside free_play | Use an identified session |
not_found | 404 | Run of another player, or unknown run | Start a new run |
plan_limit_exceeded | 402 | The account's plan is exhausted | Do not retry; check billing |
level_locked | 409 | replay above the opened level | Re-read the lobby |
mechanic_not_enabled | 409 | The project shows no game for Play | Do not offer Play |
mechanic_changed | 409 | expected_mechanic is stale; play and config_revision are in the body | Load that game and retry once with a new start_id |
client_outdated | 409 | The chosen level needs a rule version your client lacks | Reload the page |
active_run_exists | 409 | replay while a run is active; run_id, level_number in the body | Continue it, or start with replace_active: true |
idempotency_conflict | 409 | Same start_id / command_id with a different body or game | Client bug: use a fresh key |
sequence_conflict | 409 | Command built on a stale state; sequence, view in the body | Resynchronize from view |
run_not_active | 409 | The run is finished, replaced or expired | Start a new run |
run_version_unsupported | 409 | The run's rule version is not in this build | Reload the page |
run_state_invalid | 409 | Stored state is damaged or cannot be decoded; distinct from a missing rule version | Reload the page; report the request_id if it persists |
rate_limited | 429 | Per-game start or command limit; also read limits per player: run 60/min, reward status 30/min, lobby 60/min | Wait; a 429'd start created nothing |
mechanic_unavailable | 503 | The game is switched off, not built, or its rule version is unsupported | Show "temporarily unavailable"; do not retry automatically |
integrity_rejected | 500 | The server's own result check failed; the move was not saved | Report the request_id |
internal_error | 500 | Unexpected server error | Report the request_id |
Malformed JSON, a missing Content-Type: application/json or a wrongly typed body field (for example a start_id that is not a UUID) return a safe JSON validation_error with the original HTTP status (400, 415 or 422). A malformed run_id in the path is still rejected by the HTTP extractor with a plain text body.
The HTTP body limit is 2097152 bytes (2 MiB). The stored command limit applies to the entire normalized envelope (command_id, sequence, action, payload), measured as octet_length(request::text) by PostgreSQL before FraudGate or a move is applied. JSONB adds spaces and expands numeric exponents; compact client JSON length is not the stored length. The per-game payload limit remains separate. Storage checks also cap private state and client view at 131072 bytes each, and the saved command response at 262144 bytes, in the same JSONB representation. Limits are inclusive; a command above the limit creates no command row or partial progress. Its final database CHECK also maps safely to 413, while unexpected storage failures return a generic internal_error. An identical accepted command returns the saved response unchanged.
Public state/storage errors never include SQL, constraint names or private state. Their diagnosis stays in server logs correlated by request_id; request bodies and tokens are not logged. State decoder messages can quote private field values, so only the state error category is logged.
The shared widget controller returns to the lobby on level_locked (with a neutral message) or mechanic_not_enabled. A second mechanic_changed returns to fresh lobby state; a change that requires a different document CSP asks for a page reload. 429 starts are not repeated automatically: the countdown uses Retry-After when supplied, otherwise it asks the player to try later. A transport retry keeps the same start_id. An idempotency conflict offers only a return to the lobby. If a resumed run's level differs from the displayed one, the controller offers Continue or restart with replace_active: true, warning that the previous run's progress will be lost. Match3 uses these shared transitions after its MechanicModule integration.
Mode validation
A game whose template does not list your chosen game_mode in its allowed modes is rejected at save time (PATCH /v1/admin/projects/{id}) with a clear error, rather than silently accepting a reward mode the game can never pay out. The same check runs when play_mechanic changes.