# 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__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}_` (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}_`.](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}_`, тот же 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}_`, общий 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.txt (этот файл) Назад: 03 — AI-видеоредактор → https://ilyadev.xyz/cases/ai-video-editor.ru.txt Дальше: 05 — macOS VPN · per-app роутинг → https://ilyadev.xyz/cases/macos-vpn.ru.txt Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.txt