12 minutes
Mediator Pattern in Games: One Middleman Instead of a Web of Direct Dependencies
The mediator pattern is what I reach for when several parts of one feature have to stay in sync, and wiring them to each other directly is turning the scene graph into a ball of string.
It sounds abstract until you have lived it. A character-select screen has a portrait list, a preview model, a stat panel, a ready button, a team summary, and a lobby connection. Clicking a portrait changes the preview. Changing the preview changes the stats. The ready button depends on the selection being valid. The lobby needs to know when everyone is ready. If every widget holds a reference to every other widget, adding a single new panel means threading one more dependency through the whole pile.
The mediator offers a different shape. Colleagues do not know about each other. They talk to one object in the middle, and that object decides who else needs to hear about it.
That is the whole pattern in a sentence. The difficulty, as usual, is deciding where the middle object should live and how much it is allowed to know.
The shape of the pattern
A mediator has two kinds of participant.
The colleagues are the components that need to coordinate. They do the actual work: rendering a portrait, computing stats, applying a preview, sending a lobby message. Each colleague knows about the mediator, but not about the other colleagues.
The mediator knows about all of its colleagues and owns the rules for how they coordinate. When a colleague reports a change, the mediator decides what that change means and tells the affected colleagues.
In C#, a minimal version looks like this:
public interface IMediator
{
void Notify(object sender, string message);
}
public sealed class SelectionMediator : IMediator
{
private PortraitList? portraits;
private StatPanel? stats;
private PreviewModel? preview;
private ReadyButton? ready;
public void Register(object component)
{
switch (component)
{
case PortraitList p: portraits = p; break;
case StatPanel s: stats = s; break;
case PreviewModel m: preview = m; break;
case ReadyButton r: ready = r; break;
}
}
public void Notify(object sender, string message)
{
if (sender == portraits && message == "selected")
{
preview?.Show(portraits!.Current);
stats?.Refresh(portraits.Current);
ready?.SetEnabled(portraits.Current != null);
}
else if (sender == ready && message == "ready")
{
// tell the lobby, not the other widgets
}
}
}
The colleagues call the mediator when something happens, and the mediator is the only object that knows the full choreography.
Notice what is missing: no colleague holds a reference to another colleague. The portrait list does not know the stat panel exists. The ready button does not reach into the preview model. That is the payoff.
The cost is equally visible: the mediator knows a lot. That is the tradeoff the pattern asks you to accept on purpose, and most of the discipline around mediators is about keeping that knowledge contained.
Mediator is not an event bus, and not just observer
This is the distinction that causes the most confusion, because all three patterns are about communication without direct references.
Observer is one-to-many. A subject keeps a list of listeners and notifies them without knowing what they do. The subject is reusable because it does not care about its listeners.
An event bus is a global broadcast channel. Producers publish, consumers subscribe, and neither side knows the other. The bus is usually process-wide and generic.
A mediator is a scoped, opinionated object. It does know its colleagues, it does understand the feature, and it makes decisions about the flow. It is closer to a small coordinator or a mini-controller than to a dumb pipe.
The practical difference shows up in where the logic lives. With an event bus, the coordination rules are scattered across the subscribers. When a selection changes, the stat panel subscribes to SelectionChanged and refreshes, the preview subscribes and renders, the ready button subscribes and revalidates. Each listener knows its own slice, and nobody knows the whole flow. That is often what you want for fire-and-forget notifications.
With a mediator, the flow lives in one place. SelectionChanged becomes mediator.Notify(this, "selected"), and the mediator is the single object that knows “a selection change means update preview, then stats, then ready state.” If the rules change, you edit the mediator, not six scattered listeners.
Neither is universally better. Event buses scale to many-to-many communication and let systems stay ignorant of each other. Mediators scale to a bounded group that has a real, deliberate workflow. If your “mediator” has forty colleagues and a stringly-typed message protocol, you have built an event bus with extra steps and should probably just use the bus.
A concrete example: turn-based combat
Turn flow is a natural mediator candidate because it has a real sequence with several actors.
A tactics battle needs to coordinate the turn manager, the unit selection input, the action menu, the movement validator, the attack resolver, and the HUD. If the turn manager holds references to all of those, and the HUD holds references back to the turn manager, you get a cycle that is hard to reason about and harder to test.
A BattleMediator can own the sequence:
public sealed class BattleMediator
{
private readonly TurnManager turns;
private readonly UnitSelection selection;
private readonly ActionMenu menu;
private readonly MoveValidator moveValidator;
private readonly AttackResolver attackResolver;
private readonly BattleHud hud;
public BattleMediator(
TurnManager turns,
UnitSelection selection,
ActionMenu menu,
MoveValidator moveValidator,
AttackResolver attackResolver,
BattleHud hud)
{
this.turns = turns;
this.selection = selection;
this.menu = menu;
this.moveValidator = moveValidator;
this.attackResolver = attackResolver;
this.hud = hud;
}
public void OnUnitSelected(UnitId unit)
{
menu.ShowFor(unit);
hud.Highlight(unit);
hud.ShowMoveRange(moveValidator.RangeFor(unit));
}
public void OnActionChosen(IAction action)
{
if (action is MoveAction move)
{
menu.Hide();
hud.ShowMoveTargets(moveValidator.TargetsFor(selection.Current, move));
}
else if (action is AttackAction attack)
{
menu.Hide();
hud.ShowAttackTargets(attackResolver.TargetsFor(selection.Current, attack));
}
}
public void OnTargetConfirmed(TargetId target)
{
var result = attackResolver.Resolve(selection.Current, selection.Action, target);
hud.ShowResult(result);
turns.Advance();
}
}
The combat pieces stay focused on their own job. The mediator holds the script of the turn. When a new feature lands — an assist action, a reaction trigger, an undo step — it is one more branch in a class that is already the designated owner of that flow.
That is the healthy version. The unhealthy version is when BattleMediator also starts managing the inventory, the quest log, the save system, and the camera, because it happens to be convenient to route everything through one object. The scope line is the difference between a mediator and a god object.
Scope is the whole discipline
A mediator should own one feature, one screen, or one bounded workflow. The selection mediator owns selection. The battle mediator owns the battle turn. The settings mediator owns the settings screen.
The moment a mediator starts coordinating across two unrelated features, it is accumulating the same global knowledge the pattern was meant to avoid. It has not removed the web; it has rolled the web into a ball and hidden it behind a name.
A useful rule of thumb: if you can describe what the mediator coordinates in one short sentence, it is probably scoped well. “Coordinates the character-select screen.” “Coordinates the battle turn.” “Coordinates the in-game HUD and pause menu.” If the description needs a paragraph and three caveats, the mediator is doing too much and should be split along the features it touches.
Splitting is usually cheap. A LobbyMediator can talk to a SelectionMediator through a narrow interface, the same way a colleague talks to its own mediator. Mediators can be colleagues of a higher-level mediator. The pattern composes, and composing is how you keep any single object from swallowing the project.
Keep messages typed and small
The stringly-typed Notify(sender, "selected") shape is the classic textbook version, and it works for small examples. It is also a magnet for typos and for the “who sends which string” scavenger hunt that makes mediator code annoying to debug.
In real game code I usually prefer typed messages. Instead of one Notify method with a string, give the mediator explicit methods like OnUnitSelected and OnTargetConfirmed above, or route a small typed event object through it.
public readonly record struct SelectionChanged(UnitId? Unit);
public sealed class SelectionMediator
{
public void Handle(SelectionChanged changed)
{
preview?.Show(changed.Unit);
stats?.Refresh(changed.Unit);
ready?.SetEnabled(changed.Unit is not null);
}
}
Typed methods or records buy you compiler-checked wiring and make the flow greppable. The strings buy you nothing except a habit that breaks the first time you rename a message and miss a sender.
The tradeoff is that typed mediators are less generic. A single IMediator with one Notify method can be reused across many features; a typed mediator is written per feature. For a game, the per-feature cost is usually worth it, because game features are bespoke anyway and the coordination rules rarely look identical between them.
Do not build a generic reusable mediator framework before you have at least three concrete mediators that are actually painful to write. Generic message-broker infrastructure is a framework problem, and most game teams do not have it. They have three screens that need a coordinator, which is a much smaller problem.
Wiring it in Unity
There are two common ways to give a mediator its colleagues in Unity, and they map to two different stages of a project.
The simplest is a MonoBehaviour mediator that grabs references in the scene. A character-select screen is a concrete scene layout, so a SelectionScreenMediator : MonoBehaviour with [SerializeField] references to the widgets can be wired in the Inspector and left alone.
public sealed class SelectionScreenMediator : MonoBehaviour
{
[SerializeField] private PortraitList portraits;
[SerializeField] private StatPanel stats;
[SerializeField] private PreviewModel preview;
[SerializeField] private ReadyButton ready;
[SerializeField] private LobbyClient lobby;
public void OnPortraitSelected(UnitId unit)
{
preview.Show(unit);
stats.Refresh(unit);
ready.SetEnabled(unit != null);
}
public void OnReadyPressed()
{
if (portraits.Current is not UnitId unit) return;
lobby.SendReady(unit);
}
}
This is fine for a feature that lives in one scene and is authored by hand. The Inspector wiring is explicit, and the mediator is where a designer or another programmer expects to find the screen logic.
The second way is to inject a plain C# mediator with a composition root or a small DI container. This is better for gameplay flows that are created at runtime and tested without a scene: a battle mediator, a match-flow coordinator, a tutorial script. The mediator becomes an ordinary object you can construct in a test with fake colleagues.
var mediator = new BattleMediator(
new TurnManager(),
new UnitSelection(),
new ActionMenu(fakeInput),
new MoveValidator(grid),
new AttackResolver(combatRules),
new BattleHud(fakeView));
mediator.OnUnitSelected(unit);
// assert the fake view received a highlight call
The pattern does not force either choice. Keep the mediation logic in plain C# when you can, because it is the kind of flow logic that benefits from being testable without booting Unity. Wrap it in a MonoBehaviour only where the scene is the natural owner of the wiring.
Common mistakes
The first mistake is treating mediator as a default. Most communication in a game does not need a middleman. A direct call from the player controller to the audio manager is fine. A plain event for “player died” is fine. Reach for a mediator when there is a real multi-step flow with several actors, not because you feel like the architecture should have one.
The second mistake is letting the mediator grow until it knows everything. A mediator that coordinates selection, combat, quests, saves, and settings is no longer a coordinator; it is the object you were trying to avoid. Scope it to one feature and split it when it bleeds over.
The third mistake is using strings for messages and then losing track of who sends what. Typed methods or small typed events keep the flow readable and compiler-checked. If you must use strings, at least define them as constants in one place and document who sends each one.
The fourth mistake is making the mediator do the colleagues’ work. The mediator routes and sequences. It should not also compute damage, render portraits, or validate movement. If the mediator contains the feature’s actual logic rather than its choreography, the responsibilities have blurred and you should push the work back into the colleague that owns it.
The fifth mistake is trying to make one generic mediator interface cover every feature before you have evidence that it pays off. Concrete, small, per-feature mediators are boring but they are also correct. A framework that generalizes over features you have not built yet is a bet on a problem you do not have.
When a mediator is the wrong tool
There are three signals that tell you to use something simpler.
If the coordination is one-way and the sender does not care who listens, use an event or an event bus. A mediator only earns its keep when the flow is deliberate and someone needs to own the whole script.
If the group of components is tiny and stable, direct references may be clearer than a middleman. Two widgets that always change together do not need a coordinator; they can just talk to each other. The pattern is for when the mesh grows and every new widget means several more edges.
If the logic is already owned by a state machine or a controller, a mediator can be redundant. A turn-based battle already has a turn state machine; the mediator might be the state machine with a new name. That is not necessarily wrong, but do not add a second object that does the same job.
The honest way to think about it: a mediator is a tool for keeping many-to-many coordination from becoming a many-to-many reference graph. If you do not have many-to-many coordination, you do not need it.
Practical takeaway
Use the mediator pattern when a bounded group of components has a real workflow that needs a single owner, and the alternative is every component holding references to every other.
Give the mediator explicit, typed methods or small typed events instead of string messages. Scope it to one feature and split it the moment it starts coordinating unrelated systems. Keep the actual work inside the colleagues and the choreography inside the mediator. In Unity, prefer a plain C# mediator for testable gameplay flows and a scene-bound MonoBehaviour only when the scene is the natural owner of the wiring.
The mediator is not a more powerful observer or a fancier event bus. It is a deliberate decision to centralize one small piece of coordination knowledge in exchange for removing the dependency web around it. Centralize the right piece, keep it small, and the pattern stays boring — which is exactly what you want from a middleman.
References
- Refactoring.Guru, Mediator — the classic originator/colleague framing and the comparison with observer.
- Robert Nystrom, Game Programming Patterns, Observer and Event Queue — useful adjacent reading for deciding between a mediator and a broadcast channel.
- Microsoft Learn, Events (C# Programming Guide) — the language-level events that often make a mediator unnecessary for simple one-way notifications.