Skip to content

Nemesis

Nemesis is the match engine every queue runs. This document explains how it behaves and documents every parameter you can set on a ruleset’s params block.


Read this once and the parameters stop looking arbitrary.

Nemesis is stateful and iterative. It does not wake up, look at the pool, and solve the whole matchmaking problem from scratch. It keeps working on the matches it is assembling, and each tick it improves them: adding a player here, swapping one there, deciding that a group has come together well enough to ship. Progress carries over from tick to tick, so match quality visibly improves as a queue warms up, and a queue that has been running for a minute produces better matches than one that started five seconds ago.

Each tick does two kinds of work.

Decide what is allowed. Every parameter in this document is either a hard filter or a preference. Hard filters (latency budgets, attribute compatibility, match size) say which players may share a match at all. A player who fails a hard filter against a group is not a candidate for it, however good the fit would otherwise be.

Decide what is best. Among the combinations the filters allow, the engine scores candidates against a weighted set of goals: how close players are in skill, how good the connection will be for everyone, how evenly the teams come out. The weights are yours to set, and their ratios are what matter. mmr_weight and latency_weight mean nothing in isolation, only relative to each other.

Two consequences catch people out:

  • A match does not form the instant enough players are present. The engine will hold a nearly-complete group for a few more ticks if the pool suggests it can do better. On a small pool that is a few seconds of patience, spent buying a better match.
  • The engine paces itself against the pool. When the pool is thin it matches conservatively, because every match it forms removes candidates that the next one could have used. When the pool is healthy it matches as fast as it can. That is why the same configuration produces different wait times at 50 and 5,000 concurrent players, and why ticket_pool_size_min and ticket_pool_size_target are the most consequential settings in this document.

A group that has been assembling too long without coming together is abandoned, and its players go back into the pool to try again with a different set of people. Nobody gets stuck: a player only leaves matchmaking when they match, cancel, or their ticket expires.


Read this before setting any skill parameter.

Player.mmr in engine_input is a plain double and the engine does not normalise it. Skill distance is computed on raw values and divided by mmr_normalization_ref. The scale is yours to choose, but every skill-denominated parameter has to be expressed in the same units you send.

The defaults are tuned for a normalised 0.0-1.0 scale:

ParameterDefaultReads as
mmr_normalization_ref0.2“a 0.2 skill spread is the worst we’ll tolerate”
party_synergy_bonus0.0 (off)skill rating added per extra party member

If you send a raw ladder rating (say 0-3000) and leave the defaults, every skill distance is divided by 0.2, so the skill term produces enormous losses and completely dominates latency, attributes, and every other consideration. The engine will still form matches, but nothing except skill will influence them.

Two options, both fine:

  1. Normalise before submitting: map your rating to 0.0-1.0 in the game backend and use the defaults as-is. Simplest, and it makes configs portable between games.
  2. Keep your native scale and multiply every skill-denominated parameter by the same factor. For a 0-3000 scale, mmr_normalization_ref: 600 is the equivalent of the 0.2 default.

Whichever you pick, be consistent. Mixing scales between engine_input and params is the most common way to end up with a queue that “matches badly for no reason”.


ParameterTypeDefaultDescription
team_sizerange{ start: 6, end: 6 }Min/max players per team.
team_countrange{ start: 2, end: 2 }Min/max number of teams.

Ranges are objects: { start: <min>, end: <max> }. Set start == end for a fixed size.

Total match size is derived, not configured:

match_size.start = team_size.start × team_count.start
match_size.end = team_size.end × team_count.end

A match may ship once it has match_size.start players and is never allowed to exceed match_size.end.

ShapeConfig
Fixed 5v5team_size: {start: 5, end: 5}, team_count: {start: 2, end: 2}
8-player free-for-allteam_size: {start: 8, end: 8}, team_count: {start: 1, end: 1}
4-team 3v3v3v3team_size: {start: 3, end: 3}, team_count: {start: 4, end: 4}
Flexible 4v4 to 6v6team_size: {start: 4, end: 6}, team_count: {start: 2, end: 2}

Flexible ranges let a match start under-full and grow. They also make the engine more patient: a match that could still take more players is given time to find them before it ships, so a wide range trades some latency for fuller matches. With fixed sizes there is nothing to wait for and the match ships as soon as it is complete.


These are the engine’s most consequential settings. They tell it how much of a population it needs before matchmaking is worth doing, and what a healthy queue looks like.

ParameterTypeDefaultDescription
ticket_pool_size_minintmatch_size.startNothing matches at all below this pool size.
ticket_pool_size_targetintmatch_size.end × 10The pool level at which the engine matches as fast as it can.

ticket_pool_size_min is a quality floor. Below it, the engine forms no matches. It exists so a nearly-empty queue does not produce one terrible match out of the first six players who happen to arrive; with no choice of opponents there is no matchmaking happening, only grouping. The trade-off is blunt. Below that threshold nothing matches and everyone waits, so leave it at the default only if you would genuinely rather players wait than get a bad match. Set it to 0 if you would rather they always got something.

ticket_pool_size_target is what a healthy pool looks like. At or above the target, the engine has enough choice that being selective costs nothing, so it matches as quickly as the available CPU allows. Below it, the engine slows down and lets the pool refill, because a larger pool means more candidates and better matches for everyone still waiting.

In practice the engine steers the pool toward the target and holds it there. Set it too low and the queue drains to a thin pool that produces mediocre matches quickly. Set it too high and the engine hoards players, waiting for a population that never arrives. Sizing it well is mostly a matter of watching real pool levels; start near the pool size your queue sustains at peak.

That makes ticket_pool_size_target the main quality-versus-speed lever. A target near match_size.end matches almost immediately and takes whatever it can get.

ticket_pool_size_target must be strictly greater than ticket_pool_size_min, or the config is rejected.


ParameterTypeDefaultDescription
wait_timeticks45How long the engine will persist with one particular grouping.

wait_time is in ticks, not seconds. With the usual tick_rate_secs: 1 they are the same number, but change the tick rate and you have to rescale wait_time too.

A group that has been assembling for longer than this without coming together is abandoned, and its players go straight back into the pool to be regrouped on the next tick. This is a grouping timeout, not a player timeout: nobody leaves matchmaking because of it. Players only leave when pool.tickets.expiration_ttl_secs fires (see Queue Configuration).

So wait_time controls how long the engine persists with one particular set of people, while expiration_ttl_secs controls how long the player waits overall. Keep the latter comfortably larger; an order of magnitude is normal.

Raising wait_time makes the engine more patient and more selective; lowering it makes matches form faster and looser, and recycles players into fresh groupings sooner.


attr_filter declares which ticket attributes have to be mutually acceptable. It covers playlists, platform and crossplay, language, input device: anything where players have to agree before they can share a match.

Each entry is either a bare string or an object:

attr_filter:
- playlist # shorthand: unrestricted, overlap mode
- name: platform
mode: containment
- name: game_mode
allowed_values: ["TDM", "CTF"] # ruleset only serves these values
FieldRequiredDefaultDescription
nameyesThe attribute key. Must match a key in engine_input.attributes.
modenooverlapoverlap or containment.
allowed_valuesnounrestrictedRestricts this ruleset to tickets whose values fall in this set.

attr_filter defaults to empty: no attribute filtering at all. Nothing is filtered implicitly; if you want platform separation you must ask for it.

Each ticket supplies values (what it is) and accepts (what it tolerates) per attribute.

ModeCompatible whenUse for
overlapThe two tickets’ values sets intersectPlaylists, game modes, maps: “we want the same thing”
containmentEvery value on each side appears in the other side’s accepts, both directionsPlatform / crossplay: “we each permit the other”

containment is what makes opt-in crossplay work correctly. A PC player with values: ["pc"], accepts: ["pc", "xbox"] and an Xbox player with values: ["xbox"], accepts: ["xbox", "pc"] are compatible. Flip either player’s accepts to exclude the other and they never meet.

allowed_values is how one queue routes different playlists to different rulesets: give each ruleset an allowed_values list and tickets naturally land in the right one. See example D.

Two constraints:

  • An empty allowed_values list is rejected; it would reject every ticket. Omit the field to leave an attribute unrestricted.
  • At most 64 distinct values per attribute.

Also note that attr_filter is an array, so a ruleset that declares one replaces the engine_defaults array wholesale instead of extending it. Repeat the shared entries.

Typos are hard errors on both the attribute object keys and the parameter names. A misspelled key fails the config; it does not quietly disable the filter.


Nemesis handles latency in two independent layers, and conflating them is a common source of confusion.

This layer is a hard filter. It decides which hosts each ticket will accept at all, and two tickets can only share a match if their acceptable-host sets intersect. It is always active, whatever latency_weight says.

ParameterTypeDefaultDescription
max_latency_msms200Hosts above this RTT are unacceptable.
max_latency_by_originmap{}Per-origin overrides of max_latency_ms, keyed by host key. A ticket’s origin is the host its party is collectively best served from.
default_latency_msms150Assumed RTT when a player reported no measurement for a host.
min_host_coverage_fracfraction0.0 (off)Widen each ticket’s acceptable-host set, in latency order, until it covers at least this fraction of the current pool.
min_host_coverage_countintmatch_size.startAbsolute floor on the same coverage, independent of pool size.

max_latency_by_origin is the parameter to reach for in a globally-distributed game. A single global max_latency_ms either strands players in thin regions or lets well-served regions get worse matches than they need. Per-origin budgets let you hold EU and NA to 80 ms while allowing Oceania 150 ms. Keys are the same host keys you send in Player.latencies.

default_latency_ms matters more than it looks. Every host a player did not measure is treated as being this far away, so a generous default makes unmeasured hosts acceptable and a strict one rules them out. If clients only probe a handful of nearby hosts, set it above max_latency_ms to make “unmeasured” mean “not acceptable”. Set it low if unmeasured hosts should get the benefit of the doubt.

min_host_coverage_frac and min_host_coverage_count exist to stop matchability collapsing. A strict latency budget can leave a ticket accepting exactly one host, which means it can only match players who also accept that host: a fast route to an unmatchable pool. Both widen the acceptable set past what the budget alone allows, in latency order, so the cheapest additions come first. _frac responds to actual pool composition and is usually the better instrument; _count is a floor that still applies when the pool is tiny.

The host ceiling is 64 hosts per ruleset. Each ruleset runs its own engine instance with an independent host index, and hosts past that ceiling are not represented.


This layer is the preference term. It ranks the hosts and tickets that already passed the hard filter.

ParameterTypeDefaultDescription
latency_weightweight0.0 (off)Priority of latency relative to the other goals. 0.0 disables latency scoring entirely.
latency_normalization_ref_msrange0..max_latency_ms.start is a free floor; pings at or below it cost nothing. .end is the worst-acceptable scoring reference.
latency_abs_weightweight1.0Within the latency term: weight on the absolute ping level above the floor.
latency_spread_weightweight0.0Within the latency term: weight on the ping spread between players.

latency_weight defaults to 0.0, so out of the box latency is filtered but never scored. A default-configured queue treats a 20 ms match and a 190 ms match as equally good as long as both are under max_latency_ms. If connection quality matters (and for most shooters it matters more than a few points of skill rating), set it explicitly.

latency_normalization_ref_ms is what gives the term any resolution. Setting { start: 15, end: 90 } says “15 ms is free, 90 ms is as bad as it gets” and spends the whole scoring range on the band you care about. Left at the default 0..200, most real matches land in the low end of the range and differences between them barely register.

latency_abs_weight against latency_spread_weight is a fairness choice:

  • Absolute minimises everyone’s ping. Best average experience.
  • Spread minimises the difference in ping. Nobody has a connection advantage, even if everyone’s ping is mediocre.

Competitive modes usually want spread weighted more heavily than casual ones. Production configs run anywhere from abs: 4.0, spread: 1.0 (strongly prefer low ping) to abs: 0.8, spread: 0.2.

If latency_weight > 0, then latency_normalization_ref_ms.end must be greater than .start; the term divides by that span.


ParameterTypeDefaultDescription
mmr_weightweight1.0Priority of skill closeness relative to the other goals.
mmr_normalization_refskill units0.2Worst-case acceptable skill spread. The skill term reaches mmr_weight at this spread.

Both values must be in your skill rating scale; see The skill rating scale.

mmr_weight and latency_weight are on the same scale, so their ratio is the real setting. mmr_weight: 0.6, latency_weight: 0.4 says skill matters half again as much as connection quality. Both at 1.0 weights them equally; production configs use both shapes.

mmr_normalization_ref is the more powerful of the two and the easier one to get wrong. It defines what “as bad as it gets” means. A smaller value makes the engine far more sensitive to small skill differences, tightening matchmaking; a larger one flattens the term and makes skill nearly irrelevant. It is the first thing to reach for when matches feel unfair, and the first thing to loosen when nobody can find a game.

mmr_normalization_ref must be > 0, and all weights must be non-negative.


ParameterTypeDefaultDescription
party_synergy_bonusskill units0.0 (off)Bonus rating added for each additional member in a party.
party_blend_to_maxfraction0.0 (off)Interpolates every party member’s effective rating toward the party’s strongest member (plus bonuses).

A coordinated party performs better than the same players would solo. Left at their defaults, both parameters are off and a five-stack is rated as five independent players, which reliably produces matches that a premade group wins comfortably.

party_synergy_bonus handles the coordination advantage: a bonus per extra member, so a five-stack is rated higher than a duo of the same raw skill. Production values are small, 0.01 to 0.05 on a normalised scale.

party_blend_to_max handles skill carrying. At 0.25, each member is pulled 25% of the way toward the party’s strongest player, so a high-skill player queueing with low-skill friends raises the party’s effective rating instead of the group averaging down into a bracket they will dominate. Production values run 0.2 to 0.25.

For a game with parties these two change more than almost anything else here, and they are also easy to overshoot. Set them too high and premade groups face opponents they cannot beat, which is the original problem inverted. Move in small steps and watch win rates by party size.


ParameterTypeDefaultDescription
enable_team_rebalancebooltrueRedistributes parties across teams to even out skill, just before a match ships.
team_rebalance_mad_weightweight0.25Weight of the distribution-matching term relative to the team-sum term. 0.0 disables it.

The rebalance pass runs after a match is otherwise ready, so it costs nothing in wait time and has no real downside. Leave it on.

team_rebalance_mad_weight trades two definitions of “balanced”:

  • Team-sum balance equalises total team skill. Two teams can have identical sums while one is five average players and the other is a superstar plus four weak players.
  • Distribution balance also matches the shape of each team’s skill, so teams look alike player-for-player.

Raising team_rebalance_mad_weight buys distribution similarity at some cost to sum equality. The default 0.25 leans toward sum balance with a nudge toward similar shapes.


Every field, in the order it appears in the engine config.

ParameterTypeDefault
team_sizerange{ start: 6, end: 6 }
team_countrange{ start: 2, end: 2 }
ticket_pool_size_minintmatch_size.start
ticket_pool_size_targetintmatch_size.end × 10
wait_timeticks45
attr_filterlist[]
default_latency_msms150
min_host_coverage_fracfraction0.0
min_host_coverage_countintmatch_size.start
max_latency_msms200
max_latency_by_originmap{}
mmr_weightweight1.0
mmr_normalization_refskill units0.2
latency_weightweight0.0 (off)
latency_normalization_ref_msrange (ms){ start: 0, end: max_latency_ms }
latency_abs_weightweight1.0
latency_spread_weightweight0.0
party_synergy_bonusskill units0.0 (off)
party_blend_to_maxfraction0.0 (off)
enable_team_rebalancebooltrue
team_rebalance_mad_weightweight0.25

Four of these ship off and are usually wanted on: latency_weight, party_synergy_bonus, party_blend_to_max, and attr_filter.


Enforced when the queue file is parsed and again when the ruleset is applied, so a mistake surfaces as a config issue rather than at match time. An invalid edit leaves the queue running its previous spec.

Rule
ticket_pool_size_target > ticket_pool_size_min
mmr_weight, latency_weight, latency_abs_weight, latency_spread_weight, team_rebalance_mad_weight are all ≥ 0
mmr_normalization_ref > 0
latency_normalization_ref_ms.end > .start whenever latency_weight > 0
No attr_filter entry has an empty allowed_values list
No attr_filter entry allows more than 64 distinct values

Parameter names are validated strictly: an unknown field is a hard error. wait_tim: 30 fails the config; it does not fall back to the default.


Accepted and ignored, with a warning logged. Remove them from configs when you touch them.

FieldReplacement
match_sizeRemoved. Always derived from team_size × team_count.
pool_sizeticket_pool_size_min and ticket_pool_size_target.
max_latency_by_regionmax_latency_by_origin (still parsed as an alias).
match_gate, backfill_gateRemoved; gating no longer exists. Accepted (with a warning) as ruleset-level keys next to params, not inside it; inside params they are unknown-field errors. Will become hard errors in a future release.

Starting points, not finished configurations. Validate against real traffic.

Casual quick play: prioritise speed and connection quality

Section titled “Casual quick play: prioritise speed and connection quality”
params:
team_size: { start: 5, end: 5 }
team_count: { start: 2, end: 2 }
wait_time: 60
ticket_pool_size_target: 100
mmr_weight: 0.4
mmr_normalization_ref: 0.3 # loose skill matching
latency_weight: 0.6 # connection quality matters more
latency_normalization_ref_ms: { start: 20, end: 100 }
latency_abs_weight: 4.0
latency_spread_weight: 1.0
max_latency_ms: 200
default_latency_ms: 150
min_host_coverage_frac: 0.10 # keep the pool matchable
party_synergy_bonus: 0.02
party_blend_to_max: 0.20
attr_filter:
- playlist
- name: platform
mode: containment

Ranked: prioritise fairness, accept longer waits

Section titled “Ranked: prioritise fairness, accept longer waits”
params:
team_size: { start: 5, end: 5 }
team_count: { start: 2, end: 2 }
wait_time: 180 # patient
ticket_pool_size_target: 300 # a big pool buys better matches
ticket_pool_size_min: 40 # never match on a thin pool
mmr_weight: 1.0
mmr_normalization_ref: 0.1 # tight skill matching
latency_weight: 0.5
latency_normalization_ref_ms: { start: 15, end: 80 }
latency_abs_weight: 1.0
latency_spread_weight: 2.0 # nobody gets a ping advantage
max_latency_ms: 150 # stricter than casual
default_latency_ms: 150
min_host_coverage_count: 20
party_synergy_bonus: 0.04 # premades face stronger opposition
party_blend_to_max: 0.25
enable_team_rebalance: true
team_rebalance_mad_weight: 0.5 # teams should look alike, not just sum alike
attr_filter:
- name: playlist
allowed_values: ["ranked_5v5"]
- name: platform
mode: containment

Low-population launch: match anything, quickly

Section titled “Low-population launch: match anything, quickly”
params:
team_size: { start: 4, end: 5 } # accept under-full matches
team_count: { start: 2, end: 2 }
wait_time: 30
ticket_pool_size_min: 0 # never refuse to match
ticket_pool_size_target: 20 # a thin pool is the healthy state here
mmr_weight: 0.2
mmr_normalization_ref: 0.5 # skill barely matters at this population
latency_weight: 0.3
max_latency_ms: 250 # generous
default_latency_ms: 150
min_host_coverage_frac: 0.5 # maximise who can match whom
attr_filter: [] # no segmentation; the pool is too thin

Retire the launch recipe on purpose. Every setting in it trades match quality for match existence, which is right at 50 concurrent players and wrong at 5,000. Plan to move to the casual recipe as population grows; the queue file hot-reloads, so it is an edit rather than a deployment.

Whatever you tune, change one thing at a time. The scoring terms interact, and moving two weights at once makes the result impossible to attribute.