Skip to content

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:

json
{
  "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

FieldTypeMeaning
start_idUUID, requiredRetry key — a fresh UUID per start attempt
replace_activebool, default falseClose 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_numberinteger ≥ 1 or nullRequired with replay. With next only null is accepted; a number is 400 validation_error
expected_mechanicstring, optionalThe game your client currently shows. If the project's game is different: 409 mechanic_changed, no run is created
supported_game_versions1–16 integers, optionalRule 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": "…"}.

bash
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:

json
{
  "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_mechanic guards against a stale selection. With replay, level_number selects 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: true closes that run as abandoned with end_reason: "replaced" and starts a new one.
  • intent: "replay" accepts a completed level or the next opened level; a higher level is 409 level_locked. While the game has an active run and replace_active is false, the answer is 409 active_run_exists with that run's run_id and level_number next to error. A completed-level replay (is_replay: true) never advances progress or creates a promo reward. An opened, unfinished level sets is_replay: false and behaves as an ordinary start.

Retrying a start ​

  • Repeating a start with the same start_id, intent and level_number returns the run that this start_id created and does not spend the start rate limit — safe after a dropped connection or a 5xx.
  • The same start_id with different intent or level_number, or with an expected_mechanic other than that run's game, is 409 idempotency_conflict.
  • A start that answered resumed: true created nothing and does not keep its start_id: repeating it is a new start and counts against the limit.
  • A start that got 429 rate_limited created nothing either. Wait before starting again; the response has no Retry-After header.

Submitting commands ​

bash
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_id is the retry key: repeating a command with the same command_id and body returns the stored response, including its progression block. 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.
  • payload size is limited per game (match3_v1: 512 bytes) under a hard 8 KiB cap; larger is 413 validation_error.
  • Rate limits are per player and per game: match3_v1 allows 10 starts and 120 commands per minute; above that the call gets 429 rate_limited.
  • A move the rules reject is not an HTTP error: the answer is 200 with accepted: false and a rejection_code.
  • 409 sequence_conflict means the command was built on a stale state. The current sequence and view are next to error, so the client can resynchronize without another read:
json
{
  "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):

FieldMeaning
run_id, game_id, mechanicThe run and its game (mechanic equals game_id)
game_version, level.id, level.versionRule and level versions the run is pinned to
level.numberLevel number on the game's track; null on the legacy template path
level.cycle_roundСохранённый сервером круг именно этой партии (начиная с 2). Поле отсутствует у обычной партии и исторической записи без метки круга
level.best_scoreThe player's best score on this level; null before the first result
status, end_reasonSee lifecycle
sequenceNumber of the next expected command
is_replayПовтор пройденного уровня или партия циклического блока. Промо-награды не выдаются; обычный повтор не продвигает дорожку, победа в цикле увеличивает только cycle_wins
expires_atWhen an active run expires; null for a finished run
resumableWhether the run can be continued after a page reload
started_at, server_timeUse server_time to correct the client clock
viewGame-specific safe state

The run's seed and hidden state are never returned.

Run lifecycle and end_reason ​

statusend_reasonWhen
activenullThe run is in progress
woncompletedThe game's goal was reached
lostout_of_movesmatch3_v1 ran out of moves
abandonedreplacedA start with replace_active: true replaced it
abandonedexpiredThe 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:

json
{
  "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
}
FieldMeaning
level_numberThe level that was played
first_completionThis win completed the level for the first time; false after a loss
is_replayThe run was a replay, as in the run's is_replay
next_level_numberThe level the next intent: "next" will start; null after a loss
levels_totalNumber of positions on the track
track_completeAll positions are completed
cycle_roundКруг следующего уровня в цикле; null, если следующая позиция не циклическая. Это отличается от level.cycle_round, закреплённого в текущей партии
best_scoreThe level's best score including this run
new_bestThis 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:

json
{
  "label_key": "reward.status.pending",
  "status": "pending",
  "updated_at": "2026-10-01T12:00:00.123Z"
}
statusMeaninglabel_key
noneNo claim: free_play, no matching campaign, a cap or cooldownreward.status.none
pendingThe reward is being sentreward.status.pending
checkingReserved, granted after a checkreward.status.checking
confirmedYour webhook endpoint accepted the deliveryreward.status.confirmed
delayedDelivery is delayed; the outcome is not known yetreward.status.delayed
unavailableThe reward could not be grantedreward.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_at is the latest change, null for none.
  • 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 is pending or checking, 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:

KeyTypeEffect
mechanicstringOnly wins in this game. Without it the campaign fires for every game; campaigns created before this filter existed were set to match3_v1
level_numberinteger > 0Only wins on this level
min_level_numberinteger > 0Only wins on this level or higher
first_completion_onlyboolOnly 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 without first_completion_only. After the last level of a match3_v1 track, further runs replay that level and earn no claim.
  • One claim per player and campaign by default (max_per_user 1). With max_per_user above 1 — one claim per won run; with first_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's run_id, mechanic and level_number.

Errors ​

Error bodies are {"error": {"code", "message", "request_id"}}; some 409 bodies carry extra fields next to error.

CodeHTTPWhenWhat to do
validation_error400Invalid start body or command shape; legacy project whose template is not a game runFix the request
validation_error404The session's project does not existIssue a new session
validation_error413HTTP body exceeds 2097152 bytes, payload exceeds the game's limit, or the stored command envelope exceeds 8192 bytesFix the client
unauthorized, invalid_token401Missing, invalid or expired session tokenGet a fresh token
origin_forbidden403POST not sent from the API originCall from the widget iframe
anonymous_rewards_forbidden403Anonymous session outside free_playUse an identified session
not_found404Run of another player, or unknown runStart a new run
plan_limit_exceeded402The account's plan is exhaustedDo not retry; check billing
level_locked409replay above the opened levelRe-read the lobby
mechanic_not_enabled409The project shows no game for PlayDo not offer Play
mechanic_changed409expected_mechanic is stale; play and config_revision are in the bodyLoad that game and retry once with a new start_id
client_outdated409The chosen level needs a rule version your client lacksReload the page
active_run_exists409replay while a run is active; run_id, level_number in the bodyContinue it, or start with replace_active: true
idempotency_conflict409Same start_id / command_id with a different body or gameClient bug: use a fresh key
sequence_conflict409Command built on a stale state; sequence, view in the bodyResynchronize from view
run_not_active409The run is finished, replaced or expiredStart a new run
run_version_unsupported409The run's rule version is not in this buildReload the page
run_state_invalid409Stored state is damaged or cannot be decoded; distinct from a missing rule versionReload the page; report the request_id if it persists
rate_limited429Per-game start or command limit; also read limits per player: run 60/min, reward status 30/min, lobby 60/minWait; a 429'd start created nothing
mechanic_unavailable503The game is switched off, not built, or its rule version is unsupportedShow "temporarily unavailable"; do not retry automatically
integrity_rejected500The server's own result check failed; the move was not savedReport the request_id
internal_error500Unexpected server errorReport 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.

Internal & integration documentation