game-qaeconomybalanceautomationqa

Game Balance Breaks Through Numbers, Not Bugs: Catching Exploits with a Simulator

I’m making an idle tower defense game: a tower in the center, enemies coming in waves, the player buying upgrades. All tests green, every button works, every upgrade does exactly what it says. Then a tester sends a screenshot: wave 199, 70,000 coins, “beat the game, boring”. I could barely reach wave 60 myself.

There were no bugs in the usual sense. No crash, no wrong number on screen, no missing check. What broke was the balance: several mechanics that each worked correctly added up to immortality. Unit tests and manual runs don’t catch that, and that’s what this post is about.

I already wrote a separate post about cheats and attacks on a game economy. This is different: the player doesn’t hack anything, they just play better than you planned for.

Why balance can’t be tested by hand

A manual run checks what the QA engineer played. Balance breaks where they didn’t play: at wave 150, after twenty hours of upgrades, with one specific combination. My game has 46 upgrades with dozens of levels each, plus a lab, cards and perks. There are more combinations than you could try in a lifetime.

The second reason: balance is about curves, not points. The right question isn’t “how much does damage level 3 cost” but “how fast does the player’s power grow compared to the enemies’”. If the first curve overtakes the second anywhere and stays ahead, the game never ends. You only see that on a graph over hundreds of waves.

Step 1: separate combat logic from the engine

To run thousands of battles, the logic has to run without graphics. In my game all combat is plain C# with no Unity: enemies, bullets, damage, economy. Unity only draws what the simulation computed.

If your combat lives in MonoBehaviours and physics, that’s the first thing to change for testability. Without it, balance can only be checked by eye.

Step 2: a player simulator

The simulator is a bot that plays instead of a person:

  • starts a run with the current upgrades;
  • spends cash on upgrades mid-battle (greedy: the cheapest useful one);
  • after dying, spends coins in the Workshop;
  • repeats for 60 runs and writes a table: run, wave, minutes, earnings, levels.

The bot plays worse than a human, and that’s fine. It gives a lower bound: if even a dumb bot clears 100 waves in 10 runs, the game is too easy. If it dies on the same wave 15 runs in a row, there’s an invisible wall.

I found such a wall on the very first run: snipers stopped at distance 3.2 while the tower’s range was 2.8. The player physically couldn’t reach them, and for 15 runs, about two hours, every run died on waves 11–14. By hand I’d have written it off as “needs more grinding”.

Step 3: a stress test for a maxed-out player

The normal simulator checks the new-player path. Exploits live in the late game, so you need a second test: give the bot lots of upgrades up front and see whether it dies at all.

I ran a set of builds, each until the tower died but no longer than 10 hours of game time:

BuildWhat’s included
ws0nothing, fresh start
ws10every upgrade at level 10
ws10 + labs / cards / perks / ultone extra power source each
ws10 + all, ws30 + all, ws60 + alleverything at once

The key metric isn’t “which wave did it reach” but why is it still alive. So the report printed tower damage next to a normal enemy’s HP at the same wave.

The first run produced a line that explained everything: a tower with 101 damage alive at wave 241, where a normal enemy has 54,000 HP. That’s over 500 hits per enemy. So enemies weren’t dying from shots, and I had to find out what was killing them.

Step 4: a probe into what keeps the tower alive

When the simulator says “immortal”, you have to look inside. I wrote a probe that prints the battle state every 20 waves: enemies alive, how many reached the tower, minimum distance, tower HP and all defensive stats.

The result was surprising: no enemy ever reached the tower. Zero enemies at the wall at wave 160. And another oddity: tower HP 1,162 out of a 202 maximum.

That’s how four holes turned up. Each mechanic worked correctly on its own:

  1. Knockback pinned enemies. A 78% chance to push an enemy back 2 cells on every hit. An enemy stepped forward and flew right back. The tower fired faster than enemies could close in.
  2. Orbs killed instantly. Orbs around the tower killed any normal enemy on touch, whatever its HP. Enemy HP growth stopped mattering.
  3. Thorns scaled with enemy HP. An enemy that hit the tower lost a percentage of its own max HP. The stronger the enemy, the harder it hurt itself, so enemy growth again did nothing.
  4. Overheal stacked up. Health packs healed above max, capped at 5–6× HP. The tower lived with a sixfold buffer.

Separately, the tester found two more: rapid fire kept re-triggering itself almost forever, and the lab gave 7× damage and HP for pocket change.

The fix: tie everything to player power

All the holes share one root: a mechanic scaled with something other than the player’s power, like enemy HP or an uncapped percentage. Such mechanics inevitably break the curves over a long run.

  • Orbs now hit for 8× tower damage instead of killing instantly.
  • Thorns: a percentage of enemy HP, capped at 25× tower damage.
  • Knockback force cut by 5×, max chance 50% instead of 80%.
  • Overheal capped at 3×.
  • Rapid fire: the chance is rolled once per second, with a cooldown after each burst.
  • Lab: 15 levels instead of 40, with steep price growth.

Re-running the stress test: every build now dies. With everything at level 10 the tower reaches about wave 175 in 100 minutes; fully maxed, around wave 600. The new-player path barely changed.

What to build into your process

My main takeaway: balance is a test target just like an API. It needs automated tests that run after every change to the numbers.

  • A new-player simulator in CI, or at least before each release. If the “wave per run” curve shifts by more than X%, look at it.
  • A max-build stress test. The rule is simple: no build should live forever. “ALIVE” in the report is a bug.
  • Unit tests for mechanics, like “orbs don’t kill bosses” or “a locked upgrade has no effect”. They catch regressions more cheaply than the simulator.
  • All numbers outside the code. My balance lives in JSON and is mirrored in Remote Config. Found a hole after release? Fix it in the console, no app update needed.

A balance checklist for QA

  1. Combat runs without graphics; a thousand rounds take minutes.
  2. There’s a player simulator with a “run → wave → time → earnings” table.
  3. No “wall”: the bot doesn’t die on the same wave 10 runs in a row.
  4. No “elevator”: the bot doesn’t clear a hundred waves in the first five runs.
  5. A max-build stress test: every build eventually dies.
  6. Any effect that scales from something other than player power (enemy HP, uncapped percentages) is checked separately.
  7. Each power source (lab, cards, perks, ultimates) is checked on its own and all together.
  8. Chance-based effects can’t re-trigger themselves.
  9. A locked upgrade has no effect, even if it has levels.
  10. Balance numbers can change without a release.

In short

Balance breaks through numbers, not bugs: each mechanic is correct, but together they grant immortality. You won’t find it by hand. You need a bot that plays a thousand times and a stress test that asks “does it ever die?”. The most dangerous mechanics are the ones that don’t scale with player power, so check those first.