Outcomes
An outcome is anything IVK Match has to tell you about asynchronously: a match was formed, a backfill was filled, tickets expired or were removed. The message schema is identical across every sink; see Matchmaking API reference § Outcome schema. This document is about how outcomes get to the game backend.
- Why this is a pluggable choice
- Sink comparison
- Webhooks
- WebSocket
- NATS JetStream
- Choosing a sink
- Delivery guarantees
Why this is a pluggable choice
Section titled “Why this is a pluggable choice”A formed match is a commitment. By the time an outcome exists, the tickets have already left the pool and those players are no longer matchmaking. Lose the outcome and they are stranded, waiting on a screen that will never advance.
So the delivery path is not an implementation detail. It has to be durable enough that a game backend which was down for thirty seconds during a deploy resumes exactly where it left off, and simple enough that a small studio can consume it without operating a message broker. Those two requirements pull in opposite directions, so the sink is configurable.
Under every sink, an outcome is recorded durably before it is delivered and is not considered handled until the consumer acknowledges it. A failed webhook or a dropped WebSocket delays delivery; it does not lose it. That machinery is internal. What a customer chooses is how outcomes arrive.
Configured per queue
Section titled “Configured per queue”The sink is part of the queue’s YAML file, not a container-level setting. Two queues in
the same container can deliver to different endpoints, a customer can move from one sink
to another with a config edit rather than a redeploy, and delivery config hot-reloads
like everything else in the file. See
Queue Configuration § config.outcomes.
Sink comparison
Section titled “Sink comparison”| Sink | Status | External infrastructure | Delivery | Best for |
|---|---|---|---|---|
| Webhook | Planned | None (customer HTTPS endpoint) | Push, at-least-once, retried | Small studios; serverless backends; the fastest integration |
| WebSocket | Planned | None | Push, at-least-once, resumable | Backends wanting low-latency streaming without an inbound endpoint |
| NATS JetStream | Available | NATS cluster | Pull or push, at-least-once | Customers who already run NATS; multi-replica deployments |
Webhooks
Section titled “Webhooks”Planned. Not in the current build.
The lowest-effort integration for a customer. Give IVK Match an HTTPS URL and it pushes outcomes to it. No broker, no client library, no polling loop; just an HTTP handler.
POST https://backend.example.com/ivk/outcomesContent-Type: application/x-protobuf (or application/json)X-IVK-Delivery: 0f8c… unique per delivery attemptX-IVK-Outcome-Id: 3f2b… stable per outcome; dedupe on thisX-IVK-Queue-Id: acme.quickplayX-IVK-Kind: match.createdX-IVK-Timestamp: 1753600000X-IVK-Signature: sha256=… HMAC over timestamp + bodyIntended semantics:
| Aspect | Behaviour |
|---|---|
| Success | Any 2xx. The outcome is acknowledged and will not be sent again. |
| Failure | Non-2xx, timeout, or connection error; retried with exponential backoff and jitter |
| Retry budget | Bounded; on exhaustion the delivery is parked and surfaced as an alertable metric rather than dropped |
| Ordering | Per queue, in order. A stuck delivery blocks that queue’s subsequent outcomes rather than reordering around it |
| Duplicates | Possible. Dedupe on X-IVK-Outcome-Id. |
| Batching | Optional; several outcomes per request to cut round trips at high volume |
| Encoding | Protobuf by default; JSON available for backends that cannot decode protobuf |
| Auth | HMAC-SHA256 signature over timestamp and body using the configured secret. Verify it, and reject stale timestamps to prevent replay. |
Configuration, in the queue file:
config: outcomes: sink: webhook webhook: url: https://backend.example.com/ivk/outcomes secret: ${ACME_OUTCOME_SECRET} encoding: protobuf # or json timeout_ms: 5000 max_retries: 8For the receiving handler: ack fast, process asynchronously. Persist the outcome,
return 200, and do the provisioning work on your own queue. A handler that provisions a
server before responding will hit the delivery timeout and cause a retry storm, with
duplicate provisioning attempts as the visible symptom.
The ordering guarantee cuts both ways. Per-queue in-order delivery means a handler that consistently fails on one poisonous outcome halts that queue’s deliveries. That is the right default, since skipping a match is worse, but it does make the parked-delivery metric something to alert on.
WebSocket
Section titled “WebSocket”Planned. Not in the current build.
A persistent stream for backends that want push delivery without exposing a public HTTPS endpoint: useful when the backend is behind NAT, is a dedicated-server process, or is a long-lived worker.
GET /v1/outcomes/stream?queue_id=acme.quickplay&from_offset=1234Upgrade: websocketIntended semantics:
- Subscribe to one queue, a namespace prefix (
acme.>), or everything. - Resumable: connect with
from_offsetto replay everything missed while disconnected, or omit it to start from the newest outcome. - The client acks offsets on the same socket; acks advance the durable consumer position.
- Heartbeats and server-side idle timeouts detect half-open connections.
- Multiple concurrent consumers with independent offsets.
Compared to webhooks: lower latency, no inbound HTTPS endpoint required, but the client owns reconnection and offset bookkeeping.
Configuration, in the queue file:
config: outcomes: sink: websocketNATS JetStream
Section titled “NATS JetStream”Available today. This is how outcomes are published in the current build.
Requires a JetStream-enabled NATS server. That is a fine choice for customers who already run NATS, and it is mandatory for multi-replica deployments. It is also a heavy dependency to impose on a single-container tenant, which is what the other sinks are for.
Resources
Section titled “Resources”| Resource | Default name | Purpose |
|---|---|---|
| JetStream stream | IVK_MATCH_OUTCOMES | Persists all outcome events |
| JetStream KV bucket | IVK_MATCH_COORDINATION | Leader election (multi-replica only) |
| JetStream Object Store | IVK_MATCH_CHECKPOINTS | Per-queue pool checkpoints |
| JetStream stream, per queue | IVK_MATCH_REPLICATION_{QUEUE_ID} | Replication log for that queue |
With NATS_RESOURCE_MODE=create (the default) these are created automatically at startup.
With bind, they must be pre-provisioned and each queue must declare
replication.binding.stream_name.
Subjects
Section titled “Subjects”ivk.match.outcomes.{queue_id}.match.createdivk.match.outcomes.{queue_id}.backfill.createdivk.match.outcomes.{queue_id}.ticket.expiredivk.match.outcomes.{queue_id}.ticket.removedivk.match.outcomes.{queue_id}.backfill.expiredivk.match.outcomes.{queue_id}.backfill.removedivk.match.outcomes.{queue_id}.ticket.orphanedivk.match.outcomes.{queue_id}.backfill.orphanedPayloads are binary matchmaker.outcomes.v1.Outcome protobufs with
Content-Type: application/x-protobuf. The prefix is configurable via
NATS_SUBJECT_PREFIX_OUTCOMES.
Subscribing
Section titled “Subscribing”Because queue_id may be dot-separated, namespacing pays off. A customer subscribes to
its own namespace server-side and receives nothing else:
ivk.match.outcomes.acme.> # every outcome for every acme queueivk.match.outcomes.acme.quickplay.> # every outcome for one queueivk.match.outcomes.acme.quickplay.match.createdOne gotcha: per-kind-across-all-queues is not expressible when IDs are dotted.
ivk.match.outcomes.*.match.created matches only single-token queue IDs, because *
matches exactly one subject token. Subscribe per namespace instead. For the same reason, a
hand-provisioned outcomes stream in bind mode must use the subject filter
ivk.match.outcomes.>; a *-per-token filter silently drops dotted-ID outcomes.
The .expired and .removed families are published by the queue leader only, so each
fires once cluster-wide rather than once per replica.
Configuration. The NATS connection itself is a container-level setting, because it is a connection to external infrastructure:
NATS_URL=nats://nats:4222NATS_CREDENTIALS_FILE=/etc/nats/creds/creds.creds # or NATS_NKEYNATS_RESOURCE_MODE=createPOOL_REPLICATION_BACKEND=jetstreamSelecting the sink is still per queue:
config: outcomes: sink: natsUse a durable pull consumer with explicit acks. An ephemeral consumer loses its position on restart, which is the exact failure mode the durability is there to prevent.
Choosing a sink
Section titled “Choosing a sink”flowchart TD
A{Customer already<br/>runs NATS?} -->|Yes| N[NATS JetStream]
A -->|No| B{Can expose an<br/>HTTPS endpoint?}
B -->|Yes| C[Webhook]
B -->|No| E[WebSocket]
| Customer profile | Recommended sink |
|---|---|
| Small studio, no infrastructure team | Webhook: an HTTP handler and a shared secret |
| Dedicated-server process consuming directly | WebSocket: no inbound endpoint needed |
| Backend behind a strict egress-only network | WebSocket |
| Already operating NATS | NATS JetStream |
| Multi-replica IVK Match deployment | NATS JetStream (required) |
For the platform offering, lead with webhook. It matches how game backends already integrate with payment providers and platform services, and it needs no client library.
Delivery guarantees
Section titled “Delivery guarantees”Identical across all sinks:
| Guarantee | Holds? | Notes |
|---|---|---|
| At-least-once delivery | Yes | Duplicates are possible on every sink |
| Exactly-once delivery | No | Dedupe on Outcome.id or Match.id |
| Per-queue ordering | Yes | Outcomes for one queue arrive in occurrence order |
| Global ordering across queues | No | Independent queues are independent |
| Survives consumer downtime | Yes | Bounded by the retention window |
| Survives matchmaker restart | Yes | Provided the state volume is durable |
| Survives state volume loss | No | Treat volume loss as an incident |
Outcome.producer_id and producer_sequence give a monotonic per-producer sequence, which
is useful for detecting gaps: a consumer that sees sequence N+2 after N knows it missed
one and can alert instead of carrying on.
Design the consumer to be idempotent. Every sink can deliver the same outcome twice:
after a retry, a reconnect, or a matchmaker restart between write and ack. Keying session
creation on Match.id makes duplicates harmless, and it is far simpler than suppressing
them at the transport layer.