
Я делаю 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):
Формат | Бойцов в снапшоте | Байт на бойцов |
|---|---|---|
Было: полные записи, | ближайшие 220 | 3116 |
Стало: дельты, | все 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; симуляцию и рассылку снапшотов разделить на разные процессы (запись толпы уже кодируется один раз и одинакова для всех - её можно публиковать в шину и раскладывать по клиентам на шлюзах). Но это план, а не реализованная функция.
Мобильные. Сенсорное управление есть, но игра делалась для клавиатуры.
Что оказалось полезно в этой работе
go tool pprofи флаг-pprofна сервере помогли найти дорогие обходы данных. До профилирования я дважды искал проблему в алгоритмах.Компактные записи в сетке позволили отсеивать кандидатов без обращения к полной сущности. Для этого не пришлось менять API запросов.
Поведение нагрузочных клиентов заметно влияет на результат. Для этой игры полезнее тест с дракой в плотной толпе, чем случайные перемещения по улице.
На домашнем ПК фоновая нагрузка меняет результаты на десятки процентов. Поэтому я чередовал прогоны старой и новой версий с одинаковыми настройками.
Попробовать
Игра бесплатная, в браузере, без скачивания и регистрации: brawltown.com.
Если вы работали с WebSocket-рассылкой на тысячи клиентов, интересно узнать, где у вас появлялся следующий предел: при сборке сообщений, записи в сокеты или уже в пропускной способности сети. У меня после этих изменений остаются вопросы и к трафику, и к разделению симуляции и рассылки.

