Crux Kata #4: A Matchmaking Queue in an Afternoon (Join, Poll, Connect)
Hands-on kata: ship real matchmaking with Crux: players join a queue by game mode and region, poll a ticket until matched, and connect to a live server from the registry. Includes the admin force-form trick for testing with two players.
The finale of the Crux Kata series: one real backend feature per exercise, working code, under an hour. The earlier katas, cloud saves, leaderboards, and the server browser, all feed this one: matchmaking is where identity, trust, and the server registry meet.
Matchmaking has a fearsome reputation it only half deserves, and the fear is expensive: it is the single most common reason small teams ship friend-codes-only multiplayer and quietly cap their own growth. The half that deserves it, skill ratings, party constraints, backfill, latency-optimal placement at Fortnite scale, is a research career. The half most games actually need is much smaller: players press Play, wait a tolerable moment, and land on a server with opponents. That smaller thing is a queue, a matcher, and a handoff, and it is this kata.
The kata: players join a queue by mode and region, poll their ticket, and connect to the matched server from Kata #3's registry. Time: ~45 minutes. You need: a free Crux project (create one) with the Kata #3 server running, plus two terminal windows to be two players.
The shape: tickets, not sockets
Crux's matchmaking follows the pattern every production system converges on: joining the queue returns a ticket, the client polls its status, and when the status flips to matched, the response carries the match, including which server to join. No persistent connection to hold open, no lobby process to babysit, and the client logic fits on a slide.
Step 1: press Play
import { GSBClient } from "@supercraft/gsb";
const crux = GSBClient.forPlayer(CRUX_API_URL, "proj_...", "env_...", "gsb_apikey_...");
const auth = await crux.loginAnonymous();
// The whole "Play" button:
const ticket = await crux.joinMatchmaking(auth.player_id, "deathmatch", "eu-west");
Mode and region are the two parameters that matter at this scale. Mode partitions the queue (deathmatch players match deathmatch players); region keeps the eventual match playable. Use the same region identifiers you registered servers with in Kata #3, the registry and the queue speak the same vocabulary on purpose.
Step 2: the waiting room
async function waitForMatch(timeoutMs = 60000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const s = await crux.getMatchmakingStatus(); // "waiting" | "matched"
if (s.status === "matched") return s.match;
await new Promise(r => setTimeout(r, 2000)); // poll every 2s
}
await crux.leaveMatchmaking(auth.player_id); // give up cleanly
return null;
}
const match = await waitForMatch();
Three UX rules for the waiting room, all cheap, all routinely skipped. Show elapsed time, an honest timer reads as fairness, a spinner reads as a hang. Make cancel real: wire the button to leaveMatchmaking so an abandoned queue slot does not ghost-match thirty seconds after the player left (the saddest bug in multiplayer). And time out with grace: after sixty seconds, offer the Kata #3 server browser as the fallback ("or browse servers directly"), which converts a failed queue into a session instead of a quit.
Step 3: the handoff
The matched ticket carries everything the client needs to connect, including the participants and the server placement drawn from the live registry:
if (match) {
console.log("match", match.id, "on", match.server_address);
console.log("players:", match.participants.map(p => p.player_id).join(", "));
connectToGameServer(match.server_address); // your engine's join call
}
Notice what just composed: the registry knows which servers are alive (heartbeats, Kata #3), the queue knows who wants to play, and the match marries them, so a crashed server can never be a match destination, because a crashed server is not in the registry. This is the quiet payoff of building on one platform instead of three services taped together: the failure modes cancel each other out.
Step 4: testing without a crowd
The classic matchmaking development problem: you are one person, and queues need crowds. Two answers. The honest one: run the join snippet in two terminals with two anonymous players, the smallest real match. The fast one: the admin API can force-form a match from whatever is in the queue (and inspect or clear the queue), which turns "wait for the matcher" into a button during development. The queue admin endpoints live under the project-control credential, the same trust tier as the dashboard, so your test rig can drive the whole loop:
// Project-control credential (dashboard / CI only, never in clients):
// GET .../matchmaking/admin/queue - inspect who is waiting
// POST .../matchmaking/admin/form - force-form a match now
// GET .../matchmaking/admin/matches - list formed matches
Force-form plus two terminal players gives you a deterministic end-to-end test: join, force, poll flips to matched, handoff fires, both "players" print the same server address. Put exactly that in CI and matchmaking stops being the feature you are afraid to touch.
The acceptance test
Run the Kata #3 server. Join the queue from two terminals. Watch both tickets flip to matched with the same match ID, confirm the server address matches the registered server, and then kill the queue mid-wait from terminal two and confirm terminal one keeps waiting rather than matching a ghost. Total wall-clock for the full series, katas one through four: an afternoon, and you now have identity, saves, leaderboards, a server browser, and matchmaking, which is, not coincidentally, the feature list of a shippable multiplayer game's backend.
Production notes before you ship it
Be honest about what this matcher is. Mode-and-region matching is the right scope for co-op games, casual PvP, and any game whose real problem is "find people now". What it deliberately is not: skill-based rating, party-aware team balancing, or cross-region latency optimization. Most games that think they need those at launch need population first; a fast queue that always pops beats a fair queue that never does.
Queue time is a population dial, not a constant. Wire the timeout and the fallback (the Kata #3 browser) to be config-driven via the remote config bundle rather than hardcoded, so launch-week you can tighten and quiet-month you can loosen without a client patch.
Instrument the funnel from day one. Joins, matches formed, time-to-match, abandons: four counters that tell you whether matchmaking is working before the reviews do. The admin queue and match endpoints give you the raw material; a daily script gives you the chart.
Plan the empty-queue story. At 4 a.m. in a small region, the queue will not pop. The graceful ladder: widen to adjacent regions after thirty seconds, then offer the browser, then offer a solo mode if you have one. Every rung you build is a player you keep.
Kata variations to try
- The duo variation: two friends join the same mode and region within the same window and you verify they land in one match, then build the "party up" UI around that observed behavior.
- The rematch variation: at match end, both clients re-join with one tap; measure how often rematches pop instantly because the players are already co-located in queue terms.
- The CI variation: the force-form loop from step 4 as an end-to-end test that runs on every backend deploy, matchmaking as a regression-tested feature rather than a prayer.
Kata FAQ
Why polling instead of a websocket push? Because a 2-second poll is indistinguishable from push at matchmaking timescales, works through every firewall and console network stack, and removes an entire class of connection-state bugs. Boring transport is a feature in the join path.
What if no server is available when a match forms? That is a fleet-capacity signal, not a matchmaking bug: the registry from Kata #3 is your early-warning system (regions trending full), and the graceful ladder in the production notes covers the player-facing fallback.
How many players does a match need? That is queue configuration, not client code: the matcher forms matches at the size your mode defines, and the client loop in this kata is identical for 1v1 duels and 16-player lobbies, which is precisely why it belongs in a platform.
Can I run ranked and casual queues side by side? Yes, modes partition the queue, so ranked-dm and casual-dm are simply two modes. Pair the ranked one with a per-season leaderboard from Kata #2 and you have the whole competitive loop on one platform.
What you just avoided building
A self-built matchmaker is a queue store, a matcher loop with locking (the bug where one player lands in two matches will find you), ticket expiry, the admin tooling to see why "matchmaking is broken" at 2 a.m., and the integration with whatever server fleet you run. It is also the component with the spikiest load profile, dead all day, slammed at launch, which is exactly when you least want to be debugging it. The platforms that solved this well charge enterprise prices for it; the platforms that price it accessibly tend to skip the dedicated-server integration. That gap is the niche Crux deliberately sits in, and the comparison pages (PlayFab, Beamable, AccelByte) show where each alternative draws its lines.
Run this kata (and the series): create a free Crux project, and the four katas take an afternoon end to end: saves, leaderboards, server browser, matchmaking. The Unity, Godot, and Roblox SDKs ship the same calls in-engine, and the cost calculator tells you what launch week would cost before launch week does.