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.
Updated
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.
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, and a memory store name 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 returns an access code and the reserved server’s PrivateServerId. MemoryStoreHashMap:UpdateAsync supplies the atomic step. Roblox documents it as follows:
- The transform function receives the current value, or
nilwhen the key does not exist. Returningnilcancels 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, 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.
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. 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, which returns nil for a player who is no longer connected.
Set TeleportOptions.ReservedServerAccessCode to the agreed code. Combining it with ServerInstanceId or ShouldReserveServer is an error. 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 recommends wrapping it in pcall and retrying a set number of times, particularly for reserved servers.
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, with expiry as a safety mechanism, so keep the margin and the records small.
Roblox notes that a TeleportAsync call that returns can still fail at the last moment without throwing. 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.
GetPlayerByUserIdreturnsniland 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, so requeue the remaining members once the session iscanceled.
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 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. It adds that data from 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.
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 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, so publish the experience and test in the Roblox client. Memory store data in Studio is isolated from production, 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 |
| Hash map key and value size | 128 characters and 32 KB | Memory store hash map |
| Memory store requests | 1000 + 120 × concurrent users request units per minute, per experience | Memory stores |
| Memory store size | 64 KB + 1.2 KB × users, per experience. Writes that add data fail once it is reached. | Memory stores |
Before release, check that:
- Players queued on different servers arrive in a server with the same
game.JobId. - A second
claimServercall 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
canceledafter one of its players leaves. - An off-roster player is sent back from the reserved server.
Next steps
- Ticket lifecycle for match delivery and retention.
- Roblox SDK for the session, match, and ticket types.
- Limits and guarantees for retention windows and integration responsibilities.
- Roblox matchmaking options for ways to form the group.