Design an event-driven GameManager for Unity using MessagePipe and VContainer to achieve loose coupling, testable game rules, and easy extensibility, with sample code and unit tests.
When developing a game in Unity, you’ll likely create a GameManager and advance the game by writing the game logic there.
However, other components need to know about the GameManager, and the UI and other components become tightly coupled, so you can’t write things cleanly.
A few years ago, I couldn’t solve this and gave up on game development in Unity.
This time, I came up with an event-driven game manager as a way to solve it, so I’d like to share the idea.
GameManagerTo achieve these, the following approach seems good.
GameRule into an abstract class and implementationsWith a traditional GameManager, other components needed to reference it directly. By switching to an event-driven approach, each component only publishes and subscribes to events and no longer needs to know about each other. This greatly reduces dependencies between components and improves maintainability and extensibility.
By separating the game rules into pure C# classes, you can run unit tests without launching the Unity editor. This helps maintain the quality of game logic and also ensures safety during refactoring.
GameRule into an abstract class and implementationsBy defining the game rule interface in an abstract class and separating out concrete implementations, you can easily add or change different game modes.
MessagePipe is a high-performance in-memory messaging library for C#. It is optimized especially for use in Unity and can operate with Zero Allocation.
For example, in a typical FPS, when you kill an enemy, the kill log UI and the game manager can automatically receive the event and each handle it accordingly.
Unlike UniRx or R3, you can do Pub/Sub cleanly and loosely coupled by DI’ing IPublisher<T>/ISubscriber<T> from the IoC container without needing to know about each other.
By using MessagePipe, you can eliminate direct references between components and enable an event-based design. This lowers coupling across the system and improves testability.
I think it’s easier to filter when using traditional UniRx or R3.
If you really want to use MessagePipe in the UI, I think it’s good to use the BLoC pattern by filtering messages with Rx.
VContainer is a lightweight and fast DI container for Unity. By combining it with MessagePipe, you can efficiently manage dependency injection and event distribution.
If you haven’t downloaded openupm, please install it from here.
# Install MessagePipe
openupm add com.cysharp.messagepipe
# Install VContainer
openupm add jp.hadashikick.vcontainer
Now that the setup is done, let’s think through code assuming a typical competitive FPS Free For All rule.
The rules we’ll consider this time are as follows.
If you’ve played Deathmatch in CoD or Valorant, these rules should be familiar.
Also, by extending these rules, you can support various kinds of competitive games.
using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class GameLifeTimeScope : LifetimeScope
{
[SerializeField] private GameSettings gameSettings;
protected override void Configure(IContainerBuilder builder)
{
// Register MessagePipe
builder.AddMessagePipe();
// Register game settings
builder.RegisterInstance(gameSettings);
// Register game rule
builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
// Register GameManager
builder.RegisterEntryPoint<GameManager>();
// Register other services
builder.Register<PlayerManager>(Lifetime.Singleton);
builder.Register<TimeManager>(Lifetime.Singleton);
}
}
using MessagePipe;
using System;
using VContainer.Unity;
public class GameManager : IInitializable, IDisposable
{
private readonly IGameRule gameRule;
private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
private readonly ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber;
private readonly IPublisher<GameStartedEvent> gameStartedPublisher;
private readonly IPublisher<GameEndedEvent> gameEndedPublisher;
private readonly DisposableBag disposableBag = new();
public GameManager(
IGameRule gameRule,
ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber,
IPublisher<GameStartedEvent> gameStartedPublisher,
IPublisher<GameEndedEvent> gameEndedPublisher)
{
this.gameRule = gameRule;
this.playerKilledSubscriber = playerKilledSubscriber;
this.gameTimeExpiredSubscriber = gameTimeExpiredSubscriber;
this.gameStartedPublisher = gameStartedPublisher;
this.gameEndedPublisher = gameEndedPublisher;
}
public void Initialize()
{
// Set up event subscriptions
playerKilledSubscriber.Subscribe(OnPlayerKilled).AddTo(disposableBag);
gameTimeExpiredSubscriber.Subscribe(OnGameTimeExpired).AddTo(disposableBag);
// Initialize game rule
gameRule.Initialize();
gameRule.OnGameEnded += OnGameEnded;
// Publish game start event
gameStartedPublisher.Publish(new GameStartedEvent());
}
private void OnPlayerKilled(PlayerKilledEvent playerKilledEvent)
{
gameRule.OnPlayerKilled(playerKilledEvent.KillerId, playerKilledEvent.VictimId);
}
private void OnGameTimeExpired(GameTimeExpiredEvent gameTimeExpiredEvent)
{
gameRule.OnTimeExpired();
}
private void OnGameEnded(GameResult result)
{
gameEndedPublisher.Publish(new GameEndedEvent(result));
}
public void Dispose()
{
gameRule.OnGameEnded -= OnGameEnded;
disposableBag?.Dispose();
}
}
// Abstract class
using System;
using System.Collections.Generic;
public abstract class GameRule : IGameRule
{
public event Action<GameResult> OnGameEnded;
protected Dictionary<string, int> playerKills = new();
protected bool isGameEnded = false;
public abstract void Initialize();
public abstract void OnPlayerKilled(string killerId, string victimId);
public abstract void OnTimeExpired();
protected void EndGame(GameResult result)
{
if (isGameEnded) return;
isGameEnded = true;
OnGameEnded?.Invoke(result);
}
}
// Interface
public interface IGameRule
{
event Action<GameResult> OnGameEnded;
void Initialize();
void OnPlayerKilled(string killerId, string victimId);
void OnTimeExpired();
}
// Free For All implementation
public class FreeForAllRule : GameRule
{
private readonly GameSettings settings;
public FreeForAllRule(GameSettings settings)
{
this.settings = settings;
}
public override void Initialize()
{
playerKills.Clear();
isGameEnded = false;
}
public override void OnPlayerKilled(string killerId, string victimId)
{
if (isGameEnded) return;
if (!playerKills.ContainsKey(killerId))
playerKills[killerId] = 0;
playerKills[killerId]++;
// Check win condition
if (playerKills[killerId] >= settings.killsToWin)
{
EndGame(new GameResult
{
WinnerId = killerId,
WinType = WinType.KillLimit,
FinalScores = new Dictionary<string, int>(playerKills)
});
}
}
public override void OnTimeExpired()
{
if (isGameEnded) return;
// Make the player with the most kills the winner
string winnerId = "";
int maxKills = -1;
foreach (var kvp in playerKills)
{
if (kvp.Value > maxKills)
{
maxKills = kvp.Value;
winnerId = kvp.Key;
}
}
EndGame(new GameResult
{
WinnerId = winnerId,
WinType = WinType.TimeLimit,
FinalScores = new Dictionary<string, int>(playerKills)
});
}
}
using System.Collections.Generic;
// Define game events
public struct PlayerKilledEvent
{
public string KillerId { get; }
public string VictimId { get; }
public PlayerKilledEvent(string killerId, string victimId)
{
KillerId = killerId;
VictimId = victimId;
}
}
public struct GameStartedEvent
{
// Add data as needed
}
public struct GameEndedEvent
{
public GameResult Result { get; }
public GameEndedEvent(GameResult result)
{
Result = result;
}
}
public struct GameTimeExpiredEvent
{
// Add data as needed
}
// Define game result
public class GameResult
{
public string WinnerId { get; set; }
public WinType WinType { get; set; }
public Dictionary<string, int> FinalScores { get; set; }
}
public enum WinType
{
KillLimit,
TimeLimit
}
// Game settings
[System.Serializable]
public class GameSettings
{
public int killsToWin = 10;
public float gameTimeLimit = 300f; // 5 minutes
}
using NUnit.Framework;
using System.Collections.Generic;
public class FreeForAllRuleTest
{
private FreeForAllRule rule;
private GameSettings settings;
private GameResult capturedResult;
[SetUp]
public void SetUp()
{
settings = new GameSettings
{
killsToWin = 3,
gameTimeLimit = 300f
};
rule = new FreeForAllRule(settings);
rule.OnGameEnded += (result) => capturedResult = result;
rule.Initialize();
capturedResult = null;
}
[Test]
public void Test_win_condition_by_kill_count()
{
// Arrange
string playerId = "Player1";
// Act
rule.OnPlayerKilled(playerId, "Enemy1");
rule.OnPlayerKilled(playerId, "Enemy2");
// The game is not over yet
Assert.IsNull(capturedResult);
rule.OnPlayerKilled(playerId, "Enemy3");
// Assert
Assert.IsNotNull(capturedResult);
Assert.AreEqual(playerId, capturedResult.WinnerId);
Assert.AreEqual(WinType.KillLimit, capturedResult.WinType);
Assert.AreEqual(3, capturedResult.FinalScores[playerId]);
}
[Test]
public void Test_win_condition_by_time_out()
{
// Arrange
rule.OnPlayerKilled("Player1", "Enemy1");
rule.OnPlayerKilled("Player1", "Enemy2");
rule.OnPlayerKilled("Player2", "Enemy3");
// Act
rule.OnTimeExpired();
// Assert
Assert.IsNotNull(capturedResult);
Assert.AreEqual("Player1", capturedResult.WinnerId);
Assert.AreEqual(WinType.TimeLimit, capturedResult.WinType);
Assert.AreEqual(2, capturedResult.FinalScores["Player1"]);
Assert.AreEqual(1, capturedResult.FinalScores["Player2"]);
}
[Test]
public void Do_not_accept_additional_kills_after_game_ends()
{
// Arrange
string playerId = "Player1";
// Win with 3 kills
rule.OnPlayerKilled(playerId, "Enemy1");
rule.OnPlayerKilled(playerId, "Enemy2");
rule.OnPlayerKilled(playerId, "Enemy3");
var firstResult = capturedResult;
// Act - extra kill after the game ends
rule.OnPlayerKilled(playerId, "Enemy4");
// Assert
Assert.AreEqual(firstResult.FinalScores[playerId], capturedResult.FinalScores[playerId]);
}
}
Debug.Log() without changing existing codeLeaving Unity’s Debug.Log as-is can cause performance degradation or security issues. Also, having Debug.Log() scattered across components makes the code messy.
#if UNITY_EDITOR
using MessagePipe;
using UnityEngine;
using VContainer.Unity;
public class EventDebugger : IInitializable, System.IDisposable
{
private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
private readonly ISubscriber<GameStartedEvent> gameStartedSubscriber;
private readonly ISubscriber<GameEndedEvent> gameEndedSubscriber;
private readonly DisposableBag disposableBag = new();
public EventDebugger(
ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
ISubscriber<GameStartedEvent> gameStartedSubscriber,
ISubscriber<GameEndedEvent> gameEndedSubscriber)
{
this.playerKilledSubscriber = playerKilledSubscriber;
this.gameStartedSubscriber = gameStartedSubscriber;
this.gameEndedSubscriber = gameEndedSubscriber;
}
public void Initialize()
{
playerKilledSubscriber.Subscribe(evt =>
Debug.Log($"[DEBUG] Player killed: {evt.KillerId} -> {evt.VictimId}")
).AddTo(disposableBag);
gameStartedSubscriber.Subscribe(evt =>
Debug.Log("[DEBUG] Game started")
).AddTo(disposableBag);
gameEndedSubscriber.Subscribe(evt =>
Debug.Log($"[DEBUG] Game ended: Winner={evt.Result.WinnerId}, Type={evt.Result.WinType}")
).AddTo(disposableBag);
}
public void Dispose()
{
disposableBag?.Dispose();
}
}
#endif
Unity has an attribute called SerializeReference.
It was added in Unity 2019.3 to make it possible to edit interface implementations and subclasses of abstract classes from the Inspector.
However, standard SerializeReference has the drawback that you can’t swap subclasses.
So, by using Unity-SerializeReferenceExtensions, you can solve this problem.
# Install Unity-SerializeReferenceExtensions
openupm add com.mackysoft.serializereference-extensions
using UnityEngine;
using MackySoft.SerializeReferenceExtensions;
[System.Serializable]
public abstract class GameRuleBase
{
public abstract void Initialize();
public abstract void OnPlayerKilled(string killerId, string victimId);
public abstract void OnTimeExpired();
}
[System.Serializable]
public class FreeForAllRuleSettings : GameRuleBase
{
[SerializeField] private int killsToWin = 10;
[SerializeField] private float gameTimeLimit = 300f;
public int KillsToWin => killsToWin;
public float GameTimeLimit => gameTimeLimit;
public override void Initialize() { /* initialization */ }
public override void OnPlayerKilled(string killerId, string victimId) { /* kill handling */ }
public override void OnTimeExpired() { /* time out handling */ }
}
[System.Serializable]
public class TeamDeathMatchRuleSettings : GameRuleBase
{
[SerializeField] private int teamKillsToWin = 50;
[SerializeField] private int maxTeamSize = 5;
public int TeamKillsToWin => teamKillsToWin;
public int MaxTeamSize => maxTeamSize;
public override void Initialize() { /* team match initialization */ }
public override void OnPlayerKilled(string killerId, string victimId) { /* team match kill handling */ }
public override void OnTimeExpired() { /* team match time out handling */ }
}
// Integrate into GameManager
public class GameRuleConfiguration : MonoBehaviour
{
[SerializeReference, SubclassSelector]
private GameRuleBase gameRuleSettings = new FreeForAllRuleSettings();
public GameRuleBase GameRuleSettings => gameRuleSettings;
}
With this method, you can intuitively switch game rules from the Inspector, and you can also edit parameters specific to each rule.
Furthermore, by combining it with VContainer, you can inject the appropriate implementation based on the configured rule:
public class GameLifeTimeScope : LifetimeScope
{
[SerializeField] private GameRuleConfiguration ruleConfiguration;
protected override void Configure(IContainerBuilder builder)
{
builder.AddMessagePipe();
// Register implementations based on the configured rule
switch (ruleConfiguration.GameRuleSettings)
{
case FreeForAllRuleSettings ffaSettings:
builder.RegisterInstance(ffaSettings);
builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
break;
case TeamDeathMatchRuleSettings tdmSettings:
builder.RegisterInstance(tdmSettings);
builder.Register<IGameRule, TeamDeathMatchRule>(Lifetime.Singleton);
break;
}
builder.RegisterEntryPoint<GameManager>();
}
}
By combining MessagePipe and VContainer, we were able to solve the issues with the traditional GameManager.
Benefits obtained:
You can make things loosely coupled, and you can also switch rules by registering game rules in VContainer.
Also, by making GameRule pure C#, it became testable.
By applying this design pattern, you’ll be able to handle more complex game systems as well. Event-driven design takes some getting used to at first, but once you’re familiar with it, it becomes a very powerful tool.