Skip to content

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.


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 component

The 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.


version: 1 # required: schema version, always 1
queue_id: acme.quickplay # required: stable identity, dot-separated path allowed
name: 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_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.

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_id must be unique within the queue.
  • engine_id is always nemesis, and must be the same on every ruleset in the queue. Declare it once as default_engine_id and omit it per ruleset.
  • params may 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”.

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.


The minimum that will match anything. Good for a first integration test.

version: 1
queue_id: acme.smoketest
name: Smoke Test
rulesets:
- 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.

The common production shape: one queue, one ruleset.

version: 1
queue_id: acme.quickplay
name: Quick Play
labels:
tenant: acme-games
rulesets:
- 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: 1
queue_id: acme.arena
name: Arena
labels:
tenant: acme-games
default_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 endpoint

D. 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: 1
queue_id: acme.objective
name: Objective Modes
default_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: 5000

Note 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.


FieldRequiredDefaultDescription
versionyesAlways 1.
queue_idyesStable identity. Dot-separated tokens of a-zA-Z0-9_-.
nameyesHuman-readable label.
retirednofalsetrue tears the queue down. See retirement.
labelsno{}String map attached to every queue-scoped metric.
default_engine_idnoEngine for rulesets that omit engine_id.
engine_defaultsno{}Per-engine name / params merged into each matching ruleset.
rulesetsyesAt least one ruleset.
confignoall defaultsSections below.
FieldRequiredDefaultDescription
ruleset_idyesUnique within the queue. Tickets reference it by this value.
engine_idif no default_engine_idnemesis. Must be identical across all rulesets in the queue.
namenofrom engine_defaultsHuman-readable label.
paramsnofrom engine_defaultsEngine parameters, merged over engine_defaults. Full reference: Nemesis.
fairness_weightno1.0Relative priority when rulesets contend for the same ticket. Must be finite and greater than zero.

Controls how long entries live in the pool.

FieldDefaultDescription
cleanup_interval_secsserver 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_secs1800How long an unmatched ticket stays eligible before expiring.
tickets.terminal_retention_ttl_secs300How long a matched/cancelled ticket stays queryable before eviction.
backfill_requests.expiration_ttl_secs1800Same, for backfill requests.
backfill_requests.terminal_retention_ttl_secs300Same, 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.

Controls the matchmaking tick.

FieldDefaultDescription
tick_rate_secs1How often the engine runs. Must be non-zero.
invoke_timeout_secs15Hard cap on one engine invocation. Exceeding it fails the tick and enters backoff.
max_tickets_per_invocation1000Upper bound on tickets handed to the engine per tick. The main lever on per-tick CPU.
rate_window_seconds30Sliding window for the inflow/outflow rates exposed to the engine and to metrics. Must be non-zero.
max_metadata_bytes4096Per-ticket cap on caller-owned metadata.
max_engine_input_bytes32768Per-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.

Controls arbitration when several rulesets want the same ticket.

FieldDefaultDescription
deficit_weight1.0Weight of a ruleset’s service deficit (how underserved it has been).
age_weight1.0Weight of the contested match’s oldest-ticket wait.
age_norm_secs120Wait time at which the age term saturates.
ewma_half_life_secs60Half-life of the demand/service smoothing.
deferral_ticks0Ticks 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.

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.

FieldDefaultDescription
sinknatswebhook, websocket, or nats.
webhook.urlRequired when sink: webhook. HTTPS endpoint receiving deliveries.
webhook.secretRequired when sink: webhook. HMAC-SHA256 signing key; the receiver verifies X-IVK-Signature.
webhook.encodingprotobufprotobuf or json.
webhook.timeout_ms5000Per-attempt delivery timeout.
webhook.max_retries8Retry 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: 8

Delivery 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.


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.

ChangeEffect
Valid edit to a live queueApplied in place. The queue keeps serving; no tickets are dropped.
Invalid edit to a live queueRejected and reported as a config issue. The queue keeps running its last known-good spec.
New valid fileQueue starts within a few hundred ms.
New invalid fileRejected and reported. Sibling queues are unaffected.
File moved or renamed in-tree, same queue_idSame logical queue; only group_path changes.
File deletedLogged as a missing source, but the queue keeps running. Deletion is not a shutdown signal.
retired: trueQueue 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.

version: 1
queue_id: acme.quickplay
name: Quick Play
retired: true
rulesets:
- ruleset_id: 5v5
engine_id: nemesis

On 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.


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.