Как я сделал «Свою игру», где телефоны друзей становятся кнопками

с друзьями давно играем в «Свою игру». SIGame — отличная штука, но каждый раз начинается одно и то же: «скачай», «а у меня мак», «а я с телефона», «какой пароль от комнаты», «у меня не нажимается пробел». Половина вечера уходит на настройку.

Мне хотелось, чтобы было как в телепередаче: большой экран с табло, а у каждого в руке кнопка. Табло — это любой экран, который у всех перед глазами: телевизор, проектор, ноутбук или демонстрация экрана в Zoom. Кнопка — телефон, который и так у всех в кармане. Ничего не ставить, не регистрироваться — открыл ссылку и играешь.

Так появился ЖМЯК. Это бесплатная «Своя игра» в браузере: создаёшь лобби, выводишь табло на телевизор, друзья сканируют QR‑код — и их телефоны превращаются в кнопки.

В статье расскажу, как это устроено внутри и на какие грабли я наступил. Главная тема — как понять, кто нажал первым, когда у одного игрока Wi‑Fi в той же комнате, а другой сидит на мобильном интернете в электричке.

Как это выглядит

Игра — это три вида экранов:

  • Табло — на большом экране: темы, цены, вопрос, таймер, счёт. Его не нужно трогать руками, игрой управляют с телефонов.

  • Телефон игрока — огромная кнопка «ЖМИ!», а когда твоя очередь — выбор вопроса на табло и кнопки «Я ответил».

  • Телефон ведущего (если играете с ведущим) — вопрос, правильный ответ и кнопки «Верно / Неверно». Игроки ответ не видят.

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

Стек простой: Go на бэкенде, React + TypeScript на фронте, PostgreSQL, всё в Docker Compose за Caddy. Около 15 тысяч строк на Go и 10 тысяч на TypeScript, из внешних зависимостей на бэкенде — только драйвер Postgres, миграции и генератор QR‑кодов.

Главная проблема: кто нажал первым?

В телевизионной «Своей игре» кнопки соединены проводами с одним пультом, и кто первый замкнул контакт — тот и отвечает. У нас между кнопкой и «пультом» — интернет. Простейшее решение — «кто первым пришёл на сервер, тот и нажал» — работает отвратительно.

Представьте: Маша сидит рядом с роутером, пинг до сервера 15 мс. Петя — на 4G, пинг 120 мс, а иногда и 300. Петя нажал на 50 мс раньше Маши, но его нажатие пришло позже. Отвечает Маша. Петя после третьего раза закрывает вкладку.

Причём задержка бьёт по Пете дважды: сначала он позже узнаёт, что кнопки открылись (сигнал «открыто» тоже идёт по сети), потом его нажатие дольше летит обратно.

Идея: сравнивать время реакции, а не время прихода

Нам на самом деле важно не «кто раньше прилетел», а «кто быстрее среагировал с момента, когда увидел открытый вопрос». Значит, нужно:

  1. Чтобы каждый клиент знал время сервера.

  2. Чтобы клиент присылал вместе с нажатием момент нажатия по серверным часам.

  3. Чтобы сервер не просто брал первое пришедшее нажатие, а немного подождал остальных и выбрал самое раннее.

Синхронизация часов

Часы на телефонах врут на секунды, поэтому синхронизироваться надо самим. Каждое сообщение сервера несёт серверное «сейчас». Клиент считает разницу между ним и своими часами:

// Каждое сообщение сервера — замер (серверное «сейчас» − момент получения).
// Берём максимум из последних замеров: это самый быстрый путь, без случайных
// задержек сети.
let clockOffset = 0;
const clockSamples: number[] = [];

function syncClock(serverMs: number) {
  clockSamples.push(serverMs - Date.now());
  if (clockSamples.length > 20) clockSamples.shift();
  clockOffset = Math.max(...clockSamples);
}

export const serverNow = () => Date.now() + clockOffset;

Почему максимум, а не среднее? Замер «серверное время минус время получения» всегда занижен на время доставки сообщения. Чем быстрее дошло сообщение, тем точнее замер. Самый большой из последних 20 замеров — это сообщение, которое дошло быстрее всех, без случайных задержек в очередях.

Остаётся постоянная часть задержки — и вот тут важный момент. Она одинаково сдвигает и момент, когда клиент увидел открытые кнопки, и отметку нажатия. Сервер открывает кнопки в OpenAt по своим часам, клиент показывает их по serverNow(), а нажатие отмечает тоже по serverNow(). В итоге сервер сравнивает почти чистое время реакции: сколько прошло от момента, когда игрок увидел кнопку, до момента нажатия.

Окно сбора нажатий

Теперь сервер не может отдать ответ первому пришедшему: более раннее нажатие Пети может ещё лететь. Поэтому после первого нажатия сервер ждёт 250 мс, собирает все нажатия и выбирает самое раннее по заявленному времени:

var (
	buzzGrace   = 250 * time.Millisecond // сколько ждём более ранних нажатий после первого
	maxBuzzComp = 400 * time.Millisecond // насколько раньше прихода может быть заявленное нажатие
)

func (g *Game) buzz(id int64, stamp time.Time) error {
	// ... проверки: фаза, фальстарт, уже отвечал ...
	now := time.Now()

	// заявленному времени верим в пределах [приход − maxBuzzComp, приход]
	// и не раньше открытия кнопок
	if stamp.IsZero() || stamp.After(now) {
		stamp = now
	}
	stamp = maxTime(stamp, now.Add(-maxBuzzComp), c.OpenAt)
	g.buzzes = append(g.buzzes, buzzAttempt{id: id, stamp: stamp, recv: now})

	if len(g.buzzes) == 1 { // первое нажатие — запускаем окно сбора
		gen := g.gen
		time.AfterFunc(buzzGrace, func() {
			g.mu.Lock()
			defer g.mu.Unlock()
			if g.gen != gen || g.st.Phase != PhaseQuestion {
				return // пока ждали, вопрос уже закрыли
			}
			g.resolveBuzz() // сортируем по stamp, отвечает самый ранний
		})
	}
	return nil
}

Два ограничения защищают от хитрецов:

  • Не верим времени из будущего и времени до открытия кнопок. Иначе можно было бы прислать «я нажал ещё до вопроса».

  • Не верим, что нажатие летело дольше 400 мс. Иначе кто‑то с подкрученным клиентом мог бы присылать время на секунду раньше и всегда выигрывать. Максимальная «фора» ограничена.

А если игрок нажал до открытия — это фальстарт, кнопка блокируется на секунду, и табло всем об этом сообщает. Как в передаче.

250 мс ожидания на глаз незаметны: между «нажал» и «на табло загорелось твоё имя» всё равно проходит анимация. Зато Петя на 4G теперь играет на равных.

Эту всплывашку я добавил почти случайно, а она оказалась одной из самых любимых фич. Когда проигрываешь кнопку, гораздо приятнее видеть «на 12 мс», чем просто чужое имя. Появляется азарт.

Что показали реальные данные

С момента запуска игроки нажали кнопку больше тысячи раз. Медианное время реакции — 580 мс: человек читает вопрос, понимает, что знает ответ, и жмёт.

А вот 10% нажатий случились быстрее 50 мс после открытия кнопок. Человек так быстро среагировать не может — нормальная реакция на сигнал около 200 мс. Это игроки, которые держат палец над кнопкой и жмут «на удачу» в момент, когда, по их ощущениям, кнопки вот‑вот откроются. Ровно так же играют в телевизионную «Свою игру», так что фальстарт с блокировкой оказался не формальностью, а важной частью баланса.

Каждый видит своё: SSE и персональные представления

Для доставки состояния я выбрал не WebSocket, а Server‑Sent Events. Клиенту нужно только получать обновления, а свои действия (нажать, выбрать вопрос, поставить ставку) он отправляет обычными POST‑запросами. SSE работает поверх обычного HTTP, сам переподключается при обрыве (браузерный EventSource делает это из коробки) и без проблем проходит через прокси.

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

// hub рассылает подписчикам сигнал «состояние изменилось».
// Каждый подписчик сам строит свой View, поэтому сигнал пустой.
func (h *hub) notify() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for ch := range h.subs {
		select {
		case ch <- struct{}{}:
		default: // уведомление уже ждёт в канале
		}
	}
}

У канала буфер в один элемент, и select с default даёт бесплатное схлопывание: если за 10 мс состояние поменялось пять раз, медленный клиент получит одно обновление, а не пять. Сервер никогда не блокируется на отправке.

Отдельная радость — в ответ физически не попадает то, что игроку видеть нельзя. Даже если открыть DevTools, правильного ответа там нет, пока его не раскрыли.

Игра переживает перезапуск сервера

Деплой у меня — это git push: CI собирает образ и перезапускает контейнер. Если в этот момент где‑то идёт партия, обидно её потерять на середине.

Решение: всё состояние игры — одна структура, которая целиком сериализуется в JSON и после каждого изменения сохраняется в Postgres (в колонку jsonb). При старте сервер поднимает незаконченные игры из базы, заново запускает таймер текущего вопроса (а если в вопросе музыка — трек у всех с начала), а клиенты через EventSource сами переподключаются. Для игроков перезапуск выглядит как секундная заминка.

Тот же механизм даёт паузу: ведущий нажимает «стоп», все отметки времени замирают, а при продолжении сдвигаются на длину паузы. На клиенте часы игры тоже замирают — полоски таймеров стоят, пока ведущий не продолжит.

Пакеты вопросов и совместимость с SIGame

Вопросы — это пакеты: раунды, в раундах темы, в темах вопросы с ценами. У SIGame за годы накопились тысячи пакетов в формате .siq — это zip‑архив с content.xml и папками с картинками и звуками. Было бы глупо заставлять людей набирать всё заново, поэтому ЖМЯК импортирует .siq и умеет экспортировать свои пакеты обратно.

Подвох в том, что форматов content.xml минимум два. В старых версиях вопрос — это список «атомов»:

<scenario>
  <atom>Этот фильм получил «Оскар» в 1998 году</atom>
  <atom type="image">@titanic.jpg</atom>
</scenario>

В пятой версии — совсем другая структура с параметрами и ссылками на файлы. Парсер поддерживает обе.

Вторая проблема — медиа. Пакеты собирают энтузиасты, и там встречается всякое: фото 6000×4000 в PNG на 15 МБ, MP3 с битыми заголовками, WAV без сжатия. Если отдать такое на телефон в метро, игра встанет. Поэтому при загрузке:

  • картинки ужимаются: всё крупнее нужного уменьшается, фото в PNG без прозрачности становятся JPEG;

  • у звуков определяется формат и длительность — тоже своими силами, без ffmpeg. Длительность нужна, чтобы кнопки открылись только после того, как трек доиграет.

Разбор звука написан так, чтобы битый файл давал просто «не удалось», но никогда не ронял сервер: файлы приходят от пользователей, и мусор там будет обязательно.

«Быстрая игра»: темы, которых ты ещё не видел

Главная боль в «Своей игре» с одной компанией — пакеты заканчиваются. Сыграли пакет — второй раз он неинтересен, ответы все помнят.

Поэтому есть кнопка «Быстрая игра». Она собирает свежий пакет на лету из общего банка тем (сейчас в нём 339 тем из публичных пакетов), подбирая темы, которые никто из игроков в лобби ещё не видел. Для этого сервер ведёт журнал «кто какую тему видел».

Подвох — дубликаты. Одна и та же тема гуляет по десятку пакетов: кто‑то скопировал, кто‑то импортировал повторно, кто‑то поправил опечатку. Чтобы банк не подсунул тему, которую вы уже играли под другим названием пакета, у каждой темы считается отпечаток: текст без регистра, пробелов и пунктуации. Одинаковые темы схлопываются в одну.

Боты‑черепахи

Иногда собраться не с кем, а потренироваться хочется. Поэтому за стол можно посадить ботов — у нас это черепахи (маскот проекта).

Бот — обычный игрок с отрицательным id, и действует он теми же методами, что и люди: нажимает кнопку, выбирает вопрос, делает ставку. Никакого особого пути в движке нет. Бот не подглядывает в ответ: знает ли он ответ, решает бросок кубика, который зависит от уровня бота и цены вопроса. Задержка нажатия у него тоже случайная, но в человеческих пределах.

Самое сложное с ботами — гонки. Бот решил нажать через 700 мс, а за это время вопрос уже закрыли. Поэтому каждое действие бота перед выполнением сверяет «ситуацию»: если она изменилась (другой вопрос, другой ход, пауза), действие отменяется.

Инфраструктура и один неожиданный урок

Всё живёт на одной виртуалке: 2 vCPU, 2 ГБ памяти. В Docker Compose три контейнера: приложение, Postgres и Caddy, который сам получает и продлевает HTTPS‑сертификат. Go‑бинарник со встроенным фронтендом (go:embed) весит несколько мегабайт, памяти игре нужно немного.

Урок был не про код. На первом хостинге в какой‑то момент пришли жалобы: «не открывается». У меня всё работало. Оказалось, что часть российских провайдеров блокировала подсеть хостера, и сайт без VPN у части игроков просто не грузился. Для игры, в которую зовёшь друзей по ссылке, это смерть: человек открыл ссылку, увидел ошибку и ушёл.

Пришлось за вечер переехать в московский дата‑центр Timeweb Cloud. Благодаря Docker Compose переезд свёлся к дампу базы, копированию файлов и смене DNS. Мораль: если аудитория в России, проверяйте доступность сайта с разных российских провайдеров, а не только со своего компьютера.

Цифры

Проекту меньше месяца. За это время:

  • 90 игроков, 68 пакетов, из них 24 опубликованы в каталоге;

  • 67 начатых партий, из них 53 доиграны до конца;

  • больше 1200 ответов на вопросы, 65% из них — верные;

  • медианное время реакции на кнопку — 580 мс.

Немного, но самое приятное — люди приходят по ссылкам от друзей: один человек нашёл игру в поиске, создал лобби и позвал друзей.

Что дальше

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

  • Платные эксклюзивные пакеты от авторов — бесплатная часть останется бесплатной.

  • Турниры и рейтинг.

Играть — zhmyak.su, это бесплатно и без установки: нужен один большой экран и телефоны. Если у вас есть пакеты SIGame — их можно импортировать в «Мастерской». Буду рад отзывам и багам в комментариях или в Telegram‑канале.

А как вы решали задачу «кто нажал первым» в своих проектах? Интересно, есть ли способ лучше, чем окно сбора.

Комментарии (1)


  1. Watercolor
    28.10.2026 11:33

    По сути это аналог Party Quiz или есть какие-то отличия?