Introduction: The Architecture Inflection Point
Series Overview: This is Part 14 of our 16-part Unity Game Engine Series. We focus on the software architecture and design patterns that separate hobby projects from professional, maintainable codebases that can scale to hundreds of thousands of lines.
1
Unity Basics & Interface
Editor overview, assets, prefabs, architecture
2
C# Scripting Fundamentals
MonoBehaviour, coroutines, input systems, patterns
3
GameObjects & Components
Transforms, renderers, custom components
4
Physics & Collisions
Rigidbody, colliders, raycasting, forces
5
UI Systems
Canvas, uGUI, UI Toolkit, responsive design
6
Animation & State Machines
Animator, blend trees, IK, Timeline
7
Audio & Visual Effects
AudioSource, particles, VFX Graph, post-processing
8
Building & Publishing
Build pipeline, optimization, platforms, monetization
9
Rendering Pipelines
URP, HDRP, Shader Graph, lighting systems
10
Data-Oriented Tech Stack
ECS, Jobs System, Burst Compiler
11
AI & Gameplay Systems
NavMesh, FSMs, behavior trees, procedural gen
12
Multiplayer & Networking
Netcode, RPCs, latency, prediction
13
Tools & Editor Scripting
Custom editors, debug tools, CI/CD
14
Architecture & Clean Code
Service locators, DI, ScriptableObject architecture
You Are Here
15
Performance Optimization
CPU/GPU profiling, memory, object pooling
16
Production & Industry Practices
Git, Agile, asset pipelines, debugging at scale
Every Unity project hits an architecture inflection point — the moment when adding features becomes harder than it should be. For solo developers, it typically happens around 10,000 lines of code. For teams, it can happen much sooner when multiple people touch the same systems. The symptoms are unmistakable: changing one system breaks three others, adding a new enemy type requires modifying 12 files, and nobody on the team can explain how the inventory system actually works.
Game architecture is fundamentally different from enterprise software architecture. Games have hard real-time constraints (16ms per frame at 60 FPS), require tight coupling between systems for gameplay feel, and often need rapid iteration during prototyping. This means patterns that work beautifully in web applications (like full dependency injection) can be overkill or even harmful in games. The key is knowing which patterns to use and when.
Key Insight: Perfect architecture is not the goal — shippable architecture is. The best architecture is the simplest one that lets your team move fast, prevents catastrophic bugs, and can evolve as the project grows. Over-engineering kills more games than spaghetti code does.
A Brief History of Game Architecture
| Era |
Pattern |
Characteristics |
| 1980s |
Monolithic game loops |
Single file, procedural code, direct hardware access |
| 1990s |
Deep inheritance hierarchies |
Object→Entity→Actor→Enemy→FlyingEnemy chains, "diamond of death" |
| 2000s |
Component-based architecture |
Unity's model: GameObjects + Components, composition over inheritance |
| 2010s |
ScriptableObject-driven design |
Data-driven systems, SO events, Ryan Hipple's GDC talk (2017) |
| 2020s |
ECS + DI + event-driven hybrids |
DOTS for performance-critical code, DI containers, message buses |
Case Study
Hearthstone — Architecture at Scale
Blizzard's Hearthstone, built in Unity, grew from a small prototype to one of the most successful card games in history. The team has spoken publicly about their architectural evolution: starting with quick MonoBehaviour scripts, then migrating to a more structured state machine + event system architecture as the game grew. Key lessons: they use a centralized game state managed by a state machine, with visual effects driven by events rather than polling. Card effects use a command pattern for undo/redo and replay. The architecture allows them to add hundreds of new card effects per expansion without touching core systems.
Unity
State Machines
Event-Driven
Command Pattern
1. Why Architecture Matters
When your project is small — a game jam prototype, a tutorial exercise — architecture barely matters. You can wire everything to everything and ship in 48 hours. But the moment you want to maintain, extend, or collaborate on a project, architecture becomes the single biggest factor in your velocity.
1.1 Spaghetti Code Anti-Patterns
These are the most common architectural anti-patterns in Unity projects. If you recognize your project in any of these, it is time to refactor:
| Anti-Pattern |
Symptom |
Root Cause |
| God Object |
GameManager.cs is 3,000+ lines and controls everything |
No separation of concerns; one class handles state, UI, audio, save |
| Singleton Abuse |
15+ singletons, everything calls everything |
Singletons used for convenience, not necessity; hidden dependencies |
| Inspector Spaghetti |
Dozens of [SerializeField] references wired manually in Inspector |
No programmatic discovery; references break when scenes change |
| Circular Dependencies |
A depends on B depends on C depends on A |
No clear dependency direction; systems tightly coupled |
| Update Soup |
Complex logic in Update() with nested if/else chains |
No state machine; branching logic instead of state-based design |
// ANTI-PATTERN: The God Manager — do NOT do this
public class GameManager : MonoBehaviour
{
public static GameManager Instance;
// This class does EVERYTHING — a clear SRP violation
public int playerHealth;
public int playerScore;
public int playerGold;
public bool isPaused;
public bool isGameOver;
public AudioSource musicSource;
public Text scoreText;
public Text healthText;
public GameObject pauseMenu;
public GameObject gameOverScreen;
public Transform[] spawnPoints;
public GameObject[] enemyPrefabs;
// ... 50 more fields ...
void Awake() { Instance = this; }
void Update()
{
// 500 lines of mixed logic: input, UI updates, spawning,
// save/load, audio, scene transitions, achievements...
if (isPaused) { /* handle pause */ }
if (isGameOver) { /* handle game over */ }
if (Input.GetKeyDown(KeyCode.Escape)) { /* toggle pause */ }
scoreText.text = "Score: " + playerScore; // UI in game logic!
// This is unmaintainable at scale
}
}
1.2 Architecture Patterns Overview
Here is a decision matrix for choosing the right architecture pattern for your Unity project:
| Pattern |
Best For |
Complexity |
Team Size |
| Simple Singletons |
Game jams, prototypes, small solo projects |
Low |
1 developer |
| Service Locator |
Mid-size projects needing testability |
Low-Medium |
1-3 developers |
| ScriptableObject Architecture |
Data-driven games, designer-friendly systems |
Medium |
2-6 developers |
| Event-Driven + SO |
Complex games with many interacting systems |
Medium |
3-10 developers |
| Full DI (VContainer/Zenject) |
Large teams, enterprise-level projects, testable code |
High |
5+ developers |
| MVC/MVVM |
UI-heavy games, applications with complex data binding |
High |
3+ developers |
2. Service Locators
The Service Locator pattern is the most practical first step away from singleton abuse. Instead of every system being a singleton that other systems access directly, you create a central registry where services register themselves and consumers look them up. It is simpler than full DI but provides most of the same benefits for typical Unity projects.
2.1 The ServiceLocator Pattern
// ServiceLocator.cs — A clean, type-safe service locator
using System;
using System.Collections.Generic;
using UnityEngine;
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> services
= new Dictionary<Type, object>();
/// <summary>
/// Register a service implementation for its interface type.
/// </summary>
public static void Register<T>(T service) where T : class
{
var type = typeof(T);
if (services.ContainsKey(type))
{
Debug.LogWarning($"[ServiceLocator] Overwriting existing " +
$"service for {type.Name}");
}
services[type] = service;
}
/// <summary>
/// Retrieve a service by its interface type.
/// Throws if not found — fail fast is better than null bugs.
/// </summary>
public static T Get<T>() where T : class
{
var type = typeof(T);
if (services.TryGetValue(type, out var service))
{
return (T)service;
}
throw new InvalidOperationException(
$"[ServiceLocator] Service {type.Name} not registered. " +
$"Did you forget to call Register<{type.Name}>()?");
}
/// <summary>
/// Try to get a service without throwing.
/// Returns false if the service is not registered.
/// </summary>
public static bool TryGet<T>(out T service) where T : class
{
var type = typeof(T);
if (services.TryGetValue(type, out var obj))
{
service = (T)obj;
return true;
}
service = null;
return false;
}
/// <summary>
/// Remove a service (useful for scene transitions).
/// </summary>
public static void Unregister<T>() where T : class
{
services.Remove(typeof(T));
}
/// <summary>
/// Clear all services (scene cleanup).
/// </summary>
public static void Clear()
{
services.Clear();
}
}
// --- Define service interfaces ---
public interface IAudioService
{
void PlaySFX(string clipName, float volume = 1f);
void PlayMusic(string trackName, bool loop = true);
void StopMusic(float fadeTime = 1f);
void SetMasterVolume(float volume);
}
public interface ISaveService
{
void Save(string slotName);
T Load<T>(string slotName);
bool HasSave(string slotName);
void DeleteSave(string slotName);
}
public interface IInputService
{
Vector2 MoveInput { get; }
bool JumpPressed { get; }
bool AttackPressed { get; }
}
// AudioService.cs — concrete implementation
using UnityEngine;
public class AudioService : MonoBehaviour, IAudioService
{
[SerializeField] private AudioSource sfxSource;
[SerializeField] private AudioSource musicSource;
[SerializeField] private AudioClip[] sfxClips;
private void Awake()
{
// Register this service on initialization
ServiceLocator.Register<IAudioService>(this);
}
private void OnDestroy()
{
ServiceLocator.Unregister<IAudioService>();
}
public void PlaySFX(string clipName, float volume = 1f)
{
var clip = System.Array.Find(sfxClips, c => c.name == clipName);
if (clip != null)
sfxSource.PlayOneShot(clip, volume);
else
Debug.LogWarning($"SFX clip '{clipName}' not found.");
}
public void PlayMusic(string trackName, bool loop = true)
{
// Implementation here
musicSource.loop = loop;
musicSource.Play();
}
public void StopMusic(float fadeTime = 1f) { /* fade out */ }
public void SetMasterVolume(float volume) { /* set volume */ }
}
// --- Consumer: PlayerCombat uses the audio service ---
public class PlayerCombat : MonoBehaviour
{
private IAudioService audio;
private void Start()
{
// Look up the service — no singleton, no direct reference
audio = ServiceLocator.Get<IAudioService>();
}
public void Attack()
{
// Use the service through its interface
audio.PlaySFX("sword_swing", 0.8f);
// ... attack logic
}
}
2.2 Lazy Initialization & Testing with Mocks
Key Benefit: Because consumers depend on interfaces, not concrete classes, you can swap implementations for testing. Create a MockAudioService that logs calls instead of playing audio, and your combat tests run silently without audio setup.
// MockAudioService.cs — for unit testing
public class MockAudioService : IAudioService
{
public List<string> PlayedSFX { get; } = new List<string>();
public string CurrentMusic { get; private set; }
public void PlaySFX(string clipName, float volume = 1f)
{
PlayedSFX.Add(clipName);
}
public void PlayMusic(string trackName, bool loop = true)
{
CurrentMusic = trackName;
}
public void StopMusic(float fadeTime = 1f) { CurrentMusic = null; }
public void SetMasterVolume(float volume) { }
}
// In your test setup:
// ServiceLocator.Register<IAudioService>(new MockAudioService());
// Now PlayerCombat.Attack() records SFX calls without actual audio
Pattern Comparison
Service Locator vs Singleton: When to Use Each
Singletons are fine for truly global, one-instance systems where testability is not a concern (e.g., a simple game jam). Service Locators shine when you need interface-based access, swappable implementations (testing, platform-specific services), or when you want to control initialization order explicitly. The migration path from singletons to service locators is straightforward: extract an interface from your singleton, register it in the locator, and update consumers to use ServiceLocator.Get<T>() instead of Singleton.Instance.
Service Locator
Singleton
Testability
3. Dependency Injection
Dependency Injection (DI) takes the service locator concept further: instead of consumers asking for their dependencies, dependencies are given to them from outside. This makes dependencies explicit, eliminates the hidden coupling of service locator lookups, and makes classes trivially testable.
3.1 Interface-Based DI
// Manual DI — no framework needed
// Dependencies are passed through constructor or method injection
public interface IWeaponSystem
{
void Attack(Vector3 direction);
float Damage { get; }
float Cooldown { get; }
}
public interface IHealthSystem
{
float CurrentHealth { get; }
float MaxHealth { get; }
void TakeDamage(float amount);
void Heal(float amount);
event System.Action OnDeath;
}
// PlayerController receives its dependencies — it does not create them
public class PlayerController : MonoBehaviour
{
// Dependencies injected via Init method (constructor not available
// for MonoBehaviours)
private IWeaponSystem weapon;
private IHealthSystem health;
private IAudioService audio;
// Method injection — called by a factory or bootstrapper
public void Init(IWeaponSystem weapon, IHealthSystem health,
IAudioService audio)
{
this.weapon = weapon;
this.health = health;
this.audio = audio;
// Subscribe to events
this.health.OnDeath += HandleDeath;
}
private void Update()
{
if (Input.GetButtonDown("Fire1"))
{
weapon.Attack(transform.forward);
audio.PlaySFX("attack");
}
}
private void HandleDeath()
{
audio.PlaySFX("death");
// Handle player death
}
private void OnDestroy()
{
if (health != null)
health.OnDeath -= HandleDeath;
}
}
// Bootstrapper creates and wires everything together
public class GameBootstrapper : MonoBehaviour
{
[SerializeField] private GameObject playerPrefab;
private void Start()
{
// Create player
var playerObj = Instantiate(playerPrefab);
var player = playerObj.GetComponent<PlayerController>();
// Create dependencies
var weapon = playerObj.GetComponent<IWeaponSystem>();
var health = playerObj.GetComponent<IHealthSystem>();
var audio = ServiceLocator.Get<IAudioService>();
// Inject dependencies
player.Init(weapon, health, audio);
}
}
3.2 DI Containers: VContainer & Zenject
For large projects, manual DI becomes tedious. DI containers automate dependency resolution. The two major Unity DI frameworks are VContainer (lightweight, performant) and Zenject/Extenject (feature-rich, established).
| Feature |
VContainer |
Zenject/Extenject |
| Performance |
Excellent (source-gen, minimal reflection) |
Good (reflection-based, cached) |
| Learning Curve |
Moderate |
Steep |
| GC Allocations |
Minimal |
Some at startup |
| Scene Scoping |
Built-in LifetimeScope |
SceneContext / GameObjectContext |
| Community |
Growing (recommended for new projects) |
Large (established ecosystem) |
// VContainer example — GameLifetimeScope.cs
using VContainer;
using VContainer.Unity;
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private AudioService audioServicePrefab;
protected override void Configure(IContainerBuilder builder)
{
// Register services
builder.Register<ISaveService, JsonSaveService>(Lifetime.Singleton);
builder.Register<IInputService, NewInputSystemService>(Lifetime.Singleton);
// Register MonoBehaviour component from scene
builder.RegisterComponentInHierarchy<AudioService>()
.As<IAudioService>();
// Register entry point (like a main function)
builder.RegisterEntryPoint<GameFlowController>();
}
}
// GameFlowController.cs — entry point with injected dependencies
using VContainer.Unity;
public class GameFlowController : IStartable, ITickable
{
private readonly IAudioService audio;
private readonly ISaveService save;
// Constructor injection — VContainer resolves automatically
public GameFlowController(IAudioService audio, ISaveService save)
{
this.audio = audio;
this.save = save;
}
public void Start()
{
audio.PlayMusic("main_theme");
if (save.HasSave("autosave"))
{
// Load saved game
}
}
public void Tick()
{
// Called every frame, like Update()
}
}
When DI is Overkill: If your team is 1-2 people and the project is under 20K lines, DI containers add complexity without proportional benefit. Use simple service locators or manual injection. DI containers shine at 5+ developers, 50K+ lines, and when you need automated testing pipelines.
4. ScriptableObject Architecture
In 2017, Unity developer Ryan Hipple gave a landmark GDC talk titled "Game Architecture with ScriptableObjects" that fundamentally changed how the Unity community thinks about architecture. The core insight: ScriptableObjects can be more than data containers — they can serve as event channels, runtime sets, variable references, and modular system connectors that live as assets in your project.
Core Philosophy: ScriptableObject architecture replaces hard-coded references between systems with shared asset references. Instead of the health bar directly referencing the player's HealthComponent, both reference a shared FloatVariable ScriptableObject asset. The player writes its health to it; the UI reads from it. Neither knows the other exists.
4.1 SO as Event Channels
// GameEvent.cs — A ScriptableObject event channel (void)
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
private readonly List<GameEventListener> listeners
= new List<GameEventListener>();
public void Raise()
{
// Iterate backwards in case a listener removes itself
for (int i = listeners.Count - 1; i >= 0; i--)
{
listeners[i].OnEventRaised();
}
}
public void RegisterListener(GameEventListener listener)
{
if (!listeners.Contains(listener))
listeners.Add(listener);
}
public void UnregisterListener(GameEventListener listener)
{
listeners.Remove(listener);
}
}
// GameEventListener.cs — MonoBehaviour that responds to SO events
using UnityEngine;
using UnityEngine.Events;
public class GameEventListener : MonoBehaviour
{
[Tooltip("The event to listen to")]
[SerializeField] private GameEvent gameEvent;
[Tooltip("Response to invoke when the event is raised")]
[SerializeField] private UnityEvent response;
private void OnEnable()
{
gameEvent.RegisterListener(this);
}
private void OnDisable()
{
gameEvent.UnregisterListener(this);
}
public void OnEventRaised()
{
response.Invoke();
}
}
// Typed event channel for passing data
[CreateAssetMenu(menuName = "Events/Int Event")]
public class IntEvent : ScriptableObject
{
private readonly List<System.Action<int>> listeners
= new List<System.Action<int>>();
public void Raise(int value)
{
for (int i = listeners.Count - 1; i >= 0; i--)
listeners[i]?.Invoke(value);
}
public void Subscribe(System.Action<int> listener) =>
listeners.Add(listener);
public void Unsubscribe(System.Action<int> listener) =>
listeners.Remove(listener);
}
4.2 SO as Runtime Sets & Variable References
// FloatVariable.cs — SO as a shared variable reference
using UnityEngine;
[CreateAssetMenu(menuName = "Variables/Float Variable")]
public class FloatVariable : ScriptableObject
{
[SerializeField] private float initialValue;
[System.NonSerialized] public float RuntimeValue;
public void OnEnable()
{
RuntimeValue = initialValue;
}
public void SetValue(float value) => RuntimeValue = value;
public void ApplyChange(float amount) => RuntimeValue += amount;
}
// RuntimeSet.cs — SO as a dynamic collection of active objects
using System.Collections.Generic;
using UnityEngine;
public abstract class RuntimeSet<T> : ScriptableObject
{
public List<T> Items { get; } = new List<T>();
public void Add(T item)
{
if (!Items.Contains(item))
Items.Add(item);
}
public void Remove(T item)
{
if (Items.Contains(item))
Items.Remove(item);
}
}
// EnemyRuntimeSet.cs — concrete runtime set for enemies
[CreateAssetMenu(menuName = "Runtime Sets/Enemy Set")]
public class EnemyRuntimeSet : RuntimeSet<EnemyController> { }
// --- Usage examples ---
// Player writes health to a FloatVariable
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private FloatVariable playerHP;
[SerializeField] private GameEvent onPlayerDeath;
private float maxHealth = 100f;
private void Start()
{
playerHP.SetValue(maxHealth);
}
public void TakeDamage(float dmg)
{
playerHP.ApplyChange(-dmg);
if (playerHP.RuntimeValue <= 0)
onPlayerDeath.Raise();
}
}
// UI reads from the same FloatVariable — no reference to player needed
public class HealthBarUI : MonoBehaviour
{
[SerializeField] private FloatVariable playerHP;
[SerializeField] private UnityEngine.UI.Slider healthSlider;
private void Update()
{
healthSlider.value = playerHP.RuntimeValue / 100f;
}
}
// Enemies register themselves into a RuntimeSet
public class EnemyController : MonoBehaviour
{
[SerializeField] private EnemyRuntimeSet activeEnemies;
private void OnEnable() => activeEnemies.Add(this);
private void OnDisable() => activeEnemies.Remove(this);
}
// Wave manager reads from the RuntimeSet to know when to spawn more
public class WaveManager : MonoBehaviour
{
[SerializeField] private EnemyRuntimeSet activeEnemies;
private void Update()
{
if (activeEnemies.Items.Count == 0)
SpawnNextWave();
}
private void SpawnNextWave() { /* spawn logic */ }
}
Case Study
Cities: Skylines — ScriptableObject-Heavy Architecture
Cities: Skylines, built in Unity, uses a heavily data-driven architecture where game rules, building definitions, vehicle behaviors, and simulation parameters are defined as data assets. This allowed the modding community to create over 300,000 mods on the Steam Workshop — more than almost any other game. The architecture decouples data from logic, so modders can change building stats, add new vehicle types, or redefine economic rules without touching source code. This is the power of data-driven, ScriptableObject-style architecture at its peak.
Data-Driven
Modding Support
300K+ Mods
5. Event Systems
Events are the nervous system of a well-architected game. Instead of systems polling each other (checking every frame: "Is the player dead yet?"), events allow systems to react when something happens. This decouples the sender from the receiver and eliminates wasteful polling.
5.1 C# Events & Delegates
| Mechanism |
Pros |
Cons |
Best For |
| C# event/delegate |
Zero allocation, type-safe, fast |
Requires direct reference to publisher |
Component-to-component on same/child objects |
| UnityEvent |
Inspector-configurable, designer-friendly |
Slower, GC allocations, boxing for value types |
UI buttons, designer-wired simple events |
| SO Event Channel |
Fully decoupled, asset-based, scene-independent |
More setup, harder to debug event flow |
Cross-system communication (player death → UI, audio, spawner) |
| Message Bus |
Maximum decoupling, any-to-any messaging |
Can become opaque, hard to trace event flow |
Large projects with many independent systems |
// C# Events — the foundation
public class CombatSystem : MonoBehaviour
{
// C# event — zero allocation, type-safe
public event System.Action<float> OnDamageDealt;
public event System.Action<GameObject> OnEnemyKilled;
public void DealDamage(EnemyController enemy, float damage)
{
enemy.TakeDamage(damage);
OnDamageDealt?.Invoke(damage);
if (enemy.IsDead)
OnEnemyKilled?.Invoke(enemy.gameObject);
}
}
// Subscribers
public class DamageNumbers : MonoBehaviour
{
[SerializeField] private CombatSystem combat;
private void OnEnable() => combat.OnDamageDealt += ShowDamage;
private void OnDisable() => combat.OnDamageDealt -= ShowDamage;
private void ShowDamage(float amount)
{
// Show floating damage number
}
}
public class KillCounter : MonoBehaviour
{
[SerializeField] private CombatSystem combat;
private int kills;
private void OnEnable() => combat.OnEnemyKilled += OnKill;
private void OnDisable() => combat.OnEnemyKilled -= OnKill;
private void OnKill(GameObject enemy) => kills++;
}
5.2 Message Bus & Observer Pattern
// MessageBus.cs — A lightweight, type-safe message bus
using System;
using System.Collections.Generic;
public static class MessageBus
{
private static readonly
Dictionary<Type, List<Delegate>> subscribers
= new Dictionary<Type, List<Delegate>>();
/// <summary>
/// Subscribe to a message type.
/// </summary>
public static void Subscribe<T>(Action<T> handler)
{
var type = typeof(T);
if (!subscribers.ContainsKey(type))
subscribers[type] = new List<Delegate>();
subscribers[type].Add(handler);
}
/// <summary>
/// Unsubscribe from a message type.
/// </summary>
public static void Unsubscribe<T>(Action<T> handler)
{
var type = typeof(T);
if (subscribers.ContainsKey(type))
subscribers[type].Remove(handler);
}
/// <summary>
/// Publish a message to all subscribers.
/// </summary>
public static void Publish<T>(T message)
{
var type = typeof(T);
if (!subscribers.ContainsKey(type)) return;
// Copy list to allow modifications during iteration
var handlers = new List<Delegate>(subscribers[type]);
foreach (var handler in handlers)
{
((Action<T>)handler)?.Invoke(message);
}
}
}
// Define messages as structs (no GC allocation)
public struct PlayerDiedMessage
{
public Vector3 DeathPosition;
public string CauseOfDeath;
public int LivesRemaining;
}
public struct ScoreChangedMessage
{
public int OldScore;
public int NewScore;
public string Reason;
}
public struct EnemySpawnedMessage
{
public GameObject Enemy;
public Vector3 SpawnPosition;
}
// Publisher — anywhere in the codebase
public class PlayerHealth2 : MonoBehaviour
{
public void Die(string cause)
{
MessageBus.Publish(new PlayerDiedMessage
{
DeathPosition = transform.position,
CauseOfDeath = cause,
LivesRemaining = 2
});
}
}
// Subscriber — completely decoupled from publisher
public class RespawnSystem : MonoBehaviour
{
private void OnEnable()
{
MessageBus.Subscribe<PlayerDiedMessage>(OnPlayerDied);
}
private void OnDisable()
{
MessageBus.Unsubscribe<PlayerDiedMessage>(OnPlayerDied);
}
private void OnPlayerDied(PlayerDiedMessage msg)
{
if (msg.LivesRemaining > 0)
StartCoroutine(RespawnAfterDelay(3f));
else
ShowGameOver();
}
// ...
}
When to Use Each: Use C# events for tightly related components (health → health bar on same object). Use SO events for cross-system communication that designers need to configure (player death triggers multiple responses). Use a message bus when you have many independent systems that need loose coupling and the sender truly should not know about receivers.
6. SOLID Principles in Unity
The SOLID principles were defined by Robert C. Martin for object-oriented design, but they apply powerfully to Unity game development — with some game-specific adaptations.
6.1 Single Responsibility & Open/Closed
| Principle |
Definition |
Unity Application |
| S — Single Responsibility |
A class should have one reason to change |
Each component handles one concern: HealthComponent, MovementComponent, AttackComponent — not a monolithic PlayerController |
| O — Open/Closed |
Open for extension, closed for modification |
ScriptableObjects for new enemy/weapon types; create new SO assets, don't modify the base system |
// Open/Closed with ScriptableObjects
// Add new attack types WITHOUT modifying the attack system
[CreateAssetMenu(menuName = "Attacks/Attack Data")]
public class AttackData : ScriptableObject
{
public string attackName;
public float damage;
public float cooldown;
public float range;
public AnimationClip animation;
public AudioClip soundEffect;
public GameObject hitEffect;
// Each attack type can override how damage is applied
public virtual void ApplyEffect(GameObject target)
{
var health = target.GetComponent<HealthComponent>();
health?.TakeDamage(damage);
}
}
[CreateAssetMenu(menuName = "Attacks/Fire Attack")]
public class FireAttackData : AttackData
{
public float burnDuration = 5f;
public float burnDPS = 2f;
public override void ApplyEffect(GameObject target)
{
base.ApplyEffect(target);
// Add burn DOT effect
var burn = target.AddComponent<BurnEffect>();
burn.Init(burnDuration, burnDPS);
}
}
[CreateAssetMenu(menuName = "Attacks/Ice Attack")]
public class IceAttackData : AttackData
{
public float slowPercentage = 0.5f;
public float slowDuration = 3f;
public override void ApplyEffect(GameObject target)
{
base.ApplyEffect(target);
var slow = target.AddComponent<SlowEffect>();
slow.Init(slowDuration, slowPercentage);
}
}
// The AttackSystem never changes — just create new SO assets
public class AttackSystem : MonoBehaviour
{
[SerializeField] private AttackData currentAttack;
public void PerformAttack(GameObject target)
{
currentAttack.ApplyEffect(target); // Polymorphic dispatch
}
}
6.2 LSP, ISP & Dependency Inversion
// Interface Segregation — don't force classes to implement
// methods they don't need
// BAD: One fat interface
public interface IEntity
{
void Move(Vector3 dir);
void Attack(GameObject target);
void TakeDamage(float amount);
void Heal(float amount);
void OpenInventory(); // Not all entities have inventory!
void CastSpell(int id); // Not all entities cast spells!
}
// GOOD: Segregated interfaces
public interface IMovable
{
void Move(Vector3 direction);
float MoveSpeed { get; }
}
public interface IDamageable
{
void TakeDamage(float amount);
float CurrentHealth { get; }
bool IsDead { get; }
}
public interface IHealable
{
void Heal(float amount);
}
public interface IAttacker
{
void Attack(GameObject target);
float AttackDamage { get; }
}
// Now classes only implement what they need:
// Player: IMovable, IDamageable, IHealable, IAttacker
// Turret: IDamageable, IAttacker (no movement!)
// Crate: IDamageable (no movement, no attack!)
// NPC: IMovable, IHealable (no attack!)
// --- Dependency Inversion ---
// High-level modules should not depend on low-level modules.
// Both should depend on abstractions.
// BAD: High-level depends on concrete low-level
public class QuestSystem_Bad : MonoBehaviour
{
private JsonSaveService saveService; // Concrete dependency!
private UnityAudioService audioService; // Concrete dependency!
}
// GOOD: Both depend on abstractions
public class QuestSystem : MonoBehaviour
{
private ISaveService saveService; // Abstract dependency
private IAudioService audioService; // Abstract dependency
public void Init(ISaveService save, IAudioService audio)
{
saveService = save;
audioService = audio;
}
public void CompleteQuest(string questId)
{
// Works with ANY save implementation (JSON, binary, cloud)
saveService.Save("quest_" + questId);
// Works with ANY audio implementation (Unity, FMOD, Wwise)
audioService.PlaySFX("quest_complete");
}
}
// Liskov Substitution — derived classes must be substitutable
// for their base classes without breaking behavior
// BAD: Violates LSP — Penguin can't fly but inherits from Bird
public class Bird : MonoBehaviour
{
public virtual void Fly() { /* default flying */ }
}
public class Penguin : Bird
{
public override void Fly()
{
throw new System.NotSupportedException("Penguins can't fly!");
// Code that calls bird.Fly() will BREAK for penguins
}
}
// GOOD: Use interfaces to model capabilities correctly
public interface IFlyable { void Fly(); }
public interface ISwimmable { void Swim(); }
public class Eagle : MonoBehaviour, IFlyable
{
public void Fly() { /* soar majestically */ }
}
public class PenguinFixed : MonoBehaviour, ISwimmable
{
public void Swim() { /* swim gracefully */ }
}
Practical Guideline
SOLID in Practice — Don't Over-Engineer
SOLID principles are guidelines, not commandments. In game development, pragmatism wins. A 48-hour game jam prototype does not need perfect ISP. A 3-year production project with 10 developers absolutely does. The rule of thumb: apply SOLID when you feel the pain of not applying it — when a change in one system cascades to five other files, when you cannot write a unit test without setting up half the game world, or when a new team member cannot understand a system after an hour of reading. That is when the investment in clean architecture pays dividends.
Pragmatism
Refactor When Painful
YAGNI Balance
Exercises & Self-Assessment
Exercise 1
Service Locator Refactor
Take any project with 3+ singletons and refactor to use the Service Locator pattern:
- Identify all singletons (AudioManager, SaveManager, InputManager, etc.)
- Extract an interface for each (IAudioService, ISaveService, IInputService)
- Implement the ServiceLocator class from Section 2
- Have each manager register itself in Awake() and unregister in OnDestroy()
- Update all consumers to use
ServiceLocator.Get<T>() instead of direct singleton access
- Create a MockAudioService for testing
Exercise 2
ScriptableObject Event System
Build a complete SO event system for a player death scenario:
- Create a
GameEvent ScriptableObject and GameEventListener component
- Create an "OnPlayerDeath" GameEvent asset in the Project window
- Player's HealthComponent raises the event when health reaches zero
- Add listeners for: UI (show death screen), Audio (play death sound), Camera (shake effect), EnemyAI (celebrate), SpawnManager (start respawn timer)
- Verify that removing any listener does not break the others
Exercise 3
SOLID Audit
Audit a Unity project (yours or an open-source sample) against SOLID principles:
- Find the 3 largest scripts by line count — do they violate SRP?
- Identify any switch/if-else chains on type — could Open/Closed fix them?
- Find any inheritance hierarchies deeper than 2 levels — do they satisfy LSP?
- Identify interfaces with more than 5 methods — could ISP split them?
- Find any class that directly instantiates its dependencies — apply DIP
- Document your findings and propose refactoring steps
Exercise 4
Reflective Questions
- Why is the Service Locator pattern considered an "anti-pattern" in enterprise but perfectly acceptable in games? What's different about game development contexts?
- A designer wants to add a new power-up that plays a sound, shows particles, heals the player, and triggers a camera zoom. How would you architect this using SO events?
- Your project uses Zenject for DI, but compile times are 45 seconds. How would you diagnose if DI is contributing to this, and what alternatives exist?
- Explain the tradeoffs between a centralized message bus and direct C# events. When does the decoupling of a message bus become a liability?
- Ryan Hipple's SO architecture eliminates many scene references. What new challenges does it introduce, and how would you mitigate them?
Conclusion & Next Steps
You now have a comprehensive toolkit for architecting Unity projects that scale. Here are the key takeaways from Part 14:
- Architecture matters once your project exceeds ~10K lines or involves multiple developers — spaghetti code compounds into unmaintainable debt
- Service Locators are the simplest step up from singletons — they provide interface-based access and testability with minimal complexity
- Dependency Injection makes dependencies explicit and eliminates hidden coupling; DI containers (VContainer, Zenject) automate this for large projects
- ScriptableObject architecture decouples systems through shared data assets — event channels, runtime sets, and variable references eliminate direct references between systems
- Event systems replace polling with reactive communication — choose between C# events, SO events, and message buses based on your decoupling needs
- SOLID principles guide your design but should be applied pragmatically — refactor when you feel the pain, not preemptively
Next in the Series
In Part 15: Performance Optimization, we'll dive deep into CPU/GPU profiling, memory management, garbage collection strategies, object pooling, asset optimization, and the profiling workflows that professional developers use to hit their frame budgets on every target platform.
Continue the Series
Part 15: Performance Optimization
CPU/GPU profiling, memory management, garbage collection, object pooling, and asset optimization for shipping games.
Read Article
Part 16: Production & Industry Practices
Git workflows, Agile for game dev, asset pipelines, debugging at scale, and production timelines.
Read Article
Part 2: C# Scripting Fundamentals
The C# foundations that underpin all the architecture patterns covered in this article.
Read Article