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

Victor Aldecoa
Wildlife Studios Tech Blog
6 min readJan 21, 2020
The Sniper, from Sniper 3D

If you are a software engineer, you already heard and studied the SOLID principles. But just as any concept, it requires practice and time to absorb and understand it fully.

In my path to learning, I always seek more information about it, but many times I end up seeing the same examples over and over again. So the idea of this series of articles is to go beyond the typical patterns used and show some real-life examples of applying them to one of Wildlife’s longest-running games, Sniper 3D.

The need for code organization

Sniper 3D is a game launched in November 2014, and it grew a lot since then. Being launched with a single-player mode that involved buying weapons and upgrading them to progress through different cities, it evolved into not only adding new content but also in a myriad of new features:

  • synchronous 8-player free-for-all mode (we call it “Arena”);
  • four global rankings between different types of missions and complex systems such as clans and clan management features (search, creation, customization, participants management, matchmaking);
  • leagues, equipment gears;
  • multi-device progress management.

All in one mobile game. The key to Sniper’s long-term maintainability was the organization concepts for individual classes that Uncle Bob brought up together in SOLID along with the higher-level module organization principles he presented in his “Clean Architecture” book.

Naming disclaimer

SOLID is an awesome acronym. The principles’ names, though are a bit confusing and sometimes misleading. So don’t pay too much attention to their titles, focus on the subtitles.

Without further ado, let’s see the principles.

Single Responsibility Principle: A module should have only one reason to change

On the more practical side, my golden rule here is: if a class has two subsets of methods accessing two subsets of fields, then one of these subsets should probably be refactored into another class.

The starting class can start just delegating the refactored methods calls to the new class, so it will be the only class to depend on the new one.

If other modules depend only on the refactored methods, then you can replace the dependency on the previously bloated class for the new lean class. Then you can be sure the refactor was successful.

Starting with a straightforward example, imagine you implemented a class that moves a game object horizontally back and forth:

public class HorizontalMovement : MonoBehaviour
{
[SerializeField] float magnitude = 1f;
[SerializeField] float speed = 5f;
void Update()
{
var pos = transform.position;
pos.x += magnitude * Mathf.Cos(Time.time * speed);
transform.position = pos;
}
}

Now in a new feature, you want to add a vertical movement behavior. The logic is the same, it seems almost natural to refactor the two variables to call them “horizontalMagnitude”, “horizontalSpeed” and create two new ones for the new behavior. After all, mathematics is the same, and you can reuse the “transform.position” getting and setting. If you want horizontal behavior, you set the verticalMagnitude to 1f and verticalSpeed to 0f.

Don’t take me wrong; if 95% of your project will apply both horizontal and vertical movements to the game objects, then you’re probably better off having both in the same class.

Otherwise, does it make sense to make all the horizontal movement instances also serialize the new vertical movement variables? If the feature usages are different, you might also want to change the animation behavior, do you want to keep both coupled to the same class? Then you are probably better off duplicating the “transform.position” behavior and creating a new class for the vertical movement.

Now for a more practical Sniper 3D example. It has a “Profile” class, which began as a Facade for accessing the player’s information.

public class Profile
{
public CurrencyItemData HardCurrencyItem
{
get { return _data.HardCurrency; }
}
public ItemData EnergyItem
{
get { return _data.EnergyItem; }
}
}

The player spends energy on starting missions, and it recharges automatically after 10 minutes until it reaches its maximum. Back then, Profile was really small, so it made sense to add that logic to it.

public void Update()
{
var itemAmount = Amount;
var now = App.DateTime.UtcNow;
while (itemAmount < MaxQuantity && NextRechargeDate < now)
{
itemAmount++;
NextRechargeDate += TimeSpan.FromMinutes(10);
}
Amount = itemAmount;
}

Another big mistake of keeping this code there was assuming that the user would only have one kind of energy. That business logic was bound to the user profile, along with other things that ended up “naturally” orbiting the Profile class.

Then a new mode came up: World Ops. And it had its energy which would take 20 minutes to recharge. Also, it would be an AB test, which means if it didn’t work out, the code would be removed (never leave dead or commented-out code in your base. If you need it later, there’s always Git). So this code was duplicated to another class.

Not only the AB test worked, but we also developed yet another feature with a new energy type. It was undoubtedly a time to refactor. The solution was to create a new class we called RechargeableItemData (not only energy could be rechargeable, right?). The logic was removed from SinglePlayerProfile and WorldOpsProfile in favor of the new class — and the ArenaProfile also used it.

Open-Closed Principle: You should be able to extend a classes behavior, without modifying it

Source: Maksim Ivanov

The typical example for this one is a class with a big switch-case choosing the logic according to type, say calculating the area of a shape (e.g. rectangle/circle). It can appear in the forming of “ifs” as well, but the violation is just the same.

Here the example also has to do with energy. As you can imagine, the UI is pretty similar between the different kinds of energy and the implementation is almost the same. But the data source is different — it can have a different value and different maximum energy depending on which screen you are seeing. To make it a bit more complicated, there’s one subscription plan that will give you more single-player energy and another that will give you both more World Ops and Arena energy.

The class “ShowEnergyBar” soon became full of switch-cases:

public enum GameMode { SinglePlayer, WorldOps, Arena }public class ShowEnergyBar : MonoBehaviour
{
[SerializeField] GameMode energyBarMode;
[SerializeField] EnergyBarUi energyBar;
[SerializeField] UILabel energyDeltaLabel;
...

(For the following section, please forgive the “singleton-like” access to static variables, we are already phasing them out).

public void SetupUi()
{
switch (energyBarMode)
{
case GameMode.SinglePlayer:
var singlePlayerProfile = App.SinglePlayerProfile;
var isSinglePlayerSubscriber =
singlePlayerProfile.IsOnSubscriptionExtraFillGroup
&& singlePlayerProfile.HasSubscriptionExtraFill;
energyBar.SetupEnergyBars(singlePlayerProfile.MaxEnergy,
singlePlayerProfile.Energy,
isSinglePlayerSubscriber);
break;
case GameMode.WorldOps:
var worldOpsProfile = App.WorldOpsProfile;
var isWorldOpsSubscriber =
worldOpsProfile.IsOnSubscriptionExtraFillGroup &&
worldOpsProfile.HasSubscriptionExtraFill;
energyBar.SetupEnergyBars(worldOpsProfile.MaxEnergy,
worldOpsProfile.Energy,
isWorldOpsSubscriber);
break;
case GameMode.Arena:
var arenaProfile = App.ArenaProfile;
var isArenaSubscriber =
arenaProfile.IsOnSubscriptionExtraFillGroup &&
arenaProfile.HasSubscriptionExtraFill;
energyBar.SetupEnergyBars(arenaProfile.MaxEnergy,
arenaProfile.Energy,
isArenaSubscriber);
break;
}
}

(There were many others).

This is problematic for two reasons:

  • #1 by breaking the Open-Closed Principle, if we want to add a new type of energy, we will have to change each of the switch-cases in many parts of the code. Not only is this cumbersome but also error-prone.
  • #2 this class becomes confusing, with a lot of duplication.

So we refactored it to introduce a new interface: IEnergyHolder

public interface IEnergyHolder
{
int Energy { get; }
int MaxEnergy { get; }
bool HasSubscriptionExtraFill { get; }
bool IsOnSubscriptionExtraFillGroup { get; }
}

All the switch-cases were replaced, except for one Factory method to maintain “GameMode” translation compatibility:

public static IEnergyHolder GetEnergyHolder(GameMode gameMode)
{
switch (gameMode)
{
case GameMode.SinglePlayer:
return App.SinglePlayerProfile;
case GameMode.WorldOps:
return App.WorldOpsProfile;
case GameMode.Arena:
return App.ArenaProfile;
default:
throw new ArgumentOutOfRangeException();
}
}

And the SetupUi method and the many other ShowEnergyBar methods that contained switch-cases became like this:

public void SetupUi()
{
var holder = EnergyHolderFactory.GetEnergyHolder(energyBarMode);
var isSubscriber = holder.IsOnSubscriptionExtraFillGroup &&
holder.HasSubscriptionExtraFill;
energyBar.SetupEnergyBars(holder.MaxEnergy,
holder.Energy,
isSubscriber);
}

Short and much simpler. :)

Note that this example was simplified for easier understanding. Most of the time, the methods to be refactored will have different names, usually, because the person who developed the newer feature (often yourself) wasn’t fully aware of (or didn’t remember) the older feature implementation. So it’s important to seek these abstractions to make the code simpler. The time spent pays off.

Part 2

In the second part of the series, we will talk about examples on the next three principles:

  • Liskov Substitution Principle: Derived classes must be substitutable for their base classes.
  • Interface Segregation Principle: Make fine grained interfaces that are client-specific
  • Dependency Inversion Principle: Depend on abstractions, not on concretions

--

--