# Как я поместил 10 000 игроков в один мир

Большинство онлайн-игр прячут масштаб — делят игроков на комнаты по 20 или шарды по несколько сотен. Для Helix Empire я намеренно поставил цель сложнее: 10 000 игроков в одном общем мире, на одном сервере, вживую в браузере. Это полная история о том, как такое строится — четыре стены, в которые упираешься, почему настоящее горлышко это трафик, а не CPU, и момент, когда нагрузочный тест доказал, что моя красивая цифра была враньём. Длинно, технически, и каждое утверждение кончается замером. Уроки переносятся на любую высоконагруженную систему.

- **Author:** Фёдор Чищин
- **Published:** 2026-06-15
- **Tags:** architecture, methodology
- **Source:** https://fedorthinks.com/ru/blog/ten-thousand-players-one-world

---

Я делаю [Helix Empire](/ru/work/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** — а полный инженерный разбор лежит в [кейсе](/ru/work/helix-empire).
