Unity Client

Unity architecture approach: tree of controllers

By Andrey Vishnitskiy

This article is about the architecture which has been used on almost all of Playtika’s projects, since 2011. Throughout those years, we amassed expertise and experience in using this architecture on extremely large projects, such as Bingo Blitz, Slotomania, and Solitaire Grand Harvest.

Controller Tree is an HMVC-like architecture for clients that allows you to build less-tangled code. It encourages the writing of decoupled and isolated features. A code-centric architecture, it simplifies refactoring and increases flexibility.

Controller Tree is DI-friendly. Each Controller can be run in many instances. Here’s a rundown of everything about Controller Tree you need to know.

Key features

Async-based

Almost all Controller API is asynchronous. In some projects, it is classic C# TPL, while in others, it is UniTasks.

public class BaseController : Controller
{
	public BaseController()
	{
	}

	protected override UniTask OnInitialize(CancellationToken token)
	{
		return UniTask.CompletedTask;
	}

	protected override UniTask Running(CancellationToken token)
	{
		return UniTask.CompletedTask;
	}

	protected override UniTask OnStop(bool isForced)
	{
		return UniTask.CompletedTask;
	}

	protected override void OnDispose(bool disposing)
	{
		base.OnDispose(disposing);
	}

	protected override bool HandleEvent(ControllerEvent e)
	{
		return base.HandleEvent(e);
	}
}

Controller “reusability”

Each Controller can be run in multiple instances. They aren’t singletons. You create each Controller instance by the factory and then attach them to the children of the parent Controller.

public class FooController : Controller
{
	private readonly IFactory<BarController> _barControllerFactoy;
	
	public FooController(IFactory<BarController> barControllerFactoy)
	{
		_barControllerFactoy = barControllerFactoy;
	}
	
	protected override UniTask OnInitialize(CancellationToken token)
	{
		return UniTask.CompletedTask;
	}

	protected override async UniTask Running(CancellationToken token)
	{
		await Children.Run(_barControllerFactoy);
	}
}

You can run any Controller anywhere in the tree, in any part of the game. For example, say you have created a shop Controller. You can run the shop Controller on the core game, in the lobby, or on the popup.

This behavior is also very useful when you need to write integration tests or debug scenes for tech art or integrators. You can create a scene and only run the core game, but doing so could decrease your iteration time.

The ability to dispose of all allocated resources in each Controller

We attach all allocated resources to the Controller. For example assets, object pool references, and game object instances.

Usually, we load them to initialize and attach them to ResourcesHolder, which disposes of them once the Controller stops. If you attach all resources to each Controller, you will get rid of almost all memory leaks from your code.

The ability to reload whole games

According to the architecture, games are started from RootController. When you stop RootController, then all trees will stop, making it possible to re-run games in their entirety.

To do this, add an example with MainGameSessionRestartEvent.

Reload times can be optimized in Editor

Unity has an “Enter” setting in Play mode. This allows us to save around 7 seconds on each recompilation, which is an average time for domain reload. The “ability to reload the whole game,” enables us to disable the domain reload, without experiencing any issues.

Important note: third party SDK and singleton required support to reset data for reusing:

https://docs.unity3d.com/2019.4/Documentation/Manual/ConfigurableEnterPlayMode.html

The ability to hold resources in memory, to decrease level loading time

With the addon, we were unable to dispose of resources from the marked Controller. For example, when using addressable, we could not unload resources from RAM, and when we loaded the same level, resources were already in memory; there was no need to load them again.

Also, you could implement an object pooling the same way as asset resources, and bind references to each Controller, enabling you to reuse spawned objects without performing recreation by Controller Tree pattern (allocate – release).

Controller –  a good state, in which you can run

Controllers have all that is required for promoting a good state, enabling actions such as “Initialize” (Including payloads), “Execute,” “Stop,” and “Dispose of all used resources.”

As such, they can be used to create any state machine with any behavior that you like. You can also create a new state machine inside the state, as you can run any Controller in any other Controller.

Testable architecture

When it comes to integration tests, you can run any Controller in an isolated environment. It could be a small unit Controller, or it could be a run Controller with a full feature.

With respect to end-to-end tests, the Controller tree is a tree that can be used to recognize all started Controllers at any point. All you need is access to RootController.

If you use states, you could know the current game state and test to match the current state in the test.

The following is an example of an end-to-end test using states:

  1. Await certain state
  2. Obtain the instance of state
  3. Obtain the field with the button from that state (nobody except for the state has reference to that object, because it was created in the state)
  4. Press the button or form any other activity
  5. Repeat

For more details, read the end-to-end tests article.

When performing this test, you won’t be dependent on the object hierarchy or button name, should you be writing tests in a unity way. Also, it makes tests quick and independent of loading game/resources or calculation time. This is because you await the target state, instead of awaiting hardcoded seconds. It could be less time than is needed and the test will fall or more time than is needed, increasing test execution time.

Sending events up and down by a tree

Controllers cannot have public methods, only Controller’s methods. The only way to communicate with each other is through events, such as BubbleEvents and DivingEvents.

BubbleEvents send events toward the root. Each Controller can handle the event as it goes up, but won’t be processed.

DivingEvents are similar to BubbleEventsm but are sent down by a tree.

When is it good to use a Controller Tree?

Use with addressable asset management

On Solitaire Grand Harvest, we use Controller Tree with addressable, allowing us to enjoy certain benefits, such as:

  1. Holding and managing all resources in Controllers. Addressable allows you to manage load/unload assets in RAM and are perfect for Controller Tree architecture.
  2. The internal ref count is addressable. If you have two asset usages in two different Controllers and are going to dispose of one, assets won’t be released because addressable still holds the reference count.
  3. You do not need scenes in Unity (of course, this is if you do not use lighting, etc.). In 2D games, we mostly use change scenes for unloading unused resources. When we do not need to unload resources of a scene (all resources are managed by addressable and not linked to the scene), then we can work in a single scene with prefabs from addressable. Our small benchmark: there are prefab and similar scenes. TestSingleScene spawn 4 prefabs in one scene. TestMultiScene will spawn 4 additional scenes.

Cons

Not good for testing in unit tests

Because you don’t have public methods in Controller, you can run only the full Controller, not small parts of one, or one private method.

Overhead for each Controller

Our implementation includes all of the above-mentioned abilities, which leads to a higher overhead for each Controller. Each Controller also has a logger, two internal resource managers, a children list, ctx tokens, try/catch, and an event handler. This is why we do not create very small Controllers.

Summary

The controller’s tree recommended itself as a good architecture for making module video games. This approach is agile and could be adapted to meet your requirements, such as having an async base, focusing on reusing resources, supporting your DI framework, or making controllers more lightweight. Feel free to comment!



Tags