Третий месяц вайб-дебажу настолку в браузере. На что уходит столько времени

Веб-версию своей настолки я отлаживаю третий месяц. «Release любой ценой» это карточная игра про хаос IT-разработки, и про первую версию её доски я уже писал в августе: тогда доска только училась играть сцены из плейграунда, а коллега как раз смержил релиз и защиту. С тех пор партия доехала до беты, и сыграть в неё можно по ссылке: https://mythhand.github.io/ReleaseBoardGameP2P/.
Параллельно в соцсетях попадаются посты о том, как за выходные с ИИ собирают игры: с 3D, многопользовательские, выложенные, можно сыграть. Инструменты у нас одни и те же, и я не понимаю, почему у них выходные, а у меня месяцы.
Нас в проекте по-прежнему двое плюс модели. Коллега-разработчик держит движок и сеть, я всё, что видно на экране, и правила игры. Код пишут модели: у меня Opus, у коллеги Opus и Astra. Прочитать код я могу, а вот понять нет, поэтому разбор пойдёт от того, что я вижу на экране третий месяц подряд, как в дне сурка.
Без устройства статье не обойтись: как доска договаривается с хостом или откуда у всех одна картина стола, с экрана не видно. В таких местах будет вставка со слов коллеги или из описания модели, сам я это пересказывать не возьмусь.
Как движутся карты
Выбираю карту в руке, она приподнимается и показывает превью. Беру мышкой и вкладываю её на стол, веер за ней смыкается. Отпускаю, и карта садится в центре, а у соперников тоже выкладывается эта карта в том же месте. Одна секунда партии, и я в ней давно не вижу карту, только швы между движениями.

Хотел как за столом
В свою настолку я играл много, и от браузера хотел того же ощущения, что от карт в руках за столом. Решил это ещё в начале, и с первых движений в плейграунде: карта на экране перетекает с места на место вместе с поворотом и размером, без телепортаций, чтобы по анимациям на экране было понятно, что происходит. Стрелку прицела и карту, у которой пульсирует путь, я подсмотрел в больших карточных играх на ПК. И никаких линейных анимаций, они выглядят неестественно.
Не всё со стола стоило нести на экран. Перемешивание колоды я не анимировал, оно бы только затягивало партию. А затяжные игровые моменты за столом получили быстрый розыгрыш на экране.
Что пришлось изобретать для браузера
Готовой библиотеки, которая двигала бы карты в браузере как на столе, не приметил. Карты собирал вёрсткой, и все пути заложил заранее: откуда, куда, по какой кривой, через какие промежуточные состояния, что в это время делают соседние карты. Для этого появился словарь анимаций: десятки модулей, из которых собирается каждое движение на столе.
Что-то вставало сразу: переворот карты, стрелка прицела. А вот над тем, о чем за физическим столом не задумываешься, пришлось посидеть. Разыгранная карта встаёт под случайным углом, чтобы розыгрыши не выглядели одной и той же анимацией. Для сброса отлаживал вид кучей. Отдельная история вход карты в веер. Сначала карта вставлялась по прямой и в конце телепортировалась за соседнюю карту, которая её перекрывает. Потом появилась дуга: карта заходит сбоку, над веером, и на вершине дуги подныривает под него. У крайней карты своё исключение: подныривать ей не под что, и дуга становится прямой.

Код
Путь входа в веер считает одна функция из геометрии веера. Контрольная точка квадратичной кривой стоит на один интервал веера левее позиции, угол подъёма берётся из того, насколько выше позиции отпущена карта, и ограничен 40 градусами. У крайней позиции нет соседней карты справа, отступ контрольной точки равен нулю, и та же формула даёт прямую.
const STEP_ANCHORS: [number, number][] = [
[2, 136],
[8, 92],
[20, 54],
]
export function handStep(n: number): number {
const [[x0, y0], [x1, y1], [x2, y2]] = STEP_ANCHORS
const l0 = ((n - x1) * (n - x2)) / ((x0 - x1) * (x0 - x2))
const l1 = ((n - x0) * (n - x2)) / ((x1 - x0) * (x1 - x2))
const l2 = ((n - x0) * (n - x1)) / ((x2 - x0) * (x2 - x1))
return y0 * l0 + y1 * l1 + y2 * l2
}
const APPROACH_REACH = 1
const APPROACH_RISE_DEG = 40
const PATH_STEPS = 24
export function insertPath(from: Point, to: Point, slot: number, total: number): Point[] {
const reach = slot === total - 1 ? 0 : handStep(total) * APPROACH_REACH
const rise = Math.min(
Math.max(Math.atan2(to.y - from.y, Math.max(reach, 1)), 0),
(APPROACH_RISE_DEG * Math.PI) / 180,
)
const cx = to.x - reach * Math.cos(rise)
const cy = to.y - reach * Math.sin(rise)
const path: Point[] = []
for (let i = 0; i <= PATH_STEPS; i += 1) {
const t = i / PATH_STEPS
const u = 1 - t
path.push({
x: u * u * from.x + 2 * u * t * cx + t * t * to.x,
y: u * u * from.y + 2 * u * t * cy + t * t * to.y,
})
}
return path
}
Слой переключает модуль прилёта в веер: первые 140 миллисекунд из 480 карта летит над веером, потом получает слой своей позиции. В веере карты лежат внахлёст, слой определяет, какая карта какую перекрывает. Переключение стоит в той точке полёта, когда карта дальше всего от соседней карты справа, которая будет её перекрывать. Если переключать слой, когда карта уже пришла в веер, то смена слоя будет выглядеть резким переходом.
С соперниками оказалось проще. Соперник на экране это блок, его карты скрыты, а закрытый веер видеть бессмысленно. Карта, которая уходит к нему, улетает в этот блок и пропадает: со стола её забрали, а кто, видно по блоку.
Техническая сторона
Картину стола задаёт проекция состояния партии от хоста. На каждое действие движок отвечает пачкой событий. Планировщик доски читает пачку вместе с проекцией, какой она была до этой пачки, и собирает из модулей словаря такт на каждое событие, у которого есть движение на столе. Такты играют по одному из очереди: пока такт играет, доска показывает проекцию, с которой он начался, а когда очередь пуста, текущую проекцию от хоста. Последний кадр такта совпадает с началом следующего. События приходят в каждый браузер без информации, которую этому игроку видеть нельзя, например в чужом доборе нет имени карты.
Учесть всё заранее не получилось
Сцены переехали на доску на тех же модулях, и первые партии показали, сколько плейграунд о столе не знал. В плейграунде сцена кончалась посадкой карты. На доске каждому месту, куда карта садится, понадобилось своё состояние, которое игра отслеживает и показывает всем одинаково.
Центр стола. Выкладываю релиз, выбираю карту, которой за него заплатить, а у соперников на экране ничего: будто я ничего не делаю. У меня на экране при этом всё выглядело нормально. Оказалось, стол не знал, что карта уже лежит в центре, и считал её в веере: когда я доигрывал пару, выложенная карта телепортировалась обратно в веер и оттуда ещё раз выходила в центр. Так же двоилось и от хоста, его оповещение «разыграй карту» приходило на карту, которая уже стояла на месте. Решилось правкой движка: в него добавилось отслеживание карт, выложенных в центр, чтобы игра держала состояние стола и показывала всем одно и то же. В движок я лезу редко, и это был тот случай.
Техническая сторона
Центр стола это часть состояния партии: всё, в чём должны сойтись все браузеры, лежит в состоянии движка у хоста и приходит в проекции. Карта, выложенная в ожидании пары, этому правилу не подчинялась: она была только в интерфейсе выложившего, движок считал её в руке, и центр у каждого браузера был свой. Выкладка и возврат карты стали действиями движка. Движок помечает карту как выложенную в состоянии места, из руки она не уходит. Событие о выкладке открытое, проекция несёт выложенные карты каждого места, и центр все браузеры рисуют из одного состояния. Планировщик доски событие о своей выкладке пропускает: карта на месте с того момента, как игрок её положил, и по подтверждению хоста такт не строится.
Панель состояния хода. Она работала, пока партия не стала попадать в ситуации, которых панель не знала, и эти состояния пришлось дописывать.
Куча сброса. Карты в куче меняли порядок или свои повороты, и сброс пересобирался телепортацией слоёв и позиций.
DDoS по AI-карте. Бью DDoS по AI-релизу в зоне релиза соперника, и карта улетает в сброс, хотя по правилам AI-карта в сброс не уходит и возвращается в колоду событий. По логике игры карта прибавлялась в колоду событий, а на столе при этом лежала ещё и в сбросе. Стол не считал карты и не ловил такие дубли, перепроверок у него прибавилось.
И много чего ещё.

Откуда взялся третий месяц
По истории коммитов проект делится на три куска. В июне я собирал стиль и плейграунд: карты, руку, стол, словарь анимаций. Коллега в те же дни ставил монорепо, сеть и лобби. В июле и начале августа появились движок и доска, и 10 августа прошла первая играбельная партия, ещё без анимаций. С середины августа доска получала анимации из плейграунда сцена за сценой: раздача, розыгрыш, добор, стрелка, пара, релиз и защита. С этого момента идёт отладка анимаций, логики и их стыков, чтобы всё собралось бесшовно.

Проверка через экран
Отлаживаю я на стенде: та же доска и тот же движок, только состояние партии собирается из пресета, и одну сцену можно гонять сколько угодно. Разыгрываю карту, вижу, что не так, описываю модели, она правит, я смотрю снова. Долго я описывал ей увиденное словами. Потом сообразил, что анимации интерфейса можно писать в лог: скинул лог, и модель увидела те же кадры, что я глазами, а под длинные логи сама сделала анализатор, который называет проблемные места.
Лог и ответ анализатора: розыгрыш DDoS
Из лога записи стенда
{"t": 7705.6, "from": "stand", "kind": "action", "data": {"action": {"type": "PLAY", "player": "you", "card": "attack-ddos#2", "target": {"kind": "release", "player": "p2", "slot": "backend"}, "at": 1791130483830}, "events": [{"type": "attacked", "attacker": "you", "card": "attack-ddos", "sudo": false, "target": "p2", "id": 106}, {"type": "discarded", "player": "you", "card": "attack-ddos", "reason": "attackSpent", "id": 107, "parent": 106}, {"type": "releaseReturned", "player": "p2", "slot": "backend", "card": "release-backend", "id": 108, "parent": 106}, {"type": "discarded", "player": "p2", "card": "support-code-review", "reason": "destroyed", "id": 109, "parent": 108}], "table": {"turn": "you", "pending": null, "window": null, "shown": {"you": [], "p2": [], "p3": []}}}}
{"t": 7725.2, "from": "board", "kind": "beat", "data": {"key": "attack:106", "exclusive": false, "base": {"hand": ["DDoS attack-ddos#2", "DDoS attack-ddos#4", "DDoS attack-ddos#3", "DDoS attack-ddos#5"], "shown": ["you:DDoS"], "pending": null, "centreAttack": null}}}
{"t": 9940.6, "from": "board", "kind": "beatEnd", "data": {"key": "attack:106", "ended": {"hand": ["DDoS attack-ddos#4", "DDoS attack-ddos#3", "DDoS attack-ddos#5"], "shown": [], "pending": null, "centreAttack": null}}}
Из разбора анализатора
attack-ddos
7741 faceWrap<fan<hand & faceWrap<fan<hand & faceWrap<fan<hand & heapCard<stack<box & pose<board-centre-staged<centre
9460 faceWrap<fan<hand & faceWrap<fan<hand & faceWrap<fan<hand & heapCard<stack<box & pose<flyer<board-table
9926 faceWrap<fan<hand & faceWrap<fan<hand & faceWrap<fan<hand & heapCard<stack<box & heapCard<stack<box
release-backend
7741 main<pair<flyer
9460 flyer<board-table<board
9942 NOWHERE
support-code-review
7741 aux<pair<flyer
9460 pose<flyer<board-table
9926 heapCard<stack<box
(nothing gone, doubled, hopping, re-stacked, jumping, teleported or vanished)
Проверка есть и до правки: по ответу модели видно, куда она полезет, в модуль или заплаткой в сцену, и держится ли она правил игры. И после: каждый PR проверяют модели, потом смотрим сами, я со стороны экрана и правил, коллега со стороны кода.

Техническая сторона
Стенд живёт отдельной страницей фронтенда, в сборку игры не входит и своего кода не имеет: он монтирует ту же доску, тот же движок и ту же проекцию, что партия в сети, поэтому найденный на нём дефект принадлежит игре, а правка ложится в общий код. Сценарий стенда это уже раздатая партия с теми же идентификаторами карт при каждом запуске и без сетевого соединения. Переключатель места перемонтирует доску из проекции другого игрока через тот же фильтр видимости, что в сети. Запись складывает в один файл всё, что сделала доска: действия и ответы движка, каждый такт с тем, что он опубликовал, след жеста и состояние экрана на каждом кадре анимации, место, поза и слой каждой карты. Анализатор читает файл и проверяет одно правило, «одна карта в одном месте». Нарушение относится к одному из семи видов: карта нарисована нигде или дважды, мелькнула в чужом месте, поменялась слоем с соседней, прыгнула в другую позу, сдвинулась без полёта, исчезла без ухода. У каждой находки указан такт, который шёл в этот момент. То же правило держит тест на настоящей доске: он проигрывает сцены через доску и падает на кадре, где карта нарисована дважды или нигде.
Сижу я в долгих сессиях в Claude Code, и если модели по задаче нужны агенты, она сама их и запускает. По нынешним меркам вайбкодер из меня так себе: оркестра из агентов я себе не собирал. Задачи, которые себе ставлю, редко выглядят как спека с понятным итогом. Обычно это продуктовая задача, что должно происходить за столом, а до нюансов докапываюсь уже по ходу правок. Даже по точному описанию модель может принести не то. Если нет видения конечного результата, то и нет больших кусков, чтобы отдавать группе агентов. Каждая задача получается как эксперимент, который я проверяю через экран.
Правки волнами
Основной ракурс доработок идет через отладку модулей анимаций. Каждое движение на доске собирается из словаря, и правка идёт в модуль (по задумке), даже если дефект нашёлся в одной сцене: поправил модуль, и вместе с ним проверяю все сцены, которые им пользуются. Модель плохо удерживает широкую работу с модулями: пишет движение внутри сцены, в обход модулей, и правит движение ситуативно. Такая точечная правка работает только внутри своей ситуации, тогда в другом месте то же движение остаётся с тем же дефектом. Поэтому тему я не закрываю одним заходом: правлю всё, что правка задевает, и отлаживаю волной целиком, вместе с переводом частных копий на общее. Отслеживать, в верном ли ракурсе она правит, я научился не сразу, и до сих пор есть упущенные места.

Одна карта по кругу
Cherry-pick берёт любую карту из всего сброса. В одном розыгрыше много движений: карту выкладывают в центр, куча сброса раскладывается сеткой на весь стол, выбранная карта летит в руку, остальные возвращаются в сброс, а с Sudo вторая карта уходит наверх колоды добора так, чтобы соперники её не увидели.
К этой карте я возвращаюсь снова и снова.
Первый раз правил её со стороны игрока: карта выкладывалась в центр, вызывала сетку выбора и уходила наверх кучи, и всё это лагало и телепортировалось. Выправил на глаз всё, что видел, и картинка стала ровной.
Потом, уже на других картах, заметил, что правил я тогда картинку, а модули не трогал. Пересобрал розыгрыш заново на модулях (так я думал, наивный…): выход в центр, сетку, уход в сброс.
Следующие карты показали, что и этот заход прошёл мимо модулей, и розыгрыш пересобрал ещё раз.
После ревью коллеги и разбора его комментария модель научилась видеть модули анимаций, и правка одного места стала чинить соседние сценарии сразу, Cherry-pick в том числе. Заодно выяснилось, что модулям самим нужна доработка, и этот заход был уже по всем анимациям через модули.
Потом отдельный заход на выбор карты из сброса: добавил и чинил просвечивающий оверлей, карты из сброса приходили в сетку из пустоты, а не из сброса, в кучу сначала возвращались и только потом перестраивались. Затемнение стало одной поверхностью на все карты с выбором, карты вылетают из кучи со своих мест и возвращаются на них в новом порядке, а выбор карты релиза от AI Inside сел на ту же реализацию.
Последним Cherry-pick зацепил заход с центром стола, тот, после которого игра отслеживает карту, выложенную в ожидании пары. Cherry-pick играется в паре с Sudo, и пришлось пройти по нему ещё раз: пара перестала меняться местами после посадки.
Сейчас я иду по всем анимациям заново и уже вижу места, написанные мимо общего словаря. Значит, Cherry-pick буду проверять снова.

Прерывания внутри хода
«Release любой ценой» построена на прерываниях внутри хода. Выложил релиз, и открылось время атаки: каждый соперник может вбросить багов. Когда одну атаку отбили, открывается окно для новой атаки релиза. Sudo ждёт выбор карты, которую будет усиливать. Code Review не даёт атаковать свежий релиз. Error 503 срабатывает посреди добора и может выбить игрока из игры. AI-карта разыгрывается немедленно, прямо во время добора, и открыта всем. За реальным столом порядок всего этого задаёт живая очередь: люди видят друг друга, и всё происходит по наитию.
В коде так не получится. Кто сейчас может действовать, сколько у него времени, что видят остальные и что будет, если он не успеет, решено заранее, до того, как момент наступил. Всё должно работать одинаково для всех игроков.
Техническая сторона
Движок партии работает только у хоста, остальные шлют ему действие: тип, карта, цель. Отправителя хост берёт из соединения, время ставит по своим часам, значения из сообщения не использует, и только затем отдаёт действие движку. Правила читают одни часы; прислать действие от имени другого игрока или объявить срок истёкшим из браузера нельзя. Ответ движка это новое состояние и список событий. Из состояния хост строит проекцию для каждого места отдельно, с открытой только своей рукой, фильтрует события по видимости и рассылает каждому его пару: проекцию и события. Доска рисует проекцию и ничего не вычисляет сама. Сроки хода, времени атаки, защиты и выборов записаны по часам хоста и приходят в проекции как абсолютное время. Истечение любого срока фиксирует тик хоста; сообщение об истечении от браузера хост выбрасывает, не доводя до движка. Отсчёт на экране гостя идёт по его часам, выровненным с часами хоста по задержке обмена сообщениями.
Все эти решения игрок видит в панели состояния хода, под колодами добора. Панель показывает, что происходит в игре прямо сейчас, а кнопка действия отображается, только когда нужна. В игре, где ход идёт по прямой, хватило бы одной кнопки с заблокированным состоянием до следующего нажатия. Здесь панель подстраивается под игровую ситуацию, и держать клавишу на экране просто так нельзя: у соперников на её месте имя того, кто ходит, а у игрока, сделавшего релиз, количество спасовавших игроков.

Отдельное решение понадобилось для паса. Пас значит, что я не против, чтобы время атаки закончилось раньше. Что это не отказ от атаки, я решил в документации, до реализации. На экране же пас сначала жил только в логике: спасовал, движок запомнил, на столе ничего не изменилось. Потом у владельца релиза появились точки по числу соперников, и пас стал отметкой, которая игрока ни к чему не обязывает. Спасовал, и у владельца добавилась точка, без имени: он видит, сколько спасовало, а не кто. Передумал, и снимать пас не обязательно, можно просто кинуть атаку. А когда спасовали все, время атаки заканчивается досрочно.
Таймеры на действия предложил коллега: за живым столом времени нет, а в онлайне без него партия может остановиться, если кто-то отвлечётся. Таймеры выставили по своим тестовым партиям и тестировали, как они ложатся на игру. Считает таймер ожидание действия, а не ход целиком: разыграл карту в свой ход, и отсчёт пошёл заново, так что пока игрок действует, время его не поджимает. На мне же и проверилось: отвлёкся посреди живой партии, время вышло, и игра завершила мой ход. Строил таймеры из позиции: игра не разыгрывает карты за игрока. Вышло время, и она доводит начатое: завершает ход, засчитывает атаку, которую не отбили, возвращает в руку релиз, за который не заплатили. Там, где без выбора не обойтись, выбор случайный.
Техническая сторона
Отсчёт хода ведёт движок: каждое действие, после которого стол остаётся на этом игроке, заводит его заново, а время атаки, чужое решение и добор его снимают; оплата релиза не снимает, это действие самого ходящего. Все сроки проверяет тик хоста раз в 250 миллисекунд. У движка одно собственное действие истечения, закрытие времени атаки, и принимает он его только от хоста. Остальное хост доводит теми же действиями, какие прислал бы игрок: по истечении хода возвращает в руку неоплаченный релиз и всё выложенное в центр, делает обязательный добор и передаёт ход; за защитника отвечает пустым ответом; карту при атаке по руке выбирает случайно, той же политикой, что у бота; по Cherry-pick подтверждает отмеченное, а на пустое место берёт случайную. Против места, за которым никого нет, срок не срабатывает: игрока без связи ведёт правило пассивного места.
Этап | Время | Когда время вышло |
|---|---|---|
Свой ход | 30 секунд без действия, каждое действие заводит отсчёт заново | Неоплаченный релиз и всё, что лежит в центре, возвращается в руку у всех на глазах; обязательный добор игра делает сама; ход заканчивается |
Атака на свежий релиз | 15 секунд на первую возможность, по 10 на каждую следующую; досрочно, когда спасовали все | Никто не атаковал: релиз завершён |
Sudo, выложенный к атаке | 10 секунд | Sudo возвращается в руку, время атаки начинается заново |
Защита от атаки, на релиз и по руке | 15 секунд | Атака проходит |
Выбор карты из сброса по Cherry-pick | 30 секунд | Отмеченная, но не подтверждённая карта считается выбранной; если не отмечено ничего, игра выбирает случайную |
Колода событий
Игра может прерывать даже добор. Самая многочисленная карта основной колоды это AI: вытянул её, и она открывается всем, а следом игра тянет случайную карту из колоды событий и тут же её разыгрывает. Что вытянется, игрок не выбирает, в этом и хаос. Good Vibe-Coding добирает ещё две карты, и среди них может оказаться следующий AI. Bad Vibe-Coding заставляет сбросить карту. Hallucination обрывает ход прямо посреди добора, и больше ни одна карта в игре так не делает. За живым столом это пауза: отложить добор, взять вторую колоду, показать, что взял, разыграть. На экране это быстрый розыгрыш: AI-карта лежит в центре рядом с событием, пока оно разыгрывается, потом событие возвращается в колоду событий, из игры эти карты не уходят. Придумывал я их про вайбкодинг до того, как сам стал отлаживать Hallucination от моделей.

Техническая сторона
Добор в движке это одно действие с состоянием последовательности: кому добирать, с каких колод ещё снять по карте и сколько бонусных карт должно. Движок снимает по карте за шаг, пока последовательность не пуста, и перед каждым шагом проверяет, что партия идёт, игрок не выбыл и ход его; иначе последовательность сбрасывается. Обычная карта уходит в руку. Триггер в руку не попадает и срабатывает на шаге снятия; AI-триггер берёт случайную карту из колоды событий и разыгрывает её тем же шагом. Если эффект открыл решение, последовательность остаётся в состоянии и продолжается после ответа; Good Vibe-Coding прибавляет к ней две бонусные карты; Hallucination завершает ход, и проверка перед следующим шагом сбрасывает остаток. Карта событий, которая остаётся на столе, это тот же экземпляр с отметкой колоды событий. В сброс карты попадают через одну функцию, и она по этой отметке отправляет карту в колоду событий. Тест проверяет, что карта событий не попала в сброс или в колоду добора.
Уже играбельно
В базовом режиме игра уже стабильна и ей можно делиться. Мера готовности у меня одна: игра сама держит правила, и карты на столе движутся без видимых швов. Картонным картам хватает стола и гостей, остальное происходит само. В веб-версии это всё пришлось делать.
Игра держит правила и связь
Веб-версию я делал так, чтобы знакомые могли сесть играть и не выясняли, как трактовать правила. С любой новой настолкой первые партии уходят на то, чтобы в ней разобраться: правила приходится запоминать на ходу, что-то читается не так, кто-то продавливает свою трактовку, и в итоге играешь не в ту игру. Здесь правила контролирует сама игра: карта, которую сейчас играть нельзя, на стол не ляжет. Подсветка в руке появляется только тогда, когда игра ждёт выбора, например чем заплатить за выложенный релиз. Хост поверх этого проверяет каждое действие ещё раз и следит, чтобы у всех было одинаковое состояние игры. За живым столом всё это ложится на игроков.
Техническая сторона
Проекция для места несёт список играбельных карт и список допустимых целей. Оба списка считает одна функция движка, и ею же движок проверяет действие, пришедшее от игрока. Отвергнутое действие движок возвращает событием отказа с причиной. Отказ хост шлёт только отправителю и в журнал не пишет, доска по отказу возвращает карту в веер. У места за столом два идентификатора: игрока и его текущего соединения. При обрыве хост стирает у места идентификатор соединения и записывает время обрыва, само место в партии остаётся. Токен возобновления браузер создаёт при входе и хранит во вкладке 12 часов, хост запоминает его за местом. Переподключение автоматическое: до 18 попыток, около двух минут, пауза между попытками растёт до восьми секунд. Запрос входа с токеном хост сопоставляет с местом, записывает в место новое соединение, закрывает старое и отправляет вернувшемуся игроку одну полную синхронизацию: проекцию и журнал событий с раздачи.
Игрока, который пропал со стола, игра не теряет. Место остаётся за ним: обновил вкладку или открыл ссылку заново, и он продолжает игру.

Выбывший становится зрителем
Выбывший игрок остаётся за столом до конца партии и видит её так же, как зритель. Роль зрителя в игре выросла из взгляда выбывшего после Error 503. Зрители не видят карт игроков, им доступен вид стола, как если бы все игроки были для них соперниками. Игроки и зрители сидят в одном и том же текстовом и голосовом чате.

Техническая сторона
У движка две функции проекции: для игрока и для зрителя. Обе собирают один и тот же открытый вид стола, а функция для игрока добавляет к нему его руку, играбельные карты и цели. У выбывшего рука пуста и играбельных карт нет, поэтому его проекция совпадает со зрительской. Зрителей хост держит отдельным списком, не среди игроков, и шлёт им всем одно сообщение синхронизации. События, видимые только одному игроку, в него не попадают, а добор и передача карты из руки в руку приходят без имени карты. Зритель, вошедший в идущую партию, получает проекцию и журнал событий с раздачи через тот же фильтр.
Зрителем садится тот, кому не досталось места игрока, или кто пришёл по ссылке в уже идущую партию. После партии он остаётся в комнате на следующую. Сколько зрителей пускать, решает хост.
Сбор игроков
Для входа в комнату нужен ник, а чтобы не придумывать, есть кубик со случайными вариантами. Аватарку игра выдаёт сама, поменять её и ник можно в лобби. Играть или смотреть человек отмечает на входе, а решает хост: пересаживает между игроками и зрителями только он.
Техническая сторона
Код комнаты это адрес браузера хоста на сигнальном сервере. Гость подключается по нему напрямую к хосту, и дальше список участников, настройки, готовность и чат идут через хоста. Самой комнаты нигде, кроме браузера хоста, нет.
Чат я в игру не закладывал: созвониться можно где угодно, зачем он ещё и в игре. Коллеге, с которым мы вдвоём гоняли первые партии, созваниваться отдельно от них не нравилось, так в комнате появился текстовый чат. Голосовой добавился, когда коллега напомнил, что его сервер в голос уже умеет и нужен только интерфейс.

Ждать остальных в лобби знакомым было скучно, они так и сказали. Теперь в шапке сидит Баг, пиксельный раннер вроде динозаврика в браузере без сети. Нажал, и он побежал: перепрыгивает хотфиксы, пригибается под мониторами и кушает серверы. Чем дальше, тем быстрее, мониторы начинают менять свою дорожку, навстречу выбегает резиновая уточка, а подобранная карта Sudo дает дополнительную жизнь. Хост помнит рекорд забега, и в итогах партии за него можно получить ачивку.
Сыграть можно и одному: хост добавляет в комнату ботов, до пяти. Ходят они по тем же правилам, что и люди, и по ощущениям играют жёстко: атакуют всем, чем могут, как только могут.
Техническая сторона
Как боты играют, не знаем ни я, ни коллега: они появились побочной задачей, модель собрала их за заход, и с тех пор они работают. Уровня сложности у них нет, чтобы всегда были жёсткими. По коду, если верить модели, бот видит стол так же, как игрок на его месте, и выбирает только из того, что движок ему разрешает. В окно атаки бьёт первой подходящей картой, иначе пасует. В свой ход сначала атакует, цель выбирает по тому, у кого больше релизов: релиз считает ценнее мониторинга, мониторинг ценнее карт в руке. Потом играет остальное, добирает и передаёт ход. На вопросы игры отвечает первым вариантом, атаку отбивает, если есть чем, а если нужно взять карту из руки соперника, то берёт случайную. Ходит за ботов хост.
Конец игры
Партия заканчивается конфетти, которое видит только победитель, и все переходят к статистике. Её формат я подсмотрел в существующих играх, а материал для неё у игры уже был: история ходов собирается для отладки, и её видно во время партии. Раз игра помнит каждый ход, значит может их посчитать, значит статистику можно превратить в ачивки. За живым столом таких подсчётов никто не ведёт, а тут само все посчитается. Самым сложным оказалось придумать ачивки в вайбе игры.

Техническая сторона
Движок ведёт по каждому месту десять счётчиков: атаки, защиты, DDoS, карты AI, ошибки 503, взятия из сброса, атаки по этому месту, поставленные Monitoring, разделения колоды и релизы под Code Review. Пополняет их одна функция из событий каждого действия, в код правил подсчёт не встроен, и новая ачивка это новый счётчик по уже существующему событию. Экран итогов строится по местам, зафиксированным при раздаче, поэтому показывает и тех, кто покинул комнату. Ачивка достаётся единственному лидеру по своему счётчику, при нуле и при ничьей никому. Рекорд Bug Runner держит хост, по лучшему забегу каждого в комнате, включая зрителей. История ходов на доске читает тот же журнал событий.
Комната живёт дольше одной партии. Доиграли, нажали «в лобби», и та же компания с теми же ботами сидит в комнате у хоста, отметились готовыми, и играете следующую катку. Так же подряд идут и тестовые партии: всё в одной вкладке, голосовой чат не прерывается, а текстовый держит историю всех партий.
Обратно в картон
Веб-версия повлияла на правила картонной игры. В коробке партия начиналась с одной колоды добора, и добирали всегда одну карту, даже если колод на столе лежало несколько. Онлайн-партии показали, что с двумя колодами на старте и добором по карте из каждой играется динамичнее: рука наполняется быстрее, и в свой ход почти всегда есть чем действовать. В веб-версии за это отвечают два режима, добавленные уже по ходу работы над игрой, добор из всех колод и число колод на старте, и такой набор теперь стоит базовым. Карты при этом не изменились, доработан только текст правил, и с ним тираж готов к печати, но пока не напечатан.
Увидеть это помогли тесты: при отладке их стало уж слишком много, потому что партию больше не нужно раздавать каждый раз. Нажал кнопку, и всё готово, боты уже ждут за столом.

Так куда уходит время
Всё, на что ушли эти месяцы, в готовой партии не видно. Я дизайнер, и мерой хорошего интерфейса считаю то, что его не замечают, так что в каком-то смысле получил, что хотел. Как карта приходит в веер, как куча сброса ложится одинаково хаотично для всех, как розыгрыши разных карт с руки идут без швов, как обновлённая вкладка возвращает за стол, и за столом никто ничего не заметил. Заметно становится, только когда что-то ломается. Третий месяц уходит на нюансы, которые в игре незаметны и воспринимаются «ну так и должно быть», пока работают.
Анимации карт. Планка одна: как за столом, карта перетекает с места на место и никогда не телепортируется. Время ушло на разницу между сценой в плейграунде и доской: каждое движение я собирал в словаре, смотрел, как оно живёт на доске, и переделывал, пока они не совпали. Кругов по каждому движению было столько, что всех я уже не упомню.
Проверка через экран. Расхождения я вижу только через интерфейс, поэтому каждую правку проверяю с ботами или на стенде. Правка цепляет сразу несколько сценариев, и разыграть нужно каждый.
Правки волнами. Отдельная статья времени это борьба за то, чтобы игра на самом деле пользовалась словарём модулей анимаций. Модели проще починить движение внутри сцены, и мне приходится по каждой правке следить, куда она легла, в модуль или в сцену, и возвращать её в модуль. Так дизайнер, который не умеет в код, воюет за его чистоту.
Корректность правил и отладка прерываний. Окно атаки, пас, таймеры, карты событий. Время здесь ушло на вопросы, которых правила на бумаге не задавали, потому что за столом их решала живая очередь: кто сейчас может действовать, сколько у него времени, что видят остальные. Каждый ответ ложился сначала в спеку правил, потом в панель состояния хода, потом на доску, где движок и экран расходились.
Окружение партии. Что за столом понятно без слов, кто играет, кто смотрит, кого сейчас нет, здесь каждому нужно показать на экране. Экранов у комнаты вышло больше, чем у самой игры: стол один, а вокруг него лобби, приглашение и итоги, и все с единым чатом.
Самое дорогое из этого движение карт без швов. После него доработки остального интерфейса идут легко, и по времени, и по ощущению. Игровые анимации надо было с самого начала делать отдельной библиотекой, со своими тестами. Поняли мы это с коллегой только недавно.
Ответ на вопрос из начала у меня получился без открытий. Время уходит на то, что и до правок делало своё дело, просто хуже на вид. Это внимание к мелочам, которое у опытных дизайнеров считается нормой и которое, возможно, никому кроме меня не нужно. Хотя один человек разницу замечает: коллега видит стол до и после моих PR и говорит, что стало лучше, а на первых тестовых партиях сам называл, какие движения уже отлажены, а какие ещё нет, и однажды сказал «анимация как в плейграунде».
А с роликами за выходные я сравнивал не то. По ним видно, что модель умеет сегодня и что собранная за выходные игра работает, в неё можно сыграть. Что происходит после «можно сыграть», в роликах не видно. Сжалось бы оно у меня с агентами до выходных, не знаю. У нас играбельная партия случилась 10 августа, а с тех пор я отлаживаю то, что в игре обычно незаметно.

Третий месяц продолжается
Будем отлаживать дальше, хочется добиться полной стабильности. В базовом режиме уже играется хорошо, но на других режимах анимации я пока не проверял. Ещё игровые анимации надо вынести в самостоятельную библиотеку. И остаётся незаконченной сеть.
Сеть уже почти P2P: когда все собрались, партия идёт между браузерами напрямую, звездой через хоста. Сервер коллеги знакомит браузеры друг с другом и проводит через себя тех, кому напрямую не достучаться, а также держит голосовой чат. Полноценный P2P, каким его задумали изначально, остаётся целью.
Сыграть можно сейчас: https://mythhand.github.io/ReleaseBoardGameP2P/. Что сломалось и что понравилось, пишите в комментариях или в группе в телеграмме, обе стороны мне одинаково интересны. Код лежит здесь: https://github.com/MythHand/ReleaseBoardGameP2P, баг можно занести сразу в issue.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.