32 KiB
План: переход к клиент-серверной игре
Статус: выполняется. Дата: 2026-10-30. Проверенный прогресс отмечен в разделе 9.
1. Цель и рамки
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. Конкретные механики вмешательства требуют отдельного согласования.
Исходные требования:
- До 30 игроков в одной комнате.
- Комната — отдельный инстанс мира, у каждой комнаты свой город (из OSM).
- Сущностей очень много, до миллиона на комнату.
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
Не входит в первую версию: аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). Влияние ограничено командами этапа 5.
2. Ключевые решения
| Вопрос | Решение | Почему |
|---|---|---|
| Язык сервера | Rust | Нет GC-пауз, плотные данные, параллельность без data race |
| ECS | bevy_ecs как отдельная библиотека (без движка Bevy) |
Архетипы, автопараллелизм систем, change detection, Schedule. Запасной вариант: hecs |
| Асинхронность и сеть | tokio + axum + tokio-tungstenite |
Стандарт для WebSocket в Rust |
| Транспорт | WebSocket (бинарный) | В браузере работает везде, TCP достаточен: игроки наблюдают, потеря пакета не критична, мир обновляется ~5–10 раз в секунду. WebTransport подключим, только если упрёмся в задержки |
| Формат сообщений | Protobuf (prost на сервере, ts-proto или protobuf-es на клиенте) |
Общая схема, кодогенерация для обеих сторон, компактно, версионируется |
| Статика города | Тайлы-файлы, отдаются по HTTP | Кэшируются браузером и CDN, не грузят сервер симуляции |
| Генерация города | Остаётся на TypeScript, запускается офлайн (CLI) | Уже написана и отлажена (около 5 тыс. строк), портировать нет смысла |
| Сохранения | Снимки мира на диск, позже БД | Комнат немного, симуляция детерминируемая |
3. Архитектура
Браузер (TS) Сервер (Rust)
┌───────────────┐ HTTP ┌───────────────────────┐
│ Рендерер │◄─────────►│ Статика: тайлы города │
│ (src/view) │ тайлы └───────────────────────┘
│ │ WS ┌───────────────────────┐
│ Состояние │◄─────────►│ Gateway (axum) │
│ клиента: │ дельты │ - подключение к комн.│
│ интерполяция │ └──────────┬────────────┘
└───────────────┘ │ каналы
┌───────────▼────────────┐
│ Room (1 поток/задача) │
│ bevy_ecs World │
│ Schedule систем │
│ Interest manager │
└────────────────────────┘
Принципы:
- Одна комната — один
World. Комнаты независимы, масштабируются добавлением потоков и процессов. Внутри комнаты системы выполняются параллельно средствамиbevy_ecs. - Сервер авторитарен. Клиент отправляет только запросы (подписка на область, позже команды влияния), состояние никогда не принимает от клиента.
- Статика отдельно от динамики. Здания, дороги, интерьеры и мебель из OSM лежат в тайлах и не рассылаются через WebSocket. По сети идут только изменяемые сущности.
- Интерес по области (AOI). Каждый игрок подписан на прямоугольник карты (вьюпорт плюс запас). Сервер шлёт ему дельты только для сущностей в этой области. Миллион сущностей к клиенту не уходит.
- LOD симуляции. Вдали от всех наблюдателей жители симулируются грубо (потребности и расписания по шагам в десятки секунд, без пути по каждому тайлу), рядом с наблюдателем подробно. Это главный способ удержать миллион сущностей.
4. Формат данных города
Сейчас src/types.ts описывает город одним объектом, всё генерируется в браузере. Для игры нужно разрезать:
- Манифест города: границы, размер тайла, версия формата, список тайлов, метаданные.
- Тайлы: сетка (например 256 x 256 м). В тайле: дороги, здания, этажи, интерьеры, мебель, индекс проходимости (navmesh или сетка).
- Формат файла: бинарный (protobuf или FlatBuffers) со сжатием, чтобы читать и сервер, и клиент. На первом этапе допустим JSON + gzip, потом заменить.
- Навигация: для сервера отдельно строится граф дорог и проходимая сетка, чтобы жители ходили, не загружая геометрию.
Интерьеры нужны серверу (решено). Жители перемещаются по интерьерам и улицам и взаимодействуют с ними, поэтому сервер загружает полную проходимую геометрию: стены, двери, этажи, лестницы, мебель с типом, размером и точками взаимодействия (спать, сидеть, есть, работать). Загрузка по тайлам и лениво: интерьеры тайла нужны, только когда в нём есть наблюдатель или активные жители.
Для навигации строятся два уровня: грубый граф (здания, улицы, входы) для дальних путей и детальная сетка внутри зданий и вокруг для ближних. Переходы между уровнями — двери и лестницы.
Улицы сейчас «пустоваты» (замечание автора). До появления жителей на улицах нужно сгенерировать то, с чем они взаимодействуют снаружи: уличную мебель (скамейки, фонари, урны), остановки, парковки, дворы, переходы, входы в здания, места для торговли. Это отдельная задача генератора (этап 1б), см. раздел 9.
5. Модель симуляции (первая версия)
Компоненты (примерный набор):
Position,Velocity,TileId(для быстрого AOI).Agent(житель),Needs(сон, еда, работа, отдых),Schedule,Home,Workplace.Vehicle,Route.Building,Room,Furniture(служебные ссылки на статику).Dirty(было ли изменение с прошлой рассылки).
Системы (порядок и зависимости задаёт Schedule):
- Время и календарь.
- Потребности и выбор цели.
- Построение пути, движение.
- События (пожар, болезнь, происшествие), позже вмешательство игроков.
- Сбор изменений и рассылка по AOI.
Тик симуляции: 10 Гц фиксированный, скорость игры меняется множителем (как в RimWorld). Рассылка клиентам: 5–10 Гц, дельтами, клиент интерполирует.
6. Сеть и протокол
Сообщения клиент → сервер:
Join { room, client_version }Subscribe { rect, lod }— какую область смотрю.Ping.- (позже)
Intervene { ... }.
Сообщения сервер → клиент:
Welcome { room_info, tick, city_manifest_url }Snapshot { tick, entities[] }— полное состояние области при подписке.Delta { tick, spawned[], changed[], despawned[] }— изменения области.Event { ... }— редкие события (уведомления).
Правила:
- Позиции квантуются (например, в сантиметрах целым числом), идентификаторы компактные, дельты по полям.
- Версия протокола в рукопожатии, несовместимый клиент получает понятную ошибку.
- Ограничение размера и частоты запросов на клиента, ограничение размера области подписки.
7. Производительность: бюджеты
Ориентиры для проверки (замерить и уточнить):
- Тик комнаты с 1 млн сущностей: не более 50 мс на целевом сервере (при LOD, когда подробно симулируется лишь малая часть).
- Рассылка: не более 100 КБ/с на клиента в среднем.
- Память: порядка 100–200 байт на сущность в ECS, итого до 200 МБ на комнату без учёта статики.
- Подключение клиента: менее 2 с до первой картинки (за счёт тайлов, а не снимка всего мира).
Для проверки сразу закладываем нагрузочный стенд: генератор синтетической популяции (1 млн агентов на карте) и клиент-бот на 30 подключений.
8. Клиент (браузер)
src/viewостаётся, но получает данные не из воркера генерации, а из загрузчика тайлов и WebSocket-клиента.- Слой состояния: хранит сущности вокруг камеры, применяет дельты, интерполирует позиции между тиками.
- Рендер динамики: на первом этапе Canvas, дальше (при 10–50 тыс. видимых сущностей) WebGL с инстансингом. Геометрию тайлов можно кэшировать в офскрин-текстуры.
- Режим «воспроизведение» (без сервера, текущий прототип) оставить как отладочный.
9. Этапы работ
Этап 0. Подготовка (1 неделя)
- Зафиксировать решения этого документа, выбрать имя и структуру репозитория (монорепо:
server/,client/,tools/,proto/). - Определить минимальную сцену для проверки: один небольшой город, 1000 агентов, 1 клиент.
Этап 1. Тайловый формат (1–2 недели)
- Экспорт готового города из текущего генератора в тайлы + манифест (
tools/export). - Клиент загружает город по тайлам, а не генерирует в браузере.
- Тесты формата, версия формата в манифесте.
Этап 1б. Обустройство улиц в генераторе (2 недели)
- Уличные объекты и точки взаимодействия снаружи зданий: скамейки, фонари, остановки, парковки, дворы, переходы, входы.
- Единый реестр типов объектов с интерактивными слотами (как у мебели в
src/gen/catalog.ts), общий для интерьеров и улиц. - Цель: к моменту появления жителей снаружи есть куда идти и что делать.
Этап 2. Скелет сервера (1–2 недели)
- Rust-проект:
axum, WebSocket, протокол (proto/), одна комната,bevy_ecs. - Загрузка манифеста и тайлов на сервере (навигационная сетка).
- Подключение клиента, подписка на область, снимок.
Этап 2б. Сервис генерации
- Очередь с лимитами площади, зданий, одновременных задач и таймаутом.
- Изолированный TS-воркер, прогресс, кэш по нормализованному bbox, seed и версии.
CreateCity/CityJobв общей Protobuf-схеме и интерфейс создания города.- Сквозной тест запроса, отдельного Node-процесса, экспорта, открытия комнаты и сохранения без внешней сети.
Этап 3. Простая симуляция (2–3 недели)
- Агенты ходят по дорогам между домом и работой по расписанию.
- Дельты по AOI, интерполяция на клиенте.
- Детерминизм: seed комнаты, воспроизводимые тики.
- Бинарные атомарные снимки, ротация, ленивое открытие сохранённых комнат и тест эквивалентности после восстановления.
Этап 4. Масштаб и LOD (2–3 недели)
- Нагрузочный стенд (1 млн агентов, 30 клиентов).
- Профилирование, LOD симуляции, параллелизм систем. Реализованы грубый шаг дальних маршрутов, общий пространственный индекс, стабильный LOD репликации и параллельная обработка движения через пул ECS. Построение индекса зависит от движения и идёт следующим шагом.
- Оптимизация протокола и рендера. Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas.
Этап 5. Игровой слой
- Потребности, события, экономика. Общегородская казна, запасы и усреднённые потребности удалены по просьбе автора. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа.
- Ограниченное влияние игроков (команды, права, очереди, лимиты). Массовые команды запасов, обеда и отдыха удалены. Пауза/скорость остаются голосованием.
- Сохранения, восстановление комнат, админка. Схема v3 загружает снимки v1/v2, отбрасывая удалённую экономику. Общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора сохраняются.
Этап 6. Эксплуатация
- Подготовка Compose, мониторинг (метрики тика, число сущностей, трафик), логи.
По уточнению автора этап 6 ограничен подготовкой Compose. Добавлены постоянные тома, healthcheck, лимиты, SIGTERM с сохранением, ротация логов и опциональный Prometheus с правилами предупреждений. Проверка
docker compose --profile monitoring config --quietпроходит. Docker Engine выключен: сборка и запуск контейнера не проверены. - Создание комнат по запросу (выбор города), лимиты ресурсов.
Проверка минимальной сцены: npm run demo, npm run build, npm run server:dev, затем http://localhost:3000/?play.
Имя репозитория остаётся citygen; генератор и существующий рендер сохраняют свои пути в src/, новые сетевые модули находятся в client/.
Состояние и ограничения реализации описаны в server/README.md, запуск Compose — в deploy/README.md. Игровые механики этапа 5 остаются незавершёнными; этап 6 — подготовка Compose в согласованном объёме.
10. Риски
| Риск | Что делаем |
|---|---|
| 1 млн сущностей не успевают в тик | LOD, чанки, разреженные системы; замер на этапе 4, а не в конце |
| Объём рассылки вырастет | AOI, квантование, дельты, приоритет близким к камере |
| Размер тайлов и время загрузки | Компактный бинарный формат, сжатие, ленивая загрузка тайлов по камере |
| Крутая кривая Rust для команды | Держать код простым, избегать лишних абстракций, начать с одной комнаты |
| Изменение формата данных ломает сохранения | Версия формата, миграции, тесты совместимости |
| Дублирование типов между Rust и TS | Единая схема в proto/, кодогенерация |
11. Принятые решения, допущения и открытые вопросы
Принятые решения:
-
Интерьеры нужны на сервере (раздел 4): жители ходят по интерьерам и улицам и взаимодействуют с ними.
-
Без аккаунтов. Вход по нику; персистентности игрока между комнатами пока нет. Ник уникален в пределах комнаты, идентификатор сессии выдаёт сервер.
-
Создание комнат игроком с лимитом из конфига сервера (
max_rooms, по умолчанию 5). При достижении лимита создание отклоняется с понятной ошибкой. Пустые комнаты закрываются по таймауту (параметр конфига), чтобы не занимать слот. -
Время общее для комнаты. Пауза и смена скорости — через голосование: предложение принимается, если «за» большинство подключённых игроков (более половины). Один игрок в комнате решает сам. Нужны: таймаут голосования, один активный вопрос за раз, защита от спама предложениями.
-
Только десктопные браузеры. Мобильные не поддерживаем, рендер и управление рассчитываем на мышь и клавиатуру.
-
max_rooms— лимит на весь сервер одновременно открытых комнат (подтверждено). -
Игрок создаёт свой город: выбирает область на карте и запускает генерацию (раздел 12). Каталог готовых городов тоже остаётся.
-
Владельца комнаты нет: создатель не может закрыть комнату, выгнать игрока или поставить пароль. Комната закрывается только по таймауту пустоты.
-
Комнаты переживают перезапуск сервера: состояние сохраняется на диск (раздел 13).
Допущения (если неверны, скажи — поправлю):
- Серверная симуляция детерминируема от seed. Отдельный вопрос, нужна ли строгая детерминированность (для повторов и отладки).
- Деплой на один сервер, комнаты как задачи внутри одного процесса. Горизонтальное масштабирование — позже.
- Бюджет железа: современный многоядерный сервер, 8+ ядер.
Открытые вопросы:
- Лимит размера области для генерации (площадь, число зданий) и лимит одновременных генераций. Предварительно: берём из конфига, значения подобрать после замеров на этапе 1.
- Что делать с комнатой без владельца, если она не закрывается: пустая комната закрывается через N минут, но сохраняется на диск и может быть открыта заново? Предварительно: да, закрытая комната хранится как снимок, пока есть место.
- Нужна ли защита от злоупотреблений при отсутствии аккаунтов (лимит создания по IP, антиспам)? Предварительно: да, простой лимит по IP.
12. Создание города игроком
Генерация города — тяжёлая работа (запросы Overpass, построение зданий и интерьеров), поэтому выполняется отдельным сервисом, а не в потоке комнаты.
Процесс:
- Игрок на карте выбирает прямоугольник (в клиенте уже есть выбор области:
src/view/picker.ts, ограничения размера вsrc/main.ts). - Клиент отправляет серверу
CreateCity { bbox, name }. Сервер проверяет лимит площади, лимит очереди и лимит комнат (max_rooms). - Задача попадает в очередь. Воркер (текущий TS-генератор как отдельный Node-процесс, использующий
scripts/cli.tsиsrc/gen) запрашивает Overpass, генерирует город, экспортирует тайлы и манифест (этап 1) в хранилище. - Клиент видит прогресс (в очереди, загрузка OSM, генерация, экспорт). По готовности город попадает в каталог игрока и создаётся комната.
- Ошибки Overpass (таймауты, лимиты) обрабатываются повторами и понятными сообщениями; уже есть наработки в
src/osm/overpass.ts.
Требования:
- Города кэшируются по нормализованному bbox и версии генератора, чтобы одну область не генерировать дважды.
- Ограничения: максимальная площадь и число зданий, максимум одновременных задач (конфиг), таймаут задачи.
- Версия формата и генератора пишется в манифест; при смене версии кэш инвалидируется.
- Генерация должна быть детерминированной от (bbox, seed, версия), чтобы город можно было воспроизвести.
13. Сохранение и восстановление комнат
- Снимки
Worldраз в N минут и при остановке: сущности, компоненты, время игры, seed, состояние голосований не сохраняется (сбрасывается). - Формат: бинарный, версионируемый (протокол из
proto/), атомарная запись во временный файл с переименованием, хранение последних K снимков. - Восстановление: при старте сервера список комнат читается с диска, комнаты открываются лениво при первом подключении игрока, чтобы не держать в памяти все.
- Миграции: при смене схемы компонентов снимок либо мигрируется, либо комната помечается несовместимой с понятным сообщением.
- Целостность: комната ссылается на город по идентификатору и версии манифеста; удаление или смена города при существующих комнатах запрещена.
Дополнение к этапам:
- Этап 2: пустая комната сохраняется и восстанавливается (скелет сохранения).
- Этап 3: сохранение/загрузка простой симуляции, тест «остановил — запустил — состояние совпадает».
- Этап 5: миграции, ротация снимков, лимиты места.
- Новый этап 2б. Сервис генерации (2 недели): очередь задач, воркер на базе
scripts/cli.ts, кэш по bbox, прогресс и лимиты, протоколCreateCityи ответы о статусе.
14. Проверки реализации
-
TypeScript:
npm run typecheck,npm test,npm run build. -
Rust:
npm run server:test; отдельно проверяются навигация через двери, точность пространственного индекса, детерминизм, атомарные снимки и ротация. -
Интеграция: 30 настоящих WebSocket-подключений, отказ 31-му, голосование 15/30 против 16/30, повторное подключение после восстановления.
-
Очередь: Protobuf-запрос → отдельный TS-процесс с локальной OSM-выгрузкой → экспорт с контрольными суммами → открытие комнаты → сохранение. Внешний Overpass в тесте не используется.
-
Chrome: каталог, вход, загрузка тайлов, этажи и пауза через голосование. Повторяемая проверка:
scripts/browser-smoke.mjsпри установленном Playwright. -
Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась.
-
Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome.
-
После удаления городской экономики: 18 TS-тестов, 9 Rust unit-тестов и 2 интеграционных теста. Проверяются миграция v1/v2→v3 с отбрасыванием удалённых полей, детерминизм жителей, квота, авторизация админки и запись снимков.
-
Chrome: загрузка города, общий календарь, пауза, этаж/масштаб, админское сохранение; возобновление ожидания генерации без повторного POST.
Дальнейшее развитие за пределами базового слоя: индивидуальные потребности и выбор целей, детальные переходы между этажами, использование мебели и уличных слотов жителями, транспорт. Подготовленный Compose ещё нужно собрать и запустить на Docker-хосте; внешнее развёртывание в текущий запрос не входит.