April 01 2018

A Better Architecture for Unity Games


After working six months on the remake of Diamond Dash with Unity I can say that I learned quite a lot from the engineers at Wooga on top of self reflection. I often learned the soft way, but also the hard way. In any case, after experienced more successes than failures I present my perspective on how a great architecture could look like.

Whether you are building a Unity application or a Unity game, you are doing it from scratch or you are just unhappy with your current system, I think you would profit from reading.

Full-disclosure: a great part of the ideas and system implementations behind this document have been developed at Wooga. I mostly polished and enhanced them further so it would fit the needs of our new project and furthermore took my time to restructure it and write a blog post about it. Spread the knowledge!

The path to a better Unity Architecture – Summary

Let us begin!

1. Dependency Injection

Your classes are not responsible for getting the references they need, do not force them to. They should focus just on their small, well-defined tasks as an extension of the single responsibility principle.

You may use famous DI frameworks such as Zenject and StrangeIoC. However, I encourage you to write your own DI class if you have the time. You will learn a lot through it and will be prepared to deal with possible DI issues in the future. It is possible to write one in less than 100 lines of code; as reference, you may have a look at the same DI script I used when developing Diamond Dash in Unity.

DI allows you to write less code and less code means less chances of things going the wrong way. Your code will be cleaner and your developers happier. A DI system is a critical need for a great architecture. Do not ignore it.

2. Single-entry point

Have a single-entry point in your game for initializing and keeping global objects. This will help you creating, configuring and maintaining global objects: advertisement managers, audio system, debug options, etc.. Equally important is the explicit system initialization order you can set, building the dependency graph.

Another extra benefit will be present if you add a system to detect unhandled exceptions. I those cases, you can show an apologising message to the user and reload the initialization scene so that you reboot (bootstrap) the whole application without actually exiting it.

An example follows:

public class Game : BaseGame
{
    private void Awake()
    {
        DontDestroyOnLoad(this);
        SetupGame();
    }
 
    protected override void BindGame()
    {
        Container.Bind().FromInstance(new FacebookManager());
        Container.Bind().FromInstance(new Backend());
        Container.Bind().FromInstance(new MainPlayer());
    }
 
    protected override IEnumerator LoadProcess()
    {
        yield return new WaitForSeconds(2);
        yield return CommandFactory.Get<LoadMainMenu>().Load(LoadSceneMode.Single);
    }
}

3. Additive scene loading

Be careful with prefabs. Have always in mind the golden rule: as soon as one reference to a prefab (basically any other object) is held, its contents will be fully (recursively) loaded into memory. This means, all assets including textures, geometry, other referenced prefabs, audio clips etc. will be synchronously loaded. It does not matter if they are instantiated or not, since the instantiation process will just create a shallow copy and call the Awake/Start/OnEnable/… methods which can be very expensive in terms of framerate hiccup, memory, battery, etc.. Animators are a good example of an expensive system.

I have seen projects building their UIs entirely on prefabs. These projects, once they scaled up in features and users, could not maintain such a system anymore. While the idea behind it is benign, it translates very much poorly into the Unity ecosystem. You could very well end up with a tasty figure of 40+ seconds loading time in mobile devices for instance.

What are the actual options to solve this better? You can always use asset bundles but maintaining its pipeline is not particularly light. The way I would strongly recommend is using additive scene loading.

The idea is to have one root scene (e.g. main menu) that dynamically loads and unloads scenes additively and asynchronously as needed. They will be stacked on top of each other automatically, although still be careful with canvas sorting orders. This method is a bit more advanced as naive prefab loading but has considerable benefits:

  • The memory footprint is hugely reduced, as you only load the contents of the target scenes you need at any time.
  • The loading times are severely decreased, as there is so much less to process, load and deserialize.
  • The versioning conflicts are mostly avoided, since the project is split into smaller independent parts (scenes) instead of everyone working on a single.
  • The resulting hierarchy is cleaner, well-organized and its layout resembles a stack of cohesive screens. Better organization means more effective work.

You can very well achieve this by forcing every individual scene to have a top-level root object that is responsible for managing that scene. That root object is typically accessed and initialized from the scene loading it for further configuration.

4. Commands

As developers, we are encouraged to engineer decoupled systems that are (automatically) testable. Systems do, however, rarely work independently; they must be often coordinated, i.e. coupled again, to be able to correctly execute certain processes.

One of the golden rules I have is: the object starting a process is responsible for finishing and cleaning it up. Examples:

  • You drop coffee -> you clean it.
  • Function allocates memory -> that same function deallocates it,
  • Manager starts a purchase -> manager finishes it and cleans up
  • Manager Instantiates an enemy -> manager Destroys it when perished
  • Function blocks the interface in tutorial -> that same function unblocks it when finished

An idea that works well is in this context is the command pattern. Behavior can this way be treated as a first-class entity. A command is an instantiable class used for wrapping a method invocation and destroyed when completed. This has the benefit we can store temporal information along its asynchronous invocation in form of object variables. Commands do start and end with a clean state and only return when they have a final result (data, success/failure). Unity plays well with this pattern by using coroutines.

public class MainMenu : MonoBehaviour
{
    [Inject] private CommandFactory _commandFactory;
 
    private void Start()
    {
        StartCoroutine(OpenPopup());
    }
 
    private IEnumerator OpenPopup()
    {
        var popupCommand = _commandFactory.Get<ShowPopup>();
        yield return popupCommand.Run("Showing popup from main menu");
        Debug.Log("Result: " + popupCommand.Result);
    }
}
 
public class ShowPopup : Command
{
    public Popup.ResultType Result;
 
    public IEnumerator Run(string text)
    {
        var loadSceneCommand = CommandFactory.Get<LoadModalScene<Popup>>();
        yield return loadSceneCommand.Load();
 
        var popup = loadSceneCommand.LoadedRoot;
        popup.Initialize(text);
 
        yield return new WaitUntil(() => popup.Result.HasValue);
        Result = popup.Result.Value;
    }
}
public class Popup : MonoBehaviour
{
    public enum ResultType { Ok, Cancel }
    public ResultType? Result;
    [SerializeField] private Text _sampleText;
 
    public void Initialize(string text)
    {
        _sampleText.text = text;
    }
 
    private void OnOkPressed()
    {
        Result = ResultType.Ok;
    }
 
    private void OnCancelPressed()
    {
        Result = ResultType.Cancel;
    }
}

5. Transactions

Setters can be very dangerous, e.g.:

_mainPlayer.SetGold(userInput);

A safer approach is to restrict the write operations to very specific places that have a concrete, explicit reason underneath. This extra security can be simply achieved by offering an injectable read-only interface and to keep its write-enabled object reference.

The read-only interface (e.g. _mainPlayer.GetGold() ) may be injected in every type, especially in user interfaces, while the write-enabled object reference is kept instantiated but not injectable.

The write-enabled object is only available to classes deriving from Transaction. Transactions are atomic and can be remotely tracked to enhance security and debuggability. They are executed on the target class.

public interface IMainPlayer
{
    int Level { get; }
    IResource Gold { get; }
}
 
public class MainPlayer : IMainPlayer
{
    public int Level { get { return _level; } }
    public IResource Gold { get { return _gold; } }
 
    public void ExecuteTransaction(MainPlayerTransaction transaction)
    {
        _injector.Inject(transaction);
        transaction.Execute(this);
        MarkDirty();
    }
    public void SetLevel(int newLevel) { _level = newLevel; }
}
 
public class UpdateAfterRoundTransaction : MainPlayerTransaction
{
    public UpdateAfterRoundTransaction(GameState gameState, string reason)
    {
        _gameState = gameState;
        _reason = reason;
    }
 
    public override void Execute(MainPlayer mainPlayer)
    {
        Debug.Log("Updating after round for reason: " + _reason);
        mainPlayer.SetLevel(_gameState.Level);
        mainPlayer.Gold.Set(_gameState.Gold);
    }
}
 
public class FinishRoundCommand : BaseCommand
{
    public bool Successful;
 
    [Inject] private IMainPlayer _mainPlayer;
    [Inject] private IBackend _backend;
 
    public IEnumerator Run(IngameStatistics statistics)
    {
        Successful = false;
 
        var eorCall = new FinisHRoundCall(statistics);
        yield return _backend.Request(eorCall);
 
        var gameState = eorCall.ParseResponse();
        _mainPlayer.ExecuteTransaction(new UpdateAfterRoundTransaction(gameState, "Normal end of round response"));
        Successful = gameState.Successful;
    }
}

6. Signals/events vs. polling

Let’s start with simple, unofficial definitions:

  • Signals/events
    One may subscribe to those objects to receive information about future updates on a value.
  • Polling
    Every x frames one checks the current value of a variable.

Both reflect the idea of having the chance to react to changes on variables, e.g. animating the change in the gold label after purchasing a package. Let’s now discuss some relevant differences.

Events are always more efficient than polling. Period. The main drawback of events is that their complexity raises exponentially with the amount of those you need for a concrete process. E.g. the text to display in the lives text-box depends on the current amount of lives, modifiers like unlimited lives, internet connectivity, tutorial state, level of the player, special player privileges, etc.. Also, you may forget to unregister signals, which will eventually lead to deadly crashes. In these cases it is often a better alternative to do polling in a Unity-friendly way.

A recommended way of performing polling is using a coroutine that is started once in the setup phase. It runs in the background and every time it is executed you can be sure that you are working with the current state.

public class LivesView : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(MainLoop());
    }
 
    private IEnumerator MainLoop()
    {
        var wait = new WaitForSeconds(1);
        while (true)
        {
            var hasUnlimitedLives = _mainPlayer.HasUnlimitedLives;
            var waitForNewLive = _mainPlayer.Lives == 0;
            if (hasUnlimitedLives)
            {
                SetCurrentState(State.Unlimited);
                _livesUnlimitedCountdownTimer.SetTarget(_mainPlayer.Lives.UnlimitedEndDate.Value);
            }
            else if (waitForNewLive)
            {
                SetCurrentState(State.NewLifeIn);
                _newlifeInCountdownTimer.SetTarget(_mainPlayer.DateTimeOfNewLife.Value);
            }
            else
            {
                SetCurrentState(State.Normal);
                if (_mainPlayer.Lives != _lastAmount)
                {
                    _lastAmount = _mainPlayer.Lives;
                    _livesAmountText.AnimateTo(_lastAmount);
                }
            }
            yield return wait;
        }
    }
}

7.Build pipeline

The build pipeline I set consists of three cooperating technologies:

  • Docker (optional).
    It allows to quick deploy Linux containers preinstalled with the environment you need to build your project (Unity, NDK , Android SDK, etc.). Once you start it, it is ready for compiling without further setup required.
    Docker helps you especially here because there is no need to manually configure and update (Jenkins?) build-nodes anymore. They run in Mac and Linux and the builds are blazingly fast.
  • Bitrise.
    It is build runner software that will be executed in your host (either a docker image or a real host). It is in charge of managing the build process from a high-level prespective. In my case:
    • Unity license activation.
    • Unity build process.
    • Unity license deactivation.
    • Upload to Hockeyapp/aws/testflight
    • Post build link into Slack.
  • uTomate/UBS/Jenkins scripts. At the end of the day you still need some technolgy that controls the build from a low-level side: such as setting the version, changing target platforms, texture compression, texture atlases, asset bundles, etc..

If you happen to have an experienced person around it, I recommend you giving it a try. You can get a stable, powerful and maintainable build pipeline with it. More information in my previous blog post.
In our case we implemented some useful build steps to automatize repetitive tasks:

  • Check scenes unassigned references
  • Texture atlases creation
  • Texture compression formats
  • Asset bundles

I hope you found this information helpful. Lots of credits to the people at Wooga!

The Gamedev Guru Logo

Performance Labs SL
Paseo de la Castellana 194, Ground Floor B
28046 Madrid, Spain

This website is not sponsored by or affiliated with Facebook, Unity Technologies, Gamedev.net or Gamasutra.

The content you find here is based on my own opinions. Use this information at your own risk.
Some icons provided by Icons8