Building a Powerful Matchmaking Service in .NET with Microsoft Orleans and SignalR
Posted on May 18th, 2025
Creating a powerful matchmaking system, where players are instantly paired into a game session, is a critical feature for real-time multiplayer experiences, but may seem like a pretty intimidating task.
In this post, we'll explore how to implement such a service using Microsoft Orleans (a distributed virtual actor framework) and SignalR (a library for real-time communication). Orleans gives us the distributed scalability, while SignalR provides a persistent client-server connection for pushing real-time updates.
Breaking the problem down
Let's say a player clicks a "quick play" button in our game.
Behind the scenes, we want to add them to a list of people waiting to be matched for a game.
Once we have enough people for a full lobby, we want to set up the lobby for them and notify all of the players that their game is ready.
So we really only have 2 parts to this problem:
- The matchmaking logic (which we can use an Orleans grain for)
- The client-server communication (SignalR hub)
... but wait, what happens if we scale out to multiple instances of our service? Won't we have issues keeping track of our connected clients? Yep! However, we can use Orleans as a "backplane" for SignalR, so an Orleans grain can send a message to any connected client, even if they're connected to a different running instance.
Setup and required packages
First, we'll set up our Server project as an "ASP.NET Core Web API" and our client project as a Console App. Then, we'll need to install some Nuget packages to make all of this possible...
Server - Orleans and SignalR
- Microsoft.Orleans.Core
- Microsoft.Orleans.Sdk
- Microsoft.Orleans.Server
- Microsoft.AspNetCore.SignalR.Core
- SignalR.Orleans
Client - SignalR only
Because our client will not be directly talking to Orleans, we only need to add the SignalR client package
- Microsoft.AspNetCore.SignalR.Client
The Matchmaking Queue
We'll need to start by creating an (empty) SignalR hub:
public class MatchmakingHub : Hub
{}
and then register it in our Program.cs
app.MapHub<MatchmakingHub>("/matchmaking");
Now we'll define a few grains (fun fact: Microsoft calls them "Grains" because they run on a "Silo" which exists in a server "farm"):
IMatchmakerGrain
[Alias("MatchmakingExample.Server.Grains.Interfaces.IMatchmakerGrain")]
public interface IMatchmakerGrain : IGrainWithStringKey
{
[Alias("JoinQueue")]
Task JoinQueue(string connectionId);
[Alias("LeaveQueue")]
Task LeaveQueue(string connectionId);
}
MatchmakerGrain
public class MatchmakerGrain(IHubContext<MatchmakingHub> matchmakingHub) : Grain, IMatchmakerGrain
{
private readonly Queue<string> _queue = new();
public async Task JoinQueue(string connectionId)
{
_queue.Enqueue(connectionId);
if (_queue.Count >= 3)
{
var playerList = _queue.ToList();
_queue.Clear();
var gameId = Guid.NewGuid().ToString();
await GrainFactory.GetGrain<IGameSessionGrain>(gameId).Initialize(playerList);
await matchmakingHub.Clients.Clients(playerList).SendAsync("match:found", gameId);
}
}
public Task LeaveQueue(string connectionId)
{
// Simple removal
var tempList = _queue.ToList();
tempList.Remove(connectionId);
_queue.Clear();
foreach (var id in tempList) {
_queue.Enqueue(id);
}
return Task.CompletedTask;
}
}
and for the game session itself:
IGameSessionGrain
[Alias("MatchmakingExample.Server.Grains.Interfaces.IGameSessionGrain")]
public interface IGameSessionGrain : IGrainWithStringKey
{
[Alias("Initialize")]
Task Initialize(List<string> players);
}
GameSessionGrain
public class GameSessionGrain : Grain, IGameSessionGrain
{
public Task Initialize(List<string> players)
{
// TODO - Store connection IDs or other setup logic
return Task.CompletedTask;
}
}
Now we can go back and flesh out the MatchmakingHub a bit
public class MatchmakingHub(IGrainFactory grainFactory) : Hub
{
public override async Task OnConnectedAsync()
{
// add player to the matchmaking queue as soon as they connect
await QuickMatch();
await base.OnConnectedAsync();
}
public async Task QuickMatch()
{
var matchmaking = grainFactory.GetGrain<IMatchmakerGrain>("QuickMatch");
await matchmaking.JoinQueue(Context.ConnectionId);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var matchmaking = grainFactory.GetGrain<IMatchmakerGrain>("QuickMatch");
await matchmaking.LeaveQueue(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
and lastly, finish by adding Orleans and our SignalR backplane to our Program.cs
builder.Host.UseOrleans(siloBuilder =>
{
siloBuilder.UseLocalhostClustering();
siloBuilder.UseSignalR();
siloBuilder.RegisterHub<MatchmakingHub>();
});
builder.Services.AddSignalR().AddOrleans();
Client setup
Now that our server is all configured, we can set up our client! Fortunately, this is as simple as connecting to our SignalR hub and waiting for a match:found message, like this:
using Microsoft.AspNetCore.SignalR.Client;
var matchFound = false;
// Initialize SignalR connection
var hubConnection = new HubConnectionBuilder()
.WithUrl("https://localhost:44309/matchmaking")
.WithAutomaticReconnect()
.Build();
// Listen for "match:found" messages
hubConnection.On<string>("match:found", (message) =>
{
Console.WriteLine($"Match found: {message}");
matchFound = true;
});
// Start the connection
await hubConnection.StartAsync();
while (!matchFound)
{
Console.WriteLine($"Waiting for a match...");
Thread.Sleep(1000);
}
Console.ReadLine();
await hubConnection.StopAsync();
Demo
And now, if we start up our server and 3 copies of the client, we can see that as soon as the third client connects, a room with a unique ID is created and all connected clients are notified.

Tips and Notes
- In the future, you may want to add some kind of timeout logic so players don't wait forever in low-population queues
- Because the matchmaking logic is contained entirely inside of a grain, and you can have multiple instances of a grain type, we can actually change that GetGrain key from "QuickMatch" to "Casual" or "Intense" and have separate matchmaking queues for each game mode!
- Additional note: you could also keep track of a player's skill level and use this as a kind of skill-based matchmaking
Troubleshooting
- If you get an error similar to Exception thrown: 'System.TypeLoadException' in Orleans.Streaming.dll, you may need to manually install the proper version of Orleans.Streaming (or whichever other library you're getting the error for)
Wrapping up
If you're building real-time multiplayer experiences, this combo of Orleans + SignalR can serve as a foundation for everything from casual card games to competitive FPSes.
If you're interested in checking out the code, everything is public at https://github.com/r3pwn/dotnet-orleans-signalr-matchmaking.