Sample image of my game
← Back to all posts

Refactoring My Battle Units to Composition

7 min read
Game DevelopmentTactics RPGGodotC#ArchitectureRefactoring
Refactoring My Battle Units to Composition

How I broke a bloated BattleUnit class into a thin host plus drop-in components, and what changed about adding features once the refactor settled.

Refactoring My Battle Units to Composition

For most of the early phase of my tactics RPG, I was in pure "make it work" mode. Combat had to actually run before anything else mattered. That worked — but it also meant my BattleUnit class grew into a single big file holding movement, attacking, animation control, AI hooks, and turn bookkeeping all in one place. Every new feature touched it. Every bug touched it. I could feel it getting harder to reason about, and I knew it was only going to get worse.

I'm moving out of that early "just get it on screen" phase now and into more serious development of the game. Before stacking the next batch of features on top, I wanted to clean up what's already there so the next layer is easier to build on. The refactor that has helped the most so far was switching from a fat BattleUnit class to a thin host node with its behavior split across child components.

This is an early stage implementation that's working well for getting out of a bloated class, but there's definitely room for improvement — particularly in transition guards, shared base utilities for components, and decoupling cross-component calls.

What Changed

The old BattleUnit was around 450 lines doing everything — movement, attacking, AI, animation, turn bookkeeping, HP, and inline subscriptions to half the battle event bus. A simplified slice of the shape:

// What BattleUnit looked like before — one class, ~450 lines public partial class BattleUnit : Node2D { [Export] private HealthBar _healthBar; [Export] private AnimatedSprite2D _animatedSprite; [Export] private AnimatedSprite2D _turnIndicator; [Export] private CharacterData _data; [Export] public bool IsEnemy; private BattleManager _battleManager; private BattleEventBus _battleEventBus; private BattleMapLayers _battleMapLayers; // Snapshot fields just so CancelMove can rewind private Vector2 _prevPosition; private Vector2 _prevSpriteOffset; private string _prevAnimation; public int UnitHealth { get; private set; } public int TurnValue { get; private set; } public int TempTurnValue { get; private set; } private bool IsCurrentTurnUnit => _battleManager.CurrentTurnUnit == this; public override void _Ready() { // resolve manager refs... // Every event the unit cares about gets wired up inline, // and almost every handler has to self-check IsCurrentTurnUnit // because the global event bus blasts to everyone. _battleEventBus.BattleTurnStarted += () => { _turnIndicator.Visible = IsCurrentTurnUnit; if (IsCurrentTurnUnit && IsEnemy) EnemyAIMove(); }; _battleEventBus.MoveCellSelected += cell => { if (IsCurrentTurnUnit) { /* path + tween + animate move */ } }; _battleEventBus.AttackCellSelected += cell => { if (IsCurrentTurnUnit) { /* find target + animate attack */ } }; _battleEventBus.BattleStateChanged += state => { switch ((BattleState)state) { case BattleState.PreMove when !IsEnemy: /* show move guide */ break; case BattleState.PreAttack when !IsEnemy: /* show attack guide */ break; case BattleState.Moved when IsEnemy: EnemyAIAttack(); break; case BattleState.Attacked when IsEnemy: /* end turn */ break; // ... more cases } }; _battleEventBus.UnitAttacked += (_, target) => { if (target == this && UnitHealth <= 0) Die(); }; // ... and several more subscriptions } // Movement public void AnimateMove(Vector2I[] path, Tween tween = null) { /* tween + animation */ } private void CancelMove() { /* restore snapshot */ } // Combat private void AnimateAttack(BattleUnit target, Tween tween = null) { /* face, tween, damage */ } private void TakeDamage(int damage, Tween tween = null) { /* clamp, tween HP bar */ } private void Die() { /* emit, hide, cleanup */ } // Turn meter public void IncreaseTurnValue() { /* ... */ } public void DecreaseTurnValue(int v) { /* ... */ } public void SaveTurnValue() { /* ... */ } public void ResetTurnValue() { /* ... */ } // Animation public void FaceDirection(BattleFacingDirection direction) { /* play right idle */ } private void FaceDirection(Vector2I direction) { /* compute dominant axis */ } public void StopAnimation() { /* ... */ } // AI private void EnemyAIMove() { /* path to nearest opponent, dispatch */ } private void EnemyAIAttack() { /* find target in range, dispatch */ } private Vector2I GetNearestOpponentCell() { /* ... */ } private BattleUnit GetOpponentWithinAttackRange(){ /* ... */ } }

Even simplified down to one screen, you can see at least five separate concerns sharing a class: subscriptions to half the event bus, move animation with snapshot-based rewind, combat resolution, the turn meter, and AI for enemies. Anything that touched one usually meant scrolling past the other four. The IsCurrentTurnUnit self-check in nearly every handler is its own smell — the unit shouldn't have to ask itself whether an event applies to it.

The new one is more like a directory: it holds the data, exposes its children, and emits a signal whenever its lifecycle changes. The actual work lives in components attached as child nodes — BattleUnitMover, BattleUnitAttacker, BattleUnitHealth, BattleUnitTurnMeter, BattleUnitMoveAnimator, BattleUnitCombatAnimator, BattleUnitAutoCombat, and BattleActionMenu.

The host itself looks like this (simplified):

public partial class BattleUnit : Node2D { [Signal] public delegate void ActionSelectionEventHandler(); [Signal] public delegate void PreMoveEventHandler(); [Signal] public delegate void MovingEventHandler(); [Signal] public delegate void WaitingEventHandler(); [Signal] public delegate void DiedEventHandler(BattleUnit unit); // ... one signal per lifecycle step [Export] private CharacterData data; [Export] public BattleUnitFaction Faction; // Children resolved by node name public BattleUnitHealth Health; public BattleUnitMover Mover; public BattleUnitAttacker Attacker; public BattleUnitTurnMeter TurnMeter; public BattleActionMenu ActionMenu; // ... others public State CurrentState { get; private set; } public override void _Ready() { Health = GetNode<BattleUnitHealth>("BattleUnitHealth"); Mover = GetNode<BattleUnitMover>("BattleUnitMover"); Attacker = GetNode<BattleUnitAttacker>("BattleUnitAttacker"); // ... resolve the rest } // Lifecycle helpers — each just records the new state and announces it public void EmitActionSelection() { CurrentState = State.AwaitingAction; EmitSignal(SignalName.ActionSelection); } public void EmitWaiting() { CurrentState = State.EndingTurn; EmitSignal(SignalName.Waiting); } public void EmitDied() { CurrentState = State.Died; EmitSignal(SignalName.Died, this); } }

There's almost nothing in here. The host doesn't decide when to move, who to attack, or what animation to play. It just announces lifecycle events and lets whoever is listening do the work.

The State Machine Lives in Signals

What holds the components together is a simple lifecycle: ActionSelection → PreMove → Moving → PreAttack → Attacking → Waiting. Each step is a signal on the host. Components subscribe to the signals they care about and ignore the rest.

For example, the AI component subscribes to ActionSelection — that's its cue to look at the board and decide what to do:

public partial class BattleUnitAutoCombat : Node2D { private BattleUnit unit; public override void _EnterTree() { unit = GetParent<BattleUnit>(); unit.ActionSelection += OnActionSelection; } private void OnActionSelection() { // If something's in range, attack it. var target = FindOpponentInRange(); if (target != null && !unit.HadAttacked) { unit.Attacker.StartAttacking(target); return; } // Otherwise close the gap toward the nearest opponent. if (!unit.HadMoved && FindNearestOpponentCell() is { } cell) { unit.Mover.StartMoving(PathTo(cell)); return; } // Nothing to do — hand the turn back. unit.Mover.EndTurn(); } }

Notice the AI never asks the host to do anything. It calls the components directly. The host is just the bulletin board.

What I Get Out of It

The clearest benefit is that I can add a new unit behavior by writing a new component. Nothing else has to change.

A few concrete examples:

  • AI is a component, not a flag. Drop BattleUnitAutoCombat onto a unit and it acts on its own. Leave it off and the player drives it. There is no if (isEnemy) branch anywhere in BattleUnit.
  • Death-triggered cutscenes are a child node. A BattleCutsceneTriggerUnitDied lives under a unit. When the unit dies, BattleManager checks for one and fires it. Removing the trigger removes the cutscene — zero changes to the unit class.
  • The player action menu is a component. Move / Attack / End Turn isn't special-cased on BattleUnit. It's a BattleActionMenu child that listens to the same lifecycle signals and shows or hides itself accordingly.

The other thing I've noticed: it's a lot easier to delete a feature now. Removing a component is one node off the scene. Removing a fat class branch was always a hunt.

The Same Shape on the Exploration Side

The exploration scenes already had this shape even before I cleaned up battle, just on a smaller scale. BaseCharacter is also a thin host. It loads a CharacterData resource and exposes child components: Navigable (movement velocity / friction), CharacterMoveAnimator (sprite animation), ActionableFinderArea (interaction cone), ActionableArea (being-interactable marker). The subclasses are small: PlayableCharacter adds a Controllable child for input; NonPlayableCharacter is essentially empty — all its behavior comes from a CharacterNavigator and ActionableDialogue attached in the scene.

Exploration doesn't need a signal-based state machine because there's no turn order — Navigable.Move() just runs every frame. So the pattern adapts: same composition shape, lighter coordination.

What I'm Taking Forward

The biggest takeaway, more than any specific pattern, is the shift in mindset. I've been giving myself permission to refactor before features for the first time in this project. Early on, every minute spent restructuring was a minute not spent making it actually playable, and that was probably the right call then. But I'm past that phase now, and I can already feel the difference adding things on top of cleaner foundations.

Composition isn't a silver bullet — there's a real tradeoff in more small files and a bit of indirection. For me, on this codebase, at this stage, it has been working well. As I learn more I'm sure I'll find better shapes for some of these components. For now this is the foundation I'm going to keep building the rest of the game on.