Blog

How to Add Matchmaking to a Unity Game with Pairiquium

A step-by-step Unity matchmaking tutorial: install the open-source Pairiquium SDK, add a ticket endpoint, start a search, and handle the signed match webhook on your backend.

Published Last checked

Your game works with one player. Now you want two (or ten) to find each other. This guide takes you from an empty project to a working search that ends in a match, using the open-source Pairiquium Unity SDK.

If you want the short version first, the Unity matchmaking page covers what the SDK does and what you still build yourself. This post is the walkthrough with the code.

What you'll build

Three small pieces:

  1. A ticket endpoint on your backend. It asks Pairiquium for a short-lived ticket for a player. Your API key stays on your server and never ships in a game build.
  2. Client code in Unity. It requests that ticket, queues the player, and tells you when a match is found.
  3. A webhook on your backend. Pairiquium calls it with the full player list and team assignments once a match forms.
sequenceDiagram
    autonumber
    participant Game as Game client
    participant Backend as Your backend
    participant P as Pairiquium
    Game->>Backend: Log in and ask for a ticket
    Backend->>P: POST /v1/tickets (API key)
    P-->>Backend: ticket and wsEndpoint
    Backend-->>Game: ticket and wsEndpoint
    Game->>P: Open WebSocket with the ticket
    P-->>Game: Search runs, then MATCH_FOUND
    P->>Backend: Signed webhook with players and teams
How a player gets from your game client to a match: your backend mints the ticket, the client searches over a WebSocket, and your backend receives the signed match webhook.

Pairiquium doesn't host game servers. You keep yours, and the webhook tells your backend who should play together.

Before you start

  • Unity 2021.3 LTS or newer, building for Windows or Linux desktop. The SDK doesn't support WebGL, and consoles need their own transport. Other engines and platforms can use the web API directly.
  • A Pairiquium account and an API key. The key is for your backend only.
  • A backend you can add two routes to. The examples below use Node.js, but any language works.

Step 1: Install the SDK

The simplest way is OpenUPM:

openupm add com.pairiquium.sdk

Prefer the Package Manager? Choose Add package from git URL and enter:

https://github.com/Pairiquium/UnitySDK.git?path=/package#v1.2.0

The package appears as "Pairiquium Matchmaking SDK," and using Pairiquium.Unity; should compile. The SDK pulls in com.unity.nuget.newtonsoft-json for you. If you want to import the sample scenes later, you'll also need the Input System package (com.unity.inputsystem).

Step 2: Create a game mode

In the dashboard, create a game mode. Its ID (for example 1v1) is what your game passes when it starts a search. For a first test:

  • Set the Match Shape to two teams of one.
  • Choose first-come matching. It has no settings and needs no player rating, which makes it the quickest way to see a match form.
  • Add your Notification Webhook URL. It must be a public https address, so while you develop locally, expose your backend with a tunnelling tool such as ngrok.

When you're ready for skill-based matching, switch the mode to MMR and send each player's rating when you mint their ticket (see the comment in the code below).

Step 3: Add the ticket endpoint

Your game never calls Pairiquium's ticket API itself. Anything in a game build can be extracted, so the call has to come from your server. Here's a minimal Node.js version:

import express from "express";
 
const app = express();
 
// Replace with your real auth. It must set req.player.id to your own player ID.
function requirePlayer(req, res, next) {
  req.player = { id: "player-123" };
  next();
}
 
app.post("/matchmaking/ticket", express.json(), requirePlayer, async (req, res) => {
  const body = { userId: req.player.id };
  if (req.body?.preferredRegion) body.preferredRegion = req.body.preferredRegion;
  // For skill-based (MMR) modes, also send the player's rating:
  // body.attrs = { mmr: 1500 };
 
  const upstream = await fetch("https://api.pairiquium.com/v1/tickets", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-MaaS-API-Key": process.env.PAIRIQUIUM_API_KEY,
    },
    body: JSON.stringify(body),
  });
 
  if (!upstream.ok) return res.status(502).json({ error: "ticket_unavailable" });
 
  const { ticket, wsEndpoint } = await upstream.json();
  res.json({ ticket, wsEndpoint });
});

A few things worth knowing about tickets:

  • userId is your own player ID. It comes back unchanged in the match webhook, which is how you link a match to a player.
  • A ticket expires after 5 minutes, works once, and is tied to the region it was minted for. The SDK asks for a fresh one whenever it needs it.
  • Use the wsEndpoint from the response rather than hardcoding a URL.

Step 4: Start a search from Unity

Add this component to a GameObject. It's the SDK's quick start:

using Pairiquium.Tickets;
using Pairiquium.Unity;
using UnityEngine;
 
public class MyMatchmaking : MonoBehaviour
{
    private MatchmakingBehaviour _matchmaking;
 
    void Start()
    {
        var httpClient = new System.Net.Http.HttpClient();
        var ticketProvider = new HttpTicketProvider(httpClient, new System.Uri("https://your-backend.example.com/matchmaking/ticket"));
 
        _matchmaking = gameObject.AddComponent<MatchmakingBehaviour>();
        _matchmaking.Initialize(ticketProvider);
 
        _matchmaking.OnMatchFound.AddListener(metadata =>
            Debug.Log($"Matched! matchId={metadata.MatchId} team={metadata.Team}"));
        _matchmaking.OnError.AddListener((error, isTerminal) =>
            Debug.LogWarning($"{error.Code}: {error.Message}"));
 
        _ = _matchmaking.BeginSearchAsync("1v1");
    }
}

What's happening:

  • HttpTicketProvider calls your ticket endpoint. If your players are logged in, it accepts an optional hook that runs before every request, which is where you attach the player's session token. Because the hook runs each time, reconnects use the current token.
  • Initialize must be called once, before anything else. Calling other methods first throws, and so does calling it twice.
  • BeginSearchAsync("1v1") takes the game mode ID from your dashboard. An optional second argument is the region (for example us-east-1 or eu-west-2). Leave it out and Pairiquium picks the region with the lowest latency for the player.
  • Connection problems are retried with backoff, so you don't need your own retry loop. OnError tells you what went wrong, and isTerminal tells you whether to give up.
  • Events run on Unity's main thread, so it's safe to touch UI and scene objects from them.

Reuse one HttpClient for the life of your game rather than creating one per request.

Cancel, close and track state

public async void OnCancelClicked()
{
    await _matchmaking.CancelSearchAsync();   // stops searching, keeps the connection
}

Once you have a match, call CloseAsync() to close the connection. To show progress, subscribe to the state event:

_matchmaking.OnStateChanged.AddListener(state => Debug.Log($"Matchmaking state: {state}"));

The states are Idle, Connected, Connecting, Searching, Reconnecting, Closed and Faulted. A normal search moves Idle → Connecting → Searching → Idle. Also subscribe to OnSearchSuperseded: if the same player starts a search from another device, the first connection closes, and you should return that player to the menu.

Step 5: Handle the match on your backend

When a match forms, the player's client gets OnMatchFound with a MatchId and a zero-based Team. The client does not receive the other players. That list goes to your backend, as a signed webhook.

The payload includes:

  • gameMode and matchId
  • one entry per player in participants, with the player's UserId (from the ticket), their Team, how long they searched, and the MMR or ping that drove their search
  • a party ID, if the player queued with friends

Here is a handler that verifies the signature, ignores duplicates and answers quickly:

import crypto from "node:crypto";
 
function verifyPairiquiumSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header ?? "").split(",").map((part) => part.split("="))
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;
 
  // Reject anything more than 5 minutes from our clock, to block replays.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
 
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
 
  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
 
const seen = new Set(); // use a database or cache in production
 
app.post("/webhooks/match-found", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
 
  if (!verifyPairiquiumSignature(raw, req.get("Pairiquium-Signature"), process.env.PAIRIQUIUM_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
 
  // Delivery is at-least-once, so de-duplicate on the webhook ID (the matchId).
  const webhookId = req.get("Pairiquium-Webhook-Id");
  if (seen.has(webhookId)) return res.sendStatus(200);
  seen.add(webhookId);
 
  const { matchId, participants } = JSON.parse(raw);
  // Allocate or pick a game server here, then tell each player where to go.
  console.log(`Match ${matchId}`, participants);
 
  res.sendStatus(200); // respond with a 2xx within 5 seconds
});

Things to get right:

  • Verify against the raw body. The signature covers the exact bytes, so read the body before any JSON parsing (that's why this route uses express.raw).
  • Respond within 5 seconds with a 2xx. Anything else counts as a failure and is retried.
  • Expect duplicates. Delivery is at-least-once, and the webhook ID stays the same across retries.
  • Each game mode has its own signing secret. It starts with whsec_, is shown once when you create the mode, and can be rolled at any time if it leaks. Webhooks that fail during a roll are retried, so no match is lost.

From here, what you do with a match is up to you: pick or start a server, then send each player its address.

Optional: let friends queue together

Parties are off by default and switched on per game mode, with a maximum size and a choice of whether a party must stay on one team. In code, connect without searching, form a party, then search as its leader:

await _matchmaking.ConnectAsync();                    // connect without searching
_matchmaking.OnPartyUpdated.AddListener(party =>      // full state every time: render from it
    Debug.Log($"{party.Members.Count} in party, code {party.JoinCode}, {party.PartyStatus}"));
 
await _matchmaking.CreatePartyAsync();                // you are the leader; share party.JoinCode
// a friend, connected the same way:  await _matchmaking.JoinPartyAsync("K7M2QX");
// or invite by userId:               await _matchmaking.InviteToPartyAsync("bob");
//   the invitee gets OnPartyInvite -> AcceptInviteAsync(invite.PartyId)
 
await _matchmaking.BeginSearchAsync("3v3");           // leader only; every member gets OnSearchStarted

A party is matched as a unit and is never split across two matches. Skill-based modes use the average MMR of the party's members.

Try the sample projects

The SDK repo is open source under Apache 2.0, and ships two samples you can import from the Package Manager:

  • Basic Matchmaking. Pick a game mode and region, search, cancel, and see match and error handling, including superseded searches and connection-state changes.
  • Arena Duel. A real-time 2D arena game with a local reference backend, so you can see a complete flow from queue to playing the match. Its backend setup is in the sample's Backend~/README.md.

The samples need the Input System package. Note that the sample backends are for local testing: verify webhook signatures in anything you ship, as above.

Quick troubleshooting

  • Nothing happens when you search: check that Initialize ran first, that your ticket endpoint is reachable from the game, and that the game mode ID matches the dashboard exactly.
  • OnError right away: read error.Code and isTerminal. The Basic Matchmaking sample shows handling for missing attributes (for example a missing MMR in a skill-based mode) and for an unavailable game mode.
  • Your ticket endpoint returns 403 upstream: the key may be invalid, may lack ticket permissions, or the account may still be on the sign-up waitlist.
  • Config changes seem ignored: game mode changes can take up to 5 minutes to apply.

Where to go next

  • Unity SDK docs: events and states, error handling, parties and custom providers.
  • Game mode configuration: tune how strict your matching is and how quickly it widens.
  • Pricing: only matched players count toward your monthly allowance. Queue time and cancelled searches are free.
  • The SDK on GitHub: read it, verify it and change it if you need to.

Questions or stuck? Come and find us in the Discord.