DEV Community

Cover image for Unity game architecture Part 2
Clément L.
Clément L.

Posted on

Unity game architecture Part 2

More DI, FSM entry points and MessagePipe

This is part 2 of a series of articles about game architecture in Unity.

If you haven't read part 1 yet, you can find it here.

As I was thinking about the next part of this series, I realized that having access to the full source code of the project would be a great way to illustrate the concepts I'm talking about...
I cannot just share Bill's source code, so I decided to create a new project, with a similar architecture, but with a different game : Flare Survivors so you can follow along and see the code in action here : clandais/flare-survivors.

Adding Messages to the Pipe

I was looking for a way to keep things decoupled and stumbled upon Cysharp/MessagePipe.

It was perfect as it was designed to be used with Dependency Injection and it was also compatible with VContainer and built on top of UniTask.
MessagePipe promotes itself as a "high performance in-memory messaging pipeline for .NET and Unity".

It also comes with a diagnostics window to keep track of who is subscribing to what !

MessagePipe's Diagnostic Window

The integration of MessagePipe was pretty easy. It needs to be registered, in my case, in GameLifetimeScope.

public class GameLifetimeScope : LifetimeScope
{        
    protected override void Configure(IContainerBuilder builder)
    {
        RegisterMessagePipe(builder);
        /* ... */
        builder.RegisterEntryPoint<GameEntryPoint>();
    }

    private void RegisterMessagePipe(IContainerBuilder builder)
    {
        // Register MessagePipe
        MessagePipeOptions options = builder.RegisterMessagePipe(
            options =>
            {
                // Enable capture stack trace for diagnostics window
                options.EnableCaptureStackTrace = true;
            });
        // Setup GlobalMessagePipe to enable diagnostics window and global function
        builder.RegisterBuildCallback(
            c => GlobalMessagePipe.SetProvider(c.AsServiceProvider()));
        /* ... */
    }
}
Enter fullscreen mode Exit fullscreen mode

GameEntryPoint as a Finite State Machine

I wanted the GameEntryPoint to handle the game state and that each game state handled its own logic.
On entering a new state, the previous one cleans its garbage and the new one initializes itself and loads its stuff.

In order to keep it simple for now, I went with a simple Finite State Machine.

/// <summary>
///   Base class for all states.
/// </summary>
public abstract class BaseState : IDisposable
{
    public abstract void Enter();
    public abstract void Tick();
    public abstract void Exit();
    public abstract void Dispose();
}

/// <summary>
///  Base class for all game states.
/// </summary>
public abstract class BaseGameState : BaseState
{
    [Inject] protected IObjectResolver Resolver;
    [Inject] protected SceneReferences SceneReferences;
    [Inject] protected IAsyncRequestHandler<LoadSceneRequest, LoadSceneResponse> LoadSceneRequestHandler;
    [Inject] protected CoroutineRunner CoroutineRunner;

    public override void Dispose()
    {
        Resolver?.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

The GameEntryPoint is responsible for handling the current state and switching to a new one.

public class GameEntryPoint : 
        IStartable,
        ITickable,
        IDisposable
{

    // Inject the IObjectResolver to "find" the states
    [Inject] private IObjectResolver _resolver;
    // Inject the ISubscriber to listen to GameStateTransitionMessage
    [Inject] private ISubscriber<GameStateTransitionMessage> _gameStateTransitionSubscriber;

    // Here goes the garbage
    private IDisposable _disposable;
    // The current state (obviously)
    private BaseGameState _currentState;

    public void Start()
    {
        SetupSubscriber();
        // Resolve the first state   
        _currentState = _resolver.Resolve<GameBootState>();
        _currentState.Enter();
    }

    public void Tick()
    {
        // Tick the current state
        _currentState.Tick();
    }

    // When a GameStateTransitionMessage is received, we exit the current state and enter the new one
    private void OnGameStateTransition(GameStateTransitionMessage msg)
    {
        _currentState.Exit();
        _currentState = msg.GameState;
        _currentState.Enter();
    }

    // Setup the subscriber to listen to GameStateTransitionMessage
    private void SetupSubscriber()
    {
        DisposableBagBuilder disposableBag = DisposableBag.CreateBuilder();
        _gameStateTransitionSubscriber
            .Subscribe(OnGameStateTransition)
            .AddTo(disposableBag);
        _disposable = disposableBag.Build();
    }

    // Clean the mess
    public void Dispose()
    {
        _resolver?.Dispose();
        _disposable?.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

Here is an example of a state, the GameBootState.

public class GameBootState : BaseGameState
{
    // Trying to use addressables
    private AsyncOperationHandle<SceneInstance> _gameBootSceneHandle;

    public override async void Enter()
    {
        LoadSceneResponse response = await LoadSceneRequestHandler.InvokeAsync(new LoadSceneRequest
        {
            SceneReference = SceneReferences.MainMenuScene,
        });

        // Poor error handling
        if (!response.IsSuccess)
        {
            Debug.LogError("GameBootState failed to load GameScene.");
            return;
        }

        _gameBootSceneHandle = response.Handle;
        await _gameBootSceneHandle.Result.ActivateAsync();
    }

    public override void Tick() { }

    public override async void Exit()
    {
        await Addressables.UnloadSceneAsync(_gameBootSceneHandle).ToUniTask(CoroutineRunner);
    }
}
Enter fullscreen mode Exit fullscreen mode

MessagePipe in action

Pub / Sub

So, how does it work ?

In the above code sample, where do the LoadSceneRequest, LoadSceneResponse and GameStateTransitionMessage come from ?
It starts with creating messages for some data-sharing. Messages can be simple structs.

public struct GameStateTransitionMessage
{
    public BaseGameState GameState { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

To be able to Inject the ISubscriber<GameStateTransitionMessage> and IPublisher<GameStateTransitionMessage>, we need to register them in the GameLifetimeScope.

private void RegisterMessagePipe(IContainerBuilder builder)
{
    /*
        Register MessagePipe
    */

    // ISubscriber and IPublisher are registered using RegisterMessageBroker
    builder.RegisterMessageBroker<GameStateTransitionMessage>(options);
}
Enter fullscreen mode Exit fullscreen mode

I chose to make child lifetimeScopes Publish the GameStateTransitionMessage when they are done with their job.
Here is an example with the MainMenuEntryPoint (which "lives" in a child lifetimeScope: MainMenuLifetimeScope).

public class MainMenuEntryPoint : IStartable, IDisposable
{
    [Inject] private MainMenuView _mainMenuView;
    [Inject] private IPublisher<GameStateTransitionMessage> _gameStateTransitionPublisher;
    [Inject] private IObjectResolver _resolver;

    public void Start()
    {
        _mainMenuView.PlayButton.onClick.AddListener(OnPlayButtonClicked);
    }

    private void OnPlayButtonClicked()
    {
        _gameStateTransitionPublisher.Publish(new GameStateTransitionMessage()
        {
            GameState = _resolver.Resolve<GamePlayState>(),
        }); 
    }

    public void Dispose()
    {
        _mainMenuView.PlayButton.onClick.RemoveListener(OnPlayButtonClicked);
        _resolver?.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a pretty bare main menu, but you get the idea.

When the PlayButton is clicked, the MainMenuEntryPoint publishes a GameStateTransitionMessage with the GamePlayState as the new state.
The GameEntryPoint is listening to GameStateTransitionMessage and will switch to the GamePlayState when it receives it.

The Mediator Pattern

The French word for "guitar pick

The AsyncRequestHandler<LoadSceneRequest, LoadSceneResponse> works in a sligthly different way. It is a pattern called the "Mediator Pattern".

LoadSceneRequest and LoadSceneResponse are simple structs, too.

public struct LoadSceneRequest
{
    public SceneReference SceneReference { get; set; }
}

public struct LoadSceneResponse
{
    public bool IsSuccess { get; set; }
    // Using addressables, we need to keep track of the handle
    public AsyncOperationHandle<SceneInstance> Handle { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

Then, we need to create a handler for the request.


public class SceneAssetAsyncLoadingHandler 
    : IAsyncRequestHandler<LoadSceneRequest, LoadSceneResponse>
{
    // Extending the IAsyncRequestHandler interface
    public async UniTask<LoadSceneResponse> InvokeAsync(LoadSceneRequest request, CancellationToken cancellationToken = new CancellationToken())
    {
        // load the scene
        // get back the handle
        // omit error handling :D
        return new LoadSceneResponse
        {
            IsSuccess = true,
            Handle = handle,
        };

    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to register the handler in the GameLifetimeScope.

// registration in GameLifetimeScope
private void RegisterMessagePipe(IContainerBuilder builder)
{
    /*** Register MessagePipe ***/
    // Register the AsyncRequestHandler
    builder
        .RegisterAsyncRequestHandler<
            LoadSceneRequest,
            LoadSceneResponse,
            SceneAssetAsyncLoadingHandler>(options);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope I haven't bored you to death with this article. It was a bit heavy on code, but I think it was necessary to illustrate the concepts I wanted to talk about.

Next time, if there is a next time, I'd like to take a break from architecture and talk about something else. Maybe the AssetPostprocessor ?

Top comments (0)