Featured image of post 【SOLID】SOLID Principles in Project Practice

【SOLID】SOLID Principles in Project Practice

Building a small tank‑vs‑drone battle game in Unity while following the SOLID principles.

Course ReferenceUdemy-Niraj Vishwakarma

Asset Sources

Introduction of SOLID Principles

Spaghetti Code

This kind of code is basically a tangled mess where everything is tightly coupled. For example, in a script like PlayerController.cs, you might see the player’s Move(), Attack(), Jump()… all crammed together, along with extra functions like PlayAudio.

Code written this way is fragile and hard to extend. It’s like spaghetti — once everything is tangled up, pulling on one strand can easily cause multiple bugs to appear.

Solution:

Ditch the spaghetti‑style structure and switch to a modular design.

Spaghetti code can be fine when you’re quickly prototyping a demo or building a small game to test ideas. But if you expect the project to grow, with more game objects and features being added, setting up a solid architecture and modular design early on becomes crucial.

What Are the SOLID Principles?

In short, there are five main principles:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)

Core Idea

One script, one function. SRP breaks down big classes into smaller ones, each with its own independent responsibility. These small classes can be reused and don’t interfere with each other. Think of them like Lego bricks — you can combine them to build all kinds of larger models.

Benefits

When every script has a clear responsibility, you can swap things out freely without worrying about breaking other parts of the game.

Open/Closed Principle (OCP)

Core Idea

Classes, modules, and functions should be open for extension but closed for modification.

Benefits

You can add new features by creating new classes, without messing with the independence of existing ones. This makes your code more modular and easier to maintain.

How to Apply
  1. Create new classes for new features.
  2. Define interfaces or abstract classes, then implement them in your feature classes.
  3. Interact with features through abstract methods instead of calling them directly.
  4. Make good use of polymorphism.

Liskov Substitution Principle (LSP)

Core Idea

A superclass (or parent class) should be replaceable by its subclasses without breaking functionality.

Benefits

This allows you to use subclasses in place of the parent class without directly referencing each one. It ensures flexibility and scalability when new variables or behaviors are introduced.

How to Apply
  1. Create a base class and declare all the methods.
  2. Build subclasses for objects with similar behaviors.
  3. In other scripts, reference the parent class instead of the subclasses directly.

Interface Segregation Principle (ISP)

Core Idea

A class shouldn’t depend on methods it doesn’t use.

Summary

Write only what’s needed, and keep it relevant.

Dependency Inversion Principle (DIP)

Core Idea

High‑level modules shouldn’t be coupled to low‑level modules. Both should depend on abstractions.

Summary

Depend on abstract functions, not concrete implementations.

How to Apply
  1. Use interfaces to define abstract functions.
  2. Implement those interfaces in concrete classes.
  3. Interact with concrete classes through the interfaces.

Practice Project

We’ll use a 2D Tank vs. Drone War game to show how the SOLID principles can be applied in Unity game development.

Core Gameplay::

  • On the left side of the screen, there are two tanks:
    • A light tank, controlled by pressing L
    • A heavy tank, controlled by pressing H
  • The light tank can move left and right, rotate its cannon, and fire light shells.
  • The heavy tank cannot move, but it can rotate its cannon and fire heavy shells.
  • On the right side of the screen, drones move leftward. Hitting them increases the score.
  • Lose condition: If a drone collides with a tank, that tank is destroyed and becomes unresponsive to player input. When both of the tanks are unresponsive, the player loses.

Game / Feature Breakdown

Content

  • Background image

  • Tanks (light and heavy)

  • Bullets (light and heavy)

  • Drones

  • UI (score, start screen, gameplay screen, end screen)

  • Sound effects (firing, drone hit, tank hit)

Logic

  1. When the game starts, pressing L activates the light tank (move, rotate, fire shells).
  2. Pressing H activates the heavy tank (rotate, fire shells).
  3. Drones fly from right to left — entering from the right side of the screen and exiting on the left.
  4. If a tank is hit by a drone, it becomes inactive.
  5. If both tanks are inactive, the game ends.

Module Organization

Development Steps

  1. Scene Setup

  2. Tank Development

    • Movement and rotation
    • Firing
    • Switching between light and heavy tanks
    • Health system
  3. Drone Development

    • Drone prefab
    • Drone spawning
    • Health system
  4. Game Management

    • Configure game events
    • AudioManager
    • UIManager
    • GameManager

Feature Implementation and Principles Followed

1. Tank Movement

In the tank design, we follow ISP (Interface Segregation Principle) and DIP (Dependency Inversion Principle).

2. Tank Firing

When designing the firing system, the two types of projectile follow the Open/Closed Principle (OCP). Each time we add a new type of projectile, we should extend the system rather than modify TankFire.cs. Inside TankFire.cs, the firing logic should interact through an interface instead of directly calling the specific shell prefab.

It’s important to note that both types of projectiles interact with TankFire.cs through an interface. However, since the interface does not inherit from MonoBehaviour, it cannot be instantiated directly. To solve this, you need to implicitly cast it to a Component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class TankFire : MonoBehaviour
{
    [SerializeField] private IProjectile iProjectile;
    [SerializeField] private Component projectileComponent;
    [SerializeField] private Transform firePos;

    private void Start()
    {
        iProjectile = projectileComponent as IProjectile;
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (iProjectile != null)
                iProjectile.Fire(firePos);
        }
    }

3. Tank Selector Implementation

This part is designed following the Liskov Substitution Principle (LSP).

For smaller projects, you don’t necessarily need separate LightEngine.cs and HeavyEngine.cs files — you can simply put everything inside the TankEngine class.

4. Tank Health Implementation

Following the Dependency Inversion Principle (DIP), we use an IDamageable interface to interact with concrete classes. These concrete classes could be drones or other colliding objects such as bullets, missiles, and so on.

5. Airplane Movement

This part reuses the ImoveUp.cs code in the same way as tank movement. It’s also implemented through an interface, leaving room for adding new types of drones in the future.

6. Automatic Destroy of Drones and Projectiles

Here I use a single script, DestroyByTime.cs, following the Single Responsibility Principle (SRP). Unlike the course approach of handling each case separately, this unified script keeps the design simpler and cleaner.

7. Collision Detection and Health System Integration (Tanks, Projectiles, Drones)

This is the final application of the Dependency Inversion Principle (DIP) mentioned earlier. Since both projectiles and drones are destroyed upon collision, they don’t directly implement the IDamageable interface. Instead, the tank’s health system implements the interface. This way, even if new projectile types are added in the future with different damage values, they can be easily integrated. For drones or other enemy types, if different damage behaviors are needed, they can also be flexibly designed by inheriting from the interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//========== IDamageable Interface ==========
public interface IDamageable
{
    public void TakeDamage(float damageValue);
}

//================ Health System of Tank ===============
public class HealthSystem : MonoBehaviour, IDamageable
{
    private float _health = 100f;
    [SerializeField] private Image healthProgressBar;
    [SerializeField] private TankEngineBase currentTankEngine;

    public void TakeDamage(float damageValue)
    {
        _health -= damageValue;
        if (_health <= 0)
        {
            _health = 0;
            currentTankEngine.StopEngine();
        }

        float progressBarValue = _health / 100;
        healthProgressBar.fillAmount = progressBarValue;
    }
}

//================ Projectile Health =================
public class ShellHealth : MonoBehaviour
{
    [SerializeField] private float damage = 100f;

    private void OnCollisionEnter2D(Collision2D other)
    {
        IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
        if(damageable != null)
        {
            damageable.TakeDamage(damage);
        }
        Destroy(gameObject);
    }
}

//============= Drone Health ==============
public class DroneHealth : MonoBehaviour
{
    [SerializeField] private float damage = 20f;
    private void OnCollisionEnter2D(Collision2D other)
    {
        // Call TakeDamage by Interface
        IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
        if (damageable != null)
        {
            damageable.TakeDamage(damage);
        }
        Destroy(gameObject);
    }
}

8. Managing UI, Audio, and Game State with the Observer Pattern

Register the following events so that, through a unified update interface, observers can be notified and respond whenever changes occur. At its core, this is a listen‑and‑respond system.

By following the Single Responsibility Principle (SRP), scripts like AudioManager.cs and UIManager.cs only handle their specific tasks — playing sounds or managing the UI — without mixing in unrelated logic.

Suggestions on Applying SOLID Principles

  • Start with the easier solution
  • Never force yourself to use SOLID principles — apply them only when needed (this is my takeaway)
  • Avoid over‑designing, especially when your design goes beyond the scope of the project itself
  • Always try to use a modular approach, so each independent module can be reused like LEGO blocks (this is one of the most useful methods I’ve learned from other courses — it really saves time and effort)
  • At first, applying SOLID principles in code design will definitely take more time, but for projects with long‑term scalability, the investment is absolutely worth it
  • Apply → Fail → Learn → Apply → and you’ll be surprised by the results!

Project Expansion & Retrospective

Expansion

The following features were added based on the original project:

  • Added Medium Tank
    • Controlled by pressing the “M” key
    • Can move up and down, but slower and with a smaller range than the Light Tank
  • Different Bullet Types Based on Tank Type: Laser, Bullet, Bomb
    • Light Tank: Fires lasers (shortest cooldown), deals the least damage, shoots 5 beams in a fan shape
    • Medium Tank: Fires bullets (medium cooldown), deals moderate damage
    • Heavy Tank: Fires rockets (longest cooldown), deals the highest damage
  • Added Heavy Drone
    • Moves slightly up and down randomly while flying
  • Adjusted Drone Spawn Rules to Increase Difficulty Over Time
    • At the start, heavy drone have a low spawn rate, which increases as time goes on
  • Different Tanks Take Different Amounts of Damage
    • Heavy tanks are the most durable

Project Architecture Overview

The project uses a hybrid design combining Component-Based Architecture + Event-Driven Programming + Strategy Pattern.

Folder Structure
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Assets/
├── Audio/              # Sound effects (shooting, explosions, damage)
├── Fonts/              # Font assets
├── OtherResources/     # Third-party art assets
├── Prefabs/            # Prefabs
│   ├── Drone/          # Drone prefabs (Light, Heavy)
│   ├── Projectile/     # Projectile prefabs (3 types)
│   └── Tank/           # Tank prefabs (Light, Medium, Heavy)
├── Scenes/             # Scene files
├── Scripts/            # Core scripts
│   ├── Drone/          # Drone logic
│   ├── GameUtil/       # Game event system
│   ├── Mgr/            # Managers (Game, Audio, UI)
│   ├── Projectile/     # Projectile logic
│   └── Tank/           # Tank logic
│       ├── Engine/     # Engine/selection system
│       ├── Fire/       # Firing system
│       ├── Health/     # Health system
│       └── Move/       # Movement system
└── Sprites/            # Sprite assets
Architecture Overview
Module Responsibility Core Classes
Tank/Engine Tank engine switching system; player can switch tank types using L/M/H keys TankEngineBaseTankEngineLight/Medium/Heavy, TankEngineSelector
Tank/Move Tank movement system; each tank type has its own movement logic TankMoveBaseLightTankMove/MediumTankMove/HeavyTankMove, IMoveY, TankRotate
Tank/Fire Firing system; different projectile types implemented via IProjectile interface TankFire, IProjectileLightShell/MediumShell/RocketShell/LaserShellProjectile
Tank/Health Health system; unified damage handling via IDamageable interface HealthSystem, IDamageable
Drone Enemy drone system, including spawning, movement, and damage handling DroneMoveBaseLightDroneMove/HeavyDroneMove, DroneHealth, DroneSpawner
Mgr Global managers GameMgr (game flow), UIMgr (UI panels/score), AudioMgr (sound effects)
GameUtil Global event bus GameEvents (static event class)
Architecture diagram

Code Reuse

Observer Pattern

The Observer pattern usually follows the same routine. Take AudioManager as an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
//======== 1. Register events =========
//========= GameEvents.cs =========
public static class GameEvents
{
    public static Action OnGameStarted;
    public static Action OnGameOver;
    public static Action OnTankFired;
    public static Action OnTankDamaged;
    public static Action OnDroneDestroy;
}

//========= 2. Listen to events wherever they are needed =========
//========= TankFire.cs =========
private void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        GameEvents.OnTankFired?.Invoke();
        if (projectile != null)
        {
            projectile.Fire(firePos);
        }
    }
}

//========== HealthSystem.cs ==========
public void TakeDamage(int damageValue)
{
    GameEvents.OnTankDamaged?.Invoke();
    _health -= damageValue;
    if (_health <= 0)
    {
        _health = 0;
        currentTankEngine.StopEngine();
    }

    float progressBarValue = _health / 100;
    healthProgressBar.fillAmount = progressBarValue;
}

//========== DroneHealth.cs ===========
public void TakeDamage(int damageValue)
{
    GameEvents.OnDroneDestroy?.Invoke();    // The sound effect only plays when a drone is hit by a projectile
    Destroy(gameObject);
}

//========== 3. Centralize responses in AudioManager based on the events being listened to ==========
//========== AudioMgr.cs =========
public class AudioMgr : MonoBehaviour
{
    [SerializeField] private AudioClip droneDestroyClip;
    [SerializeField] private AudioClip tankFiredClip;
    [SerializeField] private AudioClip tankDamagedClip;
    [SerializeField] private AudioSource audiosource;

    private void PlayDroneDetroyAudio()
    {
        audiosource.PlayOneShot(droneDestroyClip);
    }
    
    private void PlayTankFiredAudio()
    {
        audiosource.PlayOneShot(tankFiredClip);
    }
    
    private void PlayTankDamagedAudio()
    {
        audiosource.PlayOneShot(tankDamagedClip);
    }
    
    private void OnEnable()
    {
        SubscribeEvents();
    }

    private void OnDisable()
    {
        UnSubscribeEvents();
    }

    private void SubscribeEvents()
    {
        GameEvents.OnDroneDestroy += PlayDroneDetroyAudio;
        GameEvents.OnTankFired += PlayTankFiredAudio;
        GameEvents.OnTankDamaged += PlayTankDamagedAudio;
    }
    
    private void UnSubscribeEvents()
    {
        GameEvents.OnDroneDestroy -= PlayDroneDetroyAudio;
        GameEvents.OnTankFired -= PlayTankFiredAudio;
        GameEvents.OnTankDamaged -= PlayTankDamagedAudio;
    }
}
Event Bus System — GameEvents

Create a similar static event class in any project to enable decoupled communication between modules. Simply update the event names to adapt it to different games.

1
2
3
4
5
6
7
8
public static class GameEvents
{
    public static Action OnGameStarted;
    public static Action OnGameOver;
    public static Action OnTankFired;
    public static Action OnTankDamaged;
    public static Action OnDroneDestroy;
}
Interface-Driven Damage System — IDamageable

All objects that can take damage (players, enemies, destructibles) implement this interface. Attackers simply call GetComponent<IDamageable>()?.TakeDamage(), achieving complete decoupling.

1
2
3
4
public interface IDamageable
{
    void TakeDamage(int damage);
}

Analysis of SOLID Principles in the Project

S — Single Responsibility Principle (SRP)

Tank functionalities like movement, shooting, rotation, and health are split into independent components instead of being lumped into a massive “Tank” class. This is a classic example of SRP in practice.

Class Responsibility
TankMoveBase Solely responsible for movement logic
TankFire Solely responsible for triggering shots
TankRotate Solely responsible for rotation and aiming
HealthSystem Solely responsible for health management
AudioMgr Solely responsible for playing sound effects
UIMgr Solely responsible for UI display and scoring
GameMgr Solely responsible for game flow logic
DroneSpawner Solely responsible for spawning drones
DroneHealth Solely responsible for drone damage logic
O — Open/Closed Principle (OCP)
  • Ammo System: The IProjectile interface allows adding new ammo types (e.g., LaserShellProjectile, RocketShellProjectile) without modifying TankFire. You simply create a new class implementing IProjectile.
  • Movement System: The combination of the IMoveY interface and TankMoveBase enables adding new tank movement styles without altering the base class.
  • Drone Movement: DroneMoveBase allows extending new flight behaviors via inheritance (e.g., HeavyDroneMove with random offset movements).
L — Liskov Substitution Principle (LSP)
  • The three subclasses of TankEngineBase (TankEngineLight, TankEngineMedium, TankEngineHeavy) can be used interchangeably within TankEngineSelector and GameMgr.
  • GameMgr.CheckEngine() iterates through a List<TankEngineBase> and calls GetStatus(), remaining unaware of the specific engine type.
  • Similarly, subclasses of DroneMoveBase can be swapped wherever a DroneMoveBase is required.
I — Interface Segregation Principle (ISP)
  • IMoveY: Defines only a single method, MoveY(float), forcing implementers to ignore unrelated functions like rotation or shooting.
  • IProjectile: Defines only Fire(Transform), allowing each ammo type to focus solely on “how to fire.”
  • IDamageable: Defines only TakeDamage(int), enabling any object capable of taking damage to implement it easily.

The interfaces are extremely granular, each containing just one method, perfectly adhering to ISP.

D — Dependency Inversion Principle (DIP)
  • Damage handling in DroneHealth relies on the IDamageable interface rather than the concrete HealthSystem class:
1
2
IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
damageable?.TakeDamage(damage);
  • TankFire depends on the IProjectile interface to launch ammo, not on specific ammo classes.
  • TankMoveBase casts itself to an interface (this as IMoveY) for calls, ensuring high-level logic depends on abstractions rather than concrete implementations.

Retrospective

While SOLID principles greatly enhance scalability, they also significantly increase the number of scripts. For instance, implementing the Observer Pattern for the UI took nearly a full day, and debugging a single issue consumed an entire afternoon. For beginners, pitfalls often arise from where listener code is placed and the order of execution, which requires careful attention.

Additionally, this was my first systematic dive into game architecture. At times, the concepts felt quite abstract and difficult to grasp immediately. However, the most tangible takeaway from this project is its excellent extensibility: adding new features requires writing new scripts without touching old ones, effectively avoiding the scenario where one new feature introduces three new bugs. This is crucial for large-scale projects or online mobile games.

I hope that as my skills grow, my exposure to excellent complex/large-scale projects increases, and the complexity of my personal projects rises, my understanding and practical application of software architecture will continue to improve.

Licensed under CC BY-NC-SA 4.0
Last updated on Feb 22, 2026 11:13 +0800
发表了12篇文章 · 总计5万0千字
本博客已运行
Built with Hugo
Theme Stack designed by Jimmy