Code Quality Design Help

(WIP) Unity Case Study – Refactoring “Tanks!” with SOLID Principles and Design Patterns

Code for this case study is available in the 3D-Tanks-Game-Unity repository.

The refactor began with the GameManager class from Unity’s “Tanks!” tutorial. Originally it shouldered many duties at once: driving the game loop, spawning tanks, evaluating win conditions, updating the HUD and manipulating the camera.

Here's the original monolithic GameManager class that we'll be refactoring throughout this case study:

using System.Collections; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; namespace Complete { public class GameManager : MonoBehaviour { // This class violates SRP by handling multiple // responsibilities: // - UI/HUD message display // - Game loop control and timing // - Tank spawning and lifecycle management // - Camera target configuration // - Win condition detection // - Scene management and reloading public int m_NumRoundsToWin = 5; public float m_StartDelay = 3f; public float m_EndDelay = 3f; public CameraControl m_CameraControl; public Text m_MessageText; public GameObject m_TankPrefab; public TankManager[] m_Tanks; private int m_RoundNumber; private WaitForSeconds m_StartWait; private WaitForSeconds m_EndWait; private TankManager m_RoundWinner; private TankManager m_GameWinner; // Game Loop Control private void Start() { m_StartWait = new WaitForSeconds(m_StartDelay); m_EndWait = new WaitForSeconds(m_EndDelay); SpawnAllTanks(); SetCameraTargets(); StartCoroutine(GameLoop()); } // Tank Management: spawn/setup private void SpawnAllTanks() { for (int i = 0; i < m_Tanks.Length; i++) { m_Tanks[i].m_Instance = Instantiate( m_TankPrefab, m_Tanks[i].m_SpawnPoint.position, m_Tanks[i].m_SpawnPoint.rotation ) as GameObject; m_Tanks[i].m_PlayerNumber = i + 1; m_Tanks[i].Setup(); } } // Camera Management private void SetCameraTargets() { Transform[] targets = new Transform[m_Tanks.Length]; for (int i = 0; i < targets.Length; i++) { targets[i] = m_Tanks[i].m_Instance.transform; } m_CameraControl.m_Targets = targets; } // Game Loop Control private IEnumerator GameLoop() { yield return StartCoroutine(RoundStarting()); yield return StartCoroutine(RoundPlaying()); yield return StartCoroutine(RoundEnding()); if (m_GameWinner != null) { SceneManager.LoadScene(0); } else { StartCoroutine(GameLoop()); } } // Game Loop Control; UI/HUD private IEnumerator RoundStarting() { ResetAllTanks(); DisableTankControl(); m_CameraControl.SetStartPositionAndSize(); m_RoundNumber++; m_MessageText.text = "ROUND " + m_RoundNumber; yield return m_StartWait; } // Game Loop Control private IEnumerator RoundPlaying() { EnableTankControl(); m_MessageText.text = string.Empty; while (!OneTankLeft()) { yield return null; } } // Game Loop Control; Win Detection; UI/HUD private IEnumerator RoundEnding() { DisableTankControl(); m_RoundWinner = null; m_RoundWinner = GetRoundWinner(); if (m_RoundWinner != null) m_RoundWinner.m_Wins++; m_GameWinner = GetGameWinner(); string message = EndMessage(); m_MessageText.text = message; yield return m_EndWait; } // Win Detection private bool OneTankLeft() { int numTanksLeft = 0; for (int i = 0; i < m_Tanks.Length; i++) { if (m_Tanks[i].m_Instance.activeSelf) numTanksLeft++; } return numTanksLeft <= 1; } // Win Detection private TankManager GetRoundWinner() { for (int i = 0; i < m_Tanks.Length; i++) { if (m_Tanks[i].m_Instance.activeSelf) return m_Tanks[i]; } return null; } // Win Detection private TankManager GetGameWinner() { for (int i = 0; i < m_Tanks.Length; i++) { if (m_Tanks[i].m_Wins == m_NumRoundsToWin) return m_Tanks[i]; } return null; } // UI/HUD Management private string EndMessage() { string message = "DRAW!"; if (m_RoundWinner != null) message = m_RoundWinner.m_ColoredPlayerText + " WINS THE ROUND!"; message += "\n\n\n\n"; for (int i = 0; i < m_Tanks.Length; i++) { message += m_Tanks[i].m_ColoredPlayerText + ": " + m_Tanks[i].m_Wins + " WINS\n"; } if (m_GameWinner != null) message = m_GameWinner.m_ColoredPlayerText + " WINS THE GAME!"; return message; } // Tank Management private void ResetAllTanks() { for (int i = 0; i < m_Tanks.Length; i++) { m_Tanks[i].Reset(); } } // Tank Management private void EnableTankControl() { for (int i = 0; i < m_Tanks.Length; i++) { m_Tanks[i].EnableControl(); } } // Tank Management private void DisableTankControl() { for (int i = 0; i < m_Tanks.Length; i++) { m_Tanks[i].DisableControl(); } } } }

Note: The original field m_MessageText was renamed to m_MessageTextComponent in the refactor, as the UI dependency is now owned by GameMessageUIService and injected into the service.

Phase 1 – Single-Responsibility Principle (SRP) & Testability

  • ProblemGameManager intermixed unrelated concerns, making unit tests impractical.

  • UI Extraction – HUD-message behaviour was moved to GameMessageUIService, accessed only through the IGameMessageUIService interface.

  • Rules Extraction – round-completion rules were relocated behind an IGameRulesStrategy abstraction.

Representative Code

public interface IGameMessageUIService { void DisplayRoundStart(int roundNumber); void ClearMessage(); void DisplayRoundEndResults( TankManager roundWinner, TankManager gameWinner, TankManager[] allTanks ); }
using UnityEngine; using UnityEngine.UI; public class GameMessageUIService : IGameMessageUIService { private readonly Text _messageTextComponent; public GameMessageUIService(Text messageTextComponent) { _messageTextComponent = messageTextComponent; if (_messageTextComponent == null) { Debug.LogError( "GameMessageUIService requires a Text " + "component to be provided." ); } } public void DisplayRoundStart(int roundNumber) { if (_messageTextComponent == null) return; _messageTextComponent.text = "ROUND " + roundNumber; } public void ClearMessage() { if (_messageTextComponent == null) return; _messageTextComponent.text = string.Empty; } public void DisplayRoundEndResults( TankManager roundWinner, TankManager gameWinner, TankManager[] allTanks ) { if (_messageTextComponent == null) return; var message = "DRAW!"; if (roundWinner != null) message = roundWinner.m_ColoredPlayerText + " WINS THE ROUND!"; message += "\n\n\n\n"; foreach (var t in allTanks) { message += t.m_ColoredPlayerText + ": " + t.m_Wins + " WINS\n"; } if (gameWinner != null) message = gameWinner.m_ColoredPlayerText + " WINS THE GAME!"; _messageTextComponent.text = message; } }

Phase 1.5 – SRP: Extract Game Rules into GameRulesManager (pre‑Strategy)

Before introducing patterns, rules logic was moved out of GameManager into a focused GameRulesManager. This SRP step created a clean seam that later enabled the Strategy pattern.

  • Responsibility shift: GameManager orchestrates lifecycle; rules live in GameRulesManager.

  • Benefits: smaller classes and easier unit tests; a single choke‑point for rule evaluation.

Key Excerpt – GameManager rules extraction (Phase 1.5)

private GameRulesManager m_GameRulesManager; private void Start() { // Phase 1.5: rules moved into GameRulesManager m_GameRulesManager = new GameRulesManager(m_NumRoundsToWin); } private IEnumerator RoundPlaying() { // Delegates rule-checking to service while (!m_GameRulesManager.IsOnlyOneTankLeft(m_Tanks)) { yield return null; } } private IEnumerator RoundEnding() { m_RoundWinner = m_GameRulesManager.DetermineRoundWinner(m_Tanks); if (m_RoundWinner != null) m_RoundWinner.m_Wins++; m_GameWinner = m_GameRulesManager.DetermineGameWinner(m_Tanks); }

GameRulesManager – Dedicated Rules Service

using System.Linq; namespace Complete { public class GameRulesManager { private readonly int _numRoundsToWin; public GameRulesManager(int numRoundsToWin) { _numRoundsToWin = numRoundsToWin; } public bool IsOnlyOneTankLeft(TankManager[] tanks) { if (tanks == null) return true; return tanks.Count(t => t != null && t.m_Instance != null && t.m_Instance.activeSelf ) <= 1; } public TankManager DetermineRoundWinner(TankManager[] tanks) { return tanks?.FirstOrDefault(t => t != null && t.m_Instance != null && t.m_Instance.activeSelf ); } public TankManager DetermineGameWinner(TankManager[] tanks) { return tanks?.FirstOrDefault(t => t != null && t.m_Wins == _numRoundsToWin ); } } }

Full intermediate GameManager (collapsible)

using System.Collections; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; namespace Complete { public class GameManager : MonoBehaviour { public int m_NumRoundsToWin = 5; public float m_StartDelay = 3f; public float m_EndDelay = 3f; public CameraControl m_CameraControl; public Text m_MessageTextComponent; public GameObject m_TankPrefab; public TankManager[] m_Tanks; private int m_RoundNumber; private WaitForSeconds m_StartWait; private WaitForSeconds m_EndWait; private TankManager m_RoundWinner; private TankManager m_GameWinner; private GameMessageUIService m_GameMessageUIService; private GameRulesManager m_GameRulesManager; private void Start() { m_StartWait = new WaitForSeconds(m_StartDelay); m_EndWait = new WaitForSeconds(m_EndDelay); m_GameMessageUIService = new GameMessageUIService(m_MessageTextComponent); m_GameRulesManager = new GameRulesManager(m_NumRoundsToWin); SpawnAllTanks(); SetCameraTargets(); StartCoroutine(GameLoop()); } private void SpawnAllTanks() { for (int i = 0; i < m_Tanks.Length; i++) { m_Tanks[i].m_Instance = Instantiate( m_TankPrefab, m_Tanks[i].m_SpawnPoint.position, m_Tanks[i].m_SpawnPoint.rotation ) as GameObject; m_Tanks[i].m_PlayerNumber = i + 1; m_Tanks[i].Setup(); } } private void SetCameraTargets() { Transform[] targets = new Transform[m_Tanks.Length]; for (int i = 0; i < targets.Length; i++) targets[i] = m_Tanks[i].m_Instance.transform; m_CameraControl.m_Targets = targets; } private IEnumerator GameLoop() { yield return StartCoroutine(RoundStarting()); yield return StartCoroutine(RoundPlaying()); yield return StartCoroutine(RoundEnding()); if (m_GameWinner != null) SceneManager.LoadScene(0); else StartCoroutine(GameLoop()); } private IEnumerator RoundStarting() { ResetAllTanks(); DisableTankControl(); m_CameraControl.SetStartPositionAndSize(); m_RoundNumber++; m_GameMessageUIService.DisplayRoundStart(m_RoundNumber); yield return m_StartWait; } private IEnumerator RoundPlaying() { EnableTankControl(); m_GameMessageUIService.ClearMessage(); while (!m_GameRulesManager.IsOnlyOneTankLeft(m_Tanks)) yield return null; } private IEnumerator RoundEnding() { DisableTankControl(); m_RoundWinner = null; m_RoundWinner = m_GameRulesManager.DetermineRoundWinner(m_Tanks); if (m_RoundWinner != null) m_RoundWinner.m_Wins++; m_GameWinner = m_GameRulesManager.DetermineGameWinner(m_Tanks); m_GameMessageUIService.DisplayRoundEndResults( m_RoundWinner, m_GameWinner, m_Tanks ); yield return m_EndWait; } private void ResetAllTanks() { for (int i = 0; i < m_Tanks.Length; i++) m_Tanks[i].Reset(); } private void EnableTankControl() { for (int i = 0; i < m_Tanks.Length; i++) m_Tanks[i].EnableControl(); } private void DisableTankControl() { for (int i = 0; i < m_Tanks.Length; i++) m_Tanks[i].DisableControl(); } } }

Phase 2 – Design-Pattern Flexibility

With individual responsibilities separated, traditional patterns could be added cleanly:

  • DecoratorLoggingGameMessageUIServiceDecorator injects logging without altering already-verified classes.

  • StrategyIGameRulesStrategy enables drop-in game-mode algorithms:

    • ClassicDeathmatchRulesStrategy

    • TimedRoundRulesStrategy

Strategy / Decorator Sketch

public interface ILogger { void Log(string message); void LogWarning(string message); void LogError(string message); } public sealed class UnityLogger : ILogger { public void Log(string message) { Debug.Log(message); } public void LogWarning(string message) { Debug.LogWarning(message); } public void LogError(string message) { Debug.LogError(message); } } public interface IGameRulesStrategy { void StartRound(); bool IsRoundOver(TankManager[] tanks); TankManager DetermineRoundWinner( TankManager[] tanks ); TankManager DetermineGameWinner( TankManager[] tanks ); } public class LoggingGameMessageUIServiceDecorator : IGameMessageUIService { private readonly IGameMessageUIService _wrappedService; private readonly ILogger _logger; public LoggingGameMessageUIServiceDecorator( IGameMessageUIService wrappedService, ILogger logger ) { _wrappedService = wrappedService; _logger = logger; if (_wrappedService == null) { if (_logger != null) _logger.LogError( "CRITICAL: Wrapped IGameMessageUIService " + "cannot be null in " + "LoggingGameMessageUIServiceDecorator." ); else UnityEngine.Debug.LogError( "CRITICAL: Wrapped IGameMessageUIService " + "AND ILogger are null in " + "LoggingGameMessageUIServiceDecorator." ); } if (_logger == null) { UnityEngine.Debug.LogError( "CRITICAL: ILogger cannot be null in " + "LoggingGameMessageUIServiceDecorator." ); } } public void DisplayRoundStart(int roundNumber) { _logger?.Log( "[DECORATOR] UI: Displaying round start - Round " + roundNumber ); _wrappedService.DisplayRoundStart(roundNumber); _logger?.Log( "[DECORATOR] UI: DisplayRoundStart finished." ); } public void ClearMessage() { _logger?.Log("[DECORATOR] UI: Clearing message."); _wrappedService.ClearMessage(); _logger?.Log( "[DECORATOR] UI: ClearMessage finished." ); } public void DisplayRoundEndResults( TankManager roundWinner, TankManager gameWinner, TankManager[] allTanks ) { var roundWinnerName = roundWinner != null ? roundWinner.m_ColoredPlayerText : "None"; var gameWinnerName = gameWinner != null ? gameWinner.m_ColoredPlayerText : "None"; var tankCount = allTanks?.Length ?? 0; _logger?.Log( "[DECORATOR] UI: Displaying round end results. " + "RoundWinner: " + roundWinnerName + ", GameWinner: " + gameWinnerName + ", TankCount: " + tankCount ); _wrappedService.DisplayRoundEndResults( roundWinner, gameWinner, allTanks ); _logger?.Log( "[DECORATOR] UI: DisplayRoundEndResults finished." ); } }

Phase 3 – Abstraction of External Dependencies

TimedRoundRulesStrategy referenced Time.time and live GameObject state. Two purpose-built interfaces removed those direct Unity dependencies:

public interface ITimeProvider { float Time { get; } float DeltaTime { get; } } public class UnityTimeProvider : ITimeProvider { public float Time => UnityEngine.Time.time; public float DeltaTime => UnityEngine.Time.deltaTime; } public interface IHealthProvider { float CurrentHealth { get; } }

Tests inject MockTimeProvider/MockHealthProvider, allowing every rule to execute under a standard .NET test runner.

using UnityEngine; using UnityEngine.UI; namespace Complete { public class TankHealth : MonoBehaviour, IHealthProvider { public float m_StartingHealth = 100f; public Slider m_Slider; public Image m_FillImage; public Color m_FullHealthColor = Color.green; public Color m_ZeroHealthColor = Color.red; private float m_CurrentHealth; public float CurrentHealth => m_CurrentHealth; } }

Strategy – Full implementation (Timed)

using System.Linq; using System; namespace Complete { public class TimedRoundRulesStrategy : IGameRulesStrategy { private readonly float _roundDurationSeconds; private readonly int _numRoundsToWinForGame; private float _roundStartTime; private readonly ITimeProvider _timeProvider; public TimedRoundRulesStrategy( float roundDurationSeconds, int numRoundsToWinForGame, ITimeProvider timeProvider ) { _roundDurationSeconds = roundDurationSeconds; _numRoundsToWinForGame = numRoundsToWinForGame; _timeProvider = timeProvider ?? throw new ArgumentNullException( nameof(timeProvider) ); } public void StartRound() { _roundStartTime = _timeProvider.Time; } public bool IsRoundOver( TankManager[] tanks ) { if (tanks == null || tanks.Length == 0) return true; var activeCount = tanks.Count( t => t != null && t.m_Instance != null && t.m_Instance.activeSelf ); if (activeCount <= 1) return true; var elapsed = _timeProvider.Time - _roundStartTime; return elapsed >= _roundDurationSeconds; } public TankManager DetermineRoundWinner( TankManager[] tanks ) { if (tanks == null || tanks.Length == 0) return null; var activeTanks = tanks.Where( t => t != null && t.m_Instance != null && t.m_Instance.activeSelf ).ToList(); if (activeTanks.Count == 0) return null; if (activeTanks.Count == 1) return activeTanks[0]; TankManager winnerByHealth = null; var maxHealth = -1f; foreach (var tank in activeTanks) { var healthInfo = tank.m_HealthProvider; if (healthInfo == null) continue; if (!(healthInfo.CurrentHealth > maxHealth)) continue; maxHealth = healthInfo.CurrentHealth; winnerByHealth = tank; } return winnerByHealth; } public TankManager DetermineGameWinner( TankManager[] tanks ) { return tanks?.FirstOrDefault( t => t != null && t.m_Wins == _numRoundsToWinForGame ); } } }

Note: TimedRoundRulesStrategy compares tankManager.m_HealthProvider.CurrentHealth to break ties when time expires and more than one tank is still active.

Wiring – Manual Composition inside GameManager

No DI container is required; dependencies are created once in Start() and then handed to the strategies/services:

private IGameMessageUIService m_GameMessageUIService; private IGameRulesStrategy m_GameRulesStrategy; private void Start() { m_StartWait = new WaitForSeconds(m_StartDelay); m_EndWait = new WaitForSeconds(m_EndDelay); ILogger uiServiceLogger = new UnityLogger(); IGameMessageUIService concreteMessageService = new GameMessageUIService(m_MessageTextComponent); m_GameMessageUIService = new LoggingGameMessageUIServiceDecorator( concreteMessageService, uiServiceLogger ); switch (m_SelectedGameMode) { case GameMode.TimedRounds: m_GameRulesStrategy = new TimedRoundRulesStrategy( m_RoundDurationSeconds, m_NumRoundsToWin, new UnityTimeProvider() ); Debug.Log( "Game Mode: Timed Rounds (" + m_RoundDurationSeconds + "s per round, " + m_NumRoundsToWin + " to win game)" ); break; case GameMode.ClassicDeathmatch: default: m_GameRulesStrategy = new ClassicDeathmatchRulesStrategy( m_NumRoundsToWin ); Debug.Log( "Game Mode: Classic Deathmatch (" + m_NumRoundsToWin + " to win game)" ); break; } if (m_Tanks == null || m_Tanks.Length == 0) { Debug.LogError( "GameManager: m_Tanks array is not assigned or empty in " + "the Inspector!" ); return; } if (m_TankPrefab == null) Debug.LogError("GameManager: m_TankPrefab is not assigned!"); if (m_MessageTextComponent == null) Debug.LogError( "GameManager: m_MessageTextComponent is not assigned!" ); if (m_CameraControl == null) Debug.LogError( "GameManager: m_CameraControl is not assigned!" ); SpawnAllTanks(); SetCameraTargets(); StartCoroutine(GameLoop()); }

Additional excerpts for clarity:

private void SetCameraTargets() { Transform[] targets = new Transform[m_Tanks.Length]; int validTargets = 0; for (int i = 0; i < m_Tanks.Length; i++) { if (m_Tanks[i] != null && m_Tanks[i].m_Instance != null) { targets[validTargets++] = m_Tanks[i].m_Instance.transform; } } if (validTargets < targets.Length) System.Array.Resize(ref targets, validTargets); if (m_CameraControl != null) m_CameraControl.m_Targets = targets; }
private IEnumerator RoundStarting() { ResetAllTanks(); DisableTankControl(); if (m_CameraControl != null) m_CameraControl.SetStartPositionAndSize(); m_RoundNumber++; m_GameRulesStrategy.StartRound(); m_GameMessageUIService.DisplayRoundStart(m_RoundNumber); yield return m_StartWait; } private IEnumerator RoundPlaying() { EnableTankControl(); m_GameMessageUIService.ClearMessage(); while (!m_GameRulesStrategy.IsRoundOver(m_Tanks)) { yield return null; } } private IEnumerator RoundEnding() { DisableTankControl(); m_RoundWinner = null; m_RoundWinner = m_GameRulesStrategy.DetermineRoundWinner(m_Tanks); if (m_RoundWinner != null) m_RoundWinner.m_Wins++; m_GameWinner = m_GameRulesStrategy.DetermineGameWinner(m_Tanks); m_GameMessageUIService.DisplayRoundEndResults( m_RoundWinner, m_GameWinner, m_Tanks ); yield return m_EndWait; }

GameManager therefore becomes a composition root—it assembles pure C# building blocks and then funnels Unity lifecycle events into them.

UML Overview

IGameMessageUIServiceGameMessageUIServiceLoggingGameMessageUIServiceDecoratorILoggerUnityLoggerIGameRulesStrategyClassicDeathmatchRulesStrategyTimedRoundRulesStrategyITimeProviderUnityTimeProviderGameManager

Unit Testing – Verifying Behaviour

Refactoring for SRP made core behaviour directly testable in isolation. Mocks such as MockTimeProvider and MockHealthProvider remove Unity coupling, enabling deterministic tests.

using NUnit.Framework; using UnityEngine; using UnityEngine.UI; using Complete; using UnityEngine.TestTools; public class GameMessageUIServiceTests { private GameObject _uiHostGameObject; private Text _testTextComponent; private GameMessageUIService _uiService; [SetUp] public void SetUp() { _uiHostGameObject = new GameObject("TestUIHostForTextMessageService"); _testTextComponent = _uiHostGameObject.AddComponent<Text>(); _uiService = new GameMessageUIService(_testTextComponent); } [Test] public void DisplayRoundStart_SetsCorrectText() { const int roundNumber = 5; var expectedText = "ROUND " + roundNumber; _uiService.DisplayRoundStart(roundNumber); Assert.AreEqual( expectedText, _testTextComponent.text ); } }
using NUnit.Framework; using UnityEngine; using Complete; public class ClassicDeathmatchRulesStrategyTests { private const int DefaultNumRoundsToWin = 3; private ClassicDeathmatchRulesStrategy _rulesStrategy; private GameObject _tank1GameObject; private GameObject _tank2GameObject; [SetUp] public void SetUp() { _rulesStrategy = new ClassicDeathmatchRulesStrategy( DefaultNumRoundsToWin ); _tank1GameObject = new GameObject("TestTank1_Obj"); _tank2GameObject = new GameObject("TestTank2_Obj"); } private static TankManager CreateTestTankManager( GameObject instanceReference, int playerNumber, int wins, bool isActive ) { var tankManager = new TankManager { m_PlayerNumber = playerNumber, m_Wins = wins, m_Instance = instanceReference }; if (instanceReference != null) { instanceReference.SetActive(isActive); } return tankManager; } [Test] public void IsRoundOver_MultipleActiveTanks_ReturnsFalse() { TankManager[] tanks = { CreateTestTankManager( _tank1GameObject, 1, 0, true ), CreateTestTankManager( _tank2GameObject, 2, 0, true ) }; Assert.IsFalse(_rulesStrategy.IsRoundOver(tanks)); } }

Summary

  1. SRP Implementation – UI and rule logic were separated from GameManager.

  2. Design Patterns Applied – Decorator supplies logging; Strategy supports additional game modes.

  3. Test-Oriented Abstractions – Time and health access were wrapped in interfaces.

  4. Toward Dedicated Roots – Manual wiring is now a stepping-stone to fully-fledged composition-root MonoBehaviour s.

See also

21 August 2025