# Match handoff on Roblox

How to move a matched group into one reserved server on Roblox with ReserveServerAsync and TeleportAsync, including retries and arrival checks.

Source: https://birdhouse.tech/docs/roblox-match-handoff/

Match handoff is the step after matchmaking: moving every player in a matched group into the same game server. On Roblox, the game servers that hold those players must agree on one reserved server from `TeleportService:ReserveServerAsync`, and each then sends its own players there with `TeleportService:TeleportAsync`. This guide covers the agreement step, teleporting with bounded retries, deadlines and recovery, and how the destination server checks who arrives. BirdHouse Matchmaking returns the matched group and leaves the handoff to your game, and the same pattern works with any matchmaker.

> **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 BirdHouse returns and what your game owns

When a group forms, BirdHouse Matchmaking returns the same match to every game server that holds one of its tickets. Each server sees the same `match.id` and the same `match.tickets`, and every ticket lists its players as `players[].id`. Tickets created on different servers can land in one match, so several servers can receive it, each on its own heartbeat.

BirdHouse does not reserve servers, teleport players, or track whether anyone arrived. Those steps belong to your game.

| Step | Owner | API |
| --- | --- | --- |
| Form the group | BirdHouse Matchmaking | `session.Matched`, `session.Match` |
| Agree on one reserved server | Your game | `ReserveServerAsync`, `MemoryStoreHashMap:UpdateAsync` |
| Teleport players | Your game | `TeleportAsync`, `TeleportOptions` |
| Retry and recover | Your game | `pcall`, `TeleportInitFailed`, `client:CreateSession`, `session:Cancel` |
| Check arrivals | Your game | `game.PrivateServerId`, `MemoryStoreHashMap:GetAsync` |

The example is one server `Script` for the place that queues players, shown in three parts, and a second `Script` for the place that runs matches. Both places must belong to the same experience: Roblox [teleports groups only within a single experience](https://create.roblox.com/docs/reference/engine/classes/TeleportService#TeleportAsync), and a [memory store name](https://create.roblox.com/docs/reference/engine/classes/MemoryStoreService#GetHashMap) is shared by every place in it.

## How do several servers agree on one reserved server?

If every server reserved its own destination, the group would split. The servers need one atomic decision keyed by `match.id`: the first writer wins, and every other server reads the winner's access code.

[`ReserveServerAsync`](https://create.roblox.com/docs/reference/engine/classes/TeleportService#ReserveServerAsync) returns an access code and the reserved server's `PrivateServerId`. [`MemoryStoreHashMap:UpdateAsync`](https://create.roblox.com/docs/reference/engine/classes/MemoryStoreHashMap#UpdateAsync) supplies the atomic step. Roblox documents it as follows:

- The transform function receives the current value, or `nil` when the key does not exist. Returning `nil` cancels the update.
- The new value is saved only if the key has not changed since it was read. Otherwise the transform runs again with the latest value.
- The call returns the last value the transform returned, so a canceled update returns `nil`.
- When contention exhausts its retries, the call [reports a conflict](https://create.roblox.com/docs/cloud-services/memory-stores/hash-map), so wrap it in `pcall`.

The documentation does not say that the transform may yield, so keep yielding calls out of it: reserve a candidate first, then claim.

Each server reads the claim, reserves a candidate if there is none, stores the match roster under the candidate's `PrivateServerId`, and then claims. Writing the roster first means one already exists for whichever candidate wins.

```luau
local MemoryStoreService = game:GetService("MemoryStoreService")
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local TeleportService = game:GetService("TeleportService")

local MatchmakingClient = require(ServerScriptService.ServerPackages.Matchmaking)
type Match = MatchmakingClient.Match

local MATCH_PLACE_ID = 1234567890 -- Replace with the place that runs matches.
local HANDOFF_SECONDS = 900 -- The 10-minute match retention window plus a margin.
local MAX_ATTEMPTS = 4

-- Memory store names are shared by every place in the experience.
local handoffs = MemoryStoreService:GetHashMap("MatchHandoff")

-- Every player ID in the match, across all tickets.
local function rosterOf(match: Match): { string }
    local ids = {}
    for _, ticket in match.tickets do
        for _, member in ticket.players do
            table.insert(ids, member.id)
        end
    end
    return ids
end

-- Returns the access code every source server agrees on, or nil when a
-- request failed and the caller should try again.
local function claimServer(match: Match): string?
    local claimKey = "claim:" .. match.id

    -- 1. A server that receives the match later usually finds the claim here.
    local readOk, existing = pcall(function()
        return handoffs:GetAsync(claimKey)
    end)
    if readOk and existing then
        return existing.accessCode
    end

    -- 2. Reserve a candidate first so the transform below never yields.
    local reserveOk, accessCode, privateServerId = pcall(function()
        return TeleportService:ReserveServerAsync(MATCH_PLACE_ID)
    end)
    if not reserveOk then
        return nil
    end

    -- 3. Store the roster under the candidate's server ID before claiming.
    local rosterOk = pcall(function()
        return handoffs:SetAsync("roster:" .. privateServerId, {
            matchId = match.id,
            players = rosterOf(match),
        }, HANDOFF_SECONDS)
    end)
    if not rosterOk then
        return nil
    end

    -- 4. Claim the match. The first writer wins; later writers cancel.
    local winner = nil
    local claimOk, claimed = pcall(function()
        return handoffs:UpdateAsync(claimKey, function(current)
            if current ~= nil then
                winner = current
                return nil -- Cancels this update and keeps the first claim.
            end
            return { accessCode = accessCode }
        end, HANDOFF_SECONDS)
    end)
    if not claimOk then
        return nil
    end

    local record = claimed or winner
    if record and record.accessCode ~= accessCode then
        -- Another candidate won. Delete this one's roster now, not at expiry.
        pcall(function()
            handoffs:RemoveAsync("roster:" .. privateServerId)
        end)
    end
    return if record then record.accessCode else nil
end
```

A candidate that loses the claim is discarded, and the example deletes its roster. Roblox documents that a reserved server starts when its access code is first used, so an unused candidate starts no server.

Roblox [recommends sorted maps for smaller key spaces](https://create.roblox.com/docs/cloud-services/memory-stores/hash-map). [`MemoryStoreSortedMap:UpdateAsync`](https://create.roblox.com/docs/reference/engine/classes/MemoryStoreSortedMap#UpdateAsync) follows the same transform rules.

## Teleport the players on this server

Each session hands off its own ticket: the entry in `match.tickets` whose `id` equals `session.TicketId`. Player IDs are strings, so convert each one with `tonumber` and look it up with [`Players:GetPlayerByUserId`](https://create.roblox.com/docs/reference/engine/classes/Players#GetPlayerByUserId), which returns `nil` for a player who is no longer connected.

Set [`TeleportOptions.ReservedServerAccessCode`](https://create.roblox.com/docs/reference/engine/classes/TeleportOptions) to the agreed code. Combining it with `ServerInstanceId` or `ShouldReserveServer` is an error. [`TeleportAsync`](https://create.roblox.com/docs/reference/engine/classes/TeleportService#TeleportAsync) runs only on the server and accepts at most 50 players per call as of October 2026, so split larger groups. The call can throw. Roblox's [teleport guide](https://create.roblox.com/docs/projects/teleport) recommends wrapping it in `pcall` and retrying a set number of times, particularly for reserved servers.

```luau
local MAX_PLAYERS_PER_CALL = 50 -- TeleportAsync limit for one call.

-- Players from one ticket who are still connected to this server.
local function presentMembers(match: Match, ticketId: string): { Player }
    local present = {}
    for _, ticket in match.tickets do
        if ticket.id == ticketId then
            for _, member in ticket.players do
                local userId = tonumber(member.id)
                local player = if userId then Players:GetPlayerByUserId(userId) else nil
                if player then
                    table.insert(present, player)
                end
            end
        end
    end
    return present
end

local function teleportWithRetry(players: { Player }, accessCode: string): boolean
    local options = Instance.new("TeleportOptions")
    -- Set only this destination property. It conflicts with the other two.
    options.ReservedServerAccessCode = accessCode

    for attempt = 1, MAX_ATTEMPTS do
        local connected = {}
        for _, player in players do
            if player.Parent == Players then
                table.insert(connected, player)
            end
        end
        if #connected == 0 then
            return true -- Everyone in this batch has left the server.
        end

        local ok, problem = pcall(function()
            return TeleportService:TeleportAsync(MATCH_PLACE_ID, connected, options)
        end)
        if ok then
            return true
        end
        warn("TeleportAsync failed:", problem)
        if attempt < MAX_ATTEMPTS then
            task.wait(2 ^ attempt) -- Waits 2, 4, then 8 seconds.
        end
    end
    return false
end

-- Hands off one ticket's players. Returns the players it could not send.
local function handOff(match: Match, ticketId: string): { Player }
    local accessCode: string? = nil
    for attempt = 1, MAX_ATTEMPTS do
        accessCode = claimServer(match)
        if accessCode or attempt == MAX_ATTEMPTS then
            break
        end
        task.wait(2 ^ attempt)
    end

    local members = presentMembers(match, ticketId)
    if not accessCode then
        return members
    end

    local stranded = {}
    for first = 1, #members, MAX_PLAYERS_PER_CALL do
        local last = math.min(first + MAX_PLAYERS_PER_CALL - 1, #members)
        local batch = table.move(members, first, last, 1, {})
        if not teleportWithRetry(batch, accessCode) then
            table.move(batch, 1, #batch, #stranded + 1, stranded)
        end
    end
    return stranded
end
```

With four attempts and waits of 2, 4, and 8 seconds, the claim and each teleport batch give up after 14 seconds of waiting.

## Deadlines and recovery

Three records bound a handoff.

| Record | Lifetime | Effect on the handoff |
| --- | --- | --- |
| `session.Match` | As long as your game keeps the session | In memory only. `Matched` fires once and is not replayed, even when your handler errors. |
| Match on the service | 10 minutes after publication, never extended | A server whose requests failed can still receive the match late in this window. |
| Claim and roster | The expiry you pass, 900 seconds here | Must outlast the service window with a margin, so a late server finds the first claim instead of writing a second one. |

A late server reads the claim only after it receives the match and finishes its own retries, so the example adds a 300-second margin to the 600-second service window. The winning claim and roster are left to expire, because no single server knows when the others have finished. Roblox [recommends explicit deletion](https://create.roblox.com/docs/cloud-services/memory-stores), with expiry as a safety mechanism, so keep the margin and the records small.

Roblox [notes](https://create.roblox.com/docs/projects/teleport) that a `TeleportAsync` call that returns can still fail at the last moment without throwing. [`TeleportService.TeleportInitFailed`](https://create.roblox.com/docs/reference/engine/classes/TeleportService#TeleportInitFailed) then fires once per player with a `TeleportOptions` object that targets the same destination. Roblox's sample retries `Flooded` after a longer delay and `Failure` after a short one, and reports any other result. The example follows it and caps the retries for each handoff.

Players can also drop out:

- **A player leaves before the teleport.** `GetPlayerByUserId` returns `nil` and the player is skipped. The roster still lists them, so decide in the destination whether to start short-handed or send everyone back. BirdHouse has no backfill and does not change a published match.
- **A teleport ultimately fails.** Start a new session for the stranded players: a matched session is terminal and its options cannot be edited. Cap the requeues and tell the player when you stop. The example stops after three, because a wrong place ID or a platform incident otherwise repeats match, fail, and requeue without end.
- **A player leaves while searching.** The example cancels that session in `PlayerRemoving`. This ends the whole party's search unless a match wins the [cancellation race](https://birdhouse.tech/docs/lifecycle/), so requeue the remaining members once the session is `canceled`.

```luau
local MAX_REQUEUES = 3 -- Automatic searches after failed handoffs, per visit.

local client = MatchmakingClient.new({
    baseUrl = "https://matchmaking.example.com",
    environment = "prod",
})

local searching: { [Player]: MatchmakingClient.Session } = {} -- Each player's live search.
local initFailures: { [Player]: number } = {}
local requeues: { [Player]: number } = {}

-- Players who may search again after a failed handoff. The cap stops a
-- persistent failure from repeating match, fail, and requeue without end.
local function mayRequeue(players: { Player }): { Player }
    local allowed = {}
    for _, player in players do
        if player.Parent == Players then
            local count = (requeues[player] or 0) + 1
            requeues[player] = count
            if count <= MAX_REQUEUES then
                table.insert(allowed, player)
            else
                warn("Handoff abandoned:", player.Name) -- Tell the player here.
            end
        end
    end
    return allowed
end

local function startSearch(party: { Player })
    local queued, members = {}, {}
    for _, player in party do
        if player.Parent == Players and not searching[player] then
            table.insert(queued, player)
            table.insert(members, { id = tostring(player.UserId) })
        end
    end
    if #members == 0 then
        return
    end

    local session = client:CreateSession({
        players = members,
        -- Four players across at least two tickets. A full party of four
        -- cannot match alone.
        constraints = { type = "playerCount", min = 4, max = 4 },
    })
    for _, player in queued do
        searching[player] = session
    end

    session.Matched:Connect(function()
        -- Matched fires once and is not replayed. session.Match keeps the
        -- result for your own retry.
        local match = session.Match
        if match then
            -- A matched ticket cannot be reused. Stranded players search again.
            startSearch(mayRequeue(handOff(match, session.TicketId)))
        end
    end)

    session.StateChanged:Connect(function(state)
        if state == "matched" or state == "canceled" or state == "failed" then
            for _, player in queued do
                if searching[player] == session then
                    searching[player] = nil
                end
            end
        end
    end)
end

TeleportService.TeleportInitFailed:Connect(function(player, result, message, placeId, options)
    -- Skip unrelated teleports and players who are already on their way.
    if placeId ~= MATCH_PLACE_ID or result == Enum.TeleportResult.IsTeleporting then
        return
    end
    local failures = (initFailures[player] or 0) + 1
    initFailures[player] = failures

    -- Same policy as Roblox's teleport guide: retry these two results only.
    local delay = if result == Enum.TeleportResult.Flooded
        then 15
        elseif result == Enum.TeleportResult.Failure then 1
        else nil
    if delay and failures <= MAX_ATTEMPTS then
        task.wait(delay)
        if player.Parent ~= Players then
            return
        end
        local ok = pcall(function()
            -- These options already target the same reserved server.
            return TeleportService:TeleportAsync(placeId, { player }, options)
        end)
        if ok then
            return
        end
    end

    warn("Teleport abandoned:", player.Name, result.Name, message)
    initFailures[player] = nil -- The cap applies to one handoff, not the whole visit.
    startSearch(mayRequeue({ player }))
end)

Players.PlayerRemoving:Connect(function(player)
    initFailures[player] = nil
    requeues[player] = nil
    -- Cancel the party's search so the ticket stops matching. A match can
    -- still win the race, and the Matched handler above then runs as usual.
    local session = searching[player]
    if session then
        session:Cancel()
    end
end)
```

The `Matched` handler reads `session.Match` instead of the event argument, so your own retry can run the same code while the claim record exists. After the claim expires, a rerun reserves a different server, so stop retrying a handoff by then.

## Validate arrivals on the destination server

A reserved server can identify itself. Roblox gives `game.PrivateServerId ~= "" and game.PrivateServerOwnerId == 0` as the check, and [`PrivateServerId`](https://create.roblox.com/docs/reference/engine/classes/DataModel#PrivateServerId) is the same value that `ReserveServerAsync` returned on the source server.

Do not build the roster from teleport data. Roblox states that teleport data is [visible to the client and unencrypted](https://create.roblox.com/docs/projects/teleport). It adds that data from [`Player:GetJoinData`](https://create.roblox.com/docs/reference/engine/classes/Player#GetJoinData) is transmitted by the client and can still be abused, and points to memory stores for sensitive data. The destination instead reads the roster stored under its own `PrivateServerId` and checks each arriving `UserId` against it.

```luau
local MemoryStoreService = game:GetService("MemoryStoreService")
local Players = game:GetService("Players")
local TeleportService = game:GetService("TeleportService")

local LOBBY_PLACE_ID = 1234567891 -- Replace with the place that queues players.

local handoffs = MemoryStoreService:GetHashMap("MatchHandoff")

-- A reserved server has a PrivateServerId and no private server owner.
local isReserved = game.PrivateServerId ~= "" and game.PrivateServerOwnerId == 0

local roster: { [string]: boolean }? = nil
local rejected: { [Player]: boolean } = {} -- Players this script is sending back.

-- Loads the player IDs stored for this reserved server, once.
local function loadRoster(): { [string]: boolean }?
    if roster or not isReserved then
        return roster
    end
    for attempt = 1, 3 do
        local ok, record = pcall(function()
            return handoffs:GetAsync("roster:" .. game.PrivateServerId)
        end)
        if ok then
            if record then
                local allowed = {}
                for _, id in record.players do
                    allowed[id] = true
                end
                roster = allowed
            end
            break -- A successful read with no record means no roster exists.
        end
        if attempt < 3 then
            task.wait(attempt)
        end
    end
    return roster
end

local function onPlayerAdded(player: Player)
    local allowed = loadRoster()
    if player.Parent ~= Players then
        return -- Left while the roster was loading.
    end
    if allowed and allowed[tostring(player.UserId)] then
        return -- Expected player. Start or continue the match here.
    end

    -- No roster, or not on it: send the player back to the lobby place.
    rejected[player] = true
    local ok = pcall(function()
        return TeleportService:TeleportAsync(LOBBY_PLACE_ID, { player })
    end)
    if not ok then
        player:Kick("This match is no longer available.")
    end
end

-- A send-back can fail after TeleportAsync returned. Do not leave the player here.
TeleportService.TeleportInitFailed:Connect(function(player, result)
    if rejected[player] and result ~= Enum.TeleportResult.IsTeleporting then
        player:Kick("This match is no longer available.")
    end
end)

Players.PlayerRemoving:Connect(function(player)
    rejected[player] = nil
end)

Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
    task.spawn(onPlayerAdded, player)
end
```

Two behaviors documented for [`ReserveServerAsync`](https://create.roblox.com/docs/reference/engine/classes/TeleportService#ReserveServerAsync) affect this script. Access codes remain valid indefinitely, so a player can arrive after the roster expired; with no roster, the script sends them back. Players on Xbox and PlayStation with cross-play disabled arrive in a different server than players with it enabled, and both servers share the `PrivateServerId`, so count arrivals instead of assuming one server holds the whole roster.

## Test the handoff

`TeleportService` [does not support playtesting in Studio](https://create.roblox.com/docs/projects/teleport), so publish the experience and test in the Roblox client. Memory store data in Studio is [isolated from production](https://create.roblox.com/docs/cloud-services/memory-stores), and its quotas scale with the number of users, so they are small in a solo test.

These Roblox limits apply as of October 2026 and can change, so check the linked pages.

| Limit | Value | Source |
| --- | --- | --- |
| Players per `TeleportAsync` call | 50 | [TeleportService](https://create.roblox.com/docs/reference/engine/classes/TeleportService#TeleportAsync) |
| Hash map key and value size | 128 characters and 32 KB | [Memory store hash map](https://create.roblox.com/docs/cloud-services/memory-stores/hash-map) |
| Memory store requests | 1000 + 120 × concurrent users request units per minute, per experience | [Memory stores](https://create.roblox.com/docs/cloud-services/memory-stores#limits-and-quotas) |
| Memory store size | 64 KB + 1.2 KB × users, per experience. Writes that add data fail once it is reached. | [Memory stores](https://create.roblox.com/docs/cloud-services/memory-stores#limits-and-quotas) |

Before release, check that:

- Players queued on different servers arrive in a server with the same `game.JobId`.
- A second `claimServer` call for one match returns the first access code.
- A party still moves when one member leaves before the teleport.
- Players search again when the reservation or teleport is forced to fail.
- A waiting search ends as `canceled` after one of its players leaves.
- An off-roster player is sent back from the reserved server.

## Next steps

- [Ticket lifecycle](https://birdhouse.tech/docs/lifecycle/) for match delivery and retention.
- [Roblox SDK](https://birdhouse.tech/docs/roblox-sdk/) for the session, match, and ticket types.
- [Limits and guarantees](https://birdhouse.tech/docs/limits/) for retention windows and integration responsibilities.
- [Roblox matchmaking options](https://birdhouse.tech/docs/roblox-matchmaking/) for ways to form the group.
