15 июня 2026 г.
Как я поместил 10 000 игроков в один мир
Большинство онлайн-игр прячут масштаб — делят игроков на комнаты по 20 или шарды по несколько сотен. Для Helix Empire я намеренно поставил цель сложнее: 10 000 игроков в одном общем мире, на одном сервере, вживую в браузере. Это полная история о том, как такое строится — четыре стены, в которые упираешься, почему настоящее горлышко это трафик, а не CPU, и момент, когда нагрузочный тест доказал, что моя красивая цифра была враньём. Длинно, технически, и каждое утверждение кончается замером. Уроки переносятся на любую высоконагруженную систему.
Я делаю Helix Empire — космическую стратегию реального времени в браузере, ядро которой генетика: ты выводишь касты существ, редактируя их ДНК, и эти гены расходятся в экономику, армию и науку. Но этот пост на самом деле не про игру. Он про инженерную задачу под ней — ту, что я считаю по-настоящему сложной и по-настоящему интересной: поместить 10 000 игроков в один общий мир, на одном сервере, обновляя вживую каждый браузер.
Большинство игр на это не замахиваются. Они прячут масштаб — комнаты по 20 человек или «шарды» по несколько сотен, которые друг друга не видят. Я поставил цель сложнее намеренно, потому что именно в этом ограничении живёт настоящая архитектура, и потому что хотел доказать, что смогу. Это вся история, рассказанная просто, и кончается она — как любая честная инженерия — измеренными числами, а не обещаниями. Даже если ты никогда не делаешь игр, форма этой задачи всплывает в любой системе, которой надо пушить живые обновления многим людям сразу.
Произнеси вслух — и появляются четыре стены
«Десять тысяч игроков в одном мире» — это одно предложение. В момент, когда ты пытаешься это построить, появляются четыре стены, в которые наивный дизайн влетает напрямую.
Стена 1: рассказывать всем про всех — это O(N²). Если каждый игрок должен знать, что делает каждый другой, то один цикл обновления — это каждый игрок на каждого другого:
10 000 наблюдателей × 10 000 объектов = 100 000 000 пар — за тик
Сто миллионов пар, несколько раз в секунду. Это не «медленно», это физически невозможно. И это квадратично: удвой игроков — и работа учетверится. Любой дизайн, рассылающий «всем про всех», мёртв на старте.
Стена 2: главная статья расходов — трафик, а не CPU. Вот неинтуитивная, и именно она всё решает. На облачных хостингах исходящий трафик — egress — самый дорогой ресурс, в десятки раз дороже, чем на bare-metal. Если каждый тик шлёт много байт каждому игроку, счёт за трафик банкротит тебя задолго до того, как процессор вспотеет. Так что систему надо строить вокруг минимизации того, что уходит на провод, а не вокруг скорости вычислений. Это одно осознание переупорядочивает все остальные решения.
Стена 3: один мир хочет жить на одном сервере. Размажь мир по нескольким машинам — и ты подписался на распределённый консенсус: серверы постоянно спорят, чья копия мира настоящая. Медленно и зверски сложно. Поэтому я выбрал single-writer: у каждого мира ровно один процесс, которому разрешено его менять. Никаких гонок, никакого консенсуса. В нём прячется коварная ловушка — до неё дойду.
Стена 4: тонкий клиент не вытянет. Если сервер считает всё, а браузер только рисует пиксели, сервер становится узким местом на 10 000 человек сразу. Поэтому клиент в браузере должен быть толстым — достаточно умным, чтобы достроить бóльшую часть картинки самому из крошечного объёма данных.
Четыре стены. Каждая убивает очевидное решение. Архитектура — это просто набор ответов на эти четыре, а дисциплина — позволить ограничениям, а не самой модной технологии, выбирать инструменты.
Стек, выбранный против стен
Люди любят выбирать технологию по моде. Настоящая работа — наоборот: сначала назови ограничения, потом выбери наименьший набор инструментов, который на них отвечает. Вот соответствие, потому что соответствие и есть мышление.
Симуляция крутится на Rust, компилируется двумя путями: нативный бинарь для сервера и WebAssembly для браузера. Один код, обе стороны. Это важнее, чем звучит. Раз клиент гоняет идентичную симуляцию, он может достроить мир из компактных «семян» и даже предсказывать вперёд — это уносит работу с сервера (стены 2 и 4). А у Rust предсказуемая память без пауз сборщика мусора, так что один сервер держит больше игроков.
Realtime-кадры идут по WebTransport / QUIC — быстрый бинарный поток поверх UDP, который обходит задержки, от которых страдает TCP при потерях пакетов (стена 2), с фолбэком на WebSocket. Каждый мир — single-writer с event sourcing: один процесс его меняет, а его история — поток событий, который можно переиграть, чтобы восстановить или проверить состояние (стена 3). И хостится это на bare metal (Hetzner), где egress в 20–40× дешевле, чем на гиперскейлерах (стена 2 — заметь, как часто она всплывает).
Каждое из этого служит одному выводу: горлышко — это egress и fan-out обновлений. Угадай этот выбор — и стек выпадает из него сам. Ошибись — и будешь любовно оптимизировать CPU, пока счёт за трафик тихо тебя убивает.
Строй слоями, измеряй каждый
Я не строил это одним героическим рывком. Я шёл слой за слоем и измерял на каждом — потому что кардинальное правило: нельзя оптимизировать то, что не измерил. Вот что удивляет людей: я начал с того, что измерил, как плохо было.
Базовая линия. Прежде чем что-то трогать, я записал постыдную правду: чтение игрового состояния скакало до 4–10 секунд, а обновления пушились в браузер примерно один кадр в 20 секунд. Практически замёрзло. Но теперь у меня была цифра, которую надо побить, — а это единственное, что превращает «ощущается медленно» в инженерию.
Убить ловушку single-writer. Помнишь ловушку, что я обещал? Single-writer значит, что мир меняет один процесс. Но пока он применяет тик — а тик пишет в хранилище, это медленно — все читатели застряли в ожидании того же замка. Игрок открывает экран обороны и висит десять секунд, не потому что читать тяжело, а потому что сервер случайно в середине сохранения.
Решение — самая переносимая идея во всём этом посте: отделить чтение от записи. Я построил lock-free кэши в памяти — готовые к выдаче проекции мира, обновляемые инкрементально, которые читатели бьют без взятия замка записи. Писатель по-прежнему единолично владеет изменениями (так что консистентность вне вопроса), но читатели перестали его ждать.
Это разделение чтения и записи — не игровой трюк. Это тот же ход, что стоит за read-репликами, CQRS и материализованными представлениями повсюду — и именно к нему я бы потянулся первым почти в любой системе, которая тормозит под нагрузкой.
Слать разницу, а не мир. Даже с мгновенными чтениями каждый тик всё ещё слал всё состояние — заново пересылая горы того, что не менялось. Чистая трата на стену 2. Так что я перешёл на дельты: слать только то, что изменилось с прошлого кадра. Тип дельты маленький и математически проверенный — одна функция строит разницу двух состояний, другая её переигрывает, а тест конвергенции доказывает, что длинная цепочка дельт, сложенная клиентом, воспроизводит снапшот сервера в точности. Раз клиент и сервер делят один Rust-код, они не могут разойтись. Потерян кадр? Клиент замечает и просит свежий полный снапшот.
Срезать O(N²) до линии. Это ответ на стену 1. Игроку не нужно знать про все 10 000 других — только про соседей по пространству и дипломатии. Поэтому каждая дельта обрезается по области интереса игрока: ты получаешь изменения, релевантные тебе, а общий контекст вроде чата и сделок всё равно проходит. Это превращает квадратичную рассылку в линейную — трафик растёт как N на размер твоей области интереса, а не как N². Без этого шага 10 000 невозможны на бумаге. С ним математика сходится.
Момент, когда красивая цифра оказалась враньём
Вот часть, которой я горжусь больше всего, — и часть, где я ошибался.
У меня был нагрузочный тест, и он показывал прелестные 320 Мбит/с на 10k игроков — комфортно под бюджетом. Я почти поверил. Потом посмотрел, откуда взялась эта цифра, и нашёл, что она держится на зашитой догадке: «предположим 32 байта на обновление игрока». Не замер. Допущение, которое кто-то (я) вписал.
Так что я подставил реальный бинарный кодировщик и измерил настоящее обновление. Оно было 104 байта, а не 32. Почему? Потому что я слал весь профиль игрока — одиннадцать ресурсных полей — каждый раз, даже когда менялось одно. 104 против 32 — это в 3.25 раза хуже, что раздуло проекцию до примерно 1.04 Гбит/с на 10k. Честный ответ в тот момент был неудобным: «нет — на реальном формате провода я 10 000 не держу».
Вот момент, который отделяет архитектора от того, кто выкатывает красивую демку. Лёгкий путь — продолжать цитировать 320. Правильный путь — поверить замеру, сказать вслух, что цифра плохая, и починить настоящую вещь. Решением стала пофилдовая дельта: вместо всего профиля слать id, крошечную битовую маску изменённых полей и только значения этих полей.
player_id (4 байта) + маска изменённых полей (2 байта) + только изменённые значения
# если изменились population, food и science:
4 + 2 + 4 + 8 + 8 = 26 байт (вместо 104)
Двадцать шесть байт вместо ста четырёх. Маска — шестнадцать бит, по одному на поле: стоит бит — значение следует на проводе; не стоит — поле не менялось и не стоит вообще ничего.
Доказательство, а не обещания
Архитектура без доказательства — просто уверенная история. Так что вот доказательство, измеренное.
В основе — скучные гарантии: round-trip-тесты (закодируй дельту в байты, раскодируй обратно, без потерь), конвергенс-тесты (клиент не дрейфует от сервера), контрактные тесты на каждой границе и 42 end-to-end-теста в настоящем браузере Chromium поверх WebTransport — включая те, что реально важны для живой игры, вроде «получает серверные тики по realtime-сокету без поллинга» и «два игрока в одном мире остаются синхронными в реальном времени».
И главное: тест на 10 000 ботов, который меряет egress настоящим бинарным кодеком — никаких зашитых констант на этот раз. Поднимает 10 000 ботов-клиентов в одном мире, прогоняет настоящий авторитетный тик, применяет видимые каждому боту обновления через реальный фильтр области интереса и складывает настоящие измеренные байты.
И цифру держат честной: гейт check:load перепроверяет отчёт на каждом прогоне, а проекция выводится
из измеренного значения, а не из допущения. Если какое-то будущее изменение случайно раздует формат
обновления, гейт упадёт и регресс не проскочит. Этот страж существует именно потому, что меня уже один
раз одурачила цифра, которую я не измерил.
Честная граница
Зрелость — это не только получить хорошую цифру, но и ясно сказать, что она покрывает, а что нет. Так что прямо:
Что доказано. По вычислениям и латентности разослать тик 10 000 ботам укладывается в 200 мс с кучей запаса. По трафику — 237.6 Мбит/с на 10k, 24% бюджета 1 Гбит/с, и это настоящий замер через настоящий кодек, а не надежда.
Что пока не доказано. Это проекция из одного измеренного тика на 10k, с ботами внутри одного процесса, — а не живой кластер из 10 000 реальных QUIC-сокетов, забиваемых часами. Устойчивые CPU и память под долгим многоминутным потоком и индивидуальный канал обновлений для толстого браузерного клиента — следующий слой работы. Я лучше скажу это, чем перепродам. Важно, что главный риск — egress, тот самый, что я сам пометил как за бюджетом на реальном формате, — закрыт и измерен.
Что переносится, даже если ты никогда не делаешь игр
Убери космолёты и генетику — и остаётся набор ходов, который я бы применил почти к любой системе, которой надо обслуживать много людей сразу:
- Назови настоящее горлышко, прежде чем что-либо оптимизировать. Здесь это egress, а не CPU. Всё вытекло из этого одного вывода. Самые дорогие ошибки делаются, когда красиво оптимизируешь не тот ресурс.
- Отдели чтение от записи. Один писатель для консистентности плюс lock-free read-проекции убрали контеншн, не отдавая корректность. Этот ход почти универсален.
- Слать diff, а не целые состояния — и если можешь, дели один код через границу, чтобы две стороны физически не могли разойтись.
- Найди O(N²) и сруби его до линейного. Почти за каждой историей «оно падает на масштабе» прячется квадратичность. Моя была в рассылке; Area of Interest стал ножом.
- Никогда не верь красивой цифре, которую не измерил. Мои 320 Мбит/с были вписанным допущением, ошибавшимся в 3.25 раза. Весь результат держался на том, чтобы это поймать и не побояться сказать.
- Закрепи победу гейтом, чтобы будущий регресс срабатывал тревогой, а не выезжал в прод молча.
Итог
Десять тысяч игроков в одном живом мире — это цель, которая звучит как бравада, пока не разобьёшь её на четыре стены и не ответишь на них по одной, измеряя по ходу. Движок делает это на 24% бюджета трафика, и я могу показать тебе тест, который это доказывает, а не просить поверить на слово.
Архитектура — не ловкий трюк, а цепочка решений, каждое отвечает на конкретную стену, каждое подкреплено числом. В этом вся дисциплина: назови настоящее ограничение, строй под него измеренными слоями и доверяй замеру больше, чем истории, которую хотел рассказать. Helix Empire скоро выйдет на helixempire.com — а полный инженерный разбор лежит в кейсе.
Комментарии
Пока нет комментариев
Войдите, чтобы участвовать в разговоре.
Будьте первым, кто оставит мысль.