javascript

Честная кнопка: как сравнить нажатия игроков, если у всех разный пинг

  • суббота, 31 октября 2026 г. в 00:00:03
https://habr.com/ru/articles/1101810/

Мы делаем ЖМЯК — Свою игру в браузере: табло на телевизоре или ноутбуке, а кнопки — телефоны игроков. Ничего не нужно ставить, гости заходят по 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. Казалось бы, это ошибка. Но посмотрите, что происходит:

  1. Сервер открывает кнопки в момент openAt (это поле тоже приходит в снимке, заранее).

  2. Клиент показывает «ЖМИ!», когда serverNow() ≥ openAt. По настоящим часам это openAt + d — ровно тогда, когда игрок физически мог узнать об открытии.

  3. Игрок жмёт через время реакции 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 нажатий

Мы сохраняем время реакции победителя каждого нажатия (от открытия кнопок до метки). На момент написания статьи в базе 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. Будем рады замечаниям в комментариях — особенно от тех, кто решал похожую задачу в мультиплеере.