Modular architecture for Unity 3D #1

Since I started working as Unity Developer I wanted to create framework/toolbox that will allow for rapid development of projects. Over the years I tried couple of times and established few design rules. This way I ended up with module based architecture.

Sidenote:

I’m using submodules to import packages into project. I’m fully aware of package manager’s git compatibility but I find requirements for unity package repositories to be too inconvenient.

Design philosophy

  1. Core systems like GUI should be working out of the box.
  2. Frameworks should be easily expandable with extra packages/modules.
  3. Framework should use MonoBehaviour’s magic methods as little as possible.
  4. Core package should contain all basic systems needed for creation of prototypes.
  5. Game Manager is the single initialisation point for module and takes care of their lifecycle.
  6. Game Manager should be only class using Unity’s magic methods.
  7. Heavy use of Scriptable Objects

Module Architecture

I’m fully aware that this code needs a little bit of refactor. I have decided to write this post anyway since in the process I was able to find those problems more easily.

I have placed todos in some obvious places but things that are more OCD related are left uncommented.

App Manager

AppManager handles application lifetime and main application functionalities, such as initialising game manager and loading configuration. It’s a singleton that is instantiated when GameManager.Awake() method is called.

public sealed class AppManager : ASingleton<AppManager>
{
  public static ConfigData ConfigData {get; private set;}
    
  public T GetGameManager<T>() where T : AGameManager => (T) this._gameManager;
  private AGameManager _gameManager;
  public void RegisterGameManager(AGameManager gameManager)
  {
    //Need to rethink if I should ignore the new gameManager
    //and inform user about overlap 
    if (this._gameManager != null)
    {
      this._gameManager.Uninitialize();
    }
    
    ConfigData = ConfigData.Load();
              
    this._gameManager = gameManager;
    this._gameManager.Initialize();
  }

  public static void Quit()
  {
    #if UNITY_EDITOR
    //FixMe
    EditorApplication.isPlaying = false;
    #else
    Application.Quit();
    #endif
    }
}

For now I have decided that AppManager should be sealed and implementation consistent between projects since AppManager has very particular set of tasks.

Game Manager

Game manager is responsible for initialisation and lifecycle of modules.

public abstract class AGameManager : CachedMonoBehaviour
{
  public SceneLoader SceneLoader { get; private set; }

  protected override void Awake()
  {
    base.Awake();
    this.SceneLoader = this.gameObject.AddComponent<SceneLoader>();
    AppManager.Instance.RegisterGameManager(this);
  }

SceneLoader is a component responsible for handling scene switches.Awake adds SceneLoader component and registers game manager to start initialisation process.

Modules Initialisation

public virtual void Initialize()
{
  this._logger = new Logger(GetType().Name, AppManager.ConfigData.GameManagerLoggerLevel);
            
  RegisterCoreModules();
}

[SerializeField]
protected List<AssetReference> _coreModulesReferences = new List<AssetReference>();

private void RegisterCoreModules()
{
  foreach (AssetReference modulesReference in this._coreModulesReferences)
  {
    RegisterModule(modulesReference);
  }
}

During initialisation process logger is created. Logging level is set based on config file. After that core modules are registered. Core modules are group of modules essential for app to work properly.

public void RegisterModule(AssetReference moduleReference)
{
  if(this._modulesReferences.ContainsKey(moduleReference))
  {
    this._logger.LogWarning(
    $"Module {this._modulesReferences[moduleReference]} already
    registered", "Register module action skipped.");
    
    return;
  }  
  this._moduleInitializationQueue.Enqueue(moduleReference);
  this._logger.Log(LoggerLevel.Debug, 
  $"Added Module  to initialization queue.");

  if (this._moduleRegistrationRoutine == null)
  {
    this._moduleRegistrationRoutine = StartCoroutine(RegisterModuleRoutine());
    this._logger.Log(LoggerLevel.Debug, $"Register Module Routine Started");
  }
}

Modules represent systems in the game (i.e. GUI, Sound) so by definition they should be unique. That is why if duplicate of already existing module will try to register, developer will be notified and module ignored.

After validation check module’s AssetReference is added to the initialisation queue. This is the newest addition to this architecture, it is part of the solution for module initialisation order problem. In previous versions of the framework modules had to be added to the core modules list in very specific order. Order was based on dependency needs between modules and was source of potential, needless errors.

private Dictionary<Type, List<DependencyRequestHandler>> _dependencyRequests = new Dictionary<Type, List<DependencyRequestHandler>>();
private Queue<AssetReference> _moduleInitializationQueue = new Queue<AssetReference>();
private Coroutine _moduleRegistrationRoutine = null;

private IEnumerator RegisterModuleRoutine()
{
  while (this._moduleInitializationQueue.Count != 0)
  {
    AssetReference moduleReference = this._moduleInitializationQueue.Dequeue();
    AsyncOperationHandle<GameObject> asyncInstantiateHandle = moduleReference.InstantiateAsync(this.transform);
    //ToDo: Replace that anonymous delegate with proper method
    asyncInstantiateHandle.Completed += delegate(AsyncOperationHandle<GameObject> handle)
    {
      AModuleManager moduleManager = handle.Result.GetComponent<AModuleManager>();
      
      Type moduleManagerType = moduleManager.GetType();
      this._modulesReferences[moduleReference] = moduleManagerType;
      this._modules[moduleManagerType] = moduleManager;

      CheckDependencyRequests(moduleManagerType);

      moduleManager.Initialize(this);
      this._logger.Log(LoggerLevel.Info,
      $"Module {moduleManagerType} successfully registered.",
      $"Registration Queue elements left: {this._moduleInitializationQueue.Count}",
      moduleManager.gameObject);
    };

    while (asyncInstantiateHandle.IsDone == false)
    {
      yield return null;
    }

    yield return null;
  }

  this._moduleRegistrationRoutine = null;
  this._logger.Log(LoggerLevel.Debug, $"Register Module Routine Finished");
}

private void CheckDependencyRequests(Type moduleType)
{
  if (this._dependencyRequests.ContainsKey(moduleType))
  {
    foreach (DependencyRequestHandler dependencyHandler in this._dependencyRequests[moduleType])
    {
      dependencyHandler.SetModuleManager(this._modules[moduleType]);
      this._dependencyRequests.Remove(moduleType);
    }

    this._dependencyRequests.Remove(moduleType);
    this._logger.Log(LoggerLevel.Debug, 
    $"Dependency requests for {moduleType} invoked.",
    $"Dependency requests left: {this._dependencyRequests.Count}");
  }
}

This is where fun begins. After module prefab is instantiated and assigned, system check if there are other modules which requested access to given module. If so reference is set for each dependency request and that automatically invokes event informing requester that reference is ready.

Getting modules

public DependencyRequestHandler GetModule(Type moduleType, Action<AModuleManager> dependencyReadyCallback)
{
  DependencyRequestHandler dependencyRequestHandler = new DependencyRequestHandler();
  if (this._modules.ContainsKey(moduleType) == false)
  {
    this._logger.Log(LoggerLevel.Info, 
    $"Module {moduleType} not registered.",
    $"Adding dependency request to the requests list. \n{moduleType}");

    if (this._dependencyRequests.ContainsKey(moduleType) == false)
    {
      this._dependencyRequests[moduleType] = new List<DependencyRequestHandler>();
    }
    dependencyRequestHandler.RegisterCallback(dependencyReadyCallback);
    this._dependencyRequests[moduleType].Add(dependencyRequestHandler);
    
    return dependencyRequestHandler;
  }

  this._logger.Log(LoggerLevel.Info, 
  $"Creating dependency request for {moduleType.Name}.");
  //ToDo: Change order?
  dependencyRequestHandler.SetModuleManager(this._modules[moduleType]);
  dependencyRequestHandler.RegisterCallback(dependencyReadyCallback);
  return dependencyRequestHandler;
}

Getting module returns DependencyRequestHandler if the module is already registered request is resolved automatically. If request can’t be resolved it is stored in requests dictionary for later.

Modules Uninitialisation

public virtual void Uninitialize()
{
  UninitializeCoreModules();
}

public void UnregisterModule(AssetReference moduleReference)
{
  if (this._modulesReferences.ContainsKey(moduleReference) == false)
  {
    this._logger.LogWarning($"Module {moduleReference.Asset.name} isn't registered.", 
    "Module was never registered, skipping action");

    return;
  }

  AModuleManager aModule = this._modules[this._modulesReferences[moduleReference]];
  aModule.Uninitialize(this);
  this._modules.Remove(this._modulesReferences[moduleReference]);
  this._logger.Log(LoggerLevel.Info, $"Unregistered module {aModule.name}");
}

This one is pretty straightforward, it just grabs reference to the module based on provided assetReference, uninitialises module and removes it from the list. It might be worth mentioning that whole architecture was designed with additive scenes in mind. That is why GM doesn’t bother with destroying modules.

Update

private void Update()
{
  //This line was added during creation of this note. 
  //Possible syntax errors
  if(this._dependencyRequests.Count != 0) return;
  
  foreach (AModuleManager moduleManager in this._modules.Values)
  {
    moduleManager.Tick();
  }
}

AModuleManager

public abstract class AModuleManager : CachedMonoBehaviour
{
  protected Dictionary<Type, DependencyRequestHandler> _missingDependency = new Dictionary<Type, DependencyRequestHandler>();
  public virtual void Initialize(AGameManager gameManager)
  {}
  
  public virtual void Uninitialize(AGameManager gameManager)
  {}

  public virtual void Tick()
  {
    //Not sure which dependency check should be used.
    if(this._missingDependency.Count != 0) return;
  }

  protected void RequestDependency(AGameManager gameManager,Type dependencyType, Action<AModuleManager> callback)
  {
    DependencyRequestHandler dependencyRequestHandler = gameManager.GetModule(dependencyType, callback);
            
    this._missingDependency[dependencyType] = dependencyRequestHandler;
  }

  protected void RemoveDependencyRequest(Type dependencyType)
  {
    this._missingDependency.Remove(dependencyType);
  }
}

This class is really simple, init and tick methods are virtual since not every module will have need for them. Request dependency is used during initialisation, callback should be a method that will handle requested module when it’s delivered.