Эта история началась еще в начале 2026 года. Тогда я заметил, что в некоторых модах для Brawl Stars, которые часто делаются довольно небрежно, появилась интересная фича — спидхак.
Почему она вообще интересная? Потому что нельзя просто взять и поменять скорость своего бойца. Передвижение полностью контролируется сервером, а клиент может только задать конечную точку этого движения.
Как работал спидхак в Brawl Stars? Сейчас разберемся.
Основы серверного передвижения
Когда сервер получает запрос на передвижение от клиента, представленный как ClientInput с подтипом 2, его обработка выглядит примерно так:
if (input.type == 2) {
character.moveTo(input.x, input.y, false, 0, false);
}
А что внутри moveTo()? На самом деле, много всего, но сейчас нас интересует самая важная часть:
void moveTo(int posX, int posY, ...) {
/* Some checks and unimportant stuff */
pathfind(this.path, posX, posY);
this.pathLength = getPathLength(this.path);
if (this.pathLength > 0) {
int speed = getWalkSpeed();
int ticks = (this.pathLength * 20) / speed;
this.startMovementTick = server.tick;
this.finishMovementTick = server.tick + Math.min(1, ticks);
}
}
Сначала строится путь от текущей позиции бойца до запрошенной точки. В большинстве случаев этот путь будет прямым и состоять всего из двух точек. После этого сервер считает длину пути.
Дальше, зная расстояние и скорость бойца, сервер считает время, за которое боец должен пройти от стартовой точки до конечной. По сути, это та самая формула из школьной физики, которую, хочется надеяться, помнит каждый.
Зачем здесь умножение на 20? Потому что время нужно получить в тиках. Один тик равен 50 миллисекундам, а скорость бойца указывается в единицах в секунду. Поэтому в переменной ticks получается время движения в тиках.
Всё просто, не так ли?
Но даже здесь нам подложили свинью
Значение ticks — всегда целое число. Почему? Воспользуюсь сложными словами, извините: время в Brawl Stars дискретно, и шаг этой дискретности равен одному тику.
При делении:
(this.pathLength * 20) / speed
результат всегда округляется вниз.
Посмотрим на двух простых примерах.
Пример №1.
Расстояние: 300 единиц
Скорость Кольта без баффов: 750
Расчет: (300 * 20) / 750 = 8
Время: 8 тиков, или 400 миллисекунд.
Кольт проходит 300 единиц ровно за 8 тиков.
Пример №2.
Расстояние: 337 единиц
Скорость Кольта без баффов: 750
Расчет: (337 * 20) / 750 = 8.986...
После округления вниз: 8 тиков
Время: 8 тиков, или 400 миллисекунд.
Кольт проходит уже 337 единиц, но за то же самое время.
Получается, мы можем пройти большее расстояние за те же 8 тиков.
Давайте назовем минимальное расстояние, которое можно пройти за N тиков, невыгодным. А максимальное расстояние, которое можно пройти за это же время, назовем выгодным.
В первом примере расстояние 300 — невыгодное. Во втором расстояние 337 — выгодное. Разницу между выгодным и невыгодным расстоянием можно посчитать по формуле:
speed / 20
с округлением вниз.
Как ведет себя обычный клиент
При ходьбе клиент считает точку назначения так: откладывает от текущей позиции бойца расстояние в 600 единиц по направлению движения.
Например, если позиция бойца равна {1500;1500} и игрок хочет пойти ровно вправо, точка назначения будет равна {1500;2100}. Расстояние — 600 единиц. Именно такое значение клиент отправит на сервер в момент, когда игрок взялся за синий джойстик.
Когда игрок продолжает ходьбу и держит синий джойстик, клиент периодически отправляет запросы с новыми координатами. По сути, новый запрос отправляется каждый раз, когда новая точка назначения отличается от старой больше чем на 225 единиц. Например, если игрок сменил направление ходьбы или просто прошел 225 единиц с прошлого запроса.
Для нас важно только одно: клиент рассчитывает пройти ровно 600 единиц. Но сюда вмешиваются сетевые задержки. Позиция бойца на клиенте и на сервере в конкретный момент времени всегда немного отличается.
Из-за этого при каждом запросе на сервере строится путь разной длины: иногда больше 600 единиц, иногда меньше. Будем считать, что в среднем у нас в равной степени получаются и выгодные, и невыгодные расстояния.
Пример №3.
Боец: Кольт
Диапазон расстояний: от 600 до 637 единиц
Среднее расстояние: (600 + 637) / 2 = 618.5
Время движения: 16 тиков
Расчет скорости: 618.5 / 16 * 20 = 773.125
Результат: примерно 773 единицы в секунду.
Вместо Кольта мог быть любой другой боец. Забавно, что его фактическая скорость отличается от getWalkSpeed() = 750 даже здесь.
Ускоряемся!
Можем ли мы заставить клиент отправлять только выгодные значения? Вообще, да.
Если учитывать сетевые задержки, можно отправлять такие координаты, чтобы довольно точно задавать конкретную длину пути на сервере.
Пример №4.
Боец: Кольт
Расстояние: 637 единиц
Тип расстояния: выгодное
Время движения: 16 тиков
Расчет скорости: 637 / 16 * 20 = 796.25
Результат: примерно 796 единиц в секунду.
С помощью модификации клиента мы ускорили своего бойца примерно на два процента. Но можем ли мы пойти дальше?
Да. Внимательный читатель заметит: чем чаще мы делаем moveTo(), тем чаще можем получать дополнительную выгоду.
Для этого можно значительно уменьшить длину пути, но продолжать выбирать именно выгодное расстояние. А затем заставить клиент отправлять запросы каждый тик.
Пример №5.
Боец: Кольт
Расстояние: 74 единицы
Тип расстояния: выгодное
Время движения: 1 тик
Расчет скорости: 74 * 20 = 1480
Результат: 1480 единиц в секунду.
Вот это я уже понимаю! Ускорение в 91% — почти в два раза. Как вам такое?
Проверка эксплоита
После этого был написан несложный Frida-скрипт, и эксплоит удалось воспроизвести на официальном сервере.
Правда, достичь теоретического ускорения в 91% так и не получилось. На практике ускорение оказалось около 50%.
Я написал отчет об этой проблеме и отправил его сотруднику Supercell 27 января 2026 года. По сути, отчет был похож на эту статью, только значительно короче.
Код той модификации я не разбирал, но по косвенным признакам стало понятно: скорее всего, там просто спамили сервер запросами на передвижение, даже не понимая, что именно происходит. Это неэффективно, но работало.
Почему?
The proof is left as an exercise for the reader.
Исправление в версии 69.230
1 сентября 2026 года Supercell выпустили версию 69.230, в которой проблема была полностью исправлена.
Раньше обычная ходьба опиралась на startMovementTick и finishMovementTick. После исправления вместо них используются более точные методы, которые “накапливают” фактически пройденное расстояние с момента начала движения.
Это позволяет серверу понимать, сколько бойцу еще осталось пройти и на какое расстояние он должен перемещаться каждый следующий тик.
Нулс Бравл | Null's Brawl — приватный сервер для iOS и Android Приватный сервер Нулс Бравл для iOS и Android