Что это, если совсем просто
Собрались компанией. На телевизор выводим игровое табло. Каждый берёт телефон, вводит код комнаты — и телефон становится его пультом. Ведущий ведёт игру со своего телефона. Всё как в телевизионной викторине, только дома или онлайн.
Табло
Большой экран, который видят все: вопрос, ответы, счёт команд.
Ведущий
Управляет игрой с телефона — открывает ответы, ставит очки. Как пультом.
Игроки
Заходят по коду, отвечают и жмут кнопки со своих телефонов.
Мы строим не одну игру, а платформу: общий «движок» комнат и телефонов — один раз, а игры (100 к 1, викторины, «смешные ответы» и другие) добавляются сверху, как приложения. Играть можно дома по WiFi и по интернету с друзьями из разных городов.
Три экрана и один «мозг»
Все телефоны и телевизор общаются с одним сервером-«мозгом». Он хранит игру и держит всех в одной картине — что бы у кого ни лагало.
Получаешь короткий код. Табло открываешь на телевизоре.
С телефона, без установки приложений. Выбирают команду.
Ведущий рулит с телефона, ответы появляются на табло у всех одновременно.
Ведущему не нужен ноутбук: игра сама присылает ему на телефон нужные кнопки, а игрокам — поле для ответа. Один и тот же приём работает для любой игры — поэтому новые игры добавлять легко.
▸ 🔧 Для инженеров: движок и контракт игры
Пять слоёв: клиенты (витрина / табло / пульт / игрок) → движок комнат (game-agnostic) → модули игр (SDK) → бэкенд (аккаунты, каталог, entitlements, подписка) → данные (Postgres; комнаты эфемерные). Клиент шлёт только намерение, сервер — единственный, кто меняет состояние (single-writer на комнату).
// от клиента — только намерение ClientAction = { id, clientSeq, expectedVersion?, action, payload? } // от сервера — снапшот/патч/подтверждение (пер-viewer, санитайзнуто) FullSnapshot = { roomSeq, version, snapshot } ActionResult = { actionId, status, replayed?, appliedVersion } GameModule = { meta, init, reduce(state,event), snapshot(state,viewer), ui:{board,host,player} }
Управление с телефона — принцип ядра: snapshot.controls (кнопки ведущему) и snapshot.input (button/text/choice) платформа рендерит единообразно. Один контракт покрывает и «ведущий открывает» (100 к 1), и «пишут → голосуют» (Quiplash).
Полная версия — в наших ADR (в репозитории, docs/adr/). Ниже — что они гарантируют, тоже для инженеров.
Что мы продумали заранее
Мобильный интернет капризный, серверы иногда падают, у кого-то садится телефон. Чтобы игра от этого не ломалась и никто не мог смухлевать — заложили несколько правил.
Нельзя подсмотреть
Правильные ответы физически не отправляются на телефоны игроков и на табло раньше времени.
Не задваивается
Плохая связь, случайно нажал дважды, телефон переотправил — засчитается один раз.
Ничего не теряется
Даже если сервер моргнул в момент действия — очки и прогресс на месте.
Переживает сбои
Упал сервер — другой мгновенно подхватывает комнату с последнего автосейва.
Ведущий пропал — не страшно
Сел телефон у ведущего — игра встаёт на паузу и ждёт его, а не рушится.
Не тормозит у всех
Если у одного тормозит телефон — остальные играют без задержек.
Архитектуру сверили с тем, как устроены известные движки (Colyseus, Nakama, Jackbox), и прогнали через несколько раундов независимого ревью (Codex). Вывод: фундамент правильный, начинать делать можно.
▸ 🔧 ADR-0001 — Синхронизация и доставка ● Принят
Проблема. Мобильные разрывы, двойные тапы, реконнекты, в облаке — смена владельца комнаты. Нельзя терять/задваивать действия, отдавать ack за то, что пропадёт при крэше, и утекать секретный контент.
validate → dedup → reduce → assign {roomSeq, version}
→ атомарно persist { transition, ActionOutcome, timers, outbox } под roomEpoch
→ publish per-viewer views → effects из outbox- Одна транзакционная граница с проверкой
roomEpoch. Атомарность ≠ durability: ack только после durable-барьера хранилища (Postgres-tx или Redis с явной ack-политикой). - Дубликат: дедуп хранит immutable
ActionOutcomeбез identity; повтор re-envelope-ится текущим identity,status=<исходный>, replayed=true. - Consistency boundary: владение — в том же durable-store, что журнал; реестр/
wsUrl— производный кеш (иначе TOCTOU). - Durability: облако — append-only журнал/outbox; LAN — честный
bounded-rollback(явная capabilitydurabilityMode). Гарантия: at-least-once + идемпотентность = effectively-once. roomSeq— room-wide durable, монотонный сквозь epochs;clientSeqловит пропуски входа (на сервере); идемпотентность по(actorId, id).expectedVersionточечно (конфликтные host-команды);buzz— order-sensitive first-wins. Смена epoch/viewer → только FullSnapshot; патч из санитайзнутой view.- Эффекты: ephemeral cue/sound (
expiresAt, без replay) vs durable external (retry доdone, идемпотентность поeffectId). Таймеры поdeadline, не тики. Backpressure: дисконнект медленных.
▸ 🔧 ADR-0002 — Владение комнатой и восстановление ● Принят
Проблема. Состояние комнаты в памяти одного процесса. Нужны маршрутизация по коду, поведение при падении шарда и деплое, отсутствие split-brain, реконнект ведущего, «код ≠ авторизация».
код → { roomId, incarnation, shardId, wsUrl, owner, roomEpoch, leaseUntil, checkpointRevision, status }- Клиент резолвит комнату по коду через gateway; после падения шарда — ре-резолв по коду. Владение по монотонному
roomEpoch(fencing), не только lease. - Recovery — двухфазный CAS: CAS-1 захват (
epoch+1,status=recovering, старый wsUrl не резолвится) → read committed checkpoint → replay журнала послеjournalOffsetс проверкой непрерывностиroomSeq→ overdue-таймеры детерминированно → CAS-2 (status=active, новый wsUrl); первым — FullSnapshot. - Чекпоинт хранит:
roomSeq,journalOffset, дедуп-записи, pending-outbox, таймеры, lifecycle, roster, entitlement-grant, и 5 версий (wire · engineSchema · gameModule · contentPack · viewSchema). - Lifecycle
waiting→running→paused→draining→ended; отвал ведущего → пауза + окно реконнекта (таймеры сохраняют remaining); board-loss не обязана паузить. - Дренаж при деплое: не брать новые, дать доиграть / handoff — релиз не рвёт игры. Токены host/player/board; takeover →
sessionGeneration++. Entitlement — доepoch=1.
Канонические ADR со всеми деталями и отвергнутыми альтернативами живут в репозитории: docs/adr/0001…, 0002….
С чего начнём делать
Сначала — небольшой «сквозной кусочек»: работающий движок комнат + первая игра (100 к 1) поверх него, с проверками надёжности из коробки. Потом — витрина, ещё игры, аккаунты и подписка.
▸ 🔧 Для инженеров: план Фазы 1
- Protocol types (конверт ADR-0001) → RoomActor (последовательная очередь) → pure GameModule harness → in-memory bounded-rollback за transactional-интерфейсом.
- Гарантии ADR — сразу conformance-тестами: дубликат → тот же outcome; stale epoch не коммитится; смена viewer → full snapshot; ack не опережает commit-barrier; pause/recovery не дублирует таймер.
- Затем — «100 к 1» первым модулем. Patch и production durable-store — позже, не меняя контракт.