
SOLID Principles in Game Development
1. Single Responsibility Principle (SRP)
a. Each module, class or function should have only one reason to change, meaning it should have only one responsibility, and hence should encapsulate only that part of the logic.
Unity Example: Separating UI and Logic
This separation adheres to SRP by keeping game logic and UI code distinct.
To implement SRP, it's advisable to build projects using many smaller, focused classes rather than monolithic ones. Smaller classes and methods are easier to explain, understand, and implement, enhancing maintainability and readability.
If you're familiar with Unity, you've likely seen SRP in practice. For example, when creating a GameObject, it typically consists of multiple smaller components, each responsible for a distinct function:
MeshFilter: Holds the reference to the 3D model.
Renderer: Manages the visual appearance of the model on the screen.
Transform: Stores the scale, rotation, and position of the GameObject.
Rigidbody: Enables the GameObject to interact with the physics system.
Each of these components focuses on a specific responsibility and does it well. In Unity, an entire scene is constructed from GameObjects, and the interaction between their components brings the game to life.
Similarly, when developing scripted components, design them so that each one has a clear and singular purpose. This approach allows you to build complex behaviours by having these components work together, maintaining clarity and simplicity in your codebase.
Subscribe Us
Be the first to know about any new article
2. Open/Closed Principle (OCP)
Principle: The OCP advocates that classes should be open for extension but closed for modification. This means you should design your classes so that new behaviours can be added without altering existing code.
Original Code Example : A basic implementation might look like this, where a WeaponSystem class handles the damage calculation for different types of weapons:
Explanation:
This approach works fine initially, but if you want to add more weapon types to your WeaponSystem, you'll need to create a new method for each new weapon. Suppose you want to add weapons like a spear or a magic staff later on? What if you plan to introduce 20 more types of weapons? The WeaponSystem class would quickly become unwieldy.
One possible solution is to create a base class called Weapon and then use a single method to process different weapon types. However, this would likely involve numerous if-statements to handle each weapon type within the method, which wouldn't scale well and would complicate the codebase.
Ideally, you want to design the system to allow for adding new weapons (extending functionality) without modifying the existing code (the internal workings of the WeaponSystem). While the current implementation is functional, it violates the Open-Closed Principle by requiring changes to the WeaponSystem class whenever new weapon types are introduced.
Unity Example:Let's consider a Weapon System in Unity game development. In this system, different weapons have unique behaviours for dealing damage. To adhere to the Open-Closed Principle (OCP), we design the system so that new weapon types can be added without modifying the existing code structure.
Refactored Code Using OCP : To follow the Open-Closed Principle, we introduce an interface for weapons and implement it in specific weapon classes. The WeaponSystem class will interact with this interface rather than specific weapon classes.
Explanation:
IWeapon Interface: This interface defines the CalculateDamage() method that each weapon must implement. This method encapsulates the logic for calculating the damage specific to each weapon.
Sword and Bow Classes: These classes implement the IWeapon interface. They each provide their own logic for calculating damage through the CalculateDamage() method. For example:
Sword: Damage is calculated based on BaseDamage and SharpnessMultiplier.
Bow: Damage is calculated based on BaseDamage and DrawStrengthMultiplier.
WeaponSystem Class: The GetDamage() method now accepts any object that implements the IWeapon interface. This design allows for easily adding new types of weapons by simply implementing the IWeapon interface in new classes.
By designing the Weapon System this way, we can introduce new weapon types, such as an Axe or MagicStaff, without modifying the WeaponSystem class. This approach follows the Open-Closed Principle, keeping the system open to extension but closed to modification.
3. Liskov Substitution Principle (LSP)
Principle: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This means that if your code is designed to work with a superclass, it should also seamlessly work with any subclass derived from that superclass. Inheritance in object-oriented programming allows for adding functionality through subclasses, but it can also introduce unnecessary complexity if not managed properly.
The Liskov Substitution Principle (LSP), a key aspect of the SOLID principles, guides the proper use of inheritance to ensure that subclasses are robust and flexible. For instance, in a game development scenario, you might have a base class called Vehicle, which serves as the foundation for more specific subclasses like Car or Truck. The LSP ensures that wherever a Vehicle object is expected, you can use a Car or Truck without breaking the application.
Unity Example: Vehicle System
You could create another class called Navigator to guide a vehicle along a predetermined path, as shown below:
Wherever you can use the base class Vehicle, you should be able to substitute it with a subclass like Car or Truck without causing any issues in the application.
Imagine you are creating a turn-based game where vehicles move around a board, as illustrated in the accompanying figure.
With this class, you would expect to be able to pass any vehicle into the Navigator's Navigate method, and it would work fine with cars and trucks. But what if you want to implement a class called Train?






The TurnLeft and TurnRight methods wouldn't work in a Train class since a train can't leave its tracks. If you try to pass a Train into the Navigator's Navigate method, it might throw a NotImplementedException (or do nothing) when reaching those lines. This situation violates the Liskov Substitution Principle (LSP), which states that a type should be replaceable with its subtype without affecting the program's correctness.
Since Train is a subtype of Vehicle, you would expect to use it anywhere that accepts the Vehicle class. Failing to do so can lead to unpredictable behaviour in your code. Here are some tips to better adhere to the Liskov Substitution Principle:
Avoid Removing Features in Subclasses: If you find yourself removing features when creating a subclass, you're likely breaking LSP. A NotImplementedException or leaving a method blank are clear signs of this violation. The subclass should behave consistently with the base class.
Keep Abstractions Simple: The more logic you include in the base class, the more likely you are to break LSP. The base class should only contain common functionality shared among its subclasses.
Ensure Consistent Public Members: Subclasses should have the same public members as the base class, with identical signatures and behaviour.
Consider Class API Before Hierarchies: Even though all entities might be conceptually vehicles, it may make more sense for Car and Train to inherit from different parent classes. Real-world classifications don't always translate well into class hierarchies.
Favour Composition Over Inheritance: Instead of relying on inheritance to pass functionality, consider using interfaces or separate classes to encapsulate specific behaviours. This allows you to build functionality through composition, combining various components as needed.


To address this design issue, you should eliminate the original Vehicle type and transfer much of the functionality into interfaces:
To adhere more closely to the Liskov Substitution Principle, create separate types for RoadVehicle and RailVehicle. The Car and Train classes would then inherit from their respective base classes, as depicted in the following class diagram:


In this approach, functionality is achieved through interfaces rather than inheritance. As a result, Car and Train no longer share the same base class, which aligns with the Liskov Substitution Principle (LSP). While you could technically derive RoadVehicle and RailVehicle from a common base class, it's often unnecessary in this context. This perspective may seem counterintuitive because it challenges our real-world assumptions. In software development, this concept is known as the circle-ellipse problem, where not every real-world 'is a' relationship translates into inheritance. It’s crucial to let your software design dictate the class hierarchy, rather than relying on pre-existing notions. Adhering to the LSP helps maintain an extendable and flexible codebase, limiting the overuse of inheritance.
The circle-ellipse problem
The circle-ellipse problem is a classic example in object-oriented design that illustrates the pitfalls of incorrect use of inheritance. It demonstrates why not all "is a" relationships from the real world should be modelled as inheritance in software.
The Problem in :
Real-World Analogy: In geometry, a circle can be considered a specific type of ellipse, just as a square can be considered a special type of rectangle. In terms of properties, an ellipse becomes a circle when its major and minor axes are equal. This might lead to the assumption that a Circle class should inherit from an Ellipse class in software design, or similarly, that a Square should inherit from a Rectangle.
Software Design Flaw: However, this leads to a design problem. For example, if you have a method in the Ellipse class that allows you to set different lengths for the major and minor axes, using this method on an instance of Circle (which assumes both axes are the same) would break the Circle's invariant (the rule that both axes must be equal for a circle). This violates the Liskov Substitution Principle, which states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
In the Context of Game Development:
In game development, this issue can arise when we incorrectly model relationships based on real-world analogies without considering the specific behaviours and constraints of the subclasses. For example, if you have a base class Vehicle and decide to create Car and Train subclasses, but later realise that trains do not share the same movement capabilities as cars (like turning), you've modelled an incorrect hierarchy. Here, using interfaces to define common behaviours and separating distinct capabilities into different classes can help avoid the problem.
Key Takeaways:
Design over Analogy: Just because two entities share a relationship in the real world doesn't mean they should share an inheritance relationship in software design.
Behavioural Substitution: Ensure that subclasses can replace their superclass without altering the correct functioning of the system, which is a core aspect of the Liskov Substitution Principle.
Favour Composition over Inheritance: When behaviours vary significantly between subclasses, consider using composition (interfaces and encapsulated behaviour) rather than inheritance to design flexible and maintainable systems.
4.Interface Segregation Principle (ISP)
Principle: The Interface Segregation Principle (ISP) is one of the five SOLID principles of object-oriented design. ISP states that a class should not be forced to depend on, they do not use, from the interfaces they implement. Instead of having large, bloated interfaces, it's better to have more specific interfaces tailored to the particular needs of the clients. This approach promotes cleaner, more maintainable, and more flexible code.
In the context of Unity game development, ISP can be particularly valuable as game objects often have a variety of behaviours and capabilities. By segregating interfaces, we ensure that game objects only implement the functionalities they actually need.
Detailed Explanation of ISP
Concept: ISP suggests that interfaces should be specific to a particular role or purpose. A class should not be burdened with methods it doesn't need. This avoids the problem of implementing unnecessary or irrelevant functionality, which can make the code harder to maintain and extend.
Unity Example: Let's consider a Unity game where different game objects have various interactions like movement, shooting, and inventory management. Instead of having one large interface that includes all possible methods, we can segregate these functionalities into smaller, more specific interfaces.
Example Scenario: Game Object Capabilities
Imagine a game with different types of game objects: Player, Enemy, and Item. Each type has different capabilities.
Incorrect Design: Large Interface
An incorrect approach might involve creating a single interface that tries to encompass all possible behaviours:
In this design, every game object must implement all the methods, even if they don't make sense for that object. For example, an Item might not need to Move or Shoot.
Correct Design: Segregated Interfaces
Instead, we can segregate the interfaces based on specific behaviours:
Now, we can implement these interfaces in game objects as needed:
Benefits of ISP in Unity Game Development
Reduced Complexity: By splitting a large interface into smaller, specific ones, we reduce the complexity of each class. This makes the code easier to understand and maintain.
Increased Flexibility: Segregated interfaces allow for more flexible design. Game objects can implement only the interfaces that are relevant to them, leading to a more modular and decoupled system.
Improved Maintainability: Smaller interfaces are easier to update and maintain. If a change is needed in one specific behavior, it affects only the relevant interface and its implementations, reducing the risk of unintended side effects.
Adherence to SOLID Principles: ISP complements other SOLID principles like Single Responsibility Principle (SRP) and Open/Closed Principle (OCP). It encourages well-defined interfaces that make the code more robust and extendable.
5. Dependency Inversion Principle (DIP)
Principle: The Dependency Inversion Principle (DIP) states that high-level modules should not directly depend on low-level modules. Instead, both should depend on abstractions, such as interfaces. This principle is essential for creating flexible and maintainable code, particularly in complex systems like games developed with Unity.
Let's break this down. When one class depends on another, it forms a dependency or coupling. Every dependency in software design introduces some risk. If a class knows too much about how another class operates, changes to one class can adversely affect the other. High coupling is considered poor practice because an error in one part of the application can cascade into multiple issues elsewhere.
The goal is to minimise dependencies between classes as much as possible. Each class should have its internal components working together cohesively, relying on its internal logic rather than external connections. An object is considered cohesive when it functions based on its private logic.
In the best scenario, aim for loose coupling and high cohesion.
You need to be able to modify and expand your game application. If it’s fragile and resistant to modification, investigate how it’s currently structured. The Dependency Inversion Principle can help reduce this tight coupling between classes. When building classes and systems in your application, some are naturally 'high-level' and some 'low-level.' A high-level class depends on a lower-level class to get something done. SOLID tells us to switch this up.
Suppose you are making a game where a character explores the level and triggers a door to open. You might want to create a class called Switch and another class called Door.




On a high level, you want the character to move to a specific location and for something to happen. The Switch will be responsible for that.
On a low level is another class, Door, that contains the actual implementation of how to open the door geometry. For simplification, a Debug.Log statement is added to represent the logic of opening and closing the door.
Switch can call the Toggle method to open and close the door. Although this functions correctly, the issue lies in the fact that the Switch class directly depends on the Door class. If the Switch needs to control other objects, like a light or a giant robot, this design would not be flexible enough.


You could add additional methods to the Switch class, but that would violate the open-closed principle, as you would need to modify the original code every time you want to extend functionality. Instead, you can use abstractions to solve this issue by introducing an interface called ISwitchable between your classes.
ISwitchable should have a public property to determine whether it’s active, along with methods to Activate and Deactivate it.
Here is how you modify your code:
This allows the Door to be treated as a switchable object.
The Switch class no longer depends on Door directly. Instead, it depends on the ISwitchable interface, respecting the Dependency Inversion Principle. And, the same Switch can toggle a door, a light, or any other object that implements ISwitchable.
Dependency Injection
Dependency Injection (DI) is a design pattern used to implement the Dependency Inversion Principle (DIP). It involves passing dependencies (objects or services that a class relies on) to the class instead of the class creating them internally. This decouples the class from specific implementations, making the code more flexible, modular, and testable.
In the Switchable Door example, we can apply Dependency Injection to inject the dependency (ISwitchable, which could be a Door or any other switchable object) into the Switch class, rather than the Switch class directly creating or depending on a specific implementation of Door. This decouples Switch from any specific implementation, allowing it to work with any object that implements ISwitchable.
Key Concept in the Example:
Without DI: The Switch class would directly depend on the Door class, meaning it would need to instantiate or be tightly coupled to the Door object.
With DI: The Switch class depends on the abstraction (ISwitchable), and we inject the concrete dependency (such as Door) from the outside, such as in the Unity Editor or through a constructor.
Example of Dependency Injection
Here’s how Dependency Injection can be applied to the Switch class in Unity:
1. Interface (ISwitchable)
We already have the ISwitchable interface that acts as the abstraction
2. Concrete Class (Door)
Door implements the ISwitchable interface.
3. Switch Class with Dependency Injection
Here, instead of creating a Door directly inside the Switch class, we inject any object that implements ISwitchable.
In this method, the dependency is injected through the Unity Inspector. You drag and drop the Door (or any ISwitchableobject) into the Switch GameObject in the Unity Editor.
b) Constructor Injection (Manual DI in Unity)
Although Unity doesn’t natively support constructor injection (since Unity uses MonoBehaviour), this pattern is commonly used in other C# applications and frameworks like ASP.NET. In Unity, we typically rely on field or method injection.
However, for educational purposes, here’s how constructor injection would work conceptually: