# Roblox matchmaking options

Compare Roblox matchmaking options for skill-based, party, and ranked queues: built-in server matching, a MemoryStore queue, or an external ticket service.

Source: https://birdhouse.tech/docs/roblox-matchmaking/

Roblox matchmaking covers three different jobs: the platform choosing a public server for each joining player, a cross-server queue you build on MemoryStore, and an external ticket service that groups queued parties for you. This guide explains what each approach does, what you build and operate for it, and when it fits skill-based, ranked, and party queues. BirdHouse Matchmaking is the worked example of the third approach.

> **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).

## What Roblox's built-in matchmaking does

When a player requests to join a place, Roblox [matchmaking](https://create.roblox.com/docs/matchmaking) filters out servers that are full, private, reserved, or about to shut down, scores the rest with a [weighted sum of signals](https://create.roblox.com/docs/matchmaking/scoring), and sends the player to the highest-scoring server.

The [Roblox signals](https://create.roblox.com/docs/matchmaking/attributes-and-signals) are Age, Device Type, Friends, Language, Latency, Occupancy, Play History, Voice Chat, and Text Chat. You can change their weights and add custom signals built on custom attributes: player attributes that persist in data stores, and server attributes set through [`MatchmakingService`](https://create.roblox.com/docs/reference/engine/classes/MatchmakingService).

Skill can therefore be a custom signal. Roblox's examples use an Elo value, and a numerical signal scores a server higher when its players' average is closer to the joining player's value. As of October 2026, [custom matchmaking](https://create.roblox.com/docs/matchmaking/customize-matchmaking) allows 2 custom signals, 5 player attributes, and 5 server attributes per game.

## Server scoring compared with group formation

Built-in matchmaking answers one question, for one player, at join time: which running server should this player join?

A ranked or party queue asks another: which waiting players, possibly in different servers, should play together? That takes a queue, rules for forming a group, and a move to one server. If that is a reserved server, it is among the servers built-in matchmaking filters out before scoring.

## Build a cross-server queue with MemoryStore

[Memory stores](https://create.roblox.com/docs/cloud-services/memory-stores) hold in-memory data that every server of a game can reach, as queues, sorted maps, and hash maps. Roblox lists skill-based matchmaking among the use cases: save player information such as skill level in a shared queue, and run matchmaking periodically on lobby servers.

A [queue](https://create.roblox.com/docs/cloud-services/memory-stores/queue) is first-in-first-out or priority ordered. As of October 2026, [`ReadAsync`](https://create.roblox.com/docs/reference/engine/classes/MemoryStoreQueue) returns up to 100 items with one identifier and hides them from other readers for an invisibility timeout, 30 seconds by default. `RemoveAsync` with that identifier deletes them, but only before the timeout ends. After it, the items are visible to other readers again and the call has no effect, so finish grouping and publishing inside the timeout or pass a longer one as the second argument to [`GetQueue`](https://create.roblox.com/docs/reference/engine/classes/MemoryStoreService).

```luau
local MemoryStoreService = game:GetService("MemoryStoreService")

type QueueEntry = { userIds: { number }, skill: number, queuedAt: number }

-- The name is shared by every server of the game.
local queue = MemoryStoreService:GetQueue("RankedQueue")

-- Add one entry per party. Each add starts a new 120-second expiration,
-- so pass the original queuedAt when you add an entry again.
local function enqueue(userIds: { number }, skill: number, queuedAt: number?): boolean
    local entry: QueueEntry = {
        userIds = userIds,
        skill = skill,
        queuedAt = queuedAt or DateTime.now().UnixTimestamp,
    }
    local ok, problem = pcall(function()
        queue:AddAsync(entry, 120)
    end)
    if not ok then
        warn("Enqueue failed", problem)
    end
    return ok
end

-- Worker step: read up to 100 entries without waiting, then group them.
local function readEntries(): ({ QueueEntry }, string?)
    local ok, entries, readId = pcall(function()
        return queue:ReadAsync(100, false, 0)
    end)
    if not ok then
        warn("Queue read failed", entries)
        return {}, nil
    end
    return entries or {}, readId
end

-- After publishing the groups you formed, remove everything from that read.
-- This only works before the invisibility timeout ends.
local function removeEntries(readId: string): boolean
    local ok, problem = pcall(function()
        queue:RemoveAsync(readId)
    end)
    if not ok then
        warn("Queue remove failed", problem)
    end
    return ok
end
```

The identifier covers the whole read, and a queue's `RemoveAsync` accepts nothing else. Entries you could not group are removed with it and must be added again, which gives them a new expiration, so the example stores the enqueue time for dropping stale ones. A [sorted map](https://create.roblox.com/docs/cloud-services/memory-stores/sorted-map) avoids this: it orders keys by an optional sort key such as skill and removes one key at a time.

Around that storage you write the matchmaker, then the handoff to a reserved server:

- **Queue worker.** Decide which servers read the queue and how often, without two workers placing the same player.
- **Grouping logic.** Group size, skill distance, and how ranges relax with wait time.
- **Party handling.** Store a party as one entry and count its members.
- **Cross-server notification.** [`MessagingService`](https://create.roblox.com/docs/reference/engine/classes/MessagingService) reaches the game's other servers, but its documentation says delivery is best effort and not guaranteed, so also store results where servers can poll for them.
- **Cleanup.** A queue cannot delete one chosen entry, so record departures and cancellations where the worker can check them.

Design around the documented quotas, which change. As of October 2026:

- **[Memory stores](https://create.roblox.com/docs/cloud-services/memory-stores):** per game, 64 KB + 1.2 KB per user of memory, and 1,000 + 120 per concurrent user in request units per minute. A queue read is charged per returned item, not per call.
- **[`MessagingService`](https://create.roblox.com/docs/reference/engine/classes/MessagingService):** 1 kB per message. Per minute, a server can send 600 + 240 per player in it, and a topic can receive 40 + 80 per server.

## Use an external ticket service

Here a game server describes each waiting party as a ticket and sends it to a service outside Roblox. The service holds the tickets, applies the rules, and returns the matched group to every server holding one of its tickets. BirdHouse Matchmaking works this way, through a server-side Luau SDK or an [HTTP API](https://birdhouse.tech/docs/http-api/). The API has no authentication in this phase, so call it only from a server you control.

### How tickets pool across servers

A BirdHouse ticket is created from an environment, a list of players, optional metadata, and optional matching rules. It carries no server or place identifier. Tickets sent to the same service deployment and [environment](https://birdhouse.tech/docs/environments/) can match one another whichever server created them, so players in different servers of your game can end up in one group.

Each ticket's rules are evaluated against the complete candidate group, and a group is published only when every ticket's rules pass. A match combines at least two tickets, so a party that already fills an exact player count cannot match in that queue. Grouping is greedy, not optimal; see [limits and guarantees](https://birdhouse.tech/docs/limits/).

### Create one ticket per party

This server `Script` follows [Your first match](https://birdhouse.tech/docs/quickstart/) and the [Roblox SDK reference](https://birdhouse.tech/docs/roblox-sdk/).

```luau
local Players = game:GetService("Players")
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 = "prod",
})

client.Error:Connect(function(problem)
    warn(problem.code, problem.message)
end)

-- Party registry: each queued player's UserId maps to the party's session.
local sessionByUserId: { [number]: MatchmakingClient.Session } = {}

local function queueParty(members: { Player }, partySkill: number): MatchmakingClient.Session?
    -- A match combines at least two tickets, so a party of 4 or more
    -- can never satisfy the exact count below.
    if #members == 0 or #members >= 4 then
        return nil
    end

    for _, member in members do
        if sessionByUserId[member.UserId] then
            return nil -- A member is already searching.
        end
    end

    -- Copy the IDs now; the caller may change its party table later.
    local userIds = {}
    local players = {}
    for _, member in members do
        table.insert(userIds, member.UserId)
        table.insert(players, { id = tostring(member.UserId) })
    end

    -- One ticket for the whole party. Field rules read ticket metadata,
    -- so the party supplies a single skill number.
    local session = client:CreateSession({
        players = players,
        meta = { mode = "ranked", skill = partySkill },
        constraints = {
            type = "and",
            constraints = {
                { type = "playerCount", min = 4, max = 4 },
                { type = "fieldEqualsDeep", field = "mode" },
                { type = "fieldWindow", field = "skill", spreadRate = 2, min = 50, max = 300 },
            },
        },
    })

    for _, userId in userIds do
        sessionByUserId[userId] = session
    end

    session.Matched:Connect(function(match)
        -- match.tickets lists every party in the group, including parties
        -- queued from other servers. The handoff from here is your code.
        print("Match ready", match.id, #match.tickets)
    end)

    session.Failed:Connect(function(problem)
        warn("Search ended", problem.code, problem.message)
    end)

    session.StateChanged:Connect(function(state)
        if state == "matched" or state == "canceled" or state == "failed" then
            for _, userId in userIds do
                if sessionByUserId[userId] == session then
                    sessionByUserId[userId] = nil
                end
            end
        end
    end)

    return session
end

-- A departing player ends the search for the whole party.
Players.PlayerRemoving:Connect(function(player)
    local session = sessionByUserId[player.UserId]
    if session then
        session:Cancel()
    end
end)

game:BindToClose(function()
    client:Destroy()
end)
```

Call `queueParty` from your own server code after validating the party. BirdHouse compares the skill numbers you send; it does not calculate ratings. The window starts at ±50, widens by 2 points for each second the ticket has waited, reaches ±150 after 50 seconds, and stops at ±300 after 125 seconds. [Skill-based matchmaking](https://birdhouse.tech/docs/skill-based-matchmaking/) covers choosing those numbers, and [matching rules](https://birdhouse.tech/docs/constraints/) lists every rule type.

### Heartbeats and the HttpService budget

A Roblox game server reaches an external service with [`HttpService`](https://create.roblox.com/docs/reference/engine/classes/HttpService) requests, which are [not enabled by default](https://create.roblox.com/docs/cloud-services/http-service): turn on **Allow HTTP Requests** under Security in Experience Settings. Roblox also limits external requests, to 500 per minute as of October 2026, and requests over the limit fail.

The BirdHouse Matchmaking SDK batches its requests: on each heartbeat a client sends its active sessions in requests of up to 100, so two requests for 101 to 200 sessions. The default interval is 5 seconds, so about 12 heartbeats a minute while sessions stay active. The loop stops when no sessions remain, and the next session you create restarts it with an immediate heartbeat, so do not create sessions in a tight loop.

### What your game still owns

BirdHouse returns the matched group. Your game owns the rest:

- **Reserving a server and teleporting.** Servers holding parts of one match must agree on a destination; see [Match handoff on Roblox](https://birdhouse.tech/docs/roblox-match-handoff/).
- **Handoff recovery.** There is no acknowledgement or delivery tracking. A matched result stays readable for 10 minutes after publication, and the SDK keeps it on `session.Match`.
- **The party registry.** Track which players belong to which session. The SDK rejects duplicate IDs within one party but does not deduplicate players across sessions.
- **Player departure.** Cancel the session when a member leaves; until you do, the SDK keeps confirming the ticket and it stays matchable. A ticket that stops being confirmed, for example after `client:Destroy()`, can still match until it expires, 5 minutes after creation or its last confirmation.
- **Everything around the match.** BirdHouse does not assign or balance teams, match on latency, or backfill a match in progress.

## Do different games or modes share a queue?

They do when they send tickets to the same deployment and environment, unless a rule separates them. Tickets pool by deployment and environment only, and metadata alone does not divide them: a ticket with no rules accepts any group in its environment.

The example's `fieldEqualsDeep` rule on `mode` keeps queues apart when every ticket carries it and each queue sets its own value. A separate environment isolates unconditionally, and its key must be one your deployment allows.

## Which approach fits your game?

| You need | Built-in matchmaking | MemoryStore queue | External ticket service |
| --- | --- | --- | --- |
| Players routed to a suitable public server | Its purpose. Adjust weights or add signals. | Not needed. | Not needed. |
| Fixed-size groups formed across servers | Scores servers for one joining player. | You write the worker, grouping, and notification. | A `playerCount` rule; you write the handoff. |
| Parties kept together | The Friends signal favors servers with the player's friends. | One entry per party. | One ticket per party, matched whole. |
| Skill ranges that widen with wait time | A custom numerical signal scores closeness at join time. | You implement the widening. | A `fieldWindow` rule; you supply the skill number. |
| No external dependency | Runs on the platform. | Runs on the platform, within its quotas. | Needs HTTP requests enabled and a reachable endpoint. |

## Next steps

- [Your first match](https://birdhouse.tech/docs/quickstart/): first steps with the Roblox SDK.
- [Match handoff on Roblox](https://birdhouse.tech/docs/roblox-match-handoff/): reserved servers and `TeleportAsync`.
- [Skill-based matchmaking](https://birdhouse.tech/docs/skill-based-matchmaking/): expanding skill windows.
- [Matching rules](https://birdhouse.tech/docs/constraints/): the rule reference.
- [BirdHouse Matchmaking](https://birdhouse.tech/products/matchmaking/): the product and its status.
