
Lessons from 4 months building a horde survivor in Godot (the boring stuff that actually mattered)
I'm about 4 months into building a 2D horde survivor in Godot, solo and figured I'd share what actually helped instead of the same "use signals not polling" advice that's already all over this sub. Three things ended up saving me way more time than I expected. Also linking a blog post in section 1 that you should genuinely stop and read.
Enemies by the thousand without wrecking an old GPU:
- If you're making anything in the survivor-like genre you already know the deal, hundreds of enemies and projectiles on screen at once, and it needs to run fine on hardware that isn't brand new. My own dev box has a GTX 1080 and I use it as my baseline on purpose.
- Go read this before you touch anything: Rendering a Million Objects in Godot. Genuinely one of the more useful things I read this year, saved me from badly reinventing it over two weeks.
- What I ended up with: one MultiMeshInstance2D per texture/z-index/tint combo, so anything sharing that combo is one draw call no matter how many there are. Different animation frames live in a Texture2DArray so batching doesn't fall apart just because a sprite sheet has 8 frames.
- Thing I didn't expect: pooling instances (not spawning/freeing constantly) helps, but it's not what saves you once counts get high. Draw calls are. I also swapped out around 750 individual VisibleOnScreenNotifier2D nodes for one AABB check against the viewport that runs once a frame instead of once per enemy, and that alone was a noticeable frame time win.
- Current numbers: holds 1200 enemies alive plus a pool sized for 8000+ live projectiles without falling over. At some point "feels smoother" stops being good enough, so I built a tiny benchmark that spawns a nasty scripted wave and dumps p50/p95/p99 frame times to a csv. Way better than squinting at the profiler and guessing.
Balance should be a spreadsheet, not a code change
- Something nobody warned me about going in: balancing takes longer than building the game itself. You'll change every number a bunch of times, so set it up so changing a number isn't a code change.
- Every weapon, enemy, item in my game is just a Resource file. Right now that's 35 weapons, 282 enemy resource files (about 80 enemy types), 126 items, all tweakable straight in the inspector. Want to test halving a boss's HP multiplier on one difficulty tier? Change a number, hit play, done.
- Bigger win than the individual resources honestly: I put my whole difficulty ramp (boss HP multipliers, spawn rates, alive caps, all of it) into one resource as parallel arrays instead of a bunch of copy-pasted per-level files. Now I can actually look at the whole curve at once and catch weird jumps between tiers. Wave-based elite scaling is just a Curve resource you drag points on too, no code involved.
- One trap I fell into: my registries used to just scan a folder at runtime to find all the resources. Works great in editor. Exported builds silently find nothing because PCKs don't let you walk a directory like that. Had to switch to baking an index file at edit time instead (a plugin regenerates it on save). If your stuff works in editor but vanishes in an exported build, this is probably why.
Set up localization before you have hundreds of strings to fix
- I know, nobody wants to hear "do localization early" when you're not even shipping other languages yet. Do it anyway. Costs nothing now. Costs a ton later when you're hunting hardcoded strings across every scene in the game.
- My rule from day one: no raw text ever gets typed straight into a script or scene. Every string goes through a key lookup with an English fallback, so a missing translation just falls back instead of breaking anything. Everything sits in one CSV that Godot compiles per locale on import.
- Because of that, going from 1 language to 14 was just adding a column, not touching every scene. Got a lint script running in CI too that catches missing keys, orphaned ones, copy-pasted English sitting where German should be, missing CJK characters, mismatched placeholders between languages. There's even a fake pseudo-locale mode that lights up any string that slipped through untranslated, so you don't have to read all 14 languages to check coverage.
Lock the doors, even though nothing's ever fully locked
- By default a Godot export is basically a zip file with your original source sitting right there for anyone who opens it. There are tools like GDRE Tools built specifically for pulling a shipped game's pck apart and reading your code back out. If you don't want randoms lifting your weapon logic or your whole balance sheet, you have to close some of that up. Nothing here makes it impossible, it just makes it not a 10 minute job.
- I run two layers. First, an addon called gdmaim that obfuscate my entire codebase i.e. renames every identifier and strips comments before export, so even if someone gets your source out, it's a mess of meaningless names instead of readable code. Second, Godot's built-in AES-256 encryption on the pck itself, so getting the source out in the first place is harder too.
- Here's the thing that actually got me though. I had "encrypt pck" checked, the key set, everything looked configured, and it turned out to be doing nothing. Godot only encrypts files matching an "include filter" pattern, and mine was blank. Blank filter means it encrypts zero files. No error, no warning, it just quietly ships an unencrypted pck while the checkbox sits there looking checked. I only caught it because I ran my own build through one of the public pck extraction tools to check, and my source came right back out, comments and all.
- So now I don't trust the checkbox. After every export a script tries to pull the source back out of my own build the same way an attacker would, and fails the build if it finds readable code or an original (non-renamed) identifier sitting in there. Same lesson as the balance and localization stuff honestly, don't trust that a setting did what it says, verify it actually happened.
- If you're shipping anything with real logic worth protecting, at minimum turn on pck encryption, actually check that include filter isn't blank, and run your own build through a public extraction tool (GDRE Tools is the common one) before you ship. Assume someone eventually will.
If you're wondering what this is all for, it's a horde survivor called Bonki D Bonk.
Happy to talk more about any of this in the comments, the MultiMesh stuff especially, that was the hardest to find good info on when I started.