โ† Back to blog
ChoostApril 15, 2026by Choost Games
Topic:Bullet Heaven & Bullet Hell ยท Dev Logs & Game Dev

Building a Bullet Heaven in Phaser: What We Learned

Lessons from building a bullet heaven game in Phaser 3 as a solo developer โ€” sprite management, performance, and what nobody warns you about.

We built Granny's Rampage โ€” a five-stage bullet heaven where you play as a grandmother with a minigun fighting through increasingly hellish landscapes โ€” in Phaser 3 with React, TypeScript, and Vite. It took a few months as a solo developer using AI-assisted tools, and along the way we learned a bunch of things that would've saved significant time if we'd known them upfront.

This isn't a tutorial. There are plenty of "how to make a Phaser game" guides out there. This is the stuff those guides don't cover โ€” the problems that show up specifically when you're building a bullet heaven, where hundreds of entities are on screen simultaneously and performance stops being theoretical.

Why Phaser

The choice was practical. Phaser 3 is JavaScript, which means rapid iteration on a small team โ€” change a number, hit reload, see it. Its 2D rendering pipeline handles sprite-heavy scenes well enough for a bullet heaven where the screen routinely fills with hundreds of entities. And because the runtime is just a browser engine wrapped in Electron for desktop, the resulting Windows build runs on practically anything โ€” no dedicated GPU required, no driver weirdness, integrated graphics handle it fine.

Phaser also has mature sprite handling, animation systems, physics, and particle effects built in. For a genre where the screen fills with hundreds of projectiles and enemies simultaneously, having those systems battle-tested by thousands of other developers matters more than raw performance benchmarks.

The Sprite Sheet Pipeline

This is where we spent more time than expected. A bullet heaven needs a lot of sprites โ€” enemies, projectiles, pickups, obstacles, bosses, the player character with multiple weapon states. Each one needs idle animations at minimum, and ideally death animations, damage flashes, and directional variants.

We used a Python/Pillow pipeline to process sprite sheets โ€” taking individual frames, assembling them into sheets with consistent cell sizes, and generating the JSON metadata that Phaser's animation system expects. The alternative is doing this manually in TexturePacker or similar tools, but automating it paid off quickly when we needed to iterate on enemy designs.

The lesson: standardize your sprite sheet format early. Every enemy, every projectile, every pickup should follow the same grid dimensions and naming conventions. When you inevitably need to add a new enemy type at 2 AM, you want to drop a PNG into the pipeline and have it just work, not spend twenty minutes remembering how the other sheets were set up.

Animation Gotchas

Phaser's animation system is solid, but it has a specific quirk that cost us hours of debugging. Animations must be created in the create() method before any game objects try to use them. This sounds obvious, but in a bullet heaven where enemies spawn dynamically based on stage progression and wave timers, it's easy to accidentally spawn an enemy before its animation has been registered.

The fix is simple โ€” always check this.anims.exists(key) and this.textures.exists(key) before calling play() on any sprite. Defensive coding that takes ten seconds to add and saves you from a crash that only happens on stage 4 when a specific enemy type spawns for the first time during a boss phase.

Also: if you're using video backgrounds (we used Kling-generated MP4 loops for menu screens), set opacity only on the video element itself, never on the container. And use raw HTML5 video elements behind the Phaser canvas rather than Phaser's built-in video loader. The built-in loader fights you on looping and autoplay in ways that the native HTML element handles gracefully.

Performance: The Bullet Heaven Problem

The genre-specific performance challenge is this: a typical bullet heaven run starts with maybe 20 enemies on screen and ramps up to 200+ by the midgame, each one checking collision against multiple player projectiles, each projectile potentially generating particles on hit, each kill spawning an experience gem that also needs collision detection against the player.

Object pooling is mandatory, not optional. If you're creating and destroying sprites every frame, the garbage collector will eat you alive and your frame rate will stutter every few seconds. Phaser's Group class with maxSize and recycling handles this, but you need pools for enemies, projectiles, pickups, and particles separately. Mixing them into one pool causes bizarre bugs.

Beyond pooling, the biggest performance win was spatial partitioning for collision checks. Checking every projectile against every enemy every frame is O(n*m) and it gets brutal fast. A simple grid-based spatial hash โ€” dividing the play area into cells and only checking collisions within the same cell โ€” brought our collision checks from "the game freezes when you use the flamethrower" to "buttery smooth at 300+ entities."

The Stage System

Granny's Rampage has five stages, each with different enemy types, obstacles, particle overlays, and boss fights. Managing this without the codebase becoming unmaintainable required splitting systems early.

Our GameScene.ts and EnemySystem.ts both grew too large for comfortable editing โ€” we eventually split things into ObstacleSystem, ParticleOverlaySystem, and StageSystem as separate files. The lesson is to do this from the start rather than after things get unwieldy. Each stage should be defined as data (enemy roster, spawn rates, obstacle types, music track, background) rather than code. When adding stage 5, you want to create a JSON config, not touch the core game loop.

Boss fights need their own state management. A boss that spawns minions, has phase transitions based on HP thresholds, and uses attack patterns on timers is fundamentally different from the wave-based spawning system that handles regular enemies. Trying to make the regular spawn system handle boss behavior leads to spaghetti. Give bosses their own class.

The Weapon System

Granny's Rampage has a minigun and a flamethrower, and the way each one interacts with the collision and particle systems is completely different. The minigun fires individual projectiles that travel in a direction and despawn on hit or after a distance. The flamethrower creates a cone-shaped damage zone that persists and hits continuously.

If we were starting over, we'd abstract the weapon interface more aggressively from day one. Every weapon should implement the same interface โ€” fire(), update(), getCollisionShape() โ€” and the game loop should be weapon-agnostic. We got there eventually, but retrofitting an abstraction onto two specific implementations is messier than designing for it upfront.

What AI Tools Actually Help With

We used Claude for planning, architecture decisions, and debugging logic. We used Cursor (always with Opus) for code implementation. The split matters โ€” AI is excellent at tracing through logic, spotting missed edge cases, and suggesting architecture patterns. It's less reliable at writing complete game systems from scratch without introducing subtle bugs that only manifest under specific gameplay conditions.

The most productive pattern was: figure out the design in conversation, get a clear description of what needs to happen, then give Cursor a surgical instruction. Not "build the boss fight system" but "add a phase transition to the Stage 3 boss that triggers at 50% HP, switches its attack pattern from spiral to radial burst, and spawns 4 minions at cardinal positions." Specific, bounded, testable.

Gemini generated our sprite art. Suno created the BGM tracks. ElevenLabs handled sound effects. The entire asset pipeline for a game that would have required a team of specialists five years ago ran through AI tools on a single developer's machine. That's not a flex โ€” it's just the reality of indie development in 2026, and it's why the bullet heaven genre keeps growing. The barrier to making one of these games has never been lower.

What we make at Choost

We're a small indie studio. Our games: Granny's Rampage โ€” a bullet heaven where grandma grabs a minigun and fights through hell โ€” and Granny's Gambit, a Victorian deckbuilder roguelike starring a card-slinging nan with a chip on her shoulder. Granny's Rampage is $2.99 on itch (Windows) and Google Play (Android), with the Steam launch on June 22 (also $2.99). Granny's Gambit is pay-what-you-want on itch.

The Takeaway

Phaser 3 is a genuine option for bullet heaven development. It's not the fastest engine โ€” a C++ engine with custom rendering will always beat a JavaScript framework on raw entity count. But for a game targeting itch.io and Steam via an Electron desktop wrap, the trade-off makes sense. You get cross-platform output, a mature ecosystem, and a development speed that lets a solo developer ship a polished five-stage game in months rather than years.

If you're thinking about building one, the best advice is: get your object pooling and sprite pipeline right in week one, then build everything else on top of that foundation. Everything in a bullet heaven flows downstream from "how many things can I have on screen without the frame rate dying." Solve that first.