# Bullet Reign · Roblox

`04 · roblox-game · Published`

Bullet-heaven игра для Roblox с кастомным MegaMesh-рендерером (300 врагов при ≥55 FPS на mid-range mobile, 500 — hard cap) и полным арт-пайплайном через Blender + MCP-агенты — соло, без художника.

**Скоуп:** Соло · 6 недель  
**Роль:** Performance engineering на Roblox

**Видео:** [YouTube](https://www.youtube.com/watch?v=cLk-4CwAKtA) · [RuTube](https://rutube.ru/video/53d6c5dc2c8435062436b1511b0da671/)

## Разбор видео

Bullet-heaven на Roblox с тремя сотнями врагов на экране и пятидесятью пятью FPS на среднем смартфоне через кастомный MegaMesh-рендерер с пятнадцатью draw-call независимо от количества врагов. Шесть точек интереса, 26 оружий с 20 эволюциями и 44 пассивами, два визуальных стиля, 17 типов врагов, 90 иконок — собрано соло, рендер, сеть, AI и арт-пайплайн.

Bullet-heaven на Roblox — триста врагов на экране, пятьдесят пять FPS на среднем смартфоне.

Каждый враг — Lua-таблица на сервере и кость в общем MegaMesh на клиенте. Пятнадцать draw-call’ов независимо от количества врагов на экране на низком качестве.

Шесть точек интереса — святилища, сундуки, испытания обелисков, кристаллы силы, фонтаны, магниты. Двадцать шесть оружий, двадцать эволюций, сорок четыре пассивки.

Клик по любому предмету в каталоге — полная статистика и все ветки эволюций и слияний.

Два визуальных стиля, Classic и Brainrot — семнадцать типов врагов, девяносто иконок.

Bullet Reign — играй на Roblox.

Один инженер — собственный рендер, сеть, AI, конвейер контента.

---

## Контекст

> Жанру нужны сотни врагов в кадре. Roblox по дефолту даёт десятки.

Bullet-heaven как жанр требует 300+ активных врагов на экране при стабильном framerate. На PC жанр живёт в Steam (Vampire Survivors, Megabonk). На Roblox аудитория огромная, но mobile-first — а движок дефолтно даёт `Model + Humanoid + AnimationTrack` на каждого врага и `RemoteEvent` на каждое изменение состояния. Потолок — несколько десятков до сотни enemies на mid-range mobile, прежде чем FPS обваливается в slideshow. Узкое место — дефолтный путь рендеринга, не симуляция.

Соло — это значит без художника на 15 enemy-слотов × 2 стиля × 3 LOD (≈90 mesh-вариаций), без аниматора на ~10 разных skeleton-типов под ними, без engine-программиста отдельно от gameplay-программиста. Чтобы жанр заработал на этой платформе, каждый слой — render, network, AI, контент-пайплайн — нужно построить и поддерживать одному человеку.

## Факты

| | |
|---|---|
| **Скоуп** | 6 недель соло |
| **Жанр** | Bullet-heaven · 30-минутный ран · октагональная арена R=400 studs |
| **Рендер** | 1 draw call на тип врага · hard cap 500 врагов · ≥55 FPS @ 300 на mid-range mobile |
| **Сеть** | 7 байт/враг · тик 20 Гц · ~70 КБ/с @ 500 |
| **Контент** | 17 врагов · 6 боссов · 26 weapons · 44 пассивных предмета · 13 локализаций |
| **Статус** | Опубликовано в Roblox |

## Архитектура

### Рендер-пайплайн

```text
 1  Серверный тик (20 Гц · authoritative)
        │  500 врагов = 500 Lua-таблиц (без Instances)
        │  EnemyManager.update():  pos · hp · ai-state in place
        │  ~120 Б / таблица · ноль engine-аллокаций на моба
        ▼
 2  NetworkProtocol.packEnemyBatch()
        │  переиспользуемый upvalue _syncBatch (zero alloc)
        │  на врага:  u16 id · i16 x×10 · i16 z×10 · u8 hp%   = 7 Б
        ▼
 3  UnreliableRemoteEvent  ──►  клиент      ~3.5 КБ @ 500
        │                                   ~70 КБ/с @ 20 Гц
 4  NetworkProtocol.unpackEnemyBatch(buf, fn)
        │  callback-стиль чтения  (zero alloc, hot path)
        ▼
 5  BoneRenderer v5
        │  один MeshPart на тип врага   →   ≤ 15 draw calls
        │  multi-size суб-пулы через  Ex<size>_<slot>_BoneName
        │  lazy · auto-expand · schedule-preload T-10 с
        ▼
 6  bone.Transform = restCFInv * worldCF      (~200–435 активных костей / кадр)
```

**Бюджет 20K tris = capacity planning.** `MAX_TRIS_BUDGET = 20000` в `export_megamesh.py` задаёт потолок tris на пул; `count = budget // tris` выводит, сколько копий помещается в один MeshPart. 400-tri моб → 50 копий в пуле; 1000-tri boss-preview → 20. Build-time параметр в Python напрямую определяет runtime-cap по числу одновременных врагов.

**Bone-naming protocol.** `export_megamesh.py` пишет имена костей у каждой копии как `E{NNN}_<bone>` (E000_Torso, E001_Arm_R, …). `BoneRenderer.luau` парсит префикс regex’ом `^E%d+_(.+)$` и достаёт каноничное имя кости для анимации. Один протокол, один пайплайн — каждое арт-направление обслуживается идентично; в рантайме нулевая ветвящаяся логика по стилю.

**Multi-size суб-пулы.** Один MeshPart держит x1 / x1.5 / x2 суб-пулы рядом; merge XP-гема перевешивает его на больший слот в том же пуле — без Instance-churn и без лишних draw call.

**Schedule-preload.** Сёрджи в `SpawnDefinitions` несут известный T-10 с warning. Соответствующий пул материализуется за 10 секунд до спавна — первая волна не ловит lazy-init фриз, FPS остаётся плоским на переходах.

### Формат пакета

```text
Wire format · UnreliableRemoteEvent · 20 Гц · server-authoritative

  HEADER     u16  enemyCount                              2 Б
  ENTRY × N  u16  id              0 – 65 535              2
             i16  x × 10          ±3 276.8 studs          2
             i16  z × 10          ±3 276.8 studs          2
             u8   hp %            0 – 100                 1
                                                         = 7 Б / враг

  пакет @ 500 врагов     = 2 + 500 × 7    = 3 502 Б
  трафик @ 20 Гц         = 3 502 × 20    ≈ 70 КБ / с

  hit batching:  26 weapons → 1 HitRequest / 100 мс
                 сервер: damage clamp [0, 2000] · 15 req/s/player
```

**Зачем ×10 fixed-point.** Позиции лежат в ±3 276.8 studs с точностью 0.1 stud. Радиус арены — 400, dynamic range покрывает её 8×, а 0.1 stud ниже визуального шага на мобильнике.

**Зачем u8 hp%.** Сервер хранит реальный HP для damage-математики; клиент рисует полоску. Точность 1% ниже визуального порога полоски и экономит 3 байта на враге по сравнению с u32 absolute.

**×4–5 vs Lua-table sync.** Тот же payload как `{id, x, y, z, hp}` table даёт ~16 КБ/тик — мобильный 4G рвёт коннект. Бинарный 3.5 КБ комфортно идёт даже по деградировавшему линку.

### Топология серверного тика

```text
СЕРВЕР (20 Гц · authoritative · enemies as Lua tables)

  GameManager (Lobby → Countdown → Run → End)
       │
       ├── SpawnManager    base 70% · surge 20% · event 10%
       │                   8 surge types · 7 separate spawners
       │                   soft-cap 350→500 · adaptive ±20%
       │
       ├── EnemyManager    5 модулей · 17 типов · 11 AI behaviors
       ├── BossController  6 боссов · Harvester m28 arena shrink
       ├── POIManager      shrine · chest · crystal · obelisk · …
       ├── DataManager     DataStore + schema migration · save 60 с
       └── HitValidator    damage clamp · 15 req/s / player
                                       │
                                       ▼   7 Б / враг @ 20 Гц
КЛИЕНТ (каждый кадр)

  EnemyRenderer  →  BoneRenderer v5     →  bone.Transform   (15 draw calls)
  WeaponManager  →  SpatialGrid cell=20 →  26 weapons       (O(K) per query)
  ~25 UI-модулей · CardRenderer (LevelUp / Chest / POI rewards)
```

**Services.luau вместо _G.** Каждый межмодульный референс идёт через единый ModuleScript-реестр. Волна R9 мигрировала 35 ключей / 402 точки обращения через 65 файлов — `_G` не осталось. Связь модулей через `_G` — стандартный Roblox-антипаттерн; chore-рефакторинг, не приносящий фич, — ровно то, что большинство проектов так и оставляют, а 100K-LOC Luau без этого начинает гнить.

**Single-slot vs multi-slot pool.** Боссы получают отдельный single-slot pool поверх preview-меша (×2.5–3 visualScale), чтобы `Highlight` для shield / invuln VFX вешался чисто — multi-slot pool повесил бы highlight на каждого врага, шарящего MeshPart.

**3-канальный спавнер.** Базовая частота линейно растёт от минуты (`2 + minute × 0.9`); 6 боссов посажены на фиксированные оффсеты (5 / 10 / 15 / 20 / 25 / 28 мин), а 8 surge-типов и 7 separate-spawner'ов держат остальное расписание предсказуемым. Достаточно предсказуемо, чтобы пре-грузить пулы, достаточно вариативно, чтобы раны различались.

### Output AI art-пайплайна

![Библиотека иконок — silhouettes weapon/perk, обработанные `cut_icons.py` (нарезка batch-листов) и `whiten_icons.py` (RGB(0,0,0) → RGB(255,255,255), чтобы Roblox `ImageColor3` мог тинтить).](https://ilyadev.xyz/private/roblox-icons.png)

*Библиотека иконок*

![Сцена в Roblox Studio с enemy-мешами, коллектиблами и тремя LOD-вариантами (low / medium / high) для отдельных ассетов — всё собрано через `export_megamesh.py` с протоколом имён костей `E{NNN}_<bone>`.](https://ilyadev.xyz/private/roblox-bestiary.png)

*Бестиарий + коллектиблы · 3 LOD*

**Объём в кадре.** 60 .blend-источников · 220 .fbx-экспортов · 16 Python-скриптов в bake-пайплайне (cut_icons, whiten_icons, fix_normals_and_colors, gen_currency_icons, export_megamesh + per-weapon варианты). Бестиарий покрывает 15 enemy-типов в classic-стиле с 3 LOD на отдельных ассетах; библиотека иконок покрывает 26 weapons + perks — нарезана из batch-листов и перекрашена в белый, чтобы Roblox `ImageColor3` мог тинтить в рантайме (чёрный поглощает цвет тинта, белый пропускает).

**Два стиля, один пайплайн.** Classic-набор и alt-style набор кормят один bake-пайплайн — тот же `export_megamesh.py`, тот же `cut_icons.py`, та же naming convention `E{NNN}_<bone>`, тот же FBX preset. У каждого enemy-слота свой собственный скелет (3–7 костей в зависимости от существа); split AnimData per slot — цена в Decision 5.

## Ключевые инженерные решения

### 01 · Bone-Transform MegaMesh вместо Roblox Models

**Решение.** Один MeshPart на тип врага с N skinned bones; на каждый кадр — `bone.Transform = restCFInv * worldCF` для каждого активного врага. 15 типов = 15 draw calls вне зависимости от популяции (~200–435 активных bones).

**Почему.** Жанр требует сотен врагов на экране при стабильном framerate на мобильниках. Дефолтные `Model + Humanoid + AnimationTrack` на врага упираются в десятки instance, прежде чем мобильный FPS обваливается — рендерер становится бутылочным горлом, а не симуляция.

**Цена.** Нет `AnimationTrack`, нет `Humanoid`, нет `Touched`, нет авто-репликации — всё пересобирается: `BoneRenderer` v5 (788 LOC), собственный keyframe-сэмплер `BoneAnimator`, 26 hand-baked AnimData файлов на ~10 разных skeleton-типов.

### 02 · Враги — Lua-таблицы на сервере, не Instances

**Решение.** На сервере враги существуют только как Lua-dict записи — pos · hp · ai-state · effects. Ноль `Instance.new()` в enemy hot path; на клиенте Instance существуют только как pool-Parts рендерера.

**Почему.** 500 `Instance.new()` за волну плюс столько же на смерти — фатально: engine GC-пауза + replication overhead на transient mob. Таблицы лежат в памяти бесплатно для движка и позволяют 20 Гц тику остаться плотным.

**Цена.** Любая Roblox-фича, требующая Instance, исчезает для non-boss врагов — `Touched`, `ProximityPrompt`, `Highlight`, `Tag`. Hit-детекция пересобрана клиент-сайдом через SpatialGrid; боссы получают отдельный single-slot pool, чтобы вернуть `Highlight` для shield / invuln VFX.

### 03 · Buffer-packed binary на UnreliableRemoteEvent, не table RemoteEvent

**Решение.** 7-байтная fixed-форма на врага (header + entries) поверх `UnreliableRemoteEvent`. Sender пакует через переиспользуемый `_syncBatch` upvalue array; reader читает через callback (`unpackEnemyBatch(buf, fn)`) — ноль аллокаций в обе стороны.

**Почему.** На 20 Гц × 500 enemies table-form sync даёт ~16 КБ/тик → мобильный 4G рвёт коннект. Бинарный — ~3.5 КБ. UnreliableRemoteEvent — правильный примитив для state-snapshot: потерять тик и получить следующий нормально, ретрансмиссия только повредит.

**Цена.** Нет схемы, нет Studio inspector, нет auto-versioning. Любое изменение wire-format требует ручной координации sender и reader. Отладка состояния одного врага — bone-уровень инструментовки вместо property-watch.

### 04 · AI-driven арт-пайплайн через Blender + MCP-агенты

**Решение.** Концепт → img-to-3D → Blender (через MCP-агент) → bake как MegaMesh (`export_megamesh.py`, бюджет 20K tris) → 3 LOD → FBX → upload. 16 Python-скриптов покрывают нарезку иконок, recolor, починку vertex-colors, процедурные skybox / pyramid / damage textures.

**Почему.** 15 enemy-слотов × 2 стиля × 3 LOD ≈ 90 mesh-вариаций плюс 26-weapon библиотека иконок за 6 недель соло — рисовать вручную такой объём не помещается в срок. Объём должен поглощаться инструментами.

**Цена.** 16 скриптов под поддержку. AI-генерированная топология часто требует `fix_normals_and_colors.py` post-pass; каждый новый враг несёт небольшой налог на калибровку.

### 05 · Стиль живёт в источниках, не в коде

**Решение.** Два набора `.blend`-источников (classic + alt-style) кормят один bake-пайплайн (`export_megamesh.py`, naming `E{NNN}_<bone>`, общий FBX preset) — рантайм читает из `EnemyPools` или `BrainrotPools` через один `Services.BrainrotMode` toggle. Каждый enemy-слот приносит свой собственный скелет; общим строго остаётся только bake-пайплайн.

**Почему.** 15 enemy-слотов × 2 стиля = 30 mesh-наборов, которые должны вести себя в рантайме идентично — анимация, hit-детекция, VFX. Ветвление по стилю в рантайме означало бы по две версии каждого код-пути; ветвление на bake-уровне — ноль код-путей знают про стиль. Добавить третий стиль = новый набор источников, ноль изменений в Luau, ноль нового тулинга.

**Цена.** Альт-стилевые меши иногда расходятся с classic bone-names — 11 из 15 врагов требуют собственные AnimData файлы; 4 (skeleton_spearman, priest_of_anubis, tomb_assassin, obelisk) переиспользуют classic, потому что bone-names совпадают. Граница проходит per-slot по совпадению bone-names — цена платится при добавлении контента, не в коде.

## Стек

| | |
|---|---|
| **Язык** | Luau (typed) · Rojo 7.6.1 · Rokit toolchain |
| **Рендер** | Custom Bone-Transform MegaMesh · BoneRenderer v5 · BoneAnimator (свой keyframe-формат) |
| **Сеть** | UnreliableRemoteEvent · 7-байтный packed-протокол · 20 Гц тик · собственный SpatialGrid (cell 20 studs) |
| **Persist** | Roblox DataStore + schema migration · auto-save 60 с · onLeave · onShutdown |
| **Арт-пайп** | Blender + MCP-агенты · 16 Python-скриптов · 60 .blend · 220 .fbx · weapon + perk библиотека иконок |
| **Объём** | ~100 К Luau LOC на ~370 модулей — ~100 локализационных (13 × 8 namespace), ~90 definition (26 weapons + 17 врагов + 44 пассивки), 5-модульное EnemyManager-ядро (≈3 К LOC hot-path) · 2 Roblox places · ~58 RemoteEvents · 10 R-wave рефакторингов |

## Уроки и статус

### Оставлю как есть

- Bone-Transform MegaMesh окупился 100× — день-первый паттерн для любого будущего Roblox-проекта на масштабе.
- Server-as-data (враги — Lua-таблицы) держал memory и replication budget предсказуемыми на всех фазах оптимизации.
- Buffer-packed binary с переиспользуемым `_syncBatch` upvalue — zero-allocation дисциплина дожила от прототипа до publish без переписывания.
- AI-driven арт-пайплайн — к 4-й неделе prompt → 3D → bake → upload стало muscle memory; без него ≈90 mesh-вариаций плюс полная библиотека иконок за 6 недель соло невозможны.
- Bootstrap handshake (`ClientReady`) — Bootstrap.client.luau eager-require’ит player-модули, ждёт CharacterAdded, потом сигналит серверу, и тот блокирует `startCountdown()` до тех пор, пока все живые игроки не отчитались. Закрывает класс listener-registration race’ов, которые ленивые ModuleScripts позволяют отгрузить в продакшен по неосторожности.

### Поменял бы

- Декомпозиция (R1–R10) должна была начаться раньше. К stages 24+ EnemyManager подкатывал к 2K-строчному монолиту; одна из R-волн выгрузила бы его раньше, вместо того чтобы тащить вес до rendering-overhaul на stages 24–27.
- AI-генерированные меши требуют более жёсткого pre-bake гейта. `fix_normals_and_colors.py` был реактивным — сегодня я бы запускал его внутри `export_megamesh.py` безусловно и фейлил export на плохих vertex-colors.
- Split AnimData открывался per-enemy по мере того, как всплывали расхождения bone-names между classic и alt-style. Единая spec по совпадению имён костей на первом же alt-style враге зафиксировала бы контракт один раз — вместо этого 11 из 15 получили собственные AnimData реактивно.

---

Источник: https://ilyadev.xyz/cases/roblox-game (HTML) · /cases/roblox-game.ru.md (этот файл)
Назад: 03 — AI-видеоредактор → https://ilyadev.xyz/cases/ai-video-editor.ru.md
Дальше: 05 — macOS VPN · per-app роутинг → https://ilyadev.xyz/cases/macos-vpn.ru.md
Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов
Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.md
