Тыквенный король: хэллоуинский босс для браузерной драки — от календаря до 2000 игроков на улице

Я в одиночку делаю Brawl Town - уличную драку в браузере в духе Final Fight: одна длинная улица, сотни бойцов, все против всех, круглые сутки. https://habr.com/ru/articles/1095682/
После запуска поток игроков быстро сдулся, и стало понятно, что нужен повод вернуться в игру - и повод о ней рассказать. До Хэллоуина оставалась неделя. В статье - как я за эту неделю сделал сезонный ивент: что он должен был уметь кроме тыкв, как устроен босс, почему пришлось переделывать ему анимацию и что показал нагрузочный тест на 2000 игроков.
Что хотелось получить
Список был такой:
Включаться и выключаться по датам без деплоя. 24 октября в 00:00 UTC улица сама одевается, 3 ноября сама раздевается. Никто не должен ночью нажимать кнопку.
Не ломать старые клиенты. Игра выложена ещё и на игровые порталы архивом, который проходит модерацию (до 48 часов). Значит, какое-то время на один сервер ходят клиенты разных версий.
Не выдавать живых игроков среди ботов. Принцип игры: на улице не видно, кто человек, а кто бот. Всё хэллоуинское - маски, тыквы, конфеты - должно доставаться тем и другим поровну.
Не давать одному игроку портить игру другим. Исход события должен зависеть от решений самого игрока, а не от того, что толпа что-то сделала или не сделала.
И контент: сумерки, тыквы с конфетами, маски и босс.
Календарь вместо кнопки
Настройки API собираются в образ, перечитывать их на лету он не умеет. Поэтому даты кладутся заранее, а решают часы:
"Seasons": {
"Calendar": [
{ "Id": "halloween", "From": "2026-10-24T00:00:00Z", "To": "2026-11-03T00:00:00Z" }
]
}
Игровые серверы и так раз в 15 секунд шлют в API heartbeat (онлайн, кто подключён). В ответ им давно приходит, например, чемпион для пьедестала в центре улицы. Теперь туда добавилось одно поле:
{ "stopsIn": null, "owner": null, "champion": { ... }, "season": "halloween" }
Сервер игры видит смену сезона и одевает мир: раскладывает тыквы, раздаёт маски. Клиентам он сообщает об этом отдельным сообщением, а портал спрашивает GET /api/season и показывает баннер. Если сезон надо закончить раньше, хватит переменной окружения и рестарта API. Для локальной отладки у сервера есть флаг -season halloween, который пинит сезон независимо от API.
Отдельный плюс: приватные серверы игроков тоже шлют heartbeat, так что Хэллоуин пришёл и к ним без единой строчки кода.
Протокол v6 и клиенты, которые отстают
Протокол у игры бинарный и версионируется: клиент пишет в адрес сокета ?v=6, сервер говорит на меньшей из двух версий. Каждая новая версия описывает, чего старый клиент не получает. Для Хэллоуина это:
предметы «конфета», «тыква», «тыквенная голова» и их подборы;
эффекты тыкв и короля;
событие «Тыквенный король»: старому клиенту о нём не говорят вовсе, а огненные тыквы для него - обычные бомбы с кольцом на асфальте, которые он и так умеет рисовать;
причина смерти «Тыквенный король» превращается в «взрыв».
func eventFor(ev game.WorldEvent, ver uint8) (game.WorldEvent, bool) {
if ver < 6 && (ev.Type == game.EvKing || ev.Type == game.EvEnd && ev.DurMs == uint16(game.EvKing)) {
return ev, false
}
...
}
Самое интересное - маски. У бойца в снапшоте есть байт HP в процентах. Старший бит в нём свободен (больше 100 % не бывает), и раньше там жил признак «на голове дуршлаг» - от прошлого события с макаронным монстром. В v6 этот бит значит «дальше идёт ещё один байт: что на голове» (дуршлаг, тыква, шляпа ведьмы, череп, рожки). Запись бойца при этом по-прежнему дельта: байт уходит, только когда что-то поменялось.
Как проверял: в тесте 16 клиентов разных версий 1200 тиков смотрят на арену с дождями, монстром и королём, декодируют снапшоты как браузер и сравнивают, что у них в руках, с тем, что лежит в мире. Клиент v5 не должен увидеть ни одного хэллоуинского байта.
Тыквы, конфеты и маски
Тыквы лежат вдоль бордюров. Пара-тройка ударов - и тыква лопается: чаще конфеты, иногда тыквенная голова, а каждая пятая - «гадость»: облако летучих мышей, которое валит всех вокруг, включая того, кто бил.
Конфеты - «кошелёк или жизнь». Подобрал - носишь до конца жизни. Упал - рассыпал, и твои конфеты достаются самому быстрому. Бот с пустыми карманами иногда роняет одну, чтобы конфеты вообще появлялись в толпе.
Маски получает треть бойцов при появлении, и боты, и люди с одной и той же вероятностью. Поэтому по маске живого игрока не отличить.
Все цифры я подбирал не на глаз, а пробой: тест на Go гоняет улицу с 80, 300 и 1150 ботами по 15 минут и считает, сколько лежит конфет, сколько живёт тыкв, сколько тыквенных голов на улице и чем кончается каждый выход короля. Проба нашла две проблемы, которых я бы руками не заметил.
Тыквы не доживали до игрока. На плотной улице тыква жила около четырёх секунд: дерущиеся боты разбивали её случайными ударами. Правило стало таким: бот бьёт тыкву, только если сам к ней шёл (одна тыква - один бот, ближайший из «сладкоежек»), а шальной удар в драке ей не вредит. Человек бьёт всегда.
Тыквенные головы размножались. Голова падает с бойца при смерти, ближайший бот её подбирает, и так по кругу. Через 20 минут работы сервера в тыквах ходили почти все. Теперь голов на улице не больше
2 + бойцы/50, а при нокауте половина из них раскалывается.
case r < PumpkinTrickShare+PumpkinHeadShare && w.pumpkinHeads() < 2+len(w.Ents)/PumpkinHeadsPer:
inside = PumpkinHead // rare enough to matter: kept from piling up on a long-running street
Тыквенный король: правила
Король вылезает из люка рядом с одним из игроков и 20 секунд ходит по улице: бросает огненные тыквы, топает, а в конце тянется к самому «сладкому» бойцу - тому, у кого больше всего конфет.
Здесь пригодился опыт прошлого события. У макаронного монстра была первая версия, где толпа могла его прогнать, и исход решала толпа, а не игрок. В итоге правило стало таким: защита личная и видимая. Для короля это тыквенная голова.
Если у самого сладкого на голове тыква, король принимает его за своего: кланяется и осыпает конфетами всю улицу.
Если нет - поднимает его, вытряхивает конфеты и бросает.
Если конфет нет ни у кого - смеётся и уходит обратно в люк.
Удары по королю его не прогоняют, а выбивают из карманов конфеты (не больше 40 за выход). Толпа, которая лупит короля, кормит сама себя, но на финал не влияет. Своих - в тыквенных головах - он не обжигает тыквами и не валит топотом.
Было: босс, нарисованный кодом
Первая версия короля была нарисована кодом: набор полигонов Pixi Graphics, запечённых в одну текстуру. «Анимация» - покачивание всей картинки целиком, ноги не шевелились. В скриншотах это ещё терпимо, в движении - картонка на палочке.
Арт: нейросеть рисует, я выбираю и режу
Детали короля заказывал одним листом: прозрачный фон, части в ряд с большими промежутками, чтобы скрипт мог резать лист по пустым столбцам пикселей. Скрипт режет на спрайты двойного разрешения и ставит точку привязки в центр холста: у тела это ноги, у головы - центр тыквы.
Не всё вышло с первого раза. Фонарь пришёл стоящим на деревянной доске - в игре он висел бы в воздухе, перерисовали без доски. Плечевой сустав руки торчал зелёным мячиком - дорисовали наплечник из тех же листьев, что ворот. Это классический приём cut-out-анимации: стык прячут под накладкой.

Скелет в Pixi
Иерархия простая: корень, от него «верх» (плащ, руки, голова - вращается вокруг бёдер) и два сапога снизу.
Плащ - это MeshPlane 4×7 вершин. Ворот неподвижен, а смещение вниз растёт как квадрат доли высоты. При ходьбе подол тянется назад, раскачивается с запаздыванием, по краям идёт рябь, а поднятое колено задирает переднюю половину подола.
Руки из двух звеньев. Исходную руку разрезали в самом узком месте лозы - там и стал локоть. Плечо крутится вокруг шарового сустава, предплечье - вокруг локтя. В первой версии плечи сидели на груди и выглядели анатомически дико; теперь они на краях плаща (ширину плаща на уровне плеч мерил по альфа-каналу спрайта - около 92 px).
Сапоги выглядывали из-под плаща на пару пикселей и терялись на тёмном асфальте - весь верх подняли на 14 px.

Шаг без скольжения
Главное, что я вынес из этой части: цикл шага привязан к пройденному пути, а не ко времени. Иначе при любой скорости, кроме одной, ноги скользят по асфальту.
Цикл из двух шагов - 100 px пути. Фаза φ = путь / (100 / 2π), сапог стоит в x = A·sin φ. Чтобы опорная нога не ехала, её скорость назад должна равняться скорости тела, отсюда A = STRIDE / 2π ≈ 16 px.
const STRIDE = 100, STEP = STRIDE / (Math.PI * 2);
// фаза - от пройденного пути, а не от часов; gait - тот самый «гейт» скорости
this.walk += (moved / STEP) * Math.max(0.35, this.gait);
Подъём сапога и задранный носок - только в фазе переноса (cos φ > 0), просадка тела - в момент постановки ноги. Отдельный «гейт» от 0 до 1 по скорости плавно переводит шаг в стойку: остановившись, король ставит ноги вместе.
Сглаживание поз не зависит от FPS:
const ease = (v: number, target: number, k: number, dt: number) => v + (target - v) * (1 - Math.exp(-k * dt));
Локти отстают от плеч, потому что у них k меньше. Мелочь, а рука перестаёт выглядеть доской.


Позы фаз и одна правка геймдизайна
У каждой фазы своя поза: подъём из люка (руки цепляются за край), бросок (замах за голову 90 мс, хлёст 140 мс), захват (рука указывает на жертву, из когтя тянется лоза), поклон «своему», смех (когти на животе), уход в люк. Тыква вылетает ровно из когтя: точку когтя считаю через всю цепочку трансформаций аналитически.
Во время работы над анимацией вылезла и проблема дизайна. Топот бил в тот же тик, что и начинался, - игрок не успевал увидеть угрозу. Теперь сервер сначала поднимает колено и бьёт через 0,27 с, клиент рисует замах, а удар - по событию от сервера. В играх это называется телеграфированием атак босса. Формат протокола при этом не изменился.
Мелочи, которые нашла проверка в игре
Фонари стояли на тротуаре и при масштабе 1,2 «залезали на стену» - пересчитал по арту улицы и поставил на ступеньки.
Два кадра летучей мыши (крылья вверх и вниз) были отцентрованы по своим габаритам, и тело прыгало на каждом взмахе. Выровнял по глазам.
Огненная тыква, летящая влево, разворачивалась на 180° и летела вверх ногами. Отражаю по вертикали.
Сумерки и цена для клиента
Сумерки - это та же карта света, что у события «блэкаут»: тёмный лист в половину разрешения экрана, на который складываются пятна света (фонари, тыквы, горящие машины, лицо короля), и всё это умножается на картинку мира. В Хэллоуин карта работает постоянно, но с потолком темноты 0,44 и с уклоном в фиолетовый. Потолок ниже 0,5 выбран специально: выше игра прячет стрелку над своим бойцом, а в сумерках она должна оставаться.
Замер на 1150 бойцах в одной арене (Chrome с GPU, окно 1600×900):
Режим | Бойцов в кадре | FPS | CPU на кадр |
|---|---|---|---|
Без сезона | 1003 | 100 | 9,4 мс |
Хэллоуин | 1151 | 91 | 10,3 мс |
Хэллоуин и король | 1080 | 91 | 10,1 мс |
На бойца это 9,3 и 8,9 мкс - в пределах шума. Под замедлением CPU ×4 (как слабый телефон) выходит 11–12 FPS, и дорог тут не Хэллоуин, а сама толпа: трансформы и отрисовка Pixi - около 25 % кадра, кадр игры - около 9 %, частицы - около 4 %. Поэтому добавил адаптивное качество: если 4 секунды подряд меньше 40 FPS, плотность рендера падает с 2x до 1x (на телефоне это вчетверо меньше пикселей), а карта света считается в трети разрешения.
Сервер: 2000 игроков на одной улице
Раз уж ивент должен приводить людей, хотелось понять, сколько их выдержит одна улица. Генератор нагрузки - 2000 WebSocket-клиентов, которые ведут себя как игроки и говорят на протоколе v6.
Игроков | Тик (2 ядра) |
|---|---|
500 | 3,3 мс |
1500 | 20 мс |
2000 | 39–49 мс (было 55–61) |
При 2000 игроках сервер держит 19–20 тиков в секунду из 20, исходящий трафик - около 96 МБ/с (~0,8 Гбит/с, примерно 48 КБ/с на игрока), память - 320 МБ.
Профиль до правок: кодирование снапшотов - 59 % CPU (промахи кэша по большой структуре бойца), запись в сокеты - 20 %. Что сделал: поля каждого бойца считаются один раз за тик в компактную структуру, в порядке сортировки по x (обзор клиента - непрерывный отрезок этого списка), записи толпы лежат подряд, данные клиента упакованы. Бенчмарк тика: 21,6 → 15,5 мкс на клиента, −28 %.
Чтобы убедиться, что байты на проводе не изменились, 600 клиентов версий с 1-й по 6-ю 300 тиков смотрели на «стенку на стенку» с королём. Хеш всех 312 МБ снапшотов у старого и нового кода совпал.
Вывод: на 2 vCPU потолок - 800–1000 игроков. Дальше либо второй мир (две улицы по 1000 дают 29 КБ/с на человека вместо 48), либо больше ядер и канал около гигабита.
Итог
За неделю получилось:
ивент, который сам включился 24 октября и сам выключится 3 ноября;
протокол, на котором новые и старые клиенты живут на одном сервере;
босс со скелетной анимацией и шагом, который не скользит;
проба, которая нашла две проблемы баланса раньше игроков;
замер, который говорит, сколько людей выдержит улица.
Честно про ботов: на улице в основном боты, людей обычно несколько. Хэллоуин идёт до 2 ноября, играть можно без регистрации: https://brawltown.com/?utm_source=habr&utm_medium=article&utm_campaign=halloween
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.