Skip to content

Matchmaking and lobby testing

Matchmaking is the first service players interact with after login — and the first service to fail when a game launches. Queue storms, party fragmentation under load, and fairness degradation are failure modes that a basic load test will not catch unless the test models the matchmaking workflow explicitly. This page shows you how.

  • A game server load test has established the game server’s concurrent-session capacity. Matchmaking is the funnel that fills those sessions — test it against the same capacity ceiling.
  • Know your matchmaking API surface: the endpoint that submits a match request, the polling or WebSocket callback that returns a match assignment, and the session-join endpoint.

Matchmaking services experience three distinct failure patterns at high concurrency:

Queue storm. At launch or after a patch, thousands of players hit the matchmaking queue simultaneously. The queue backend — typically a priority queue or Redis sorted set — must handle a write spike that is several times the steady-state enqueue rate. Services that are tuned for steady-state throughput often saturate on the initial burst, causing 429 or timeout responses.

Match-formation latency drift. When the queue is full, the matchmaker must evaluate thousands of candidates per second to form balanced matches. Under load, this computation competes with enqueue/dequeue I/O. The visible symptom is that wait times grow super-linearly with queue depth — a queue at 10× normal depth can have 50× normal wait times.

Party fragmentation. Party (group) matchmaking is harder than solo matchmaking: the system must keep a group of 2–5 players together and find a match that fits them as a unit. Under load, the combinatorial search space grows, and parties may be split across matches or left in queue while solo players are matched immediately. Fairness under load — ensuring parties are not systematically disadvantaged — requires a specific test pattern.

A matchmaking load test has three phases per virtual user:

  1. Enqueue — POST to the matchmaking endpoint to join the queue.
  2. Wait and poll — GET or WebSocket-listen for a match assignment, with realistic backoff.
  3. Join session — POST to the session-join endpoint with the match token.
# taurus.yml — matchmaking queue storm
execution:
- executor: k6
concurrency: 1000 # 1 000 players queuing simultaneously
ramp-up: 30s # fast ramp to simulate launch storm
hold-for: 5m
scenario: matchmaking
scenarios:
matchmaking:
script: matchmaking.js
matchmaking.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const queueWaitTime = new Trend('matchmaking_wait_ms');
const matchFoundRate = { matched: 0, timed_out: 0 };
export default function () {
const authHeader = { headers: { Authorization: `Bearer ${__ENV.PLAYER_TOKEN}` } };
// 1. Enqueue
const enqueueRes = http.post(
'https://matchmaking.staging.example.com/v1/queue',
JSON.stringify({ mode: 'ranked-5v5', region: 'us-east' }),
{ headers: { ...authHeader.headers, 'Content-Type': 'application/json' } }
);
check(enqueueRes, { 'enqueue 202': (r) => r.status === 202 });
if (enqueueRes.status !== 202) return;
const ticketId = enqueueRes.json('ticket_id');
const enqueueTs = Date.now();
// 2. Poll for match (up to 60 s)
let matched = false;
for (let i = 0; i < 30; i++) {
sleep(2);
const statusRes = http.get(
`https://matchmaking.staging.example.com/v1/queue/${ticketId}`,
authHeader
);
if (statusRes.status === 200 && statusRes.json('status') === 'matched') {
queueWaitTime.add(Date.now() - enqueueTs);
matched = true;
// 3. Join session
const joinRes = http.post(
'https://matchmaking.staging.example.com/v1/sessions/join',
JSON.stringify({ match_token: statusRes.json('match_token') }),
{ headers: { ...authHeader.headers, 'Content-Type': 'application/json' } }
);
check(joinRes, { 'session join 200': (r) => r.status === 200 });
break;
}
}
check(null, { 'match found within 60s': () => matched });
}

The custom matchmaking_wait_ms trend metric tracks the queue wait time per virtual player. Set a threshold on it — e.g. p(95)<30000 (30 s at p95) — so MaxoPerf fails the run automatically if the matchmaker is too slow under load.

To model party (group) matchmaking, you need multiple virtual users to act as a unit. The most practical approach in k6 is to pre-generate party tokens server-side (via a setup fixture or a small admin script) and feed them as test data:

import { SharedArray } from 'k6/data';
const parties = new SharedArray('parties', function () {
// Each row: { party_token: "...", size: 4 }
return JSON.parse(open('./parties.json'));
});
export default function () {
const party = parties[__VU % parties.length];
// Use party.party_token to enqueue the whole party as one unit
const res = http.post(
'https://matchmaking.staging.example.com/v1/queue',
JSON.stringify({ mode: 'ranked-5v5', party_token: party.party_token }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, { 'party enqueue 202': (r) => r.status === 202 });
}

Upload parties.json as a Test asset in MaxoPerf alongside the script entrypoint.

For launch-day simulation, use a very fast ramp — 30 seconds from zero to full VU count — to reproduce the “game just went live” traffic shape:

execution:
- executor: k6
concurrency:
- const: 0
duration: 0s
- const: 5000 # 5 000 concurrent match requests
duration: 30s # instant storm
hold-for: 3m
scenario: matchmaking

This produces the sharpest possible load shape. Monitor the matchmaking service’s queue depth metric alongside the MaxoPerf results to correlate enqueue rate with queue depth growth.

  • matchmaking_wait_ms p95. This is the headline metric. Growth across the hold phase indicates match-formation latency is degrading.
  • Enqueue error rate. 429 or 503 responses on the /queue POST indicate the backend is rate-limiting or overloaded at the ingestion layer.
  • match found within 60s check failure rate. Any virtual player that times out in the 60-second poll window counts as a failed check. A rising failure rate means players are being abandoned in the queue.
  • Session join errors. A match ticket issued but session join fails indicates a mismatch between the matchmaker’s session-allocation output and the session server’s actual capacity.

Do:

  • Run the matchmaking test alongside a game server load test to validate end-to-end capacity — matchmaking capacity only matters if the game server can handle the matched sessions.
  • Include party tokens in your data set to test group matchmaking, not just solo queues.
  • Monitor server-side queue depth metrics (e.g. Redis queue length) alongside MaxoPerf results to understand the queue pipeline health, not just the client-visible wait time.

Don’t:

  • Use a single static player token for all virtual users — most matchmaking services deduplicate queue entries by player ID and will silently ignore repeat enqueues from the same identity.
  • Treat a 30-second average wait time as acceptable without checking the p99 — a small fraction of players stuck in a 5-minute queue destroys retention even if the median is fine.
  • Skip the session-join step — the test only validates the full funnel if it verifies that a matched ticket can actually convert to a live session.