Честная кнопка: как сравнить нажатия игроков, если у всех разный пинг
- суббота, 31 октября 2026 г. в 00:00:03
Мы делаем ЖМЯК — Свою игру в браузере: табло на телевизоре или ноутбуке, а кнопки — телефоны игроков. Ничего не нужно ставить, гости заходят по QR-коду и вводят только имя.
Главное в такой игре — кнопка. Вопрос прочитан, все тянутся к телефонам, кто-то нажал первым — он и отвечает. И вот тут быстро выясняется неприятное: в браузерной игре первым часто оказывается не самый быстрый игрок, а тот, у кого быстрее интернет.
В статье — как мы это чинили: синхронизация часов из уже существующего потока событий, метка времени нажатия, границы доверия клиенту, окно сбора нажатий и фальстарты. И немного реальных цифр: на проде уже 2,5 тысячи нажатий, и они кое-что рассказывают о людях.
Наивная реализация очевидна: клиент шлёт POST /buzz, сервер берёт первое пришедшее нажатие.
Маша (Wi-Fi, 20 мс) ──видит кнопку──► реакция 300 мс ──► 20 мс ──► сервер: 340 мс Петя (4G, 150 мс) ────────видит кнопку────────► реакция 250 мс ──► 150 мс ──► сервер: 550 мс
Петя нажал на 50 мс быстрее Маши, но проиграл: он и кнопку увидел позже (сообщение «кнопки открыты» шло к нему 150 мс), и его нажатие летело обратно столько же. Разница в сети — 260 мс туда-обратно — легко перекрывает разницу в реакции между живыми людьми, которая обычно десятки миллисекунд.
В офисе на одном Wi-Fi это незаметно. А вот когда половина компании сидит на мобильном интернете или играет по видеосвязи из разных городов — «кнопка тормозит», и игроки это чувствуют сразу.
Правильный вопрос не «чьё нажатие пришло первым», а «кто быстрее среагировал с момента, когда увидел открытую кнопку». Для этого нужны общие часы.
Состояние игры приходит клиенту через SSE: при каждом изменении сервер присылает игроку снимок игры (почему SSE, а не WebSocket — в конце). В каждом снимке есть поле now — серверное время отправки в миллисекундах.
Значит, каждое сообщение — это бесплатный замер:
offset = serverNow − Date.now() // в момент получения сообщения
Если бы сеть была мгновенной, это и была бы разница часов. Но сообщение какое-то время летело, поэтому замер всегда меньше истинной разницы на величину задержки доставки. Задержка бывает только положительной: случайные лаги её увеличивают, но никогда не уменьшают. Отсюда простое правило — брать максимум из последних замеров: это сообщение, которое долетело быстрее всех.
// Разница между часами сервера и браузера: таймеры и нажатия считаем по серверному времени. // Каждое сообщение сервера — замер (серверное «сейчас» − момент получения). Берём максимум // из последних замеров: это самый быстрый путь, без случайных задержек сети. 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;
Никаких отдельных ping-запросов и NTP: за минуту игры приходят десятки сообщений, и оценка быстро стабилизируется. Окно из 20 последних замеров нужно, чтобы оценка подстраивалась, если телефон перескочил с Wi-Fi на 4G.
После этого у клиента serverNow() отстаёт от настоящего серверного времени ровно на минимальную задержку доставки d. Казалось бы, это ошибка. Но посмотрите, что происходит:
Сервер открывает кнопки в момент openAt (это поле тоже приходит в снимке, заранее).
Клиент показывает «ЖМИ!», когда serverNow() ≥ openAt. По настоящим часам это openAt + d — ровно тогда, когда игрок физически мог узнать об открытии.
Игрок жмёт через время реакции r. Клиент ставит метку serverNow() — это openAt + d + r − d = openAt + r.
Задержка d вычитается сама собой. Сервер сравнивает чистые r — время реакции каждого игрока с момента, когда он увидел кнопку. Пете больше не мешает его 4G.
// buzz отправляет нажатие с моментом нажатия — сервер выберет самое раннее. const buzz = () => act({ action: 'buzz', at: Math.round(serverNow()) });
Мелочь, которая тоже важна: кнопка реагирует на pointerdown, а не на click. click срабатывает при отпускании пальца — это ещё 50–100 мс, причём у разных людей по-разному. На компьютере работает пробел.
Теперь клиент сам сообщает, когда он нажал. Очевидная атака — прислать метку «я нажал раньше всех». Поэтому сервер верит заявленному времени только в разумных пределах:
var ( buzzGrace = 250 * time.Millisecond // сколько ждём более ранних нажатий после первого maxBuzzComp = 400 * time.Millisecond // насколько раньше прихода может быть заявленное нажатие ) // заявленному времени верим в пределах: [прихода − maxBuzzComp, прихода], и не раньше открытия if stamp.IsZero() || stamp.After(now) { stamp = now } stamp = maxTime(stamp, now.Add(-maxBuzzComp), c.OpenAt)
Три ограничения:
Не позже прихода. Метка «из будущего» — это сбитые часы, берём время прихода.
Не раньше открытия кнопок. Нельзя «нажать» до того, как кнопки открылись: нажатие раньше openAt вообще не принимается (об этом ниже, в фальстартах).
Не раньше, чем за 400 мс до прихода. Это компенсация сети: даже медленный мобильный аплоад укладывается в эти 400 мс. А читеру это даёт максимум 400 мс форы — и только в пределах честного окна после открытия кнопок.
Да, человек со скриптом, подделывающим метку, всё ещё может выиграть. Но человек со скриптом выиграет и у кнопки без всяких меток — просто нажав автоматически в момент открытия. Защищаться от этого в дружеской игре мы не стали: задача была убрать несправедливость сети для честных игроков, а не построить античит.
Если сервер сразу отдаст ответ первому пришедшему нажатию, метки времени ничего не дадут: Петино нажатие с более ранней меткой ещё в пути. Поэтому после первого нажатия сервер ждёт 250 мс, собирая остальные, и только потом выбирает победителя по метке:
g.buzzes = append(g.buzzes, buzzAttempt{id: id, by: by, stamp: stamp, recv: now}) if len(g.buzzes) == 1 { gen := g.gen time.AfterFunc(buzzGrace, func() { g.mu.Lock() if g.gen != gen || g.st.Phase != PhaseQuestion || len(g.buzzes) == 0 { g.mu.Unlock() return // за это время вопрос сменился — таймер устарел } g.resolveBuzz() g.mu.Unlock() g.changed() }) }
// resolveBuzz отдаёт ответ самому раннему нажатию из окна сбора. func (g *Game) resolveBuzz() { slices.SortStableFunc(g.buzzes, func(a, b buzzAttempt) int { if c := a.stamp.Compare(b.stamp); c != 0 { return c } return a.recv.Compare(b.recv) // одинаковые метки — кто раньше дошёл }) win := g.buzzes[0] if len(g.buzzes) > 1 { second := g.buzzes[1] g.setToast(fmt.Sprintf("%s быстрее, чем %s, на %d мс", g.player(win.id).Name, g.player(second.id).Name, second.stamp.Sub(win.stamp).Milliseconds())) } // … }
Почему 250 мс: это больше разницы аплоада между хорошим Wi-Fi и плохим 4G, но меньше порога, после которого пауза между нажатием и «Отвечает Маша!» начинает ощущаться как тормоза. Окно открывается только после первого нажатия, так что в обычной ситуации, когда жмёт один человек, оно просто добавляет четверть секунды до показа отвечающего.
Отдельная радость — всплывашка «Маша быстрее, чем Петя, на 37 мс». Она снимает 90% споров «я же первый нажал!» и почему-то очень нравится игрокам.
Маленькая деталь в коде выше — gen. Это счётчик, который увеличивается при каждой смене фазы игры. Все отложенные таймеры запоминают его при создании и ничего не делают, если фаза уже сменилась. Без этого таймер окна сбора от прошлого вопроса однажды «ответил» бы на следующий.
Кнопки открываются не сразу, а после «полоски чтения»: её длина зависит от текста вопроса — 55 мс на символ, от 1,5 до 6 секунд, плюс секунда на картинку. Если нажать раньше — фальстарт: секунда блокировки, и все видят «Петя: фальстарт!».
if now.Before(c.OpenAt) { g.blocked[id] = now.Add(falseStartBlock) // 1 секунда g.setToast(p.Name + ": фальстарт!") return errors.New("фальстарт! кнопки ещё закрыты") }
Без этого правила оптимальная стратегия — долбить по кнопке, не читая вопрос. И данные показывают, что люди именно так и пытаются играть.
Мы сохраняем время реакции победителя каждого нажатия (от открытия кнопок до метки). На момент написания статьи в базе 2577 таких нажатий от 79 игроков в 79 партиях:
время реакции | |
|---|---|
10-й перцентиль | 59 мс |
медиана | 657 мс |
90-й перцентиль | 5,4 с |
быстрее 300 мс | 33% нажатий |
Человеческая реакция на зрительный сигнал — около 200–250 мс. Значит, треть нажатий быстрее 300 мс — это не реакция, а игра на опережение: люди следят за полоской и жмут в момент, когда она должна закончиться. Фальстарт с блокировкой это и сдерживает: промахнулся на 50 мс раньше — сидишь секунду, а соперник спокойно жмёт. А совсем маленькие значения (вплоть до 1 мс) — это нажатия, у которых метку прижало к openAt: человек нажал чуть раньше, но нажатие пришло уже после открытия.
Самое интересное — разница между верными и неверными ответами:
медиана реакции | |
|---|---|
ответили верно | 510 мс |
ответили неверно | 1179 мс |
Кто знает ответ — жмёт сразу. Кто сомневается — тянет, думает, а потом жмёт «на удачу», потому что соперники вот-вот нажмут сами. Вдвое более долгое нажатие — хороший предиктор ошибки. Своя игра в чистом виде.
Недавно нам написал человек, который готовил игру для мероприятия с ведущим. Проблема оказалась не в миллисекундах, а в секундах:
Есть длинные вопросы, которые чётко и громко прочитать — секунд 10 только уходит. А если вопрос ещё и на подумать, то явно могут быть ситуации, когда в командах никто не нажмёт кнопку за это время.
У нас было окно 10 секунд на нажатие и пауза, которая останавливала время. Но пауза блокировала и кнопку: человек, который понял ответ на середине вопроса, должен был ждать, пока ведущий дочитает и снимет паузу, — а там уже успевал догадаться кто-то ещё.
Сделали две вещи. Во-первых, время на кнопку настраивается: 10, 20, 30 секунд или «без лимита» (в игре с ведущим — он сам откроет ответ, если никто не знает). Во-вторых, пауза теперь останавливает только время. Нажатие на паузе снимает её и сразу передаёт ход:
if g.st.Paused { // ведущий дочитывает вопрос на паузе — кто уже знает, жмёт сразу g.resume() if now := time.Now(); now.Before(c.OpenAt) { c.OpenAt = now } }
А resume() честно сдвигает все отметки времени на длину паузы — открытие кнопок, дедлайн, блокировки за фальстарт:
func (g *Game) resume() { shift := time.Since(g.st.PausedAt) move := func(t *time.Time) { if !t.IsZero() { *t = t.Add(shift) } } if c := g.st.Cur; c != nil { move(&c.OpenAt) move(&c.Deadline) move(&c.AnswerBy) move(&c.PlayAt) } for id, t := range g.blocked { g.blocked[id] = t.Add(shift) } // …и перезапускаем таймер с тем же остатком }
На клиенте пауза тоже замораживает часы: serverNow() не растёт дальше момента паузы, поэтому полоски и секунды стоят у всех одновременно, а не дёргаются при снятии паузы.
Частый вопрос. Данные в игре идут в одну сторону: сервер рассылает состояние, а клиент шлёт несколько действий в минуту. Поэтому:
состояние — SSE: EventSource сам переподключается после обрыва (на мобильных это постоянно), а сервер в ответ просто присылает свежий снимок целиком — никакого «догоняния» пропущенных событий;
действия — обычные POST: на них уже работают авторизация по cookie, права, лимиты и коды ошибок, ничего не нужно изобретать поверх своего протокола сообщений;
на Go это net/http и Flush(), через Caddy поток идёт как обычный HTTP/2-ответ.
Цена — каждое нажатие отдельный HTTP-запрос. Но победителя определяет метка времени, а не момент прихода, так что лишние миллисекунды на запрос не делают игру нечестной. Это как раз то, ради чего всё затевалось.
Часы синхронизируем бесплатно — максимумом из последних замеров serverNow − Date.now() по сообщениям, которые и так приходят.
Сравниваем реакцию, а не приход: оставшаяся задержка одинаково сдвигает и показ кнопки, и метку нажатия, и сокращается.
Клиенту верим в границах: не раньше открытия кнопок и не больше чем на 400 мс раньше прихода.
Ждём 250 мс после первого нажатия, чтобы медленная сеть не решала исход.
Фальстарт обязателен — иначе треть игроков просто долбит по кнопке.
Если хотите посмотреть, как это ощущается вживую, — игра бесплатная, без регистрации для гостей: zhmyak.su. Будем рады замечаниям в комментариях — особенно от тех, кто решал похожую задачу в мультиплеере.