I promised last time I’d write up the rendering trick that lets the Godot port of DET-33 push far more enemies than the JS version. Here it is, and it’s a little unusual: the game runs its logic in 2D and its rendering in 3D.

Why split them
A top-down bullet-hell wants to look 2D, and all the gameplay genuinely is 2D. Physics, collision, movement, combat, skills, traps, and the entire map-generation pipeline stay in 2D where they’re simple to reason about. But drawing a thousand individual 2D sprites means a thousand draw calls, and culling what’s behind a wall means doing visibility checks on the CPU. Both were exactly the costs that capped the JS build.
So rendering moves to 3D. The 2D world is rendered through a camera tilted to about an 80 degree pitch, which still reads as top-down but buys three things for free:
- Ground is one draw call. The whole floor is a single piece of 3D geometry instead of thousands of tilemap cells.
- Walls occlude via the Z-buffer. Sprites behind a wall are hidden by the GPU at no CPU cost. The manual field-of-view culling is just gone.
- Enemies batch. This is the big one.
MultiMesh instead of a thousand sprites
Every enemy sharing a spritesheet is drawn through a single MultiMeshInstance3D. Each instance carries its transform plus a frame index packed as custom instance data, and a shader reads that index to sample the right cell of the atlas and handle billboarding. A thousand enemies collapse into one to three draw calls total. The switch from individual sprites to MultiMesh kicks in automatically above roughly 50 enemies.
The bridge between the two worlds is a small node that, each frame, copies every 2D entity’s position, frame, and tint onto its 3D proxy. One pixel maps to 1/64 of a world unit, so a 64 pixel radius in 2D is a clean 1.0 unit in 3D.
The part I didn’t expect
Getting it running was the easy half. The hard half was that C# and Godot talk through an interop layer, and passing a plain string where the engine wants a NodePath or StringName quietly allocates a native temporary on every single call. In a per-frame, per-bullet game that adds up fast. I profiled a heavy combat scene and found roughly half of all managed CPU was the garbage collector’s finalizer thread destroying those leaked interop temporaries. Tearing down node paths alone was 30% of it.
The fixes are unglamorous and effective:
- Intern the hot-path names once as static
NodePathandStringNameconstants instead of writing string literals inline. - Resolve a node’s structural children one time when it’s registered, not with a lookup every frame.
- Dispose the per-frame arrays that
GetNodesInGroupand friends hand back. - Throttle cosmetic rebuilds (trap meshes at 30 Hz, the minimap at 10 Hz) while gameplay logic stays at full frame rate.
- Pool bullets and enemies by hiding and reparenting them instead of instantiating and freeing.
None of that is the fun graphics work. It’s the work that actually let the graphics work hold up. The lesson I keep relearning is that on a constrained device like the Deck, the rendering approach and the allocation discipline are the same problem wearing two hats. Get both right and you get your order-of-magnitude more enemies. Get only one and you get a slideshow.
