Мы делаем ЖМЯК — Свою игру в браузере: табло на телевизоре или ноутбуке, а кнопки — телефоны игроков. Ничего не нужно ставить, гости заходят по 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.gentime.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 нажатий
Мы сохраняем время реакции победителя каждого нажатия (от открытия кнопок до метки). На момент написания статьи в базе 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, а не WebSocket
Частый вопрос. Данные в игре идут в одну сторону: сервер рассылает состояние, а клиент шлёт несколько действий в минуту. Поэтому:
-
состояние — SSE:
EventSourceсам переподключается после обрыва (на мобильных это постоянно), а сервер в ответ просто присылает свежий снимок целиком — никакого «догоняния» пропущенных событий; -
действия — обычные
POST: на них уже работают авторизация по cookie, права, лимиты и коды ошибок, ничего не нужно изобретать поверх своего протокола сообщений; -
на Go это
net/httpиFlush(), через Caddy поток идёт как обычный HTTP/2-ответ.
Цена — каждое нажатие отдельный HTTP-запрос. Но победителя определяет метка времени, а не момент прихода, так что лишние миллисекунды на запрос не делают игру нечестной. Это как раз то, ради чего всё затевалось.
Что в итоге
-
Часы синхронизируем бесплатно — максимумом из последних замеров
serverNow −Date.now()по сообщениям, которые и так приходят. -
Сравниваем реакцию, а не приход: оставшаяся задержка одинаково сдвигает и показ кнопки, и метку нажатия, и сокращается.
-
Клиенту верим в границах: не раньше открытия кнопок и не больше чем на 400 мс раньше прихода.
-
Ждём 250 мс после первого нажатия, чтобы медленная сеть не решала исход.
-
Фальстарт обязателен — иначе треть игроков просто долбит по кнопке.
Если хотите посмотреть, как это ощущается вживую, — игра бесплатная, без регистрации для гостей: zhmyak.su. Будем рады замечаниям в комментариях — особенно от тех, кто решал похожую задачу в мультиплеере.
ссылка на оригинал статьи https://habr.com/ru/articles/1101810/