Queue Configuration
A queue groups players who want the same kind of match. Everything about a queue is declared in one YAML file in the container’s queue-config directory. There is no registration API, no database, and no restart: drop a file in, and the queue exists within seconds.
This document is for the game teams who will author these files.
- File layout
- Anatomy of a queue file
- Worked examples
- Configuration reference
- Hot reload and retirement
- Troubleshooting
File layout
Section titled “File layout”The container watches one directory recursively. Both .yaml and .yml are accepted.
queues/├── quickplay.yaml # group_path = ""├── ranked/│ ├── solo.yaml # group_path = "ranked"│ └── duos.yaml # group_path = "ranked"├── _template.yaml # ignored: leading underscore└── .drafts/ └── experiment.yaml # ignored: hidden path componentThe directory tree is metadata only: the runtime derives a group_path from it for
grouping in the dashboard and metrics. Queue identity comes from the queue_id field
inside the file, never from the filename or path. Two files declaring the same queue_id
collide; the runtime picks one deterministically and reports the other as a config issue.
Use _-prefixed filenames for templates and shared YAML anchors, and .-prefixed
directories for work in progress. Both live safely alongside real configs.
Anatomy of a queue file
Section titled “Anatomy of a queue file”version: 1 # required: schema version, always 1queue_id: acme.quickplay # required: stable identity, dot-separated path allowedname: Quick Play # required: human-readable label
labels: # optional: attached to every metric for this queue tenant: acme-games game: robot-arena
default_engine_id: nemesis # optional: engine for rulesets that don't name one
engine_defaults: # optional: params shared by all rulesets on an engine nemesis: params: wait_time: 90 max_latency_ms: 200
rulesets: # required: at least one - ruleset_id: 5v5 # unique within the queue fairness_weight: 3 # optional (default 1.0): relative priority in conflicts params: # engine params; merged over engine_defaults team_size: { start: 5, end: 5 } team_count: { start: 2, end: 2 }
config: # optional: all sections have defaults pool: { ... } invocation: { ... } fairness: { ... } outcomes: { ... }Queue IDs
Section titled “Queue IDs”queue_id may be a dot-separated path, such as acme.quickplay or acme.ranked.solo,
where each token uses a-zA-Z0-9_-. Namespacing this way is recommended: it groups a
customer’s queues in the dashboard and lets outcome consumers subscribe to one namespace
rather than filtering client-side.
Rulesets
Section titled “Rulesets”A ruleset is a named matching configuration inside a queue. One queue can run
several side by side (different team sizes, playlists, or skill brackets), and each
ticket chooses which rulesets it competes in via its ruleset_ids field.
Constraints:
ruleset_idmust be unique within the queue.engine_idis alwaysnemesis, and must be the same on every ruleset in the queue. Declare it once asdefault_engine_idand omit it per ruleset.paramsmay differ freely between rulesets. Full reference: Nemesis.
When the same ticket is a candidate in matches from more than one ruleset in the same
tick, the runtime arbitrates using fairness_weight and the config.fairness settings
below. A higher fairness_weight makes a ruleset win conflicts more often. It is how you
say “5v5 is our headline mode, FFA is a side mode”.
engine_defaults and params merging
Section titled “engine_defaults and params merging”Repeating twenty engine parameters across five rulesets is a maintenance trap. Declare
them once under engine_defaults.<engine_id>.params and override per ruleset. Merging
follows JSON Merge Patch semantics: objects merge recursively, while scalars and arrays
are replaced wholesale. A ruleset that sets attr_filter replaces the default
attr_filter entirely instead of appending to it. Setting a param to null in a ruleset
deletes the inherited default.
Unknown fields are rejected at parse time, so a typo fails the config instead of doing nothing.
Worked examples
Section titled “Worked examples”A. Simplest possible queue: 5v5
Section titled “A. Simplest possible queue: 5v5”The minimum that will match anything. Good for a first integration test.
version: 1queue_id: acme.smoketestname: Smoke Testrulesets: - ruleset_id: default engine_id: nemesis params: team_size: { start: 5, end: 5 } team_count: { start: 2, end: 2 }Everything else defaults. Skill matching is on (mmr_weight: 1.0), latency scoring is
off (latency_weight: 0.0) though the hard 200 ms host filter still applies, and no
attribute filtering happens because attr_filter defaults to empty. See
Nemesis for what that means in practice.
B. Skill- and latency-aware quick play
Section titled “B. Skill- and latency-aware quick play”The common production shape: one queue, one ruleset.
version: 1queue_id: acme.quickplayname: Quick Playlabels: tenant: acme-gamesrulesets: - ruleset_id: 5v5 engine_id: nemesis params: team_size: { start: 5, end: 5 } team_count: { start: 2, end: 2 }
# How long the engine is willing to keep growing a match, in ticks. wait_time: 120
# Relative importance of connection quality vs. fair skill. # latency_weight defaults to 0.0; latency is only *scored* if you set it. latency_weight: 0.5 mmr_weight: 0.5
# Worst-case acceptable skill spread, in YOUR mmr units. # This must match the scale you send in engine_input. mmr_normalization_ref: 0.2
# Hard latency gate: a host is only acceptable below this RTT. max_latency_ms: 200 default_latency_ms: 150 # assumed when a player has no measurement
# Only group players who agree on these ticket attributes. attr_filter: - playlist # overlap: share at least one value - name: platform mode: containment # crossplay opt-in must be mutual
config: pool: tickets: expiration_ttl_secs: 1800 # 30 min before a waiting ticket ages out terminal_retention_ttl_secs: 300 # matched tickets stay queryable 5 min invocation: tick_rate_secs: 1 max_tickets_per_invocation: 5000 outcomes: sink: webhook # where formed matches are delivered webhook: url: https://backend.acme.example/ivk/outcomes secret: ${ACME_OUTCOME_SECRET}C. Multi-mode queue with a shared engine baseline
Section titled “C. Multi-mode queue with a shared engine baseline”One queue, three modes, engine tuning declared once. Taken from a live configuration.
version: 1queue_id: acme.arenaname: Arenalabels: tenant: acme-gamesdefault_engine_id: nemesis
engine_defaults: nemesis: params: attr_filter: - playlist - name: platform mode: containment wait_time: 150
latency_normalization_ref_ms: { start: 15, end: 90 } latency_weight: 1.0 mmr_weight: 1.0 mmr_normalization_ref: 0.2
latency_spread_weight: 1.0 latency_abs_weight: 4.0
default_latency_ms: 100 max_latency_ms: 200
# Party handling: parties get a small MMR bonus per extra member, and # members are pulled toward the party's strongest player. party_synergy_bonus: 0.05 party_blend_to_max: 0.25
rulesets: - ruleset_id: 4v4 fairness_weight: 9 # headline mode; wins most conflicts params: team_size: { start: 4, end: 4 } team_count: { start: 2, end: 2 } - ruleset_id: 3v3 fairness_weight: 5 params: team_size: { start: 3, end: 3 } team_count: { start: 2, end: 2 } - ruleset_id: ffa_8p fairness_weight: 3 # side mode; yields to the others params: team_size: { start: 8, end: 8 } team_count: { start: 1, end: 1 } # one team = free-for-all
config: fairness: deferral_ticks: 10 # hold a contested match 10 ticks so a slower # ruleset can field a competitor deficit_weight: 1.0 age_weight: 1.0 age_norm_secs: 120 pool: tickets: expiration_ttl_secs: 1800 invocation: tick_rate_secs: 1 invoke_timeout_secs: 15 max_tickets_per_invocation: 5000 outcomes: sink: websocket # this backend streams outcomes instead of # exposing an inbound HTTPS endpointD. Per-mode playlist gating and per-origin latency caps
Section titled “D. Per-mode playlist gating and per-origin latency caps”Different playlists routed to different team sizes within one queue, with per-origin latency budgets.
version: 1queue_id: acme.objectivename: Objective Modesdefault_engine_id: nemesis
engine_defaults: nemesis: params: attr_filter: - playlist - name: platform mode: containment wait_time: 90
latency_normalization_ref_ms: { start: 55, end: 200 } latency_weight: 0.4 mmr_weight: 0.6
default_latency_ms: 200 max_latency_ms: 200
# Per-origin hard caps override max_latency_ms for tickets whose origin # host is listed. Keys are host keys from Player.latencies. max_latency_by_origin: us-east: 80 us-west: 80 eu-central: 80 oce-sydney: 125 sea-singapore: 125 sa-saopaulo: 150
# Widen each ticket's acceptable-host set until it covers at least this # fraction of the pool, so a match is never pinned to one fragile host. min_host_coverage_frac: 0.10
rulesets: - ruleset_id: exact_6v6 fairness_weight: 6 params: attr_filter: - name: playlist allowed_values: ["TDM", "Upload", "Intel", "Capture_The_Flag"] - name: platform mode: containment team_size: { start: 6, end: 6 } team_count: { start: 2, end: 2 } ticket_pool_size_target: 120
- ruleset_id: exact_4v4 fairness_weight: 1 params: attr_filter: - name: playlist allowed_values: ["Duel_Arena"] - name: platform mode: containment team_size: { start: 4, end: 4 } team_count: { start: 2, end: 2 } ticket_pool_size_target: 100
config: fairness: deferral_ticks: 5 pool: cleanup_interval_secs: 60 tickets: expiration_ttl_secs: 1800 terminal_retention_ttl_secs: 3600 invocation: tick_rate_secs: 1 invoke_timeout_secs: 15 max_tickets_per_invocation: 5000Note that the ruleset-level attr_filter replaces the one in engine_defaults
(arrays are not merged), which is why platform is repeated in each ruleset.
Configuration reference
Section titled “Configuration reference”Top level
Section titled “Top level”| Field | Required | Default | Description |
|---|---|---|---|
version | yes | — | Always 1. |
queue_id | yes | — | Stable identity. Dot-separated tokens of a-zA-Z0-9_-. |
name | yes | — | Human-readable label. |
retired | no | false | true tears the queue down. See retirement. |
labels | no | {} | String map attached to every queue-scoped metric. |
default_engine_id | no | — | Engine for rulesets that omit engine_id. |
engine_defaults | no | {} | Per-engine name / params merged into each matching ruleset. |
rulesets | yes | — | At least one ruleset. |
config | no | all defaults | Sections below. |
rulesets[]
Section titled “rulesets[]”| Field | Required | Default | Description |
|---|---|---|---|
ruleset_id | yes | — | Unique within the queue. Tickets reference it by this value. |
engine_id | if no default_engine_id | — | nemesis. Must be identical across all rulesets in the queue. |
name | no | from engine_defaults | Human-readable label. |
params | no | from engine_defaults | Engine parameters, merged over engine_defaults. Full reference: Nemesis. |
fairness_weight | no | 1.0 | Relative priority when rulesets contend for the same ticket. Must be finite and greater than zero. |
config.pool
Section titled “config.pool”Controls how long entries live in the pool.
| Field | Default | Description |
|---|---|---|
cleanup_interval_secs | server default (60) | How often the eviction pass runs. Zero is rejected; the platform may cap it via MAX_QUEUE_POOL_CLEANUP_INTERVAL_SECS. |
tickets.expiration_ttl_secs | 1800 | How long an unmatched ticket stays eligible before expiring. |
tickets.terminal_retention_ttl_secs | 300 | How long a matched/cancelled ticket stays queryable before eviction. |
backfill_requests.expiration_ttl_secs | 1800 | Same, for backfill requests. |
backfill_requests.terminal_retention_ttl_secs | 300 | Same, for backfill requests. |
expiration_ttl_secs is the practical ceiling on how long a player can sit in queue.
Set it to slightly more than the longest wait the game is willing to show a player; when
it fires, a ticket.expired outcome is emitted so the backend can surface a timeout.
config.invocation
Section titled “config.invocation”Controls the matchmaking tick.
| Field | Default | Description |
|---|---|---|
tick_rate_secs | 1 | How often the engine runs. Must be non-zero. |
invoke_timeout_secs | 15 | Hard cap on one engine invocation. Exceeding it fails the tick and enters backoff. |
max_tickets_per_invocation | 1000 | Upper bound on tickets handed to the engine per tick. The main lever on per-tick CPU. |
rate_window_seconds | 30 | Sliding window for the inflow/outflow rates exposed to the engine and to metrics. Must be non-zero. |
max_metadata_bytes | 4096 | Per-ticket cap on caller-owned metadata. |
max_engine_input_bytes | 32768 | Per-ticket cap on engine_input. |
The two size caps are enforced at the API boundary before any parsing; violations are
rejected with INVALID_ARGUMENT. They bound worst-case memory directly, so lower them if
the game’s payloads are small.
config.fairness
Section titled “config.fairness”Controls arbitration when several rulesets want the same ticket.
| Field | Default | Description |
|---|---|---|
deficit_weight | 1.0 | Weight of a ruleset’s service deficit (how underserved it has been). |
age_weight | 1.0 | Weight of the contested match’s oldest-ticket wait. |
age_norm_secs | 120 | Wait time at which the age term saturates. |
ewma_half_life_secs | 60 | Half-life of the demand/service smoothing. |
deferral_ticks | 0 | Ticks a contested multi-ruleset match is held before commit, so a slower ruleset can field a competitor. 0 = same-tick arbitration only. Max 60. |
In a single-ruleset queue this whole section is irrelevant. In a multi-mode queue,
deferral_ticks in the range 5 to 10 noticeably improves how evenly the modes are
served, at the cost of that many ticks of extra latency on contested matches.
config.outcomes
Section titled “config.outcomes”Planned. In the current build outcomes are published to NATS JetStream for every queue, and this section does not exist. The shape below is what the managed-service release is being built against; treat the exact field names as provisional.
Where this queue’s formed matches, expirations, and removals are delivered. Full discussion of the trade-offs is in Outcomes.
| Field | Default | Description |
|---|---|---|
sink | nats | webhook, websocket, or nats. |
webhook.url | — | Required when sink: webhook. HTTPS endpoint receiving deliveries. |
webhook.secret | — | Required when sink: webhook. HMAC-SHA256 signing key; the receiver verifies X-IVK-Signature. |
webhook.encoding | protobuf | protobuf or json. |
webhook.timeout_ms | 5000 | Per-attempt delivery timeout. |
webhook.max_retries | 8 | Retry budget before a delivery is parked and alerted on. |
config: outcomes: sink: webhook webhook: url: https://backend.example.com/ivk/outcomes secret: ${ACME_OUTCOME_SECRET} encoding: protobuf timeout_ms: 5000 max_retries: 8Delivery is configured per queue rather than per container. Two queues in the same container can deliver to different endpoints, and a customer can repoint or re-secure delivery with a config edit instead of a redeploy. It hot-reloads like everything else in the file.
Keep the secret out of the YAML itself. Reference it from the environment, as above, and the queue-config directory stays safe to hold in git.
Hot reload and retirement
Section titled “Hot reload and retirement”Changes to the config directory are picked up automatically: a filesystem watcher with a 250 ms debounce, plus a full reconcile every 30 seconds as a backstop. Every reconcile reloads the whole tree from disk; there is no incremental patching, so in-memory state cannot drift from what is on disk.
| Change | Effect |
|---|---|
| Valid edit to a live queue | Applied in place. The queue keeps serving; no tickets are dropped. |
| Invalid edit to a live queue | Rejected and reported as a config issue. The queue keeps running its last known-good spec. |
| New valid file | Queue starts within a few hundred ms. |
| New invalid file | Rejected and reported. Sibling queues are unaffected. |
File moved or renamed in-tree, same queue_id | Same logical queue; only group_path changes. |
| File deleted | Logged as a missing source, but the queue keeps running. Deletion is not a shutdown signal. |
retired: true | Queue is torn down on that reconcile pass. |
Two of those behaviours are deliberate and catch people out.
A bad edit never takes a queue down. A validation error leaves the previous spec running, which is what you want in production. It does mean that “I edited the file and nothing changed” is a reason to check the config-issue log, not evidence that hot reload is broken.
Deleting a file does not remove a queue. To decommission a queue, set
retired: true, let one reconcile pass observe it, then delete the file.
Retiring a queue
Section titled “Retiring a queue”version: 1queue_id: acme.quickplayname: Quick Playretired: truerulesets: - ruleset_id: 5v5 engine_id: nemesisOn the next reconcile the queue is torn down synchronously. Pooled tickets are cleared,
not matched to completion: every waiting player is removed and a ticket.removed
outcome is emitted with reason QUEUE_RETIRED. New submissions are rejected with
FAILED_PRECONDITION and the metadata header x-ivk-reason: QUEUE_RETIRING.
If retiring a queue must not strand players, stop routing new tickets to it in the game
backend first, wait for the pool to empty, then set retired: true.
Changing a ruleset’s engine params in place can also strand pooled tickets. If a ticket
no longer satisfies the new params (after an attr_filter change, say), the revalidation
sweep removes it with reason RULESET_PARAMS_CHANGED. The game backend should treat
ticket.removed as an ordinary event and re-queue the player.
Troubleshooting
Section titled “Troubleshooting”A new queue never appeared. Check that no path component starts with ., that the
filename does not start with _, and that the file is not a symlink resolving outside
the config directory. Then check the config-issue log for a validation error.
A queue is serving stale config. The edit almost certainly failed validation: the
queue is still running its previous spec, and the config-issue log names the file. If the
file is definitely valid, wait 30 s for the periodic reconcile. If the file is mounted
with Kubernetes subPath, hot reload will never work; use a normal directory mount.
Duplicate queue_id warnings. Two files claim the same ID. The config-issue log
names both. Usually the leftover of a moved file from a partial deploy.
Tickets rejected for an unknown ruleset. The ticket’s ruleset_ids contains a value
not declared in the queue’s rulesets. Typo, a rename that the submitting code did not
follow, or the ticket is being sent to the wrong queue.
Backfill rejected in a multi-ruleset queue. ruleset_id is required on backfill
requests when a queue has more than one ruleset; there is no sensible default. It may be
omitted only in single-ruleset queues.
A queue disappeared and I only deleted the file. Deletion does not tear a queue down.
It was either retired via retired: true or quarantined after a state error. Quarantine
requires operator intervention; check the logs and the ivk.match.queue_quarantine_total
metric.