# Bullet Reign · Roblox `04 · roblox-game · Published` Bullet-heaven game for Roblox with a custom MegaMesh renderer (300 enemies at ≥55 FPS on mid-range mobile, 500 hard cap) and the full art pipeline running through Blender + MCP agents — solo, no artist. **Scope:** Solo · 6 weeks **Role:** Performance engineering on Roblox **Video:** [YouTube](https://www.youtube.com/watch?v=eflEYbm6dHM) · [RuTube](https://rutube.ru/video/d11492c853016477e8c77bacf2ad41c3/) ## Video walkthrough Bullet-heaven on Roblox running 300 enemies at 55 FPS on mid-range mobile through a custom MegaMesh renderer with 15 draw calls regardless of population. Six points of interest, 26 weapons with 20 evolutions and 44 passives, two art styles, 17 enemy types, 90 icons — built solo across render, network, AI and content pipeline. Bullet-heaven on Roblox — three hundred enemies on screen, fifty-five FPS on mid-range mobile. Every enemy is a Lua table on the server and a bone in a shared MegaMesh on the client. Fifteen draw calls regardless of population on low quality. Six points of interest — shrines, chests, obelisk trials, power crystals, fountains, magnets. Twenty-six weapons, twenty evolutions, forty-four passives. Click any item in the catalog — full stats, every evolution and fusion path it can fold into. Two art styles, Classic and Brainrot — seventeen enemy types, ninety icons. Bullet Reign — live on Roblox. One engineer · custom render, network, AI, content pipeline. --- ## Context > The genre needs hundreds of enemies on screen. Roblox defaults give you tens. Bullet-heaven as a genre demands 300+ active enemies on screen at sustained framerate. On PC the genre lives on Steam (Vampire Survivors, Megabonk). On Roblox the audience is enormous but mobile-first — and the engine's defaults (one Model + Humanoid + AnimationTrack per enemy, one RemoteEvent per state change) hold out for tens to maybe a hundred enemies before mid-range mobile FPS collapses into a slideshow. The default rendering path is the gate, not the simulation. Solo means no artist for 15 enemy slots × 2 styles × 3 LODs (≈90 mesh-variations), no animator for the ~10 distinct skeleton types underneath, no engine specialist separate from the gameplay programmer. For the genre to work on this platform every layer — render, network, AI, content pipeline — has to be custom-built and operated by one person. ## Facts | | | |---|---| | **Scope** | 6 weeks solo | | **Genre** | Bullet-heaven · 30-min run · octagonal arena R=400 studs | | **Render** | 1 draw call per enemy type · 500-enemy hard cap · ≥55 FPS @ 300 on mid-range mobile | | **Network** | 7 bytes/enemy · 20 Hz tick · ~70 KB/s @ 500 | | **Content** | 17 enemies · 6 bosses · 26 weapons · 44 passive items · 13 locales | | **Status** | Published on Roblox | ## Architecture ### Render pipeline ```text 1 Server tick (20 Hz · authoritative) │ 500 enemies = 500 Lua tables (no Instances) │ EnemyManager.update(): pos · hp · ai-state in place │ ~120 B / table · zero engine alloc per mob ▼ 2 NetworkProtocol.packEnemyBatch() │ reusable _syncBatch upvalue (zero alloc) │ per enemy: u16 id · i16 x×10 · i16 z×10 · u8 hp% = 7 B ▼ 3 UnreliableRemoteEvent ──► client ~3.5 KB @ 500 │ ~70 KB/s @ 20 Hz 4 NetworkProtocol.unpackEnemyBatch(buf, fn) │ callback-style reader (zero alloc, hot path) ▼ 5 BoneRenderer v5 │ one MeshPart per enemy type → ≤ 15 draw calls │ multi-size sub-pools via Ex__BoneName │ lazy · auto-expand · schedule-preload T-10 s ▼ 6 bone.Transform = restCFInv * worldCF (~200–435 active bones / frame) ``` **20K-tri budget = capacity planning.** `MAX_TRIS_BUDGET = 20000` in `export_megamesh.py` sets the per-pool tri ceiling; `count = budget // tris` derives how many copies fit in one MeshPart. A 400-tri mob → 50 copies per pool; a 1000-tri boss-preview → 20. The Python build-time parameter directly determines the runtime cap on simultaneous enemies. **Bone-naming protocol.** `export_megamesh.py` writes per-copy bone names as `E{NNN}_` (E000_Torso, E001_Arm_R, …). `BoneRenderer.luau` parses the prefix with `^E%d+_(.+)$` to recover the canonical bone name for animation. One protocol, one pipeline — every art direction is served identically; runtime has zero conditional logic on style. **Multi-size sub-pools.** One MeshPart hosts x1 / x1.5 / x2 sub-pools side by side; an XP-gem merge re-attaches to a larger slot in the same pool — no Instance churn, no extra draw call. **Schedule-preload.** Surges in `SpawnDefinitions` carry a known T-10 s warning. The matching pool materializes ten seconds before spawn — the first wave does not stall on lazy-init, FPS stays flat through transitions. ### Wire format ```text Wire format · UnreliableRemoteEvent · 20 Hz · server-authoritative HEADER u16 enemyCount 2 B 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 B / enemy packet @ 500 enemies = 2 + 500 × 7 = 3 502 B bandwidth @ 20 Hz = 3 502 × 20 ≈ 70 KB / s hit batching: 26 weapons → 1 HitRequest / 100 ms server: damage clamp [0, 2000] · 15 req/s/player ``` **Why ×10 fixed-point.** Positions fit ±3 276.8 studs at 0.1-stud precision. Arena radius is 400 — the dynamic range covers it 8×, and 0.1 stud is below visual snap on mobile. **Why u8 hp%.** Server keeps real HP for damage math; client renders a bar. 1% precision is below the bar's visual threshold and saves 3 bytes per enemy compared to a u32 absolute value. **×4–5 vs Lua-table sync.** The same payload as a `{id, x, y, z, hp}` table is ~16 KB / tick — mobile cellular drops the connection mid-run. Binary at 3.5 KB rides comfortably even on a degraded link. ### Server-tick topology ```text SERVER (20 Hz · 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 modules · 17 types · 11 AI behaviors ├── BossController 6 bosses · Harvester m28 arena shrink ├── POIManager shrine · chest · crystal · obelisk · … ├── DataManager DataStore + schema migration · save 60 s └── HitValidator damage clamp · 15 req/s / player │ ▼ 7 B / enemy @ 20 Hz CLIENT (every frame) EnemyRenderer → BoneRenderer v5 → bone.Transform (15 draw calls) WeaponManager → SpatialGrid cell=20 → 26 weapons (O(K) per query) ~25 UI modules · CardRenderer (LevelUp / Chest / POI rewards) ``` **Services.luau, not _G.** Every cross-module reference goes through one ModuleScript registry. R9 wave migrated 35 keys / 402 references across 65 files — zero `_G` left. Cross-module wiring through `_G` is the standard Roblox antipattern; the chore-refactor that ships no features is exactly the one most projects skip — and 100K-LOC Luau without it starts to rot. **Single-slot vs multi-slot pool.** Bosses get a dedicated single-slot pool over a preview mesh (×2.5–3 visualScale) so `Highlight` for shield / invuln VFX attaches cleanly — multi-slot pools would attach the highlight to every enemy sharing the MeshPart. **3-channel spawner.** Base rate scales linearly with minute (`2 + minute × 0.9`); the 6 bosses sit on fixed offsets (5 / 10 / 15 / 20 / 25 / 28 min), while 8 surge types and 7 separate spawners keep the rest of the schedule predictable. Predictable enough to preload pools, varied enough that runs differ. ### AI art pipeline output ![The icon library — weapon and perk silhouettes processed through `cut_icons.py` (batch-sheet slicing) and `whiten_icons.py` (RGB(0,0,0) → RGB(255,255,255) for Roblox `ImageColor3` tinting).](https://ilyadev.xyz/private/roblox-icons.png) *Icon library* ![Roblox Studio scene with enemy meshes, collectible props, and three LOD variants (low / medium / high) for select assets — all baked through `export_megamesh.py` with the `E{NNN}_` bone-naming protocol.](https://ilyadev.xyz/private/roblox-bestiary.png) *Bestiary + collectibles · 3 LODs* **Volume in the frame.** 60 .blend sources · 220 .fbx exports · 16 Python scripts in the bake pipeline (cut_icons, whiten_icons, fix_normals_and_colors, gen_currency_icons, export_megamesh + per-weapon variants). The bestiary covers 15 enemy types in classic style with 3 LODs on select assets; the icon library covers 26 weapons + perks — sliced from batch-sheets and recolored to white so Roblox `ImageColor3` can tint them at runtime (black absorbs the tint colour, white passes it through). **Two styles, one pipeline.** Classic and the alt-style set feed the same bake pipeline — same `export_megamesh.py`, same `cut_icons.py`, same `E{NNN}_` naming, same FBX preset. Each enemy slot brings its own skeleton (3–7 bones depending on the creature); per-slot AnimData split is the price — see Decision 5. ## Key engineering decisions ### 01 · Bone-Transform MegaMesh, not Roblox Models **Decision.** One MeshPart per enemy type with N skinned bones; per frame, set `bone.Transform = restCFInv * worldCF` for each active enemy. 15 enemy types = 15 draw calls regardless of population (~200–435 active bones). **Why.** The genre demands hundreds of enemies on screen at sustained framerate on mobile. Roblox's default `Model + Humanoid + AnimationTrack` per enemy peaks at dozens of instances before mobile FPS collapses — the renderer is the gate, not the simulation. **Cost.** No `AnimationTrack`, no `Humanoid`, no `Touched`, no auto-replication — all rebuilt: `BoneRenderer` v5 (788 LOC), own keyframe sampler `BoneAnimator`, 26 hand-baked AnimData files across ~10 distinct skeleton types. ### 02 · Server enemies as Lua tables, not Instances **Decision.** Server-side enemies live entirely as Lua dict entries — pos · hp · ai-state · effects. Zero `Instance.new()` in the enemy hot path; only the renderer's pool-Parts exist as Instances on the client. **Why.** 500 `Instance.new()` per wave plus the same on death is fatal — engine GC pause + cross-process replication overhead per transient mob. Tables sit in memory at zero engine cost and let the 20 Hz tick stay tight. **Cost.** Every Roblox feature that takes an Instance is gone for non-boss enemies — `Touched`, `ProximityPrompt`, `Highlight`, `Tag`. Hit detection is rebuilt client-side over the SpatialGrid; bosses run a dedicated single-slot pool to bring `Highlight` back for shield / invuln VFX. ### 03 · Buffer-packed binary on UnreliableRemoteEvent, not table RemoteEvent **Decision.** 7-byte fixed-form per enemy (header + entries) over `UnreliableRemoteEvent`. Sender packs through a reusable `_syncBatch` upvalue array; receiver reads via callback (`unpackEnemyBatch(buf, fn)`) — zero allocations in either direction. **Why.** At 20 Hz × 500 enemies, table-form sync is ~16 KB / tick → mobile cellular drops the connection. Binary is ~3.5 KB. UnreliableRemoteEvent is the right primitive for state-snapshots — losing a tick and getting the next one is fine; retransmission would actively hurt. **Cost.** No schema, no Studio inspector, no auto-versioning. Every wire-format change requires coordinating sender and reader by hand. Debugging a single enemy state takes bone-level instrumentation rather than a property watch. ### 04 · AI-driven art pipeline through Blender + MCP agents **Decision.** Concept → img-to-3D → Blender (via MCP agent) → bake as MegaMesh (`export_megamesh.py`, 20K-tri budget) → 3 LODs → FBX → upload. 16 Python scripts cover icon cuts, recolors, vertex-color repair, procedural skybox / pyramid / damage textures. **Why.** 15 enemy slots × 2 styles × 3 LODs ≈ 90 mesh-variations plus a 26-weapon icon library in 6 weeks of solo work — hand-authoring on this volume does not fit the schedule. Tooling has to absorb the volume. **Cost.** 16 scripts to maintain. AI-generated topology often needs `fix_normals_and_colors.py` post-pass; each new enemy carries a small calibration tax. ### 05 · Art style lives in source, not in code **Decision.** Two `.blend` source sets (classic + alt-style) feed the same bake pipeline (`export_megamesh.py`, `E{NNN}_` naming, common FBX preset) — runtime reads from `EnemyPools` or `BrainrotPools` via one `Services.BrainrotMode` toggle. Each enemy slot brings its own skeleton; the bake pipeline is the only thing strictly shared. **Why.** 15 enemy slots × 2 styles = 30 mesh-sets that must behave identically at runtime — animation, hit-detection, VFX. Branching by style at runtime would mean two of every code path; branching at bake-time means zero code paths know about style. Adding a third style is a fresh source set, zero Luau changes, zero new tooling. **Cost.** Alt-style meshes occasionally diverge from classic bone names — 11 of 15 enemies need their own AnimData files; 4 (skeleton_spearman, priest_of_anubis, tomb_assassin, obelisk) reuse the classic ones because bone names match. The boundary sits per-slot at bone-name parity — paid at content-add time, not in code. ## Stack | | | |---|---| | **Language** | Luau (typed) · Rojo 7.6.1 · Rokit toolchain | | **Render** | Custom Bone-Transform MegaMesh · BoneRenderer v5 · BoneAnimator (own keyframe format) | | **Network** | UnreliableRemoteEvent · 7-byte packed protocol · 20 Hz tick · custom SpatialGrid (cell 20 studs) | | **Persist** | Roblox DataStore + schema migration · auto-save 60 s · onLeave · onShutdown | | **Art pipe** | Blender + MCP agents · 16 Python scripts · 60 .blend · 220 .fbx · weapon + perk icon library | | **Scale** | ~100 K Luau LOC across ~370 modules — ~100 locale (13 × 8 namespaces), ~90 definition (26 weapons + 17 enemies + 44 passives), 5-module EnemyManager core (≈3 K LOC of hot-path) · 2 Roblox places · ~58 RemoteEvents · 10 R-wave refactors | ## Lessons & status ### Carry forward - Bone-Transform MegaMesh paid back 100× over — day-one pattern for any future Roblox project at scale. - Server-as-data (enemies are Lua tables) kept memory and replication budgets predictable through every optimization phase. - Buffer-packed binary with a reusable `_syncBatch` upvalue — zero-allocation discipline survived from prototype to publish unchanged. - AI-driven art pipeline — by week 4, prompt → 3D → bake → upload was muscle memory; without it ≈90 mesh-variations plus a full icon library in 6 weeks is not solo-feasible. - Bootstrap handshake (`ClientReady`) — Bootstrap.client.luau eager-requires player ModuleScripts, waits for CharacterAdded, then signals the server, which blocks `startCountdown()` until every alive player has reported in. Closes a class of listener-registration races that lazy ModuleScripts make easy to ship by accident. ### Would change - Modular decomposition (R1–R10) should have started earlier. By stages 24+ EnemyManager was creeping toward a 2K-line monolith; one of the R-waves could have unloaded it sooner instead of carrying the weight all the way to the rendering overhaul on stages 24–27. - AI-generated meshes need a stricter pre-bake gate. `fix_normals_and_colors.py` was reactive — today I would run it inside `export_megamesh.py` unconditionally and fail the export on bad vertex colors. - AnimData split was discovered per-enemy as classic↔alt-style bone-name mismatches surfaced. A single bone-name parity spec at the first alt-style enemy would have set the contract once — instead, 11 of 15 ended up with their own AnimData files reactively. --- Source: https://ilyadev.xyz/cases/roblox-game (HTML) · /cases/roblox-game.txt (this file) Previous: 03 — AI Video Editor → https://ilyadev.xyz/cases/ai-video-editor.txt Up next: 05 — macOS VPN · per-app routing → https://ilyadev.xyz/cases/macos-vpn.txt Index: https://ilyadev.xyz/llms.txt — full case-study list Author: Ilya Kazantsev — https://ilyadev.xyz/index.txt