A component-based architecture for Unity

An MVVM-inspired approach to building games faster and easier

Andrés Alberto Estevez
etermax technology
Published in
12 min readJan 25, 2023

--

By Andrés Estevez, Technical Leader, and Augusto Salgado, Software Engineer at etermax

What’s the deal with Software Architecture in Unity?

Why is it so hard to find articles focusing on the different existing approaches and how to implement them in this acclaimed engine?

Here at etermax we have tried several options, and though there is no silver bullet or magical answer, we have recently found an interesting approach that is helping us build scalable and easy-to-test games faster than ever.

But first of all…

What do we want out of an architecture?

Before we can start talking about a specific software architecture design, we should first think about what we need from it, which of the advantages it offers suits best our needs and which of its disadvantages are not that painful to us… specially when building mobile games that will be played by millions of users around the world.

Simplicity

What we mean by simplicity is not some utopian concept of the code being written by itself, rather the architecture not complicating things just for the sake of it. For example, making a button play a sound whenever it’s tapped should not require a lot of classes and boilerplate code in order to go through different layers of abstraction, only because your architecture is forcing you to do so. Not only does this extend the amount of time required to code the feature and feels frustrating, but it also makes the code harder to read and follow as the project grows and new teammates are incorporated to it.

In other words, we want our architecture to minimize boilerplate and the amount of classes involved in specific functionality.

Resilience

Either because you are working in the mobile games market with agile or SCRUM methodologies, or because you need your project to react fast and smoothly to changes in the requirements, you’ll need the architecture you use to be resilient to changes. Thus, having an architecture more oriented towards components rather than inheritance helps us react to those changes by simplifying, removing, changing, or adding new components. This reduces the necessity to modify existing code in order to adapt functionality and makes the workflow of the whole team more agile.

Reusability

Yet another benefit of having a component-oriented architecture… It is very common to have the same functionality repeated across different screens and features. For example, playing a sound whenever a button is tapped (yes, again) will certainly be needed lots of times. Having the functionality well separated in a general purpose component enables you to simply reuse the same component wherever you need it. This reduces duplicated code, makes things easier to develop, and enhances prototyping times.

Testability

At a first glance, the possibility to test your code efficiently may not seem related to the architecture you choose. However, when building games with Unity, there is always a temptation to decouple your code from the engine as much as possible, with the excuse of making it more testable, as “testing monobehaviours is a headache”.

Following that approach in other opportunities has led us, for instance, to creating an interface for each component (meaning the component inherited from MonoBehaviour as well as from the interface). Those components would have a “Presenter” in charge of handling it, so we would finally make unit tests of those presenters, in which we would have a substitute for the component interface and we would assert that the expected methods were called.

However, as you can imagine, the components themselves were not tested, so the implementation of those methods could be completely wrong and our unit tests would have never failed. In conclusion, we can’t completely trust our suite of tests unless they also include the components that interact directly with the engine.

Our approach, which we will elaborate further in this post, allows us to have small components with clear responsibilities and few dependencies. This makes the process of unit testing them much easier, by simply creating game objects and attaching the components to them, resulting in much bigger code coverage and eliminating the need to interface the components (which also requires you to architect your code in a way that supports that).

Thinking in Components

To comply with these pillars, we changed the way we see features. We went from building them as large and closed modules to designing them oriented towards more specific components.

When creating them, we seek to make these components as atomic as possible. This way, they’ll be more reusable, easy to test, and remove or replace in case they are no longer needed.

But, if each of these components had its own state, it’d be tricky to know the general state of the application. This is why ViewModels were introduced. These objects are in charge of maintaining the state, and components modify them or react to their changes.

Having this separation between ViewModels and input and output components allows us to have many input components modifying the ViewModels. This is not the case for output components, where the process is transparent as they only showcase the information of the ViewModels when their data changes, and it doesn’t matter what changed them.

Does the term ViewModel ring a bell? This is because, to make the architecture easier to understand — and given that it presents some similarities — , a few concepts from MVVM were borrowed.

In MVVM architecture, the view interacts with a ViewModel giving commands and reacting to changes of state for them to be represented. In addition, the ViewModel knows the model and is in charge of managing access to it.

In our case, ViewModels become data containers, and the view is integrated by different components, some of which modify the state of the ViewModels, while others react to these changes of state. The flow of the application doesn’t remain within the ViewModels, but it’s the result of the changes on its properties.

Given that our games are mostly client/server based, we consider the model to be our application’s backend, while only an abstraction layer remains on the customer side for communication purposes.

Practical Example: “Active Quests List”

We’re asked to build a scene in which the three active missions of the user are showcased. Once completed, missions must allow the user to obtain the reward by clicking a button. Otherwise, the button must not be displayed.

To showcase the mission’s details, we need a source. In our case, this role is played by the ViewModels. We chose to use scriptable objects as they offer the following advantages:

  • As the project can be created as assets, these objects can maintain the value between scenes.
  • We can see and modify the value of the ViewModel’s properties selecting the asset at any time while the application is running.
  • Because they’re not associated with a scene in particular, they can be referenced from various scenes, prefabs, or other assets.
  • We can create multiple instances and give them different uses, unlike with a Singleton or static references.

What we need from each mission for this scene is:

  • The title
  • To know whether it has been completed to enable the “claim” button
  • To know how much gold it gives as a reward when claiming it

Also, the ViewModel must be able to notify components when its data changes. In this regard, we chose to use the UniRx library, which provides us with reactive properties that will allow us to subscribe to changes from the components to represent the state of the ViewModel. This library is not compulsory as the same may be implemented using events or applying the observer/observable pattern, but it simplifies the code so much and brings so many tools to the table that it’s difficult not to use it.

This way, we defined the ViewModel of a mission the following way:

[CreateAssetMenu(menuName = "Quests/Quest ViewModel", fileName = "QuestViewModel")]
public class QuestViewModel : ScriptableObject
{
public StringReactiveProperty Title = new StringReactiveProperty(string.Empty);
public BoolReactiveProperty IsCompleted = new BoolReactiveProperty(false);
public IntReactiveProperty GoldReward = new IntReactiveProperty(0);
}

Now that our ViewModel has been defined, we can begin to develop the output components (in case you don’t remember them, refer back to the architecture’s graph clicking here). These components will be responsible for showing the data on the scene. To remain resilient to change, we chose to make these components as atomic as possible, so they can be added or removed when needed:

public class DisplayQuestTitle : MonoBehaviour
{
public QuestViewModel Quest;
public TMP_Text Label;

public void Start()
{
Quest.Title.Subscribe(title => Label.text = title).AddTo(this);
}
}
public class DisplayQuestGoldReward : MonoBehaviour
{
public QuestViewModel Quest;
public TMP_Text Label;

public void Start()
{
Quest.GoldReward.Subscribe(amount => Label.text = amount.ToString()).AddTo(this);
}
}
public class DisplayOnQuestCompleted : MonoBehaviour
{
public QuestViewModel Quest;
public GameObject Target;

public void Start()
{
Quest.IsCompleted.Subscribe(Target.SetActive).AddTo(this);
}
}

Because of their atomic nature, these components are easily reusable and can be added to any game object. Also, in case they’re no longer needed, we can remove them from the scene without affecting the functionality of other components. Yet another advantage is that, because they’re so simple, running tests is very easy:

[TestFixture]
public class DisplayOnQuestCompletedTest
{
private DisplayOnQuestCompleted _component;
private QuestViewModel _quest;

[SetUp]
public void SetUp()
{
// create an instance of the viewmodel
_quest = ScriptableObject.CreateInstance<QuestViewModel>();

// create a gameobject, add the component and assign it's dependencies
var gameObject = new GameObject();
_component = gameObject.AddComponent<DisplayOnQuestCompleted>();
_component.Quest = _quest;
_component.Target = new GameObject();
}

[Test]
public void target_inactive_when_quest_is_not_completed()
{
_component.Start(); // GivenComponentStarted()

_quest.IsCompleted.Value = false; // WhenQuestIsNotCompleted()

Assert.AreEqual(false, _component.Target.activeSelf); // ThenTargetIsInactive()
}

[Test]
public void target_active_when_quest_is_completed()
{
_component.Start(); // GivenComponentStarted()

_quest.IsCompleted.Value = true;// WhenQuestIsCompleted()

Assert.AreEqual(true, _component.Target.activeSelf);// ThenTargetIsActive()
}
}

Going back to Unity, if we wanted to test what we implemented, we should create an instance of our ViewModel for each of our missions first.

Then, if we add the components mentioned before to the game objects of each mission and assign their references, when we run the application we will see how the elements of the scene change their value when the data of the ViewModels is changed in runtime.

To ensure the integrity of the scene, we can carry our tests that validate the presence of these components in it, while also validating that their references are assigned.

[TestFixture]
public class QuestSceneTest
{
private const string ScenePath = "Assets/Scenes/Quests.unity";

[SetUp]
public void SetUp()
{
// Load scene in editor so we can assert the components are included
EditorSceneManager.OpenScene(ScenePath);
}

[Test]
public void contains_display_quest_title()
{
// Find all objects in scene containing DisplayQuestTitle component
var components = Object.FindObjectsOfType<DisplayQuestTitle>().ToList();

// There should be 3 of them
Assert.AreEqual(3, components.Count);
foreach (var component in components)
{
// assert their references are not null
Assert.IsNotNull(component.Quest);
Assert.IsNotNull(component.Label);
}
}
...
}

Now that we can display the data of the missions on the scene, we need a way to stimulate the properties of the ViewModels.

Following the previous steps, we would need a component that obtains data from missions (whether it comes from a backend, an in-memory repository, or other) and assigns the values to the corresponding ViewModels.

To reduce complexity, we added an abstraction to communicate with the data access layer, in our case, using the Facade design pattern; this way, the interface is much simpler. We may change its implementation in the future and reduce the number of dependencies on the component, which makes testing it easier.

public class LoadQuestsOnStart : MonoBehaviour
{
public List<QuestViewModel> Quests;
public IQuestsFacade QuestsFacade = new QuestsFacade();

public void Start()
{
var questsResponse = QuestsFacade.GetQuests();
for (var i = 0; i < Quests.Count; i++)
{
Quests[i].Title.Value = questsResponse[i].Title;
Quests[i].GoldReward.Value = questsResponse[i].GoldReward;
Quests[i].IsCompleted.Value = questsResponse[i].IsCompleted;
}
}
}

Just like the previous components, we can test this one quite seamlessly. We just need to create instances of the ViewModels and replace the implementation of the Facade with a mock before calling the Start method. We encourage you to try it out!

But…even if the component complies with the use case, there’s some room for improvement:

  • The data load is done directly in the Start event. If we wanted to execute this same use case in other event, such as clicking a button, we would have to repeat the code
  • If there are several instances of this component, we would have to assign the same references to each of them.

To address the first point, we could extract the logic of searching for data with the Facade and assign it to the ViewModels using the Command pattern.

public class LoadQuestsCommand
{
private readonly List<QuestViewModel> _quests;
private readonly IQuestsFacade _questsFacade;

public LoadQuestsCommand(List<QuestViewModel> quests, IQuestsFacade questsFacade)
{
_quests = quests;
_questsFacade = questsFacade;
}
public void Execute()
{
var questsResponse = _questsFacade.GetQuests();

for (var i = 0; i < _quests.Count; i++)
{
_quests[i].Title.Value = questsResponse[i].Title;
_quests[i].GoldReward.Value = questsResponse[i].GoldReward;
_quests[i].IsCompleted.Value = questsResponse[i].IsCompleted;
}
}
}

This way, components can instantiate this Command and execute it. For our previous component, it would look like this:

public class LoadQuestsOnStart : MonoBehaviour
{
public List<QuestViewModel> Quests;
public IQuestsFacade QuestsFacade = new QuestsFacade();

public void Start()
{
new LoadQuestsCommand(Quests, QuestsFacade).Execute();
}
}

This brings us a new problem: if various components used this Command, and the constructor signature were to change (for instance, if a new parameter was added), we would have to change all the components. Additionally, our second suggestion for improvement would get worse given that we would have to add new references to all these components.

To address this, we delegated the construction of the Commands to a Command Factory. These factories also inherit from ScriptableObject, allowing us to assign the references from the editor.

[CreateAssetMenu(menuName = "Quests/Command Factory", fileName = "QuestsCommandFactory")]
public class QuestsCommandFactory : ScriptableObject
{
public List<QuestViewModel> Quests;
public IQuestsFacade QuestsFacade = new QuestsFacade();

public LoadQuestsCommand LoadQuests()
{
return new LoadQuestsCommand(Quests, QuestsFacade);
}
}

Finally, if we modify out component to use the Command Factory, it looks this way:

public class LoadQuestsOnStart : MonoBehaviour
{
public QuestsCommandFactory CommandFactory;

public void Start()
{
CommandFactory.LoadQuests().Execute();
}
}

If we apply these changes to the initial architecture diagram, we’d obtain the following:

Final Thoughts

Neither this nor any architecture design is a silver bullet that will be perfect for all cases. As you can imagine, some architectures suit better for some contexts depending on the technologies, workflows, or other characteristics of the project. So why do we like this one more than others? Well… it feels like this architecture can talk Unity. And that’s because in some way it does not go against the nature of the engine.

Let’s think about it. If you didn’t think of having an architecture at all, your intuition-guided use of Unity would be to just create components for any piece of feature you’d need (we’ve actually all been there) and to put the logic of those requirements inside the components. Then, whenever you’d need different data to be used across different screens you would probably create Scriptable Objects and make the components reference them in order to modify and read their information. You see, you’ve given your code a more data-oriented approach, which adds up to the already component-oriented characteristic of Unity and, without even realizing you almost already used the MVVM architecture in your project. The next big step is just adding commands in the middle of the road which helps you keep your game logic well-separated and reusable by any component without the need of duplicating it. And…

that’s pretty much it!

As you see, the daily task of coding a game feature using this architecture feels a lot like you are doing what you would normally do in Unity, with just a bit of extra considerations in order to keep the code reusable, maintainable, and organized across a whole big project with lots of team members involved.

--

--