Understanding the dependency rule and sticking with it might be enough to solve most of your issues with writing manageable, clean architecture in Unity. It’s a great way to avoid a lot of the headaches you might face.
What sort of headaches? As your project grows, if you don’t make sure your features are separated and specialized, you might end up having to keep track of a long list of things. Otherwise everything breaks. A friend of mine was telling me recently about how he worked on a codebase once where changing the way shooting works completely broke networking. That’s a recipe for pain killers!
The Dependency Rule
The Dependency Rule states that an application should have layers. Something in an inner (lower) layer shouldn’t directly reference something from an outer (top) layer.
For example, if you have a SaveGamePopup managed by a UIController class, then SaveGamePopup doesn’t need to reference UIController or the actual class responsible for saving the game. It should fire an event instead. To explore why, let’s view an example.
Example
Let’s say that we need to implement a power up in our game. When the player picks up this item, a UI screen shows up to inform the player, a sound plays, and the player’s stats are updated.
Let’s say we’ve decided, for the sake of this example, to give that responsibility to the power up class.
public void HandlePickup()
{
DeactivatePowerUp();
hideAnimation.Play();
powerUpUI.Show()
soundManager.PlaySound(Sounds.PowerUp);
player.Pickup(this);
}
Ok, sweet.
Weeks later, someone decides that other characters should also be able to get the same power up. Except that they’ll play an animation instead. Also, we need to inform the achievements and saving systems.
So you make the following changes:
public void HandlePickup(GameCharacter character)
{
DeactivatePowerUp();
hideAnimation.Play();
if (character is Player)
{
powerUpUI.Show()
soundManager.PlaySound(Sounds.PowerUp);
}
else
{
character.PlayAnimation(Animations.PowerUp);
soundManager.PlaySound(Sounds.EnemyPowerUp);
}
character.Pickup(this);
achievments.RegisterPowerUp(this);
saveManager.SaveItem(this);
}
Now, here’s a list of things that could go wrong:
- You decide to port the game to another platform that doesn’t have achievements. So now you’ll have to reference the achievments component conditionally between #if and #endif directives wherever its used. You check usage, and you find your achievments script referenced in 50 different places. Nightmare.
- Your entire UI code needs to be replaced or modified
- One of the enemies in the game actually plays a unique animation that involves spawning a particle
- Your save code changes and needs to be able to check if the item was picked before
Or pretty much any of the many expected changes that could occur over the next few months as your scope grows. Suddenly your item class (along with many others) are dependent on an increasing number of features, and these features are actually expected to change over time, significantly increasing the possibility of introducing bugs.
💡Note
You might notice that in the first half of this book I’m re-iterating over similar points with similar examples in different contexts. This is intentional. If there’s one issue to tackle in order to write scalable code, it’s this one in particular. I hope you don’t get bored!
Cleaning Up
This is what the code might look like after applying the dependency rule:
public void HandlePickup(GameCharacter character)
{
DeactivatePowerUp();
OnPickedUp?.Invoke(this, character);
}
Ideally, a top level controller is listening for all the events from all the items, with the sole responsibility of handling their lifecycle. It can then fire events that other systems like achievements, saving, UI, and others can listen to. They could also not be there to listen to it. Their choice.
But why?
On the surface, we did nothing here. We just delegated this code to another class.
But we’ve actually done a lot by doing so.
Closing the feature for modification
Your classes should follow the single-responsibility principle. Simply enough, it should only care about doing one thing. This is powerful for multiple reasons. The main reason being: when you’re done implementing your feature, you’re done. Changes to the UI can’t break it. Adding more logic as a response to your feature won’t break it. And you don’t have to touch it again if changes in other layers are required.
Making the feature modular
Remember when we had to reuse it for the enemy as well? You can do that without disturbing the power up feature now. It simply doesn’t care what uses it, making the feature truly modular.
It doesn’t reference other systems
Being modular means that it likely won’t require systems that don’t exist in specific situations. Remember that when you hard reference other systems, you require the references to exist during runtime. This can be problematic when your requirements change, or when different contexts (such as different modes, online play, testing tools) are introduced.
You don’t have to spend too much time thinking of how your architecture connects and how the references are passed/grabbed
An important thing we ignored in our example: how does the power up have references to all of these other classes?
Another layer of complexity is added when you have to provide the necessary references for every system or feature your feature has to access. It’s a lot more work if you think about it.
That’s how you end up with a lot of things being unnecessarily a singleton, a lot of FindObjectOfType<T>()…or just simply, a lot of rewiring your architecture every time you need a small change.
Separation of logic and presentation
This is very important for testing, debugging, and for generally making your life far easier.
The logic in your system should be testable on its own without any presentation. But also changes in the presentation layer should never hinder your logic. You should aspire to never mix presentation code with your game logic.
A point that’s important to remember is that your initial logic for the feature could be final, but your presentation code (animations, UI, sound, etc..) is likely going to start out as placeholders, before being fully implemented.
Easier to debug
Being modular means that it’s easier to implement something like a level editor or special development/testing modes. This becomes very helpful in many situations.
Easier to understand and track
I’ve seen a lot of code in the past where player controls, for example, are mixed with physics, sound, and animation. Making the slightest changes requires you to re-familiarize yourself or learn what each line does so you don’t end up breaking things. This just makes your code way harder to deal with, and makes it tougher for other developers to familiarize themselves with your code.