How to Build a Real-time Matchmaking System with PubNub and Epic Online Services (EOS)
This tutorial will combine EOS and PubNub to go from nothing to a dynamic, scalable matchmaking system. So what does this mean?
Let’s rewind. Matchmaking is at the heart of every great multiplayer experience. It takes time and iterations (blood, sweat, and tears) to get it right and form that competitive feedback loop that keeps players returning for more. We have been there before, waiting in a pre-lobby for 5 minutes looking for a match just to match against players way above your skill level. However, here is the real point: good matchmaking isn’t just about skill anymore.
Players care about fair latency (nobody wants to play with 300ms ping). They care about regional proximity (to keep matches smooth and responsive). They want play style compatibility (casual players don’t want to end up in sweaty lobbies). Increasingly, toxicity prevention is becoming a matchmaking consideration too. …
And the list goes on and on.
If you’re still matching only on performance metrics, you’re missing critical factors that impact player retention, satisfaction, and trust.
Prefer to Follow Along with a Video?
You can watch the full tutorial here:
In the video, we’ll go step-by-step through the project, explaining not just what to build, but why each piece matters.
Approaches to Matchmaking
Traditional matchmaking systems like Skill-Based Matchmaking (SBMM) or Engagement Optimized Matchmaking (EOMM) have tried to solve player retention challenges. Still, they often fall into patterns, like predictable win/lose cycles or unfair latency gaps, that eventually break players' trust. If you want more information about Engagement Optimized Matchmaking, check out this document.
Instead of using static models, we will build a dynamic system that
Continuously evaluates the live player pool — adapting to who is online, where, and how they play.
Matches players on multiple live factors — not just skill, but also latency, region, playstyle compatibility, and toxicity levels.
Dynamically adjusts matchmaking rules on the fly, without needing to rebuild or redeploy your servers.
Creates and manages full multiplayer sessions automatically — using Epic Online Services (EOS) to spin up, manage, and destroy game sessions seamlessly once matches are formed.
Scales elastically — whether you have 10 players or 10 million, matchmaking stays fast, fair, and real-time. You’ll also be able to simulate players joining queues and observe how the system forms matches dynamically — perfect for iterating and testing your matchmaking logic.
Why Epic Online Services?
Epic Online Services (EOS) is an open, modular, and free set of online services for game development. EOS’s universal set of game services (multiplayer, achievements, anti-cheat, and more) gives creators the freedom to ship on all platforms while their account services (friends, crossplay, accounts and more) connect hundreds of millions of players with billions of cross-platform friendships.
In this particular use case, we’ll use EOS to manage the lifecycle of matches after they are formed—creating sessions, adding players to sessions, and tearing them down after matches are completed. Key EOS Features used in Player Matching:
Authentication - Player identity across platforms
Sessions Interface - Manage multiplayer sessions and server infrastructure
Why PubNub?
PubNub is built for real-time messaging at massive scale. But it’s not just chat, it powers presence, metadata, pub/sub messaging, and decision intelligence globally with low latency. In our case, PubNub acts as the real-time brain for our matchmaking, evaluating player state, forming matches, and coordinating between server and client. Key PubNub Features used in Player Matching:
Pub/Sub Messaging - Powers the player data between services
App Context - To solve dynamic player metadata that can be uploaded and retrieved at any time
Illuminate - To apply real-time decision-making rules to our matchmaking algorithm
Architecture Overview
Here’s the high-level flow we’ll be implementing:
Players join a matchmaking queue (sending their metadata: skill, region, playstyle, etc.)
PubNub listens for incoming players, aggregates their metadata, and evaluates matchmaking constraints in real time.
When a valid match is found (based on constraints), PubNub sends a message to the server.
The server uses EOS to create a session and assign matched players to it
Players are notified that a match has been found and transition into gameplay
After the game, the session is destroyed, and players can requeue if desired
Getting Started
To follow along with this project, make sure you have the following ready:
Node.js + TypeScript is installed on your local machine
A PubNub account (free tier is fine)
Access to the Epic Online Services (EOS) Developer Portal
A basic understanding of multiplayer game architecture (queues, sessions, etc.) If you don’t have PubNub or EOS accounts yet, take a moment to create them — you’ll need API keys and credentials for both.
Access the Full Project on GitHub
Everything we build in this tutorial — including the matchmaking logic, player simulation scripts, and EOS session management — is available in a public GitHub repository.
You can clone the repo to:
Follow along with the tutorial step-by-step
Explore additional examples (like batch player simulations and advanced constraint tuning)
Extend the project with your features
View the Full Matchmaking Project on GitHub
Feel free to fork it, modify it, or use it as a starting point for your multiplayer projects!
Simulation Approach
In this tutorial, we will build the matchmaking system as a TypeScript-based simulation. This allows us to focus purely on the core matchmaking logic, without worrying yet about integrating with a game client. The core ideas you’ll learn here (queuing players, applying real-time constraints, creating sessions) are the same principles you would use when integrating with an actual game engine. If you’re ready to connect matchmaking to a real game, PubNub also offers SDKS for Unreal Engine and Unity that let you seamlessly implement these systems in production. Once you understand the flow from this TypeScript simulation, you can extend the same logic into your Unreal Engine or Unity projects without starting from scratch.
Project Setup (Orchestration Layer)
Let’s start by scaffolding our project with the tools you will need:
Step 1: Initialize your project
Install the dependencies
Create your tsconfig.json
This tsconfig will tell TypeScript how to compile your project. In this case, it will be outputted in a folder called ./dist, so our package.json must compile the project from that folder.
Create a package.json file
Here, the entry point looks at “dist/main,” where we said our project would be compiled. This file will also hold all the dependencies we need to run this project.
Create a folder called “src” in the root directory and a file named main.ts. The main.ts file will start our project by calling the listener file, which we have yet to create.
Now that we’ve set up main.ts to start our matchmaking loop, let’s build the actual matchmaking logic.
Step 2: Set up the PubNub Keyset
Next, sign in to the PubNub Admin Portal and create a new application.
Inside that application, create a keyset. At the end, you will get a publish and subscribe key. These keys allow your application to send and receive real-time messages through PubNub’s global data network.
What do they do in this demo?
The Publish Key is used to send messages, such as notifying players when they’ve been matched or when matchmaking is in progress.
The Subscribe Key allows the server to listen for new players joining the matchmaking queue.
Together, these keys enable our simulation to behave like a real matchmaking backend — players send metadata, the server subscribes to it, and publishes match results or status updates.
Below are my key settings for this demo. These are the bare minimum settings you will need to run this project, and don’t worry—it’s not a lot.
Note: Message persistence is unnecessary; you can set it to 0 days. The only thing necessary on this key set was App Context. Select the closest region to you to enable that. App Context will be used to upload and retrieve player metadata such as skill level, toxicity, play style or whatever attribute you track within your game.
Step 3: Create a .env
After obtaining your publish and subscribe key from the PubNub Portal, we can take those keys and create an .env file so our project can access them. Make sure your .env is in the root of your project.
From the PubNub perspective, our project is entirely set up. I will set up EOS just in case you follow along using the GitHub repository.
Project Set-up (EOS Server)
Step 1: Set up your Environment
To get started, make sure you have
A C++17-compatible compiler (e.g., clang++ or g++)
CMake installed
The EOS SDK was downloaded from the Epic Developer Portal
Step 2: Create EOS Product
To connect your server to Epic Online Services using the EOS SDK, you’ll need to first create a product in the Epic Developer Portal. This product will serve as the base container for all your services and credentials.
Step 3: Create the EOS Client
Once your product is created, you need to register a client. Your EOS server will use this client to authenticate and interact with EOS services. In this demo, we will utilize the Session API, which will give users the ability to host, find, and interact with online gaming sessions.
To create a client:
Navigate to Product Settings
Click on “Add a new client.”
Give the client a name Click on “Add new client policy” for the next step
Step 4: Create the EOS Policy
Your client must have the correct access policy to perform session management (e.g., creating, starting, and destroying sessions). Give the Policy a name and select the policy type TrustedServer. This designates your client as a backend-only client, capable of calling privileged EOS operations that players cannot (such as force-starting sessions).
Step 5: Install the EOS SDK
We will then navigate back to the product page on the developer portal to install the C SDK. Since our server will utilize C++, the C SDK will be needed to connect to the EOS API.
We will then unzip the SDK and put it in a place we will remember. For the repository on GitHub, you can see that I have an additional .env file inside the “eos” folder. We will have to retrieve those keys from the EOS developer portal. Here are the keys we will have to get in order to connect to EOS.
Don’t worry, it is easy to find all these variables. Navigate to “Product Settings” and scroll all the way down, and you will see all your EOS Credentials there.
Building the Matchmaking Core
This section focuses on the logic that powers real-time matchmaking. Here, we will build out the core component of our server. We’ll break it down into modular parts: constraints, metadata handling, and the core loop that evaluates and matches players.
These fundamental components determine how players are evaluated and grouped into sessions. Once you understand the core, you can customize it to fit your game’s unique needs – whether it’s skill-based matchmaking (SBMM), latency-aware grouping, or custom logic like toxicity or input (controller, mouse) preference.
Defining Matchmaking Constraints
To look at the full constraints file, navigate to this GitHub file: View full constraints.ts
Before players can be matched, we must define the rules against which they’ll be judged. These constraints allow the system to make decisions like:
How much skill difference is acceptable? Can players from different regions match? Should toxic players be excluded?
Here’s an example constraint config used in this demo:
This file will act as the brain behind our matchmaking decisions - we’ll use it in the upcoming matchmaking algorithm.
Dynamic Constraints with PubNub Illuminate
In a real-world matchmaking system, you don’t want to redeploy code every time you adjust your rules. That’s where PubNub Illuminate comes in – it allows you to externalize and manage constraints dynamically in your PubNub dashboard.
To learn more about the basics of PubNub Illuminate, navigate to this blog, which will walk you through setting up your first business object, dashboard and decision.
The first thing I will do with PubNub Illuminate is set up a business object. Business objects allow you to track different types of data sent through PubNub to put it simply. You can also map data from external sources, but for this demo, we will be mapping data that is sent from our client’s games/devices that are using the publish message.
First, navigate to business objects under Illuminate and click on “Create Business Object”. Enter a name and a description and select the keyset you want to use for this demo.
Next, we will map the data fields below to track these metrics. We must be careful here and map it the same way it is getting published through PubNub. First, let’s look into that. In the demo, I am sending a message through PubNub in the following way. After every game is played out, my server will calculate the skill gap of the players (Difference in skill) and the average skill of the game that was just played. I am publishing this message through “illuminate-data”; the channel name does not matter, though Illuminate can filter any data as long as it is published through the same keyset as your business object.
This code can be found in the game.ts file.
Next, under Data Fields inside the business object, you can see that we are mapping the data with the JSONPath $.message.body.skillGap
and $.message.body.avgSkill
, which match the data I am publishing. Note: Ensure your business object is active when editing the data fields, as it will not save.
After the data fields are mapped, we will create some metrics. Metrics allow us to define how we want to track or aggregate our data. For example, do we want to sum, count, average the data and so on over a period of time? Below is an overview of the two metrics I have set up in this demo.
This metric essentially says to track the average Skill Gap across all my games and evaluate these metrics every minute. If I wanted to customize my metrics more, I could have filtered out specific game modes, such as casual or arcade types. I could have also evaluated it over a more extended period, such as 10 minutes, if I didn’t have that many players competing with each other at any single time.
We can map these metrics into a dashboard to visualize them. To do this, we need to navigate to Dashboards. Click on Create Dashboard, insert a dashboard name, and select Add Chart. We will add two charts for each of our metrics. One chart will show us the skill average over time, and the other the skill gap average. Currently, there is no data, but we can simulate data for this demo to show you what that would look like.
Here is what your charts should look like after they are added to the dashboard. Ignore the bottom of the chart, as we haven't attached a decision to our metric yet.
We can now attach a decision to our metrics. Again, decisions allow us to adjust those constraints that we have pre-defined in our code base based on our metric data or in other words, real data from our players.
Let’s navigate to the Decisions tab in the hamburger menu. We will need to make our first decision. Our condition will be based on the metric that we defined previously.
To configure an action, you have a couple of different options to choose from. For this demo, we will select sending a message, which means later we will need to subscribe to the channel the message is sent on to receive the data for the variables that need to be adjusted.
After selecting your method of sending the action, we will select the keyset and channel we want to send the message across. In this case, I named the channel “conditions,” but feel free to select any name that works for you.
The body of the message will send a JSON and use the syntax “${}” to send a variable inside the message. We will define what this variable will be set to later based on a pre-defined set of rules. Let’s go ahead and save that action and set some rules based on our metric “Skill Gap Avg” inside our business object “Player Matching Demo”.
To keep this as simple as possible, we will essentially create a simple “if” statement specifying if the “Avg Skill Gap”, our metric, is greater than 50 over any period, adjust the “Skill Condition” variable in our action to 30 and send that message. If it is less than 50, change the “Skill Condition” variable in our action to 50 and send that message.
Now that we have our action set up officially, we can also add the action to our dashboard, just for visualization purposes, to see when the metric hits a certain threshold, in this case, “50”, we can see an action fire off. For example, when data flows through Illuminate, it will look like the following. The blue line represents the metric “Skill Gap Avg.” When it passes 50 or less than 50, the red bar below will be seen, showing that the action has been fired.
We need a way to subscribe to this action to receive it in the code base now. As represented in the GitHub file constraints.ts, we are subscribing to the channel “conditions” to receive this action and setting the variable “MAX_SKILL_DIFF” in our constraints dynamically to whatever our action's value is.
Creating the Matchmaking Queue and Matcher Logic
Our dynamic constraints adjust based on player metrics. How can we utilize PubNub to match two players together based on these metrics? Well, let’s make a queuing system using PubNub. If you want an overview of the code I will be discussing in this section, navigate to the following files:
View listener.ts on GitHub View matcher.ts on GitHub
Let’s start with the listener.ts. To listen for incoming requests from players to join the matchmaking queue, we will subscribe to a channel to which our players will publish a message. On the server side, we will listen to this channel, and if a message is received, we will accept that as a valid join request from our player.
We can update a specific queue variable to hold the users to be processed later. In this demo, we are using one channel. However, if you have a globally distributed game, we can use channels per region if you need low-latency synchronization across your game. In this case, our code base could look like the following. In this case, the code base for our server could look like the following:
Our player would publish a message to a dedicated server depending on where they live. The code on the client side that would communicate with our server would look like the following:
This would add them to the matchmaking queue, and the players would be matched based on our dynamic constraints.
How do our players get notified that they have been matched together now?
Creating the Matchmaking Queue and Matcher Logic
Now that we have defined a matchmaking queue and set up our dynamic constraints in Illuminate, it is time to build the logic that matches players together. To do this, we are going to use PubNub AppContext.
PubNub App Context allows us to store and retrieve real-time metadata associated with users, channels, and objects. In the context of matchmaking, it’s especially useful for uploading player metadata, such as skill rating, latency, region, toxicity level, input device, or any other attributes relevant to your game.
This metadata becomes the foundation for making matchmaking decisions. Instead of relying on what’s sent in a single message, we can pull the most up-to-date metadata about a player directly from PubNub when evaluating matches.
Note: All data stored in App Context is custom-defined. In this demo, I’ve chosen to upload fields like skill, region, and playStyle, but you can (and should) customize these fields to reflect what matters most in your game.
Setting up PubNub AppContext
To use AppContext, we must first enable it on our PubNub Keyset. We can allow App Context by navigating to the PubNub Portal and App & Keysets. Under Configuration, you will see the option to Enable App Context.
For this demo, we won’t need to enable User Metadata Events, Channel Metadata Events, or Membership Events, since we manually fetch metadata when required. However, if you wanted to react automatically to real-time changes — for example, if a player’s skill, region, or play style changes while in the matchmaking queue — you could enable User Metadata Events.
By doing so, your server or client could subscribe to those metadata changes and immediately update the matchmaking queue or session logic without polling for new data. This can be especially useful in larger-scale games where players frequently update their settings, rank, or preferences.
To enable these events, simply toggle them in the PubNub Admin Portal under App Context settings in your keyset configuration.
After we have enabled App Context, we need a way to upload data to the user's metadata. For more information on this section, check out the App Context docs. In the repository, you can see how I am uploading data by viewing the setMetadataTest.ts.
To upload data, we need to use the setUUIDMetadata function. You would want to do this on the client side of your application, whether that is JavaScript, Unity, or Unreal Engine, you will be able to run this function. The critical thing to notice is that we are setting customFields to true to upload a custom JSON and store the parameters that make sense to us.
We can access this information on the server side of our code base by pulling it in from AppContext. We can see this on GitHub by viewing the matcher.ts file. We will include the uuid of the user and set include customFields to true.
Now, we will have access to the player's skill, latency, input device, favourite weapon, region, etc., and will be able to match based on those parameters.
Tying it Together with the Matcher Logic
Now we have the parameters from App Context and the dynamic constraints from Illuminate, all we have to do now is input a custom equation to match these players based on these constraints and parameters. To view the equation I used in this demo, check the matcher.ts file and navigate to the pairUsersWithConstraints function.
The conditions from Illuminate will act as our conditions and we will analyze both players'players metadata to see if it passes those constraints.
However, we still need to notify the players that they have been matched together. To do this, it's as simple as publishing another message from our server. On the client side, our players will subscribe to their uuid as a channel. This way, we can ensure that the player is the only one subscribed to that channel. Our server will send a message to each uuid individually.
To view where I am notifying the individual players, navigate to matcher.ts file and to the function notifyPlayers.
On the client side, the subscribe will look like such. This way, we can see who the user has been matched with as well.
Integrating with EOS Sessions
Now that we’ve matched two players and notified them, it’s time to go one step further and create a session using Epic Online Services (EOS). A session allows the matched players to join the same lobby or game instance and begin interacting in-game. We’ll be using EOS’s Sessions API, which allows us to:
Programmatically create game sessions
Assign matched players to that session
Manage session lifecycle (start, update, end)
We’ve already configured EOS earlier in the setup section and have the credentials loaded via our .env file in the ./eos directory.
Session Server in C++
For this demo, we have built a small C++ server using the crow web framework. This server is responsible for receiving HTTP requests to create and destroy sessions and calling EOS SDK functions to manage those sessions. To view where the C++ server is in the GitHub repository, navigate to the eos_api.cpp file.
Before we can interact with any EOS services (like Sessions, Auth, or Lobbies), we must initialize the EOS Platform, which acts as the core interface to the EOS SDK. Initializing the platform using EOS_Platform_Create returns a pointer known as the Platform Handle—typically, PlatformHandle in your codebase. The platform handle is essential because it is passed into every major EOS function to identify your running application.
Here is a simplified code snippet which shows how we initialize the platformHandle:
Since we are not using the EOS SDK inside of Unreal Engine and are creating the server completely from scratch, we will need to call the EOS ticker to notify the platform when we are making a request. To do this, I created a function in the code to tick the EOS platform.
We will utilize the sessions interface in the EOS SDK, so we must create the sessions handle. The session handle has the same idea as the platform handle, but is only used when communicating with the EOS sessions API. We can create a session handle by using the following line:
Now that we have all the prerequisites to create a session, we just have to pass in the bucket name, which identifies where the session will be stored and the maximum number of players allowed in each session. With this demo, it is a one vs one player use case so we will set our max players per session to 2.
We are not modifying an existing session so we can set our SessionModificationHandle to null.
We can then interact with this server using Crow to send HTTP requests to our server to notify it and create an EOS session when a match has been made.
Interacting with the C++ Server
In the demo project, I have created a simple API folder in order to send HTTP requests to the server. For the full code, check out the sessions.ts file. For the API request, I will send a simple request containing both the player ids that are matched together.
For best practices, you should create a session with the EOS server before notifying PubNub's players that a match has been made. This ensures the session is created before either player tries to join it from the client side of the game.
Conclusion
In this tutorial, we walked through the complete architecture of a real-time, matchmaking system – built from scratch using PubNub, PubNub Illuminate, PubNub AppContext, and Sessions from Epic Online Services (EOS).
We started by setting up what I like to call the orchestration layer in TypeScript, defining dynamic matchmaking constraints through Illuminate, and managing your player metadata using App Context. From there, we built a region-aware matchmaking queue, matched players based on configurable metrics like skill, region, latency, and play style, and notified matched users with PubNub messages. Finally, we extended our backend by creating a custom EOS session server in C++, allowing our matched players to join an actual game session in a scalable and production-friendly way.
This system demonstrates how you can combine real-time communication with metadata-driven logic and session management APIs to create a matchmaking pipeline that mirrors those used by top-tier multiplayer games. However, most importantly, it is flexible, region-aware, and easy to evolve.
Ready to Build Your Own?
The full source code is available on GitHub and is designed to be forked, customized or scaled:
Add support for more regions or game modes
Tune constraint logic via PubNub Illuminate
Expand player metadata fields to track performance, role, or preference
Integrate with other EOS services such as lobbies, leaderboards, or achievements
Whether you are building a casual game, a competitive shooter, or a massive online experience, this template gives you the tools to move fast, stay flexible, and scale confidently.