Черновик · понятными словами

Игры для компании — как в телике

Ведущий на большом экране, друзья играют с телефонов, заходят по короткому коду. Одна платформа — много игр. Как PARTYstation или Jackbox, только наша.

К О Д 📺 телевизор + 📱 телефоны
Идея

Что это, если совсем просто

Собрались компанией. На телевизор выводим игровое табло. Каждый берёт телефон, вводит код комнаты — и телефон становится его пультом. Ведущий ведёт игру со своего телефона. Всё как в телевизионной викторине, только дома или онлайн.

📺

Табло

Большой экран, который видят все: вопрос, ответы, счёт команд.

🎛️

Ведущий

Управляет игрой с телефона — открывает ответы, ставит очки. Как пультом.

📱

Игроки

Заходят по коду, отвечают и жмут кнопки со своих телефонов.

🧩 Главная мысль

Мы строим не одну игру, а платформу: общий «движок» комнат и телефонов — один раз, а игры (100 к 1, викторины, «смешные ответы» и другие) добавляются сверху, как приложения. Играть можно дома по WiFi и по интернету с друзьями из разных городов.

Как это работает

Три экрана и один «мозг»

Все телефоны и телевизор общаются с одним сервером-«мозгом». Он хранит игру и держит всех в одной картине — что бы у кого ни лагало.

1
Создаёшь комнату

Получаешь короткий код. Табло открываешь на телевизоре.

2
Друзья заходят по коду

С телефона, без установки приложений. Выбирают команду.

3
Играете

Ведущий рулит с телефона, ответы появляются на табло у всех одновременно.

🎛️ Почему пульт — в телефоне

Ведущему не нужен ноутбук: игра сама присылает ему на телефон нужные кнопки, а игрокам — поле для ответа. Один и тот же приём работает для любой игры — поэтому новые игры добавлять легко.

🔧 Для инженеров: движок и контракт игры

Пять слоёв: клиенты (витрина / табло / пульт / игрок) → движок комнат (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 за то, что пропадёт при крэше, и утекать секретный контент.

Порядок обработки (single-writer)
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 (явная capability durabilityMode). Гарантия: 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 harnessin-memory bounded-rollback за transactional-интерфейсом.
  • Гарантии ADR — сразу conformance-тестами: дубликат → тот же outcome; stale epoch не коммитится; смена viewer → full snapshot; ack не опережает commit-barrier; pause/recovery не дублирует таймер.
  • Затем — «100 к 1» первым модулем. Patch и production durable-store — позже, не меняя контракт.