Why Turn-Based Combat Code Becomes Unmaintainable So Fast

Apr 10, 2026

Why Turn-Based Combat Code Becomes Unmaintainable So Fast

Turn-based tactical RPG battle scene with unit selection, turn UI, and grid highlights

A lot of turn-based combat code stays surprisingly manageable right up until the moment it does not.

At first, everything feels fine. You can enter battle, spawn units, pass turns, attack, maybe even end the fight cleanly. It looks like the hard part is behind you. Then a few more features land. Restart battle. Mid-battle objectives. Unit death reactions. Different encounter setups. Suddenly the code starts feeling fragile in a way that is hard to name.

Usually the problem is not that the combat system got too complicated too quickly. The problem is that nobody clearly owns the runtime truth of the battle.

One of the easiest ways to get a prototype moving is to let one global manager hold everything.

That manager starts out with harmless enough responsibilities:

  • selected level
  • selected party
  • spawned units
  • current turn
  • objective status
  • battle result

Nothing seems wrong with that setup early on. It is convenient, and every other system can reach it without much friction.

The trouble starts when battle state and game state quietly become the same thing.

Now the object that chooses which encounter to load is also the object that knows which units are still alive. The object that handles scene transitions is also responsible for deciding whether the battle is over. A restart feature has to remember which state should be reset and which state should persist. Cleanup gets shaky. Bugs start surviving from one encounter to the next.

That is usually when the code begins to feel "random." It is not random. It is just missing a real runtime boundary.

The common wrong approach is not "bad code." It is a reasonable prototype shape that never got a second boundary.

The global coordinator keeps absorbing responsibilities because it is already there:

GameManager
  -> loads encounter
  -> stores selected roster
  -> tracks living units
  -> tracks turn phase
  -> evaluates victory
  -> stores final result
  -> clears battle state

That works for a while, but it creates a quiet architectural problem: encounter state lives inside something that outlives the encounter itself.

Once that happens, every new feature tends to attach itself to the same global object. Not because it belongs there, but because it is available.

What helped here was separating global coordination from encounter ownership.

The game can still have a global coordinator. That part is fine. It can choose which level to load, which party enters combat, and what to do after the battle ends.

But once the battle starts, one dedicated runtime object should own the mutable truth of that encounter.

Something like this:

Meta Game -> Create Battle Session -> Register Units -> Run Battle -> Report Result

That battle session is not powerful because it is central. It is useful because it is scoped.

It exists for one encounter.
It owns one encounter's mutable state.
It is discarded when that encounter ends.

That one boundary clears up a lot:

  • unit lists belong to the battle, not the whole game
  • objective evaluation belongs to the battle, not the UI
  • victory and defeat belong to the battle, not whichever script notices them first
  • cleanup becomes replacing one runtime object instead of untangling leaked state

The difference is small on paper and huge in practice.

Instead of this:

# global object owns everything
GameManager.player_units
GameManager.enemy_units
GameManager.current_turn
GameManager.battle_result

You move battle truth into something encounter-scoped:

class_name BattleSession

var player_units = []
var enemy_units = []
var battle_active = false
var battle_result = &""

func evaluate_battle_state() -> void:
	if are_victory_objectives_completed():
		finish(&"victory")
	elif is_defeat():
		finish(&"defeat")

The global layer can create the session. It should not impersonate the session.

That distinction matters more than it sounds like it should.

Once the runtime state has a real owner, the rest of the system gets easier to reason about.

New mechanics have a place to attach. Debugging gets less annoying because battle truth is not duplicated across unrelated systems. And "what belongs to this encounter" stops being a fuzzy architecture discussion and turns into a very practical boundary in code.

It also makes restart, cleanup, post-battle transitions, and future systems like replay or telemetry much less awkward. Those features stop feeling like special cases and start feeling like normal lifecycle work.

Principle

Global systems can create a battle.

They should not pretend to be the battle.

I cover the wider battle architecture in the full course if you want to see how this boundary connects to turn flow, actions, combat resolution, and AI.

GodotLabs

GodotLabs

Why Turn-Based Combat Code Becomes Unmaintainable So Fast | Blog