Как один сервер на Go держит 2500 бойцов в браузерной драке


Я в одиночку делаю Brawl Town — уличную драку в браузере в духе Final Fight и Streets of Rage, только онлайн: одна улица, сотни игроков и ботов, все против всех, круглые сутки. Открыл ссылку, выбрал ник — и через пять секунд ты в толпе.
С первого дня у проекта была инженерная цель: сколько людей выдержит один процесс на обычном десктопе, если честно считать бой на сервере, а не рисовать «MMO» из картинок. Ответ на сегодня — около 2500 одновременных бойцов на i7-7700K при тике около 30 мс из бюджета 50. Ниже — как к этому пришёл, что оказалось узким местом (спойлер: не алгоритмы, а кэш процессора и трафик) и где цифры пока не подтверждены.
Что вообще считает сервер
Улица — это прямоугольник примерно 3600×720 px: длина в два экрана и «глубина», как в классических beat 'em up. На ней:
игроки и боты (по одному коду: бот — это просто ввод, сгенерированный на сервере);
удары с таймингами: цепочка из четырёх ударов продолжается, только если нажать в ритм после попадания; блок только спереди со своей шкалой; парирование в окне 0,15 с; захваты, броски, кувырки с неуязвимостью, выносливость;
оружие с прочностью, еда, разрушаемые объекты (баки, будки, гидранты, машины, которые горят и взрываются);
события раз в 22–45 секунд: авиаудар, машина-гонщик через всю улицу, землетрясение с пропастью, блэкаут, «стенка на стенку», где с двух концов входят толпы до 2000 бойцов.
Последнее — главный стресс-тест. Когда две стенки сходятся, в кадре одного игрока оказываются тысячи бойцов, и каждому клиенту нужно их показать.
Архитектура в двух абзацах
Сервер авторитарен. Клиент шлёт только ввод (u16 seq, i8 mx, i8 my, u8 buttons, u8 press) ровно 20 раз в секунду. Сервер потребляет по одному вводу за тик, считает движение, удары, урон и события и рассылает каждому снапшот. Свой боец на клиенте предсказывается и сверяется по номеру последнего применённого ввода, чужие рисуются с интерполяцией.
Один мир — одна горутина. Симуляция идёт с фиксированным тиком 20 Гц (50 мс) в одной горутине: никаких мьютексов в горячем коде. Сетевые горутины только кладут ввод в очередь. После шага мира клиенты делятся между воркерами (по GOMAXPROCS), и снапшоты кодируются параллельно — мир в этой фазе только читается.
Транспорт — WebSocket (gorilla/websocket) и собственный бинарный протокол. Postgres, Redis и брокеры в горячем пути не участвуют: аккаунты, рекорды и магазин живут в отдельном API на .NET, игра шлёт туда heartbeat и итоги жизней раз в несколько секунд.
Протокол: дельты и три кольца частоты
Первая версия слала каждому клиенту полные записи 220 ближайших бойцов: u32 id, координаты, состояние, HP, имя при первом появлении. В плотной арене это было 3116 байт на снапшот, и показать всех было невозможно — только ближайших.
Сейчас:
Записи — дельты. Снапшоты идут по TCP и не теряются, поэтому каждая запись — разница с тем, что этот клиент уже получил. Нет записи — ничего не изменилось. Позиция — либо абсолютная (
u16с шагом 0,25 px), либоi8дельта. Маска из одного байта говорит, какие поля есть.id —
u16, освобождённые id переиспользуются через 2 секунды.Имён на улице нет вообще. Имя видно только в таблице лидеров и в сообщении об убийстве — его получают только убийца и жертва.
Три кольца частоты вокруг игрока:
64 ближайших по x — полные записи, 20 Гц;
следующие до 160 — полные записи, 10 Гц (через тик, по чётности id);
остальные — «толпа»: 5 Гц со сдвигом по id (каждый тик четверть толпы), только позиция в целых пикселях, состояние и направление.
Эффекты экономятся. Приземление, нокдаун, смерть и возрождение не передаются вообще: клиент видит смену состояния бойца и играет эффект ровно в кадре, где поза меняется. Искры ударов — только в пределах 800 px.
Результат на одном и том же снапшоте плотной арены (тест TestSnapshotSizeDenseArena):
Формат | Бойцов в снапшоте | Байт на бойцов |
|---|---|---|
Было: полные записи, | ближайшие 220 | 3116 |
Стало: дельты, | все 1118 в кадре | 1485 |
То есть в пять раз больше бойцов за половину байтов. На живом прогоне с 1000 клиентов исходящий трафик упал с 65 до 37–40 МБ/с, при том что каждый клиент теперь видит всех, а не 220.
Interest management: отрезок вместо запросов
Классический путь — для каждого клиента запросить пространственный индекс «кто в моём прямоугольнике». У нас мир одномерный по сути (улица), и это сильно упрощает жизнь.
Раз за тик сервер сортирует бойцов по x в компактный массив пар (x, id). Окно обзора клиента — это просто отрезок этого массива, который находится двумя бинарными поисками. Внутри отрезка дистанция по x уже известна, и кольца «ближние 64 / следующие 160 / толпа» выбираются без кучи.
Вторая идея — запись толпы кодируется один раз на бойца за тик, а не на каждого клиента. Каждый тик сервер строит для четверти толпы два готовых варианта записи: короткую дельту (для тех, кто получил прошлую запись этого бойца) и абсолютную (для всех остальных). Клиент при сборке снапшота просто копирует нужные байты.
// crowdRec is one fighter as the crowd sees it: a whole-pixel position that
// only moves on the fighter's update ticks, and its records for this tick
// pre-encoded once for every client.
type crowdRec struct {
ent *game.Entity
cx, cy int32 // whole px
z, state uint8
left bool
delta [12]byte
abs [12]byte
dn, an uint8
tick uint32 // tick these records belong to
fresh bool // no previous position: nobody can hold a delta base
}
Узкое место номер один: промахи кэша
После переделки протокола я ожидал, что упрусь в алгоритмы. Профиль показал другое.
Учёт «что знает клиент». Раньше на каждого клиента была таблица последних отправленных записей: 40 байт на id в 40-килобайтном массиве. Для толпы из тысячи бойцов каждый клиент трогал запись каждого бойца — и на тысяче клиентов это были сплошные промахи кэша, 23 % времени живого сервера. Для толпы же достаточно байта флагов и 16-битного номера тика, когда запись последний раз ушла. Два маленьких массива помещаются в кэш; полная запись нужна только ближнему кругу. Когда боец переходит из толпы в ближние, его запись уходит целиком — это несколько байт и бывает редко.
Сетка мира. Для поиска соседей (боты ищут цель, бойцы расталкиваются) используется равномерная сетка, перестраиваемая каждый тик. Ячейки были по 200 px — в плотной арене в такой ячейке полсотни бойцов, и проверка расталкивания на 28 px перебирала около 200 кандидатов. Уменьшил ячейку бойцов до 48 px — около 15 кандидатов. Эффекты (взрывы, землетрясения) остались на крупных ячейках: их запросы размером с экран.
Указатели в ячейках. Самая неожиданная находка — уже в октябре, на свежем профиле тика в арене с 2150 бойцами: 30 % — поиск цели ботом, 19 % — обход сетки, 14 % — расталкивание. Почти всё это — промахи кэша: ячейка хранила указатели на сущности, и каждый кандидат разыменовывал 400-байтную Entity, чтобы прочитать один байт состояния и две координаты. Теперь ячейка хранит компактную запись:
// cellRec is what a grid cell holds for one fighter: its position and the
// state bits the hot scans reject on, next to the pointer.
type cellRec struct {
X, Y float32
ID uint32
E *Entity
Flags uint8 // dead, grabbed, in the sky, rolling, in the air, down, fresh, bot
Team uint8
}
Поиск цели и расталкивание отсеивают кандидатов по записи и трогают Entity только для тех, кто действительно ближе найденного или в пределах удара. Бенчмарк шага мира в арене на 2150 бойцов: 2,39 → 1,88 мс (−21 %). Старый API запросов при этом не менялся.
Поиск цели ботом. Бот сначала смотрит в радиусе 90 px. В толпе цель почти всегда находится там, а цель внутри кольца гарантированно ближайшая — точность та же, а перебора в разы меньше.
Как я мерю: боты, которые играют как люди
Нагрузочный генератор cmd/loadbot поднимает N WebSocket-клиентов. Сначала они слали случайный ввод — и врали о нагрузке: случайные боты разбредаются, почти не дерутся и не провоцируют эффектов. Сейчас по умолчанию клиенты «играют как люди»: подходят к ближайшему бойцу, выравниваются по глубине, бьют в ритм с паузами, ставят блок, прыгают, делают суперудар и сообщают серверу ширину своего экрана. Генератор печатает трафик, интервал между снапшотами (p50/p99/max) и пинг.
Худший случай для сервера и клиента — все в одном месте. Для этого есть флаг -arena 1600: улица сжимается до 1600 px, и все бойцы, предметы и события оказываются на одном экране.
Отдельный урок: генератор сам может стать узким местом. Он декодировал толпу через map и при 2000 клиентах съедал 4,5 ядра, мешая мерить сервер. Бойцы в срезе по id вместо map — 1,9 ядра на те же 2000 клиентов.
Цифры
Стенд — один ПК: i7-7700K, 4 ядра, 8 потоков. Сервер и генератор нагрузки работают на одной машине, поэтому это скорее нижняя оценка. Прогоны по 30 секунд, «было» и «стало» мерились одним и тем же генератором.
Худший случай: все на одном экране (-arena 1600 -bots 150):
Клиентов | Было: тик, ядер сервера | Стало: тик, ядер сервера |
|---|---|---|
1000 | 9,5–10,9 мс, 1,67 | 4,6–5,8 мс, 0,83 |
1500 | 21–24 мс, 3,1 | 12–15 мс, 1,6 |
2000 | 44–53 мс, 4,9: бюджет превышен, доходит 35–37 тыс. снапшотов/с вместо 40 | 16–19 мс, 2,7; интервал снапшотов p99 64 мс, пинг p99 17 мс |
2500 | — | 28–48 мс, 4,0 (машина загружена полностью вместе с генератором) |
Вся улица 3600 px:
Клиентов | Было | Стало |
|---|---|---|
2500 | — | тик 28–31 мс, интервал снапшотов p99 67 мс |
3000 | тик ~100 мс, снапшоты фактически 10 Гц | тик ~55 мс; CPU машины исчерпан (сервер 4,2 + генератор 3,2 ядра) |
Микробенчмарки:
Бенчмарк | Было | Стало |
|---|---|---|
Снапшот на клиента, 1150 бойцов в кадре | 40–44 мкс | 20–25 мкс |
Кодирование снапшота, 3000 бойцов | 39 мкс | 20 мкс |
Шаг мира, 2150 бойцов в арене | 3,3 мс | 1,14 мс |
Итог: на этом ПК плавная игра держится примерно до 2500 человек на сервер вместо ~1500 до оптимизаций. Дальше предел задаёт процессор всей машины, а на отдельном сервере без генератора запас больше.
Корректность проверяет тест TestMirrorStaysExactInADenseArena: плотная арена, 400 тиков, 24 клиента разной ширины экрана, уход, приход и переиспользование id. Каждый клиент декодирует снапшоты так же, как браузер, и сверяется с миром: около 780 тысяч ближних записей совпадают точно, 1,1 миллиона записей толпы — в пределах полпикселя.
Клиент: показать всех и не умереть
Сервер может прислать 2000 бойцов, но браузер должен их ещё нарисовать. Клиент на PixiJS 8.
Ближайшие 60 — полные риги. Персонажи процедурные: каждая конечность — капсула, скругление которой стоит точно в суставе, поэтому суставы не расходятся ни при каком угле. Все детали облика и четыре выражения лица запекаются в одну текстуру — боец рисуется одним вызовом отрисовки.
Остальные — двойники. Каждый облик один раз запекается тем же ригом в девять поз (стойка, четыре фазы шага, удар, попадание, лёжа, в воздухе), и боец толпы — это один спрайт с тенью, который переключает позы.
Самая дорогая ловушка оказалась в самом Pixi. В Pixi 8 любое изменение zIndex или видимости пересобирает список отрисовки всей группы. У бойцов zIndex равен y (кто ниже на улице, тот ближе к камере), и тысяча идущих бойцов пересобирала фон, объекты, следы и частицы каждый кадр. Решение: мир — отдельная группа отрисовки (камера стала одной матрицей на GPU), у бойцов, теней, частиц и следов свои группы; глубина пишется прямо в _zIndex, а слой бойцов пересортировывается 10 раз в секунду. Мигающие детали (шлейфы ударов, полоски HP) включаются через прозрачность, а не видимость, — изменение видимости тоже пересобирает группу.
На сцене с ~1000 бойцов в кадре кадр подешевел с 20–21 до 15–16 мс, число вызовов отрисовки — с 60–80 до 40–55.

Чего пока нет и где цифры честно не подтверждены
Прод слабее стенда. Боевой сервер — виртуалка на 2 vCPU старого ноутбучного i5. Там я измерил 300 игроков + 150 ботов: тик 7–11 мс в спокойное время, 22–29 мс во время события, исходящий трафик 36 Мбит/с. По линейной оценке 1000 игроков там держатся в спокойное время, но «стенка на стенку» на 2000 временных бойцов выйдет за бюджет. Поэтому сейчас там лимит 500 игроков.
Трафик — главная статья расходов. Около 300 кбит/с на игрока в плотной толпе. 3000 игроков — это уже почти гигабит. Следующие шаги: толпа 3 Гц вместо 5, id в порядке x разницей вместо
u16, WebTransport-датаграммы (снапшот «последний важнее» без блокировки очереди), сжатие словарём на шлюзе.Шардинга нет. План есть: улица одномерная, поэтому у зоны всего два соседа, а не восемь, как в 2D-MMO; симуляцию и рассылку снапшотов разделить на разные процессы (запись толпы уже кодируется один раз и одинакова для всех — её можно публиковать в шину и раскладывать по клиентам на шлюзах). Но это план, а не реализованная функция.
Мобильные. Сенсорное управление есть, но игра делалась для клавиатуры.
Что бы я сделал иначе с самого начала
Профилировать раньше, чем оптимизировать. Я дважды был уверен, что упираюсь в алгоритм, а упирался в разыменование указателей.
go tool pprofи флаг-pprofна сервере окупились с первого раза.Сразу компактные записи рядом с данными, а не указатели на «толстые» сущности в горячих структурах.
Нагрузочный бот должен играть, а не шуметь. Случайный ввод даёт красивые и бессмысленные цифры.
Мерить попеременно. На домашней машине фоновая нагрузка гуляет на десятки процентов; сравнивать можно только «было/стало», снятые поочерёдно в одних условиях.
Попробовать
Игра бесплатная, в браузере, без скачивания и регистрации: brawltown.com. Если зайдёте вечером, то, скорее всего, встретите на улице не только ботов.
Буду рад вопросам в комментариях — про протокол, Pixi, нагрузочные тесты, что угодно. Особенно интересно мнение тех, кто доводил WebSocket-рассылку до тысяч клиентов на одном узле: куда смотреть дальше.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.