How we applied SOLID principles on Sniper 3D (part 2)

In part 1, we discussed why organizing the code in games that will be maintained for many years, such as Sniper 3D, is so important, and we saw examples of the two first principles. They are:

  • The Single Responsibility Principle
  • The Open-Closed Principle

Now we will discuss the next three.

  • The Liskov Substitution Principle
  • The Interface Segregation Principle
  • The Dependency Inversion Principle

These five complete the acronym SOLID. Except that, to make it easier to explain with examples, I will invert the last two, so we will discuss the Dependency Inversion before the Interface Segregation.

The Liskov Substitution Principle: Derived classes must be substitutable for their base classes.

What does “substitutable” in this case mean? Does it mean that the application behavior should remain the same? Absolutely not, because that is the base of polymorphism. You want to be able to change the behavior of code just by injecting instances with different types into classes.

In Unity, that is incredibly common. GameObject’s components inherit from MonoBehaviour. They define the object’s behavior through the changes it imposes through that inheritance. Although you don’t declare the methods Awake, Start and Update as “override,” they are, in fact, a perfect example of polymorphism, except that Unity preferred to implement them via reflection, to make it a bit simpler for developers.

So, what would it mean to break this principle?

To understand how we can end up breaking this principle, let’s take one more Sniper 3D example.

We have a class called ItemData, which inherits from ScriptableObject. Many classes extend ItemData’s behaviors.

Now there is one particular kind of weapon that we want to include in the game: a crossbow, a weapon that can only be used in a specific mission and is always used and equipped in that mission; it cannot be fitted throughout the whole game.

So, to make sure no one will accidentally call “Equip()” on it, we create a class CrossbowWeaponData which overrides Equip() and throws an exception in case it’s called. But now, the implementations that reference WeaponData (which could get a CrossbowWeaponData instance) will be at risk when calling Equip on it.

They may have to start checking for the “typeof” the instance or add try-catch clauses. Those are some significant signs of an LSP violation — and this case is a violation.

How do you fix it? First, you should consider if the Equip()/IsEquipped is the only extension that WeaponData serves for. If that’s the case, then instead of creating the new class CrossbowWeaponData, you could just make the Crossbow be an instance of ItemData. That will make it impossible to call “Equip” on, and the compiler would tell you that you cannot do that.

But, more commonly, WeaponData will also include other extensions that you want the Crossbow to inherit. In that case, the thing to consider is that when you added the “Equip()” behavior in the WeaponData class, that is a statement that every WeaponData can be equipped. If this is no longer true, then that implementation should change.

A quick solution is to include a new field “canBeEquipped”. The crossbow instance of WeaponData would have this field set to false, and the “Equip()” method could just check for that and do nothing if this variable is wrong.

You could also have a public property as well for other places to check that — which is much better than checking for the typeof. Another solution is to remove the Equip()/IsEquipped behavior entirely from WeaponData.

There could be a new class inheriting from WeaponData called “EquippableWeaponData” with that behavior (for all weapons except the Crossbow), or there could be a completely separate component dedicated to handling the currently equipped weapon.

The bottom line here is: child classes are meant to extend the parent’s behavior. If it’s narrowing it down, restricting methods, then you probably have the wrong abstraction. Making decisions about class hierarchy using the “crossbow purely is a weapon” line of thinking is a mistake. While it’s essential to create useful real-world abstractions, they have to serve your game’s business logic.

Interfaces — a small detour

Before talking about the next two principles, let’s make a detour to talk about to something that we don’t see much on Unity tutorials — interfaces.

What are interfaces for in Object-Oriented languages? Well, one metaphor I like is the power plug one. You don’t see microwaves soldered to the power plug on the wall too often, do you? :)

IPowerPlug — https://softwareengineering.stackexchange.com/questions/108240/why-are-interfaces-useful

The idea is that electric devices comply with specific protocols. Some countries might have different protocols, the signal coming from your power plug might be any combination between 110V, 127V, or 220V and 50Hz or 60Hz. Some regions also have different power plugs standards for 10A devices or 20A devices. We see adapters everywhere, but it’s essential to create that difference and prevent us from plugging in a device that doesn’t conform to that power plug’s protocol.

Interfaces (or what some languages call protocols) have the purpose of allowing the developers to create specific rules that if a class obeys than it becomes “pluggable” to other classes. The runtime can call the correct class accordingly through polymorphism, even without having a direct reference to that class. Awesome, isn’t it?

The Dependency Inversion Principle: Depend on abstractions, not on concretions.

I’ll invert things a bit and start talking about the Dependency Inversion Principle before the Interface Segregation one.

Say you want to implement an Achievements system in your game. You may want to start by only using the off-the-shelf Google Play and Game Center systems by implementing Unity’s social interface. But then you want to implement a similar solution for Steam. And then, you may decide to replace both for your answer.

Knowing about all those future changes, you can use interfaces to isolate each of those implementations so that you won’t even have to touch your core game code to do any of those changes. Your game’s code contains the achievements logic and UI, and it knows high-level interfaces that will then be implemented by classes with solution details. The game has the power plug, and each achievements solution is a pluggable electric device.

So naively you could start mixing implementation details and adding a bunch of dependencies in your core project like this:

public class AchievementSystem
{
public void UnlockAchievement(string id,
double progressPercentage,
double maxProgress)
{
#if UNITY_IOS || UNITY_ANDROID
UnityEngine.Social.ReportProgress(id,
progressPercentage,
b => { });
#else
SteamWorks.IndicateAchievementProgress(id,
(uint)progressPercentage,
(uint)maxProgress);
#endif
}
}

This principle states that your code should depend on abstractions, not concretions. So instead, you make the implementation details pluggable:

public class AchievementSystem
{
readonly IAchievementPlatform platform;
public AchievementSystem(IAchievementPlatform platform)
{
this.platform = platform;
}
public void UnlockAchievement(string id,
double progressPercentage,
double maxProgress)
{
platform.ReportProgress(id, progressPercentage, maxProgress);
}
}
public interface IAchievementPlatform
{
void ReportProgress(string id,
double progressPercentage,
double maxProgress);
}
// each of the classes below could be in a separate asmdef
public class SteamAchievementPlatform : IAchievementPlatform
{
public void ReportProgress(string id,
double progressPercentage,
double maxProgress)
{
SteamWorks.IndicateAchievementProgress(id,
(uint)progressPercentage,
(uint)maxProgress);
}
}
public class UnityAchievementPlatform : IAchievementPlatform
{
public void ReportProgress(string id,
double progressPercentage,
double maxProgress)
{
UnityEngine.Social.ReportProgress(id,
progressPercentage,
(b) => { });
}
}

Is that a lot more verbose? Indeed, it is. That’s why you have to select very well the things you want to make pluggable. But imagine that when you want to stop supporting Steam, you can just delete ONE folder, and you’re done. This is what enabled this dream to come true.

So, why is this called “inverting the dependency”? To make that a bit easier to understand, we can think of them being on separate Assemblies because then you would have to include the dependencies explicitly in the Assembly Definition Files (asmdefs).

Imagine that the AchievementSystem is inside your Core assembly, the Steam API is in its assembly and you already isolated SteamAchievementPlatform in the SteamWrapper assembly, which depends on the Steam API.

Now, if AchievementSystem had a direct reference to SteamAchievementPlatform, the Core would have to depend on SteamWrapper, and therefore rely indirectly on the Steam API. That would make things harder when you want to stop supporting Steam, right?

But because the AchievementSystem knows only the IAchievementPlatform, which is also on the Core assembly, then the Core does not have to depend on anything else. Instead, SteamWrapper depends on Core through knowing and implementing IAchievementPlatform.

The Interface Segregation Principle: Make fine grained interfaces that are client-specific.

Let’s say hypothetically we want to support checking a friend’s progress, and only Steam supports that. The most straightforward way to do that is, of course, to include a new method in the IAchievementPlatform interface

public interface IAchievementPlatform
{
void ReportProgress(string id,
double progressPercentage,
double maxProgress);
double GetFriendProgress(string userId, string achievementId);
}

And then implement “GetFriendProgress” on SteamAchievementPlatform class. But then, you’ll have to implement GetFriendProgress on UnityAchievementPlatform as well, which does not support it. So what do you do?

Well, if your game just shows friends progress when it’s higher than 0f, then you could just hard-code it to return 0f. Simple and effective! It’s great when it’s possible to use the game logic to simplify the code.

But maybe that’s not the case. So, going down this rabbit hole, you would have to implement one more method: bool IsFriendProgressSupported. This not only starts complicating the solution but also breaks the Interface Segregation Principle in two ways:

  1. Parts of the code that care only for reporting the player’s progress will depend on the two methods they don’t need
  2. UnityAchievementPlatform will rely on a method that it doesn’t know how to implement — yes, implementing an interface (or inheriting from other classes) is also a form of dependency!

So the best solution is also the not-so-straightforward one. Create a new interface, say “IFriendAchievementsRepository” with one method: “GetFriendProgress.” This might have only one implementation for now: the “SteamFriendAchievementsRepository” and that’s okay.

Again, IFriendAchievementsRepository might be in the Core and SteamFriendAchievementsRepository in the SteamWrapper assembly. No asmdef dependencies have to change.

Another thing that this principle states is that “Many client-specific interfaces are better than one general-purpose interface.” That’s why at Sniper 3D, we avoid the use of the word “Manager” in classes.

This word makes the intent of the class very misleading, and these classes usually get big very fast — after all, the manager can handle this one more method, right? There are no limits on what a “manager” can do.

A similar example at Sniper 3D was a class called MetagameClient, which provided direct access to our backend services. Its interface was general-purpose because all of its public methods were accessible to every class that had a direct reference to it.

It was refactored by separating it into IMetagameStatus (methods providing connection status), IMetagameCommunication (methods enabling sending data), and IMetagameConnection (methods to connect and disconnect). This way, the classes can have access only to the methods that they need through each one of these interfaces.

Conclusion

This is a very complex and vital subject. Each of the principles was created after years of research and experience. I hope this series of articles could give you a taste of it and motivate you to search more about it. You can find excellent resources in Uncle Bob’s blog and in his book Clean Architecture.

Special thanks to Luciano “Lut” Puhl, who helped me reviewing the article and providing examples.

--

--

--

Wildlife Studios is building next-generation mobile games, and it takes a lot of data, innovation, and knowledge. Our tech people are here to share how they are building the best in class technology to improve people's life with fun and innovation.

Recommended from Medium

Kotlin Multiplatform Mobile (KMM) at Granular

.Net Core 3.0 API Best Practices

Shopify Shop Apps

CODE Review June 7th

Web Elemets and Where to Find Them?

code常用標點符號

One Community Weekly Progress Update #462

Ever since I was a little boy, I have only ever wanted to be a Doctor.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Victor Aldecoa

Victor Aldecoa

More from Medium

The GraphQL specification + best-practices: The quest for standardisation

How To Choose A Sound Issue On Github To Continue Increasing The Bits Of Knowledge

Dynamic Bundling-Trials in performance

Signoz: The Startup’s Answer For Distributed Tracing and Metrics Tracking