(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.
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.
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;
}
GameManager therefore becomes a composition root—it assembles pure C# building blocks and then funnels Unity lifecycle events into them.
UML Overview
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
SRP Implementation – UI and rule logic were separated from GameManager.