Nemesis Engine
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.
How the engine works
Section titled “How the engine works”The parameters make far more sense once you know what they act on.
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 things follow from that, and neither is obvious:
- 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_minandticket_pool_size_targetmatter more than anything else here.
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.
Where to start
Section titled “Where to start”There are twenty-one parameters here and you do not need to set them all. Most queues are defined by a handful; the defaults are reasonable for the rest.
| If you want to change | Set |
|---|---|
| Match shape | team_size, team_count |
| How long players wait, against how good matches are | ticket_pool_size_target, wait_time |
| How closely skill is matched | mmr_normalization_ref, mmr_weight |
| Whether connection quality counts at all | latency_weight |
| Which players may meet (crossplay, playlists) | attr_filter |
| How parties are rated against solo players | party_synergy_bonus, party_blend_to_max |
Four things ship off and are usually wanted on. If matches feel wrong out of the box, check these before anything else:
| Parameter | What being off means |
|---|---|
latency_weight | Latency is filtered but never scored: a 20 ms match and a 190 ms match rank equally |
attr_filter | Nothing is segmented: no playlist separation, no crossplay rules |
party_synergy_bonus | A five-stack is rated as five unacquainted players |
party_blend_to_max | A high-skill player carrying low-skill friends drags the party into a bracket it will dominate |
The pair that decides the most is ticket_pool_size_min and
ticket_pool_size_target. They set how much population the engine insists on
before it will match at all, and every other parameter operates inside that constraint.
The skill rating scale
Section titled “The skill rating scale”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:
| Parameter | Default | Reads as |
|---|---|---|
mmr_normalization_ref | 0.2 | “a 0.2 skill spread is the worst we’ll tolerate” |
party_synergy_bonus | 0.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:
- Normalise before submitting: map your rating to
0.0-1.0in your backend and use the defaults as-is. Simplest, and it keeps a configuration portable between games. - Keep your native scale and multiply every skill-denominated parameter by the same
factor. For a 0-3000 scale,
mmr_normalization_ref: 600is the equivalent of the0.2default.
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”.
Match shape
Section titled “Match shape”| Parameter | Type | Default | Description |
|---|---|---|---|
team_size | range | { start: 6, end: 6 } | Min/max players per team. |
team_count | range | { 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.startmatch_size.end = team_size.end × team_count.endA match may ship once it has match_size.start players and is never allowed to exceed
match_size.end.
| Shape | Config |
|---|---|
| Fixed 5v5 | team_size: {start: 5, end: 5}, team_count: {start: 2, end: 2} |
| 8-player free-for-all | team_size: {start: 8, end: 8}, team_count: {start: 1, end: 1} |
| 4-team 3v3v3v3 | team_size: {start: 3, end: 3}, team_count: {start: 4, end: 4} |
| Flexible 4v4 to 6v6 | team_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.
Pool sizing
Section titled “Pool sizing”These two settings do more than anything else in this document. They tell the engine how much population it needs before matchmaking is worth doing, and what a healthy queue looks like.
| Parameter | Type | Default | Description |
|---|---|---|---|
ticket_pool_size_min | int | match_size.start | Nothing matches at all below this pool size. |
ticket_pool_size_target | int | match_size.end × 10 | The 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. 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.
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.
So ticket_pool_size_target is the main quality-versus-speed lever. A target near
match_size.end matches almost immediately and takes whatever it can get.
Wait time
Section titled “Wait time”| Parameter | Type | Default | Description |
|---|---|---|---|
wait_time | ticks | 45 | How 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 governs how long the engine persists with one particular set of people, while
expiration_ttl_secs governs 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.
Attribute filters
Section titled “Attribute filters”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| Field | Required | Default | Description |
|---|---|---|---|
name | yes | — | The attribute key. Must match a key in engine_input.attributes. |
mode | no | overlap | overlap or containment. |
allowed_values | no | unrestricted | Restricts 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.
The two modes
Section titled “The two modes”Each ticket supplies values (what it is) and accepts (what it tolerates) per
attribute.
| Mode | Compatible when | Use for |
|---|---|---|
overlap | The two tickets’ values sets intersect | Playlists, game modes, maps: “we want the same thing” |
containment | Every value on each side appears in the other side’s accepts, both directions | Platform / 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 and multi-mode queues
Section titled “allowed_values and multi-mode queues”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_valueslist 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.
Latency: hard filtering
Section titled “Latency: hard filtering”Nemesis handles latency in two independent layers, and people conflate them constantly.
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
max_latency_ms | ms | 200 | Hosts above this RTT are unacceptable. |
max_latency_by_origin | map | {} | 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_ms | ms | 150 | Assumed RTT when a player reported no measurement for a host. |
min_host_coverage_frac | fraction | 0.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_count | int | match_size.start | Absolute 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 is easy to overlook and worth getting right. 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, and a pool full of those is a
pool that cannot match anyone. 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.
Latency: scoring
Section titled “Latency: scoring”This layer is the preference term. It ranks the hosts and tickets that already passed the hard filter.
| Parameter | Type | Default | Description |
|---|---|---|---|
latency_weight | weight | 0.0 (off) | Priority of latency relative to the other goals. 0.0 disables latency scoring entirely. |
latency_normalization_ref_ms | range | 0..max_latency_ms | .start is a free floor; pings at or below it cost nothing. .end is the worst-acceptable scoring reference. |
latency_abs_weight | weight | 1.0 | Within the latency term: weight on the absolute ping level above the floor. |
latency_spread_weight | weight | 0.0 | Within 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. Useful settings run
from abs: 4.0, spread: 1.0 (strongly prefer low ping) to abs: 0.8, spread: 0.2.
Skill scoring
Section titled “Skill scoring”| Parameter | Type | Default | Description |
|---|---|---|---|
mmr_weight | weight | 1.0 | Priority of skill closeness relative to the other goals. |
mmr_normalization_ref | skill units | 0.2 | Worst-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. Both shapes are reasonable.
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. Reach for it first when matches feel unfair, and loosen it
first when nobody can find a game.
Parties
Section titled “Parties”| Parameter | Type | Default | Description |
|---|---|---|---|
party_synergy_bonus | skill units | 0.0 (off) | Bonus rating added for each additional member in a party. |
party_blend_to_max | fraction | 0.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. Keep it 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.
0.2 to 0.25 is a sensible band.
In a game with parties these two change more than almost anything else here, and they are easy to overshoot. Set them too high and premade groups face opponents they cannot beat, which is just the original problem the other way round. Move in small steps and watch win rates by party size.
Team balancing
Section titled “Team balancing”| Parameter | Type | Default | Description |
|---|---|---|---|
enable_team_rebalance | bool | true | Redistributes parties across teams to even out skill, just before a match ships. |
team_rebalance_mad_weight | weight | 0.25 | Weight 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.
Full defaults table
Section titled “Full defaults table”Every field, in the order it appears in the engine config.
| Parameter | Type | Default |
|---|---|---|
team_size | range | { start: 6, end: 6 } |
team_count | range | { start: 2, end: 2 } |
ticket_pool_size_min | int | match_size.start |
ticket_pool_size_target | int | match_size.end × 10 |
wait_time | ticks | 45 |
attr_filter | list | [] |
default_latency_ms | ms | 150 |
min_host_coverage_frac | fraction | 0.0 |
min_host_coverage_count | int | match_size.start |
max_latency_ms | ms | 200 |
max_latency_by_origin | map | {} |
mmr_weight | weight | 1.0 |
mmr_normalization_ref | skill units | 0.2 |
latency_weight | weight | 0.0 (off) |
latency_normalization_ref_ms | range (ms) | { start: 0, end: max_latency_ms } |
latency_abs_weight | weight | 1.0 |
latency_spread_weight | weight | 0.0 |
party_synergy_bonus | skill units | 0.0 (off) |
party_blend_to_max | fraction | 0.0 (off) |
enable_team_rebalance | bool | true |
team_rebalance_mad_weight | weight | 0.25 |
Four of these ship off and are usually wanted on: latency_weight, attr_filter,
party_synergy_bonus, and party_blend_to_max. See Where to start for what each
one being off actually means.
Validation rules
Section titled “Validation rules”Enforced when the configuration is parsed and again when the ruleset is applied, so a mistake is reported immediately rather than surfacing at match time. A rejected edit leaves the queue running its previous configuration.
| 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.
Deprecated fields
Section titled “Deprecated fields”Accepted and ignored, with a warning. Remove them next time you touch the configuration.
| Field | Replacement |
|---|---|
match_size | Removed. Always derived from team_size × team_count. |
pool_size | ticket_pool_size_min and ticket_pool_size_target. |
max_latency_by_region | max_latency_by_origin (still parsed as an alias). |
match_gate, backfill_gate | Removed; 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. |
Tuning recipes
Section titled “Tuning recipes”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: containmentRanked: 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: containmentLow-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 thinPlan to retire the launch recipe. Every setting in it trades match quality for match existence, which is right at 50 concurrent players and wrong at 5,000. Move to the casual recipe as population grows; configuration applies live, so it is an edit rather than a deploy.
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.