golang

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

  • понедельник, 19 октября 2026 г. в 00:00:13
https://habr.com/ru/articles/1095682/
стенка на стенку
стенка на стенку

Я делаю Brawl Town - браузерную драку в духе Final Fight и Streets of Rage. На одной улице дерутся игроки и боты. Установки и регистрации нет: достаточно открыть игру и выбрать ник. Проект разрабатываю один.

Мне хотелось понять, сколько бойцов можно обсчитывать в одном процессе на обычном ПК. В текущем нагрузочном тесте i7-7700K выдерживает 2500 подключённых клиентов на улице шириной 3600 px: тик занимает 28–31 мс при бюджете 50 мс. Если собрать всех на одном экране, время вырастает до 28–48 мс. Это замеры с генератором нагрузки на той же машине, а не проверка на 2500 живых игроках. Дальше расскажу, что пришлось поменять в протоколе, структурах данных и отрисовке, чтобы получить эти результаты.

Что приходится считать на каждом тике

Улица - это прямоугольник примерно 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 байт на снапшот, и показать всех было невозможно - только ближайших.

Сейчас:

  • Записи - дельты. WebSocket работает поверх TCP, поэтому в рамках соединения сообщения доставляются по порядку. Для дельт сервер хранит состояние, ранее отправленное этому клиенту. Если поле не менялось, его можно не передавать; дальние бойцы при этом обновляются реже, по своему расписанию. Позиция - либо абсолютная (u16 с шагом 0,25 px), либо i8 дельта. Маска из одного байта говорит, какие поля есть.

  • id - u16, освобождённые id переиспользуются через 2 секунды.

  • Имена убраны из записей бойцов. Имя видно только в таблице лидеров и в сообщении об убийстве - его получают только убийца и жертва.

  • Три кольца частоты вокруг игрока:

    • 64 ближайших по x - полные записи, 20 Гц;

    • следующие до 160 - полные записи, 10 Гц (через тик, по чётности id);

    • остальные - «толпа»: 5 Гц со сдвигом по id (каждый тик четверть толпы), только позиция в целых пикселях, состояние и направление.

  • Часть эффектов клиент восстанавливает сам. Для приземления, нокдауна, смерти и возрождения не нужны отдельные сообщения: клиент видит смену состояния бойца и играет эффект ровно в кадре, где поза меняется. Искры ударов - только в пределах 800 px.

Результат на одном и том же снапшоте плотной арены (тест TestSnapshotSizeDenseArena):

Формат

Бойцов в снапшоте

Байт на бойцов

Было: полные записи, u32 id, имена

ближайшие 220

3116

Стало: дельты, u16 id, облегчённая толпа

все 1118 в кадре

1485

В этом тесте записей бойцов стало примерно в пять раз больше, а их суммарный размер уменьшился более чем вдвое. На живом прогоне с 1000 клиентов исходящий трафик упал с 65 до 37–40 МБ/с, при том что каждый клиент теперь видит всех, а не 220.

Interest management: отрезок вместо запросов

Чтобы собрать снапшот, нужно выбрать бойцов в области обзора клиента. Можно делать отдельный запрос к пространственному индексу для каждого подключения. В моей игре улица вытянута по горизонтали, поэтому для выбора видимой области я использую координату x.

Раз за тик сервер сортирует бойцов по 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. На 3000 клиентах времени уже не хватало. При этом процессор занимал и сервер, и генератор, поэтому из этого теста нельзя узнать предел сервера на отдельной машине. Плавность игры у реальных пользователей он тоже не проверяет.

Корректность проверяет тест TestMirrorStaysExactInADenseArena: плотная арена, 400 тиков, 24 клиента разной ширины экрана, уход, приход и переиспользование id. Каждый клиент декодирует снапшоты так же, как браузер, и сверяется с миром: около 780 тысяч ближних записей совпадают точно, 1,1 миллиона записей толпы - в пределах полпикселя.

Как браузер рисует толпу

Сервер может прислать 2000 бойцов, но браузер должен их ещё нарисовать. Клиент на PixiJS 8.

  • Ближайшие 60 - полные риги. Персонажи процедурные: каждая конечность - капсула, скругление которой стоит точно в суставе, поэтому суставы не расходятся ни при каком угле. Все детали облика и четыре выражения лица запекаются в одну текстуру - боец рисуется одним вызовом отрисовки.

  • Остальные - двойники. Каждый облик один раз запекается тем же ригом в девять поз (стойка, четыре фазы шага, удар, попадание, лёжа, в воздухе), и боец толпы - это один спрайт с тенью, который переключает позы.

На клиенте много времени уходило на перестроение списков отрисовки. В используемой версии PixiJS 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; симуляцию и рассылку снапшотов разделить на разные процессы (запись толпы уже кодируется один раз и одинакова для всех - её можно публиковать в шину и раскладывать по клиентам на шлюзах). Но это план, а не реализованная функция.

  • Мобильные. Сенсорное управление есть, но игра делалась для клавиатуры.

Что оказалось полезно в этой работе

  1. go tool pprof и флаг -pprof на сервере помогли найти дорогие обходы данных. До профилирования я дважды искал проблему в алгоритмах.

  2. Компактные записи в сетке позволили отсеивать кандидатов без обращения к полной сущности. Для этого не пришлось менять API запросов.

  3. Поведение нагрузочных клиентов заметно влияет на результат. Для этой игры полезнее тест с дракой в плотной толпе, чем случайные перемещения по улице.

  4. На домашнем ПК фоновая нагрузка меняет результаты на десятки процентов. Поэтому я чередовал прогоны старой и новой версий с одинаковыми настройками.

Попробовать

Игра бесплатная, в браузере, без скачивания и регистрации: brawltown.com.

Если вы работали с WebSocket-рассылкой на тысячи клиентов, интересно узнать, где у вас появлялся следующий предел: при сборке сообщений, записи в сокеты или уже в пропускной способности сети. У меня после этих изменений остаются вопросы и к трафику, и к разделению симуляции и рассылки.