Introduction
In previous posts, I discussed the SOLID principles and their relevance in system architecture. For this post, I aim to offer examples of how we can apply the SOLID principles within Unity, particularly concerning Unity’s Monobehaviours.
In Unity, a game object can house numerous Monobehaviour scripts. Enhancing communication between these scripts and between scripts of other game objects can be achieved by implementing SOLID principles in our game architecture.
I won’t delve deeply into each SOLID principle in this post, as I’ve already covered them extensively in my series of posts on SOLID:
- Software Architecture in Game Development
- The Single Responsibility Principle
- The Open Closed Principle
- The Liskov Substitution Principle
- Conceptual Meaning Of Interfaces
- The Interface Segregation Principle
- The Dependency Inversion Principle
- SOLID: How to use it, Why and When
Instead, I will provide specific examples of how each principle can be applied in Unity and specifically for Monobehaviour scripts in game objects.
SOLID Warning
As mentioned in previous posts, architecture is about time investment and the crux of the SOLID principles lies not in the principles themselves, but in the problems they resolve.
By understanding the SOLID principles, individuals can identify these problems and make informed decisions about whether they might lead to detrimental consequences for their programs in the future. If they anticipate negative repercussions, they can then apply the relevant SOLID principle to address them.
In the forthcoming example, I will demonstrate the application of each SOLID principle individually. While this may seem excessive in many scenarios, particularly in game development, where we prioritize efficiency and performance, it’s worth noting that good architecture aids in extensibility, debugging, and comprehension of the code. Occasionally, attempting to adhere strictly to the SOLID principles as rules may yield the opposite result.
The following examples aim to illustrate how the SOLID principles can be utilized in Unity’s game objects, rather than prescribing a definitive approach to writing a simple script.
Feel free to utilize and combine these examples according to your specific use cases and requirements.
The Single Responsibility Principle
The most effective approach to adhere to the Single Responsibility Principle (SRP) in Unity is to employ different components, each serving a single purpose. Unity employs the composition of various components within a single game object, all collaborating to deliver the necessary functionality.
If we aim to create reusable components for deployment across different game objects, it’s imperative to ensure that each Monobehaviour adheres to the single responsibility principle.
For instance, consider an enemy game object. Having an enemy script responsible for multiple tasks such as enemy movement, health management, attack methods, etc., confines the script’s utility solely to enemy game objects.
By decomposing the script into smaller classes, each serving as its own component and addressing a singular responsibility, we enhance the reusability of our Monobehaviours across various scenarios.
For example, a health Monobehaviour class can now be employed not only in enemy entities but also in any entity requiring health management. Methods associated with health, such as taking damage, healing, and death, can be utilized universally. Consequently, we can utilize a single script for any entity requiring health management, eliminating the need for duplicative code across different classes like player characters, NPCs, and more.
In the forthcoming examples, I will utilize this concise health script as an illustration of how each SOLID principle can be applied:
public class Health : MonoBehaviour
{
[SerializeField] private float startingHealth = 100f;
public float CurrentHealth { get; private set; }
private void Start() => CurrentHealth = startingHealth;
public void ApplyDamage(float damage)
{
CurrentHealth -= damage;
Debug.Log(CurrentHealth);
if(CurrentHealth <= 0)
Die();
}
public void Heal(float amount)
{
CurrentHealth += amount;
CurrentHealth = Mathf.Clamp(CurrentHealth, 0, startingHealth);
}
public void Die()
{
Debug.Log($"{gameObject.name} has died!");
Destroy(gameObject);
}
}
This demonstrates the application of the SRP to our Monobehaviours. Instead of this code being part of a larger script, it is now isolated and responsible for a single task, meaning it has only one reason to change: if we want to alter how health functions in our game. For further information on the SRP, refer to this link:
The Single Responsibility Principle
The Open Closed Principle
Our game objects interact with each other. For instance, an object that inflicts damage can utilize a collider with the trigger option and inspect whether the object possesses the appropriate script using the OnTriggerEnter
method.
It would be beneficial not to have to modify the objects that depend on this implementation whenever we want to make changes.
Consider a scenario where we have something that damages our enemies, but we also wish to later damage certain items in our environment. These items lack health; once they sustain any damage, they are destroyed. It would be advantageous for us to accomplish this without altering the script containing the OnTriggerEnter
method.
This can be achieved by creating an interface for our health script. Instead of checking if the colliding game object possesses the health component, we ascertain if it has a component that implements this interface. For instance, by introducing the IHealth
interface:
public interface IHealth
{
float CurrentHealth { get; }
void ApplyDamage(float damage);
void Heal(float amount);
void Die();
}
we can check with:
if (!other.TryGetComponent(out IHealth _)) return;
in our OnTriggerEnter
method.
If we need to implement a new functionality now, as before, we simply create a script where the ApplyDamage
method, instead of subtracting health, immediately calls the Die
method
However, someone familiar with the SOLID principles can recognize that this approach is not ideal. While it aids in adhering to the Open/Closed Principle (OCP) by allowing us to introduce new behaviors without altering the scripts dependent on previous behaviors – as we now depend on abstractions – it violates the Interface Segregation Principle. Not to worry; we will address this shortly. In the meantime, for a more detailed explanation of the OCP, you can refer to this link:
The Liskov Substitution Principle
The Liskov Substitution Principle (LSP) doesn’t entail any specifics in Unity that differ from regular C# scripts. LSP primarily concerns inheritance, so whenever we utilize a class derived from MonoBehaviour as a base class, we must ensure that we avoid violating the LSP just as we strive to do in any other scenario.
For further details about the LSP in general, you can refer to this link:
The Liskov Substitution Principle
The Interface Segregation Principle
In the OCP example, we established an interface for our health script to accommodate various implementations for our ApplyDamage
method. However, this approach introduces a potential issue wherein implementations may omit the use of other methods.
For instance, consider a door affected by any damage source but lacking health. Such a door would be destroyed by any amount of damage, rendering the CurrentHealth
and Heal
methods redundant.
Through the ISP, we segregate our interfaces into groups containing meaningful methods. For example, our Health
class could be structured as follows:
public class Health : MonoBehaviour, IDamageable, IHeal, IBurn
with the interfaces:
public interface IDamageable
{
public void ApplyDamage(float damage);
}
public interface IHeal
{
public void Heal(float amount);
}
public interface IBurn
{
public void ApplyDamage(float damage);
}
Now, for our objects that we want to be destroyed whenever they sustain damage, we can employ an Interactable
script like this:
public class Interactable : MonoBehaviour, IDamageable
{
public void ApplyDamage(float damage)
{
Destroy(gameObject);
Debug.Log($"The {gameObject.name} is destroyed!");
}
}
An interface doesn’t need to exclusively offer different implementations; conversely, an implementation may belong to two or more interfaces. This is evident in the case of the IBurn
interface, which mirrors the IDamageable
interface, both featuring only the ApplyDamage
method.
This occurrence arises because we may need game objects susceptible to damage by fire, while others are only affected by standard damage. In the provided scripts, any entity inflicting fire damage will validate against the IBurn
interface, causing game objects with the Health
script to sustain damage from fire. Conversely, game objects featuring the Interactable
script remain unaffected.
Moreover, it’s possible to have the same interface implemented in different scripts within the same game object. If we want to adhere to the SRP then it is necessary to avoid having scripts responsible for multiple functionalities.
For instance, if we also wish to include sound effects triggered whenever something occurs to our game object, such as taking damage, it’s preferable to avoid embedding sound functionality directly within individual scripts like the Health
script. Instead, we can introduce a Sounds
script that implements the relevant interfaces:
public class Sounds : MonoBehaviour, IDamageable
{
[SerializeField] private AudioClip damageSound;
private AudioSource audioSource;
private void Awake() => audioSource = GetComponent<AudioSource>();
public void ApplyDamage(float damage)
{
if(audioSource != null && audioSource.isActiveAndEnabled)
audioSource.PlayOneShot(damageSound);
}
}
Now, anything that checks for the IDamageable
interface will inspect all the interfaces and invoke the ApplyDamage
method on each of them, utilizing the GetComponents
method (notice the plural). For example:
public class DamageDealer : MonoBehaviour
{
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.TryGetComponent(out IDamageable _)) return;
var damageables = other.GetComponents<IDamageable>();
foreach (var damageable in damageables)
{
damageable.ApplyDamage(20f);
}
}
}
For more about interfaces and the ISP, you can check here:
Conceptual Meaning Of Interfaces The Interface Segregation Principle
The Dependency Inversion Principle
The Dependency Inversion Principle (DIP) has been applied in the previous examples through the utilization of interfaces, which serve as abstractions. As long as we don’t have distinct higher and lower level policies in our game objects, we need not be overly concerned with the creation of our interfaces and their conceptual placement.
However, if our game objects contain scripts that clearly represent lower level policies, such as scripts responsible for our UI, then it’s crucial to ensure that our interfaces are implemented by our higher level policy scripts and utilized by our UI scripts. We can achieve this by using names that clearly define their usage (e.g., ButtonPressed, Clicked, etc.).
For more information about the DIP, check here:
The Dependency Inversion Principle
Conclusion
This was an example demonstrating how the SOLID principles can be applied to Unity game objects, specifically scripts that are Monobehaviours, while still leveraging the advantages that the SOLID principles offer.
For a more detailed explanation of each of the SOLID principles, you can refer to the provided links. It’s important to be cautious not to overdo it; if the principles don’t seem to make much sense in your particular case or fail to provide significant benefits, consider the insights shared in my post: SOLID: How to use it, Why and When, as a more detailed epilogue, for this article.
Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.