# 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<size>_<slot>_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}_<bone>` (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>` 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}_<bone>` 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}_<bone>` 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.md (this file)
Previous: 03 — AI Video Editor → https://ilyadev.xyz/cases/ai-video-editor.md
Up next: 05 — macOS VPN · per-app routing → https://ilyadev.xyz/cases/macos-vpn.md
Index: https://ilyadev.xyz/llms.txt — full case-study list
Author: Ilya Kazantsev — https://ilyadev.xyz/index.md
