Все проекты
В работе2026 — present

Helix Empire — 10 000 игроков в одном реальном мире

Браузерная космическая стратегия в реальном времени, ядро которой — генетика: игроки выводят касты существ, редактируя ДНК. Сложное здесь — движок: 10 000 игроков в одном общем мире на одном сервере, вживую. Я спроектировал архитектуру вокруг настоящего бутылочного горлышка (исходящий трафик, а не CPU) и доказал измеренным нагрузочным тестом: 237.6 Мбит/с на 10k, 24% бюджета. Сделано в одиночку, сейчас в альфе.

Роль
Архитектор-разработчик (соло)
Стек
Rust · WebAssembly · WebTransport / QUIC · Event sourcing · Area of Interest · Hetzner bare-metal
Период
2026 — present

Задача

Helix Empire — космическая стратегия реального времени в браузере. Её ядро — не строительство, а генетика: игрок выводит касты существ, редактируя их ДНК (отсюда «Helix» — двойная спираль). Гены задают признаки, признаки решают, как существа работают в разных средах, это двигает экономику и армию, что двигает науку, а та открывает новые правки генома. Замкнутая петля: гены → признаки → экономика → наука → лаборатория → снова гены. Сила империи = гены × планеты × технологии.

Технически это значит тысячи игроков в одном общем мире, живущем в реальном времени — экономика тикает, существа размножаются, флоты летают, ресурсы меняются каждую секунду — и всё это должно доходить до браузера каждого игрока без задержек.

Большинство онлайн-игр прячут масштаб: делят игроков на комнаты по 20–50 или на «шарды». Я поставил цель сложнее намеренно — 10 000 одновременных игроков в одном мире на одном сервере — потому что ровно в этом ограничении и живёт интересная архитектура.

Четыре стены

Произнесите «10 000 в одном мире» вслух — и появляются четыре стены, о которые разбивается наивное решение.

  1. Рассылка «всем про всех» — это O(N²). Если каждый должен знать про каждого, один тик — 10 000 × 10 000 = 100 000 000 пар, и так несколько раз в секунду. Физически нереализуемо. Удвоили игроков — учетверили работу.
  2. Главная статья расходов — трафик, а не CPU. Неинтуитивный факт хостинга: исходящий трафик (egress) — самое дорогое, на гиперскейлерах в десятки раз дороже, чем на bare-metal. Если каждый тик шлёт каждому игроку много байт — счёт за трафик убивает проект раньше, чем нагрузка на процессор. Значит, архитектуру надо строить вокруг минимизации трафика.
  3. Один мир хочет жить на одном сервере. Размазать мир по машинам — это распределённый консенсус (чья версия мира правильная?), медленно и сложно. Я выбрал single-writer: ровно один процесс имеет право менять мир. Никаких гонок и консенсуса. (У него есть коварная ловушка — о ней ниже.)
  4. Тонкий клиент не вытянет. Если сервер считает всё, а браузер только рисует, сервер становится узким местом на 10 000 человек сразу. Поэтому клиент должен быть толстым — уметь достроить картинку из минимума данных.

Стек, выбранный против стен, а не под моду

Смысл архитектуры в том, что технологии подчинены ограничениям, а не моде. Каждый выбор отвечает на конкретную стену.

Rust + детерминированное ядро в WebAssembly

Ядро симуляции написано один раз на Rust и компилируется и в нативный серверный бинарь, и в WASM для браузера. Один и тот же код на обеих сторонах. Толстый клиент сам досчитывает мир из компактных «семян» и предсказывает вперёд — нагрузка уходит с сервера (стены №2, №4). Предсказуемая память Rust без сборщика мусора — больше игроков на сервер.

Почему: один источник истины для симуляции означает, что клиент и сервер физически не могут разойтись — конвергенция структурна, а не «тест, на который надеемся».

WebTransport / QUIC, single-writer-миры, event sourcing

Realtime-кадры идут по WebTransport / QUIC (быстрый бинарный поток поверх UDP, без задержек TCP при потерях), с фолбэком на WebSocket. У каждого мира один писатель, а его история — это поток событий, так что внутри мира нет консенсуса, а состояние можно восстановить или проверить по событиям (стена №3).

Почему: single-writer даёт консистентность бесплатно, event sourcing — восстановимость. Цена — ловушка контеншна на чтениях, которую закрывают read-model-кэши ниже.

Что я построил, шаг за шагом — измеряя на каждом шаге

Я не строил всё сразу. Шёл слоями, измеряя на каждом — нельзя оптимизировать то, что не измерил.

Шаг 0 — базовая линия. Прежде чем что-то чинить, я измерил, как плохо: чтения состояния со скачками до 4–10 секунд, пуш клиенту примерно 1 кадр в 20 секунд. Появилась точка отсчёта.

Шаги 1–3 — убрать контеншн single-writer. Вот та ловушка. Раз мир меняет один процесс, то пока он применяет тик (а тик пишет в хранилище — медленно), все читатели ждут тот же замок. Игрок открывает экран обороны и висит 10 секунд, потому что сервер в этот момент сохраняет тик. Решение — lock-free read-model-кэши в RAM, обновляемые инкрементально и читаемые без замка записи.

0.002с
чтение обороны, было 0.002–4.4с со скачками
~0.006с
лента событий, было ~0.2с
чтение ≠ запись
разделены: читатели больше не ждут писателя

Архитектурный ход: отделить чтение от записи. Писатель по-прежнему владеет консистентностью, читатели читают RAM-проекции и перестают ждать. Контеншн исчез, все эндпоинты стали стабильно sub-second.

Шаги 4–5 — слать разницу, а не всё состояние. Даже с быстрыми чтениями каждый тик слал полное состояние — заново пересылая горы того, что не менялось. Решение — дельты. Математически проверенный тип: одна функция строит разницу двух состояний, другая её применяет, а тест конвергенции доказывает, что цепочка дельт, сложенная клиентом, в точности воспроизводит снапшот сервера. Один и тот же Rust-код на сервере и клиенте — они не могут разойтись. Потерян кадр? Клиент это замечает (ResyncRequired) и просит полный снапшот.

Шаг 6 — Area of Interest рубит O(N²) до линейного. Игроку не нужны все 10 000 — только соседи по пространству и дипломатии. AreaOfInterest + filter_frame режут дельту по области интереса: каждый подписчик получает релевантное ему, а общеколониальный контекст (чат, сделки) сохраняется. Квадратичная рассылка становится линейной: трафик растёт как N × (размер области интереса), а не N².

Шаг 7 — честная последняя миля. Самая важная часть истории. Нагрузочный тест показал, что красивая цифра «320 Мбит/с» держалась на зашитой константе «32 байта на дельту игрока». Я подставил реальный бинарный кодек и измерил: настоящая дельта — 104 байта, потому что я слал весь профиль игрока (11 ресурсных полей), даже если менялось одно. 104 против 32 — это в 3.25 раза больше, проекция вылетала в ~1.04 Гбит/с на 10k. Честный ответ на тот момент: «нет, на реальном формате 10k я не держу».

Решение — пофилдовая дельта WirePlayerDelta:

player_id (4 байта) + битовая маска изменённых полей (2 байта) + ТОЛЬКО изменённые значения

Если изменились population, food и science — на провод уходят ровно эти три плюс маска: 4 + 2 + 4 + 8 + 8 = 26 байт вместо 104. Маска — 16 бит, по одному на поле; стоит бит — значение следует в payload, не стоит — поле не изменилось и не стоит ничего.

Как это доказано — и почему цифре можно верить

Архитектура без доказательства — обещание. Я доказал числами: round-trip- и конвергенс-тесты, контрактные тесты на каждый порт и 42 e2e-теста в настоящем Chromium поверх WebTransport (включая «получает серверные тики по realtime-сокету без поллинга» и «два игрока в одном мире синхронизируются в реальном времени»).

Главное доказательство — тест на 10 000 ботов, который меряет egress настоящим бинарным кодеком — никаких зашитых констант. Создаёт 10 000 ботов-клиентов в одном мире, прогоняет настоящий авторитетный тик, применяет видимые каждому боту дельты через реальную AoI-фильтрацию и суммирует измеренные байты.

26 байт
типичная дельта игрока, было 104
237.6 Мбит/с
egress при 10 000 × 5 тиков/с
24%
бюджета 1 Гбит/с — запас ~76%
≤ 200 мс
латентность fan-out тика на 10k

Цифра защищена гейтом check:load: отчёт проверяется на каждом прогоне, а проекция выводится из измеренного значения, а не из допущения. Раздует кто-то формат дельты — гейт упадёт.

Честная граница

Зрелость архитектора — не только хорошая цифра, но и честность про её границы.

Доказано: по вычислениям и латентности fan-out на 10 000 ботов укладывается в 200 мс с большим запасом; по egress — 237.6 Мбит/с (24% бюджета 1 Гбит/с), причём это измерение настоящим кодеком, а не допущение.

Пока не доказано: это проекция из одного измеренного тика на 10k (боты в одном процессе), а не живой кластер с 10 000 реальных QUIC-сокетов под нагрузкой часами. Поведение CPU/RAM под устойчивым многоминутным потоком и индивидуальный per-subscriber AoI-канал для толстого WASM-клиента — следующий слой работы. Но главный риск — egress, который я сам честно пометил как «за бюджетом на реальном формате», — закрыт и измерен.

Что здесь от архитектора, а не только от разработчика

  1. Правильно назвал бутылочное горлышко: egress, а не CPU. Весь стек подчинён этому выводу.
  2. Отделил чтение от записи: single-writer для консистентности + lock-free RAM-проекции для чтений.
  3. Свёл O(N²) к линейному через Area of Interest — без этого 10k невозможны в принципе.
  4. Дельты вместо снапшотов, с одним кодом на сервере и клиенте — конвергенция гарантирована архитектурно.
  5. Не поверил красивой цифре, пока не измерил. Нашёл, что «32 байта» — допущение, измерил 104, придумал пофилдовую дельту, довёл до 26 и доказал замером.
  6. Закрепил результат гейтом, чтобы регресс не прошёл незамеченным.

Движок, который измеренно обслуживает 10 000 игроков в одном мире на 24% бюджета трафика, — это не везение и не один трюк. Это последовательность архитектурных решений, каждое отвечает на конкретную стену и подтверждено числом.

Снимок стека

СлойВыборКакую стену решает
Ядро симуляцииRust, детерминированное, в нативку + WASMтолстый клиент, больше игроков/сервер (№2, №4)
ТранспортWebTransport / QUIC, фолбэк WebSocketбинарный поток с низкой задержкой (№2)
Модель мираSingle-writer + event sourcingбез консенсуса, восстановимо (№3)
ЧтенияLock-free RAM read-model-проекцииsub-second чтения без контеншна
ОбновленияПофилдовые бинарные дельты + Area of InterestO(N²) → линейно, минимум egress (№1, №2)
ХостингHetzner bare-metalegress в 20–40× дешевле гиперскейлеров (№2)

Helix Empire в альфе, сделан в одиночку — продуктовый дизайн, Rust-ядро симуляции, WASM-клиент, транспорт, разделение чтения/записи, конвейер дельт + AoI, нагрузочный харнесс и измеренное доказательство. Скоро будет доступен на helixempire.com.