April 01 2018
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!
Let us begin!
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.
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);
}
}
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:
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.
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:
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;
}
}
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;
}
}
Let’s start with simple, unofficial definitions:
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;
}
}
}
The build pipeline I set consists of three cooperating technologies:
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:
I hope you found this information helpful. Lots of credits to the people at Wooga!