# Skill-based matchmaking

Design skill-based matchmaking with a skill window that widens as players wait, for Roblox and other multiplayer games, with worked numbers and limits.

Source: https://birdhouse.tech/docs/skill-based-matchmaking/

Skill-based matchmaking groups players whose skill ratings are close to each other. Every skill queue trades match quality against wait time, and the common answer is a skill window that starts narrow and widens the longer a search waits. This guide explains that trade-off and shows how BirdHouse Matchmaking expresses it with the `fieldWindow` rule: the formula, worked numbers, parties, an optional deadline, and the limits to design around.

> **Availability.** BirdHouse Matchmaking is in early access. SDK package access and a service endpoint are arranged with the team, and this guide does not confirm a published package or a hosted service. [Apply for early access](https://birdhouse.tech/?product=matchmaking#signup).

On Roblox, the platform's own [matchmaking](https://create.roblox.com/docs/matchmaking) scores the servers a joining player can enter, and a [custom signal](https://create.roblox.com/docs/matchmaking/customize-matchmaking) can score on an attribute such as skill level. Forming a group from a queue is a different job; [Roblox matchmaking options](https://birdhouse.tech/docs/roblox-matchmaking/) compares the two.

## The trade-off: match quality versus wait time

A narrow skill limit produces close matches only when enough players of similar skill are searching at once. Players at either end of the rating range, in a less popular mode, or online at a quiet hour have fewer candidates. A fixed narrow limit can leave them waiting, and a fixed wide limit makes skill nearly irrelevant.

A window that widens with wait time asks for a close match first, then accepts a larger gap each second, up to a cap you choose.

## How an expanding skill window works

BirdHouse Matchmaking matches tickets. A ticket is one party's search: its players, optional metadata, and an optional rule tree. `fieldWindow` compares one numeric ticket metadata field across a candidate group.

```json
{
  "type": "fieldWindow",
  "field": "skill",
  "min": 50,
  "spreadRate": 2,
  "max": 300
}
```

The allowed difference between a ticket's value and each other ticket's value is `min(max, min + ageSeconds × spreadRate)`.

| Parameter | Meaning | If omitted |
| --- | --- | --- |
| `field` | Ticket metadata field to compare. | Required. |
| `min` | Window at age zero, in the field's own units. Zero or greater. | `0` |
| `spreadRate` | Units added per second of ticket age. Zero or greater; `0` keeps the window at `min`. | Required. |
| `max` | Largest window at any age. At least `min`. | No cap. |

With the rule above:

| Ticket age | Allowed difference |
| --- | --- |
| 0 s | 50 |
| 25 s | 100 |
| 50 s | 150 |
| 125 s | 300 |
| 200 s | 300 (`max` caps the computed 450) |

A difference exactly equal to the window passes. A missing or non-numeric value on either ticket fails the rule. Numeric strings are read as numbers, and an empty or whitespace-only string is read as `0`, so send the value as a number.

## Whose window applies?

Each ticket evaluates its own rules against the complete candidate group, using its own age, and a group is valid only when every ticket's rules pass. When two tickets carry the same rule, the narrower window decides, and it belongs to the ticket that has waited less.

Take two solo tickets with the rule above. Ticket A has skill 1200 and has waited 100 seconds, so its window is 250. Ticket B has skill 1400 and was just created, so its window is 50. The gap is 200: A accepts it and B does not, so they do not match yet. B's window reaches 200 when B is 75 seconds old (50 + 75 × 2). A is then 175 seconds old with a capped window of 300, and the pair becomes valid.

In a larger group the check is pairwise: the window limits the distance between any two tickets' values, not the distance from a group average. Tickets at 1200, 1240, and 1290 with a window of 50 are not a valid group, because the outer two are 90 apart.

Age counts from the time the service accepts the ticket. Confirming it, as the SDK heartbeat does, does not reset its age. A live ticket cannot be edited. To change a party or its rules, cancel the ticket and wait for the outcome, because a match can still win. If it is canceled, create a new ticket, whose window starts again at `min`. See [ticket lifecycle](https://birdhouse.tech/docs/lifecycle/).

## You supply the skill number

BirdHouse compares the value you send. It does not calculate ratings or update them after a match. Your game computes and stores ratings such as Elo, Glicko-2, or TrueSkill.

The window is in the field's own units, so choose `min`, `max`, and `spreadRate` for your scale: a gap of 50 is small on a scale centered on 1500 and half of a scale from 0 to 100. Read the value from trusted player data on your server.

## How should a party's skill be represented?

Field rules read ticket metadata, and a ticket is one party, so a party has one skill value. Player metadata is returned with the match and is not read by rules. The window therefore limits the gap between party values, not between individual players, and two players in one match can be further apart than `max`. How you turn several ratings into one number is your decision: the highest member's rating, the average, or an average adjusted for party size. Apply the same method to every ticket, including solo tickets.

`playerCount` counts players across all tickets, and a match always combines at least two tickets. A party that already fills an exact player count therefore cannot match: alone it is one ticket, and any added player exceeds the count. Its ticket would only wait, so start that match in your game instead.

## Make skill optional after a deadline

To start a wider match instead of waiting on a capped window, wrap the window in `or` with a `timeout`. This follows the ranked queue pattern from [matching rules](https://birdhouse.tech/docs/constraints/), with a later deadline so the window reaches its cap first, sent as an [HTTP API](https://birdhouse.tech/docs/http-api/) create action.

```http
POST /api/progress
Content-Type: application/json
```

```json
{
  "actions": [
    {
      "type": "create",
      "id": "b5e44619-c4b3-4c1d-a68b-83c48dbdf820",
      "options": {
        "environment": "dev",
        "players": [{ "id": "123456789" }],
        "meta": { "mode": "ranked", "skill": 1200 },
        "constraints": {
          "type": "and",
          "constraints": [
            { "type": "playerCount", "min": 4, "max": 4 },
            { "type": "fieldEqualsDeep", "field": "mode" },
            {
              "type": "or",
              "constraints": [
                {
                  "type": "fieldWindow",
                  "field": "skill",
                  "min": 50,
                  "spreadRate": 2,
                  "max": 300
                },
                { "type": "timeout", "after": 180 }
              ]
            }
          ]
        }
      }
    }
  ]
}
```

Access-token authentication is not available in this phase. Call the HTTP API only from a server you control, never from a player's device. The ID is illustrative; generate a new one for each search.

The same rule through the [Roblox SDK](https://birdhouse.tech/docs/roblox-sdk/), in a server `Script`:

```luau
local ServerScriptService = game:GetService("ServerScriptService")
local MatchmakingClient = require(ServerScriptService.ServerPackages.Matchmaking)

local client = MatchmakingClient.new({
    baseUrl = "https://matchmaking.example.com", -- placeholder for your endpoint
    environment = "dev",
})

-- The same rule as the JSON request. Send it on every ranked ticket.
local RANKED_RULES: MatchmakingClient.Constraint = {
    type = "and",
    constraints = {
        { type = "playerCount", min = 4, max = 4 },
        { type = "fieldEqualsDeep", field = "mode" },
        {
            type = "or",
            constraints = {
                { type = "fieldWindow", field = "skill", min = 50, spreadRate = 2, max = 300 },
                { type = "timeout", after = 180 },
            },
        },
    },
}

-- skill is the rating your game loaded for this player on the server.
local function startRankedSearch(player: Player, skill: number?): MatchmakingClient.Session?
    -- A ticket with no skill value fails its own window on every check,
    -- and CreateSession throws on NaN. Do not queue without a usable rating.
    if skill == nil or skill ~= skill then
        warn("No usable skill rating for", player.UserId)
        return nil
    end

    local session = client:CreateSession({
        players = { { id = tostring(player.UserId) } },
        meta = { mode = "ranked", skill = skill },
        constraints = RANKED_RULES,
    })

    session.Matched:Connect(function(match)
        print("Match ready", match.id)
        -- Reserve a server and hand off the matched players in your game.
    end)

    session.Failed:Connect(function(problem)
        warn("Search ended", problem.code, problem.message)
    end)

    return session
end
```

For each ticket, the window takes `(max - min) / spreadRate` seconds, here 125, to reach `max` and then holds at 300. At 180 seconds `timeout` passes and the `or` no longer needs the window. With an `after` earlier than 125, skill becomes optional while the window is still widening, and `max` never limits a match.

`timeout` only flips a boolean in the ticket's own rule tree. It does not cancel the search, expire the ticket, or force a match. A ticket past its deadline still needs every other ticket to accept it, through that ticket's own window or deadline. Keep `playerCount` and the `mode` rule outside the `or`, so the deadline relaxes only skill.

## Limits to design around

- **A capped window does not guarantee a match.** If no compatible tickets arrive, the ticket waits for as long as it is confirmed. Your game decides when to cancel.
- **Grouping is greedy.** The matcher publishes the first group that satisfies all its tickets. It does not search for the closest skill match or an optimal assignment across the queue, or hold a valid group back to fill a `playerCount` range. Set `max` to the widest gap between ticket values you are willing to accept.
- **Environments isolate queues. Metadata does not.** Tickets in different [environments](https://birdhouse.tech/docs/environments/) never match. Inside one, only rules separate tickets, and a ticket with no rules accepts any group. Add `fieldEqualsDeep` for anything that must be equal, such as mode.
- **The result is a group.** Team assignment, balancing, server reservation, and teleporting are your game's work. On Roblox, see [match handoff](https://birdhouse.tech/docs/roblox-match-handoff/).

## What to measure while tuning

Record these for each search:

- **Wait time**: seconds from creating the search to its match, cancellation, or failure.
- **Match rate**: the share of searches that end in a match.
- **Skill gap at match**: highest minus lowest `skill` across the matched tickets.

```luau
-- Highest minus lowest skill value across the matched tickets.
local function skillGap(match: MatchmakingClient.Match): number?
    local lowest: number? = nil
    local highest: number? = nil
    for _, ticket in match.tickets do
        local value = ticket.meta and tonumber(ticket.meta.skill)
        if value then
            lowest = if lowest then math.min(lowest, value) else value
            highest = if highest then math.max(highest, value) else value
        end
    end
    return if lowest and highest then highest - lowest else nil
end

-- Inside startRankedSearch, right after CreateSession:
local startedAt = os.clock()
session.Matched:Connect(function(match)
    -- Record these with your game's own analytics.
    print("waitSeconds", os.clock() - startedAt, "skillGap", skillGap(match))
end)
```

The SDK learns of a match on its next successful heartbeat (every five seconds by default), so this wait runs longer than the service-side match duration.

Long waits with small gaps suggest the window widens too slowly for your population, so raise `spreadRate` or `min`. With the ranked rule, gaps near `max` show how often a match needs the fully widened window. A gap above `max` can form only after the tickets at both ends of it pass the deadline, so it shows how often the fallback decides. Each ticket keeps the rule it was created with, so during a rollout a group may have to satisfy old and new parameters.

[Monitoring](https://birdhouse.tech/docs/monitoring/) describes service-side signals such as pending tickets and match duration from ticket creation to publication. Confirm with the team which of them you can read in early access. For event design, see [analytics events for multiplayer games](https://birdhouse.tech/docs/game-analytics-events/).

## Next steps

- [Matching rules](https://birdhouse.tech/docs/constraints/): every rule type.
- [Ticket lifecycle](https://birdhouse.tech/docs/lifecycle/) and [limits and guarantees](https://birdhouse.tech/docs/limits/).
- [HTTP API](https://birdhouse.tech/docs/http-api/) and [Roblox SDK](https://birdhouse.tech/docs/roblox-sdk/) reference.
- [Roblox matchmaking options](https://birdhouse.tech/docs/roblox-matchmaking/) and [match handoff on Roblox](https://birdhouse.tech/docs/roblox-match-handoff/).
- [BirdHouse Matchmaking](https://birdhouse.tech/products/matchmaking/) product page.
