Matchmaking API
Everything a game backend can call, and everything it receives back. This is the hand-written reference for the current API; the protobuf definitions are the contract source and will support generated reference material as the public surface expands.
- Surfaces
- Two transports, one API
- Operations
- Core message schemas
- Engine input and output
- Outcome schema
- Error semantics
Surfaces
Section titled “Surfaces”| Surface | Port | Audience | Status |
|---|---|---|---|
gRPC matchmaker.core.v1.MatchmakerService | 9001 | Game backend (write side) | Available |
| HTTP/JSON, the same operations | 8080 | Game backend (write side) | Planned |
| Outcome delivery (webhook / WebSocket / NATS) | — | Game backend (read side) | See Outcomes |
The API is split on purpose: submission is a request/response call, results are
asynchronous. CreateTicket returns as soon as the ticket is durably in the pool, not
when a match is found. Matches arrive later over the outcome sink. There is no
long-polling “wait for my match” call, because a match is a multi-party event and pushing
it is the only shape that works.
Two transports, one API
Section titled “Two transports, one API”Every operation below is available two ways, and they are the same API:
- gRPC on port
9001, servicematchmaker.core.v1.MatchmakerService. - HTTP/JSON on port
8080, using the canonical protobuf JSON mapping.
Field names, validation, and error semantics are identical; only the envelope differs. Pick gRPC if the backend already speaks it and you want the generated client and the lower per-call overhead. Pick HTTP/JSON for Unreal or Unity dedicated-server code, PHP or Ruby backends, serverless functions, or anywhere a gRPC toolchain is more trouble than it is worth. Mixing them is fine: nothing in the server distinguishes a ticket created over gRPC from one created over HTTP.
Planned. In the current build port
8080serves only the health paths. The JSON routes below are part of the managed-service release. The gRPC surface is available today.
| Operation | gRPC | HTTP |
|---|---|---|
| Create a ticket | CreateTicket | POST /v1/queues/{queue_id}/tickets |
| Cancel a ticket | CancelTicket | DELETE /v1/queues/{queue_id}/tickets/{ticket_id} |
| Reactivate tickets | ReactivateTickets | POST /v1/queues/{queue_id}/tickets:reactivate |
| Create a backfill request | CreateBackfillRequest | POST /v1/queues/{queue_id}/backfills |
| Cancel backfill requests | CancelBackfillRequests | POST /v1/queues/{queue_id}/backfills:cancel |
| Clear a queue pool | ClearQueuePool | POST /v1/queues/{queue_id}:clear |
Connecting
Section titled “Connecting”Neither port terminates TLS inside the container: gRPC is plain HTTP/2 (h2c) and the
JSON API is plain HTTP/1.1. Terminate TLS at the platform’s ingress if the customer’s
backend is off-cluster.
gRPC server reflection is enabled, so a customer can explore the API without the protos:
grpcurl -plaintext localhost:9001 listgrpcurl -plaintext localhost:9001 describe matchmaker.core.v1.MatchmakerServiceStandard gRPC health checking (grpc.health.v1.Health) is served on 9001; the
equivalent HTTP probes are /livez and /readyz on 8080.
Bytes fields over JSON
Section titled “Bytes fields over JSON”Three fields are protobuf bytes: engine_input, metadata, and engine_output. In
the canonical protobuf JSON mapping those are base64-encoded strings, so an HTTP
client still has to produce a serialized EngineInput and base64 it.
Planned. A JSON-native alternative for
engine_inputandengine_outputis under consideration: post the engine payload as a plain JSON object and let the server encode it. That would remove the need for HTTP clients to carry protobuf at all, which is most of the point of offering the transport.
Operations
Section titled “Operations”Each operation is shown with its protobuf definition, which is also the JSON body shape.
| Operation | Purpose |
|---|---|
| Create a ticket | Put a player or party into a queue. |
| Cancel a ticket | Remove one ticket from matchmaking. |
| Reactivate tickets | Return resolved tickets to the pool. |
| Create a backfill request | Ask for players to fill an existing match. |
| Cancel backfill requests | Withdraw backfill requests. |
| Clear a queue pool | Empty a queue’s pool. Test tooling. |
Create a ticket
Section titled “Create a ticket”| gRPC | CreateTicket |
| HTTP | POST /v1/queues/{queue_id}/tickets |
rpc CreateTicket(CreateTicketRequest) returns (CreateTicketResponse);
message CreateTicketRequest { Ticket ticket = 1; }message CreateTicketResponse { string ticket_id = 1; }Stores a ticket and makes it immediately eligible for matching. Returns as soon as the ticket is durable (typically single-digit milliseconds), not when a match is found.
The caller supplies the ticket ID, and it has to be a UUID. The game backend generates the ID, so a retry after a timeout is idempotent for free and the backend never has to reconcile an ID it does not recognise.
Over gRPC:
grpcurl -plaintext -d '{ "ticket": { "id": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90", "queue_id": "acme.quickplay", "ruleset_ids": ["5v5"], "engine_input": "<base64 nemesis EngineInput>", "metadata": "<base64 opaque bytes>" }}' localhost:9001 matchmaker.core.v1.MatchmakerService/CreateTicketThe same call over HTTP:
curl -X POST http://localhost:8080/v1/queues/acme.quickplay/tickets \ -H 'Content-Type: application/json' \ -d '{ "ticket": { "id": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90", "queue_id": "acme.quickplay", "ruleset_ids": ["5v5"], "engine_input": "<base64 nemesis EngineInput>", "metadata": "<base64 opaque bytes>" } }'
→ 200 { "ticketId": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90" }Note the JSON field name: the canonical protobuf JSON mapping is lowerCamelCase, so
ticket_id on the wire is ticketId. The original snake_case names are also accepted
on input.
Cancel a ticket
Section titled “Cancel a ticket”| gRPC | CancelTicket |
| HTTP | DELETE /v1/queues/{queue_id}/tickets/{ticket_id} |
rpc CancelTicket(CancelTicketRequest) returns (CancelTicketResponse);
message CancelTicketRequest { string queue_id = 1; string ticket_id = 2;}Removes the ticket from matchmaking. Call this when a player backs out of the queue.
There is an unavoidable race here. The ticket may already be in a match that has been
committed but whose outcome has not yet been delivered. Cancellation then fails with
FAILED_PRECONDITION, and the game backend should expect the match to arrive anyway.
Never treat a failed cancel as “the player is out of queue”.
Reactivate tickets
Section titled “Reactivate tickets”| gRPC | ReactivateTickets |
| HTTP | POST /v1/queues/{queue_id}/tickets:reactivate |
rpc ReactivateTickets(ReactivateTicketsRequest) returns (ReactivateTicketsResponse);
message ReactivateTicketsRequest { string queue_id = 1; repeated string ticket_ids = 2;}message ReactivateTicketsResponse { repeated string failed_ticket_ids = 1;}Returns already-resolved tickets to the active pool. This is the compensating action for a match the backend cannot honour, above all a match whose server provisioning failed. Reactivate the tickets and the players are back in queue without losing their place.
failed_ticket_ids lists tickets that could not be reactivated because they are no
longer in the pool at all, usually because they expired past
terminal_retention_ttl_secs. Those players need a fresh CreateTicket.
That makes terminal_retention_ttl_secs a real integration parameter: it is the window
in which a failed provisioning attempt can still be undone. Set it comfortably above the
orchestrator’s worst-case provisioning timeout.
Create a backfill request
Section titled “Create a backfill request”| gRPC | CreateBackfillRequest |
| HTTP | POST /v1/queues/{queue_id}/backfills |
rpc CreateBackfillRequest(CreateBackfillRequestRequest) returns (CreateBackfillRequestResponse);
message CreateBackfillRequestRequest { BackfillRequest backfill_request = 1; }message CreateBackfillRequestResponse { string backfill_id = 1; }Asks the matchmaker to find players for a match that is already running. Auto-activates
immediately. If a backfill request already exists for the same match_id, it is
cancelled and replaced, so a running server can safely re-post its current open-slot
count on a timer rather than tracking request state.
Planned. The engine does not fulfil backfill requests yet. A request is accepted and pooled, but will expire rather than produce a
backfill.createdoutcome. See Integration Flow § Backfill.
id may be left empty and the server will generate one. match_id must be a UUID.
ruleset_id is required when the queue has more than one ruleset. A slots_requested
of 0 is treated as 1.
Cancel backfill requests
Section titled “Cancel backfill requests”| gRPC | CancelBackfillRequests |
| HTTP | POST /v1/queues/{queue_id}/backfills:cancel |
rpc CancelBackfillRequests(CancelBackfillRequestsRequest) returns (CancelBackfillRequestsResponse);
message CancelBackfillRequestsRequest { string queue_id = 1; repeated string backfill_ids = 2;}Hard removal. Call it when the match fills up or ends.
Clear a queue pool
Section titled “Clear a queue pool”| gRPC | ClearQueuePool |
| HTTP | POST /v1/queues/{queue_id}:clear |
rpc ClearQueuePool(ClearQueuePoolRequest) returns (ClearQueuePoolResponse);
message ClearQueuePoolRequest { string queue_id = 1; }message ClearQueuePoolResponse { uint64 tickets_cleared = 1; uint64 backfill_requests_cleared = 2;}Destructively empties a queue. Intended for E2E test setup and teardown. It ejects every
waiting player and emits ticket.removed and backfill.removed outcomes with reason
CLEAR_COMMAND.
Managed IVK Match requires account credentials. A self-hosted server has no built-in application authentication, so its operator must restrict network access to the customer’s backend. See Authentication.
Core message schemas
Section titled “Core message schemas”Ticket
Section titled “Ticket”message Ticket { string id = 1; // required, UUID, caller-supplied string queue_id = 2; // required google.protobuf.Timestamp created_at = 3; // server-set; any caller value is overwritten optional bytes engine_input = 4; // engine-schema payload repeated string ruleset_ids = 5; // empty = all rulesets in the queue optional bytes metadata = 6; // opaque, round-tripped verbatim}The two payload fields carry most of the integration, and they do different jobs:
engine_input | metadata | |
|---|---|---|
| Who reads it | The match engine | Nobody; IVK Match never inspects it |
| Schema | Fixed by the engine (see below) | Entirely yours |
| Validated | Yes, parsed once at ingestion; a bad payload is rejected | No |
| Returned to you | No (the engine’s own output comes back instead) | Yes, verbatim, on every outcome |
| Default size cap | 32 KiB (max_engine_input_bytes) | 4 KiB (max_metadata_bytes) |
Put matchmaking-relevant facts in engine_input: players, skill ratings, measured
latencies, platform and playlist attributes. Put your own correlation state in
metadata: session ID, party ID, region preference, region of the requesting shard,
anything you want handed back when the match arrives. Use metadata well and you will
not need a side lookup table keyed by ticket ID.
ruleset_ids selects which rulesets the ticket competes in. Empty means all of them.
Unknown or duplicated IDs are rejected at submission.
BackfillRequest
Section titled “BackfillRequest”message BackfillRequest { string id = 1; // optional; server generates if empty string queue_id = 2; // required google.protobuf.Timestamp created_at = 3; // server-set; any caller value is overwritten string match_id = 4; // required, UUID of the running match string ruleset_id = 5; // required in multi-ruleset queues uint32 slots_requested = 6; // open slots to fill optional bytes engine_input = 7; optional bytes metadata = 8;}message Match { string id = 1; // UUID; use it as your session key repeated Ticket tickets = 2; // every ticket, each with its metadata optional bytes engine_output = 3; // engine-defined; teams, host selection google.protobuf.Timestamp created_at = 4; string ruleset_id = 5; // which ruleset produced this match}tickets is a flat list. Team composition lives inside engine_output, since how
players are split into teams is the engine’s business. Decode engine_output with the
schema of the engine your ruleset names.
Backfill
Section titled “Backfill”message Backfill { string id = 1; BackfillRequest request = 2; // your original request, echoed in full repeated Ticket assigned_tickets = 3; // tickets assigned to fill it google.protobuf.Timestamp created_at = 4;}Engine input and output
Section titled “Engine input and output”engine_input and engine_output are protobuf messages whose schema is set by the match
engine. Queues run the nemesis engine; its schemas live in package
matchmaker.engines.nemesis.v1.
message EngineInput { repeated Player players = 1; // one entry per player; >1 = a party map<string, Attribute> attributes = 2; // keyed by attr_filter name}
message Player { string player_id = 1; double mmr = 2; // skill rating; unitless, see below map<string, uint32> latencies = 3; // host key -> measured RTT in ms}
message Attribute { repeated string values = 1; // what this ticket *is* repeated string accepts = 2; // what this ticket will *accept*}Four things to get right:
Parties are one ticket. A party of three is a single ticket with three Player
entries. The engine never splits a ticket across teams.
mmr is unitless, and the queue config has to agree with your scale. The engine does
not normalise it. It compares raw values and divides by the reference parameters in the
queue config. The parameter defaults are tuned for a normalised 0.0-1.0 scale, so
if you send a raw ladder rating in the thousands, the skill term will dominate everything
else until you rescale mmr_normalization_ref and party_synergy_bonus to match. See
Nemesis § The skill rating scale.
latencies keys are your host identifiers. The map is opaque to IVK Match. Whatever
string you use (eu-central, fra1, a specific machine ID) is echoed back as the
match’s chosen host, which is the join point with the orchestrator. Use keys that map
straight onto something the platform’s provisioning API accepts and the game backend gets
a lookup instead of a translation layer. A host with no measurement for a player is
assumed to be default_latency_ms.
values and accepts are what implement crossplay. For an attribute in overlap
mode (the default, e.g. playlist) two tickets are compatible if their values share at
least one entry. For containment mode (e.g. platform) every value on each side has to
appear in the other side’s accepts, which is how “a PC player who allows console
opponents can be matched with a console player who allows PC opponents” gets expressed.
A two-player party on a normalised skill rating scale:
{ "players": [ { "player_id": "p1", "mmr": 0.62, "latencies": { "eu-central": 22, "eu-west": 41, "us-east": 96 } }, { "player_id": "p2", "mmr": 0.55, "latencies": { "eu-central": 28, "eu-west": 35, "us-east": 104 } } ], "attributes": { "playlist": { "values": ["TDM", "Upload"], "accepts": [] }, "platform": { "values": ["pc"], "accepts": ["pc", "xbox", "playstation"] } }}message EngineOutput { repeated Team teams = 1; repeated Attribute attributes = 2; // per-attribute intersection across the match HostSelection host = 3; // unset if there were no host candidates
// NOT the same message as the input Attribute: no `values` field. message Attribute { string name = 1; repeated string accepts = 2; // the match-wide intersection for this attribute }}
message Team { string id = 1; repeated string player_ids = 2;}
message HostSelection { string preferred = 1; // the host to provision on repeated string acceptable = 2; // every host acceptable to all tickets}HostSelection is the field the orchestration integration hangs off. preferred is the
best host key for this match; acceptable is every host key that all tickets in the match
can tolerate, which makes it a ready-made fallback list when provisioning on preferred
fails. attributes carries the intersection of each filtered attribute, so the backend
knows which playlist the match settled on without recomputing it. Note that the output
Attribute is a nested message of EngineOutput with name and accepts fields; it is
not the input Attribute type.
For the parameters that control how these inputs are scored, see Nemesis.
Outcome schema
Section titled “Outcome schema”Every asynchronous result is a matchmaker.outcomes.v1.Outcome, regardless of which sink
delivers it.
message Outcome { string id = 1; string kind = 2; // e.g. "match.created" string queue_id = 3; google.protobuf.Timestamp occurred_at = 4; string producer_id = 5; uint64 producer_sequence = 6; // monotonic per producer
oneof payload { MatchCreated match_created = 10; BackfillCreated backfill_created = 11; TicketsExpired tickets_expired = 12; TicketsRemoved tickets_removed = 13; BackfillRequestsExpired backfill_requests_expired = 14; BackfillRequestsRemoved backfill_requests_removed = 15; TicketOrphaned ticket_orphaned = 16; BackfillRequestOrphaned backfill_request_orphaned = 17; }}kind | Payload | Meaning | Backend must |
|---|---|---|---|
match.created | MatchCreated { Match } | A match was formed | Provision a server and notify players |
backfill.created | BackfillCreated { Backfill } | A backfill request was filled | Send the new players to the running server |
ticket.expired | TicketsExpired | Tickets aged out unmatched | Tell the player the search timed out |
ticket.removed | TicketsRemoved + RemovalReason | Tickets left the pool | Re-queue or notify, per reason |
backfill.expired | BackfillRequestsExpired | Request aged out unfilled | Stop waiting for players |
backfill.removed | BackfillRequestsRemoved + reason | Request left the pool | Stop waiting for players |
ticket.orphaned | TicketOrphaned | A ticket referenced a ruleset that was removed mid-flight | Re-queue the player |
backfill.orphaned | BackfillRequestOrphaned | Same, for a backfill request | Re-post the request |
The expired and removed families are batched: one outcome carries a repeated list of
IDs plus an OutcomeBatch { operation_id, chunk_index, chunk_count } so a large sweep can
be chunked across messages and reassembled. Orphaned outcomes are not batched; each one
carries a single ticket or backfill request.
RemovalReason tells the backend whether the removal is the player’s problem or the
operator’s:
| Reason | Cause | Suggested handling |
|---|---|---|
CLEAR_COMMAND | ClearQueuePool was called | Re-queue |
QUEUE_CONFIG_CHANGED | The queue spec changed incompatibly | Re-queue |
QUEUE_RETIRED | The queue was retired | Stop routing to this queue |
RULESET_PARAMS_CHANGED | Engine params were hot-reloaded and stranded the ticket | Re-queue |
Handle outcomes idempotently. Delivery is at-least-once on every sink. Use
Outcome.id, or Match.id for match outcomes, as the dedupe key.
Error semantics
Section titled “Error semantics”One set of failure conditions, expressed as gRPC status codes on 9001 and as HTTP
status codes on 8080.
| gRPC code | HTTP | When | Retry? |
|---|---|---|---|
INVALID_ARGUMENT | 400 | Malformed request: non-UUID id, missing queue_id, payload over max_metadata_bytes / max_engine_input_bytes | No; fix the request |
NOT_FOUND | 404 | queue_id does not exist, when creating a ticket or a backfill request. (Clearing a pool reports every failure as NOT_FOUND.) | No; check the queue config |
FAILED_PRECONDITION | 409 | The request conflicts with the queue’s current state: unknown or duplicated ruleset_id, engine_input that does not parse, a ticket already in a committed match, a retiring queue. Also returned for an unknown queue_id when cancelling or reactivating. | Depends; see below |
UNAVAILABLE | 503 | The queue runtime is starting, restarting, or shutting down | Yes, with backoff |
INTERNAL | 500 | A serialization or internal failure | Retry once, then alert |
Over HTTP the body carries the same detail as the gRPC status message:
{ "code": "FAILED_PRECONDITION", "message": "queue is retiring" }Two cases need explicit handling in the game backend:
Queue retiring. FAILED_PRECONDITION / 409, carrying x-ivk-reason: QUEUE_RETIRING as gRPC response metadata or as an HTTP response header. Do not retry
against this queue; it is going away. Route players elsewhere or surface a maintenance
message.
Container restarting. UNAVAILABLE during a container restart or upgrade. Retry with
exponential backoff. The pool is restored from its checkpoint on boot, so tickets created
before the restart are still in queue. Show this to a player as a hard failure and a few
seconds of unavailability becomes a visible outage.
Because ticket creation uses caller-supplied IDs, retrying it after a timeout is safe: the same ID cannot produce two pool entries.