Skip to content

TeleportRetry

Sends framework teleports, retries failed attempts on a short ladder chosen by TeleportResult, and keeps one unresolved teleport per player.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.TeleportRetry
Since
v0.8.0
Source
TeleportRetry.luau

TeleportRetry is the teleport lifecycle behind Matchmaking and SoftShutdown. It sends a group through TeleportService:TeleportAsync, matches each TeleportInitFailed event to the attempt that caused it, retries on a short ladder and reports the players it could not move. The rule that defines it: a player has at most one unresolved engine teleport across every tracker on the server, and a deadline limits retries, never the engine. Roblox has no call that cancels a teleport once it has started, so an attempt whose outcome is unknown keeps the player owned until the engine reports a failure or the player leaves.

Trackers built on the same TeleportService share a hub. The hub holds the pending map (player to attempt state) and the only two connections the module makes: TeleportService.TeleportInitFailed and Players.PlayerRemoving. Matchmaking and SoftShutdown both build their tracker on the game’s TeleportService, so a player one of them is teleporting is pending for the other as well. The hub disconnects once no tracker is alive and no attempt is pending.

send skips every player who is already pending. It never replaces an earlier destination and never starts a second engine call for that player; the other members of the group still go. A shutdown that finds a player in the middle of a match teleport leaves the match destination in control, because superseding it could send the player to two servers.

Every engine call carries a fresh GUID under the reserved teleport data key __ChloeKernelTeleportAttempt. The tracker builds each call’s options by cloning the caller’s TeleportOptions and setting the teleport data on the copy: the data read from the source plus the token. It sets the data explicitly because the engine reference does not say that Clone carries teleport data, and SoftShutdown’s transit flag has to survive every attempt.

A TeleportInitFailed event counts only when the player is pending on this tracker, the event’s place id matches the attempt, and the token inside the event’s options matches the current attempt. Duplicate events and events left over from an earlier attempt are ignored.

An attempt is in one of four states: calling (TeleportAsync is still yielding), awaiting departure (the call returned without a known failure), waiting for a retry, or unknown (the deadline passed without an answer). A normal return from TeleportAsync does not prove the player left, so a returned call only moves the attempt to awaiting departure.

A retry needs a known failure (a thrown call or a correlated TeleportInitFailed) and a call that has returned. A failure event that arrives while the call is still yielding is recorded and acted on once the call returns, so there are never two engine calls in flight for one player.

  • At most 3 engine calls per player per send.
  • The wait before the next call is the number of calls made so far, in seconds: 1 s, then 2 s. After a Flooded result it is five times that: 5 s, then 10 s.
  • A retry uses the options object that TeleportInitFailed supplied. Roblox builds a new object prefilled with the exact destination, including a reserved access code generated for ShouldReserveServer and the public server picked for a failed member of a group. A thrown call retries with the options it was sent.
  • All attempts share one absolute deadline: 30 s after send by default, or the deadline the caller passes. No attempt starts after it.

Retries are per player. When a group call fails late for one member, only that member is retried, so members whose teleport went through are never teleported a second time.

Result Tracker response
GameNotFound, Unauthorized Finish at once with that result as the reason. The same destination cannot succeed.
IsTeleporting Not a failure: the player is already on the way. The result and message are recorded, ownership stays, and the tracker waits for departure or a later failure.
Flooded Retry with the longer backoff (5 s, then 10 s) so the retries do not add to the flood.
GameFull, GameEnded Finish at once when the destination is pinned: the event’s options carry a ReservedServerAccessCode or a ServerInstanceId, as every Matchmaking and transit teleport does. An unpinned place teleport retries, and the engine picks another server.
Failure, or a thrown call The retry ladder above.

The engine passes an Enum.TeleportResult. Anything else is treated as Failure.

send takes onFailure(player, reason, result?, message?):

  • reason is the result name that ended the attempt (GameNotFound, Unauthorized, GameFull, GameEnded), TeleportFailed when three calls failed, or TeleportTimedOut when the deadline passed with a known failure.
  • result is the last attempt’s TeleportResult name, and message its error text. A thrown call leaves result nil and puts the thrown error in message.

The tracker releases the player before calling back, so the callback can queue the player again (Matchmaking does). Each callback runs on its own thread inside a pcall. It may yield, a yielding callback cannot hold up the rest of the group, and an error is logged as [TeleportRetry] failure callback failed: .... The callback does not run when the player has left, the attempt was cancelled, or the tracker was destroyed.

TeleportService has no operation that cancels an initiated teleport. Cancelling a Luau thread or letting a timer expire does not roll the engine back, so the tracker treats the deadline as the end of its own retries only. When it arrives:

  • An attempt with a known failure and a returned call finishes with TeleportTimedOut.
  • An attempt whose call is still yielding, or whose call returned with no failure and no departure, becomes unknown. Its scheduled retry is cancelled, the failure callback does not run, and the player stays pending.

A definitive failure that arrives later releases the player and reports it. A departure releases the player without a callback: once the player has left there is nothing to retry, whether or not they arrived. If neither signal ever arrives, the player stays blocked from new tracked teleports for the rest of the session. That is deliberate: another attempt could send the player to two destinations.

cancel(player) and destroy() stop this tracker’s scheduled retry and deadline jobs and suppress its future callbacks. Neither stops an engine teleport. An attempt whose call returned with a known failure is released at once. An unresolved one keeps only its hub ownership, so the hub still sees the failure or departure after the tracker that started it is gone. Callbacks that are already running are not interrupted.

cancel affects only players this tracker sent. Matchmaking calls it on dequeue and at session end, and destroys its tracker in destroy(). SoftShutdown destroys its tracker at the end of its close handler.

Matchmaking and SoftShutdown create their own trackers, and most games never touch this module. A game that teleports players itself, such as a portal to another place in the universe, can send through a tracker so its teleports share the same ownership and never overlap a match or shutdown teleport.

src/Server/Game/Config/Places.luau
--!strict
-- Places: place ids in this universe that the game teleports to.
return {
Dungeon = 123456789, -- replace with your dungeon place id
}
src/Server/Game/Services/PortalService.luau
--!strict
-- PortalService: sends groups to the dungeon place through the shared teleport tracker.
local ServerScriptService = game:GetService("ServerScriptService")
local TeleportService = game:GetService("TeleportService")
local Places = require(script.Parent.Parent.Config.Places)
local TeleportRetry = require(ServerScriptService.ChloeKernelServer.TeleportRetry)
local PortalService = {
Name = "PortalService",
}
function PortalService.init(self: any, kernel: any)
self.Kernel = kernel
self.Teleports = TeleportRetry.new(TeleportService)
end
-- Yields while TeleportAsync runs. (false, "TeleportPending") means every player already had a teleport in flight.
function PortalService.send(self: any, players: { Player }, entrance: string): (boolean, string?)
local Options = Instance.new("TeleportOptions")
Options:SetTeleportData({ Entrance = entrance }) -- teleport data is client-visible and spoofable: no rewards in it
return self.Teleports:send(Places.Dungeon, players, Options, function(player, reason, result, message)
self.Kernel.Bus:publish("Portal.Failed", player, reason, result, message)
end)
end
function PortalService.stop(self: any)
if self.Teleports then
self.Teleports:destroy()
end
end
return PortalService

The dungeon is an unpinned place teleport, so a GameFull result retries and the engine picks another server. Call send from a spawned thread or a handler that may yield, never from a Scheduler job.

Member Description
TeleportRetry.new(service, options?) -> tracker A tracker on service (a TeleportService). Joins or creates the hub for that service. options are test injection points: Players (the PlayerRemoving source), Clock (default os.clock), Delay (default task.delay), CancelDelay (default task.cancel) and Present(player) (default player.Parent ~= nil).
tracker:send(placeId, players, options?, onFailure, deadline?) -> (boolean, string?) Yields while TeleportAsync runs. Removes duplicates and players who left, skips players pending on any tracker for this service, and sends the rest in one call. options is a TeleportOptions (nil sends a plain place teleport). deadline is an absolute time on the tracker’s clock, default 30 s from now. Returns true when an engine call was made, with "TeleportPending" as the second value when some players were skipped. Returns false with "TeleportPending" (every player was pending), "NoPlayers", "Destroyed", or "TeleportTimedOut" (the deadline had already passed; each player’s callback gets TeleportTimedOut and nothing is sent). true means the call went out; it does not mean anyone arrived.
tracker:isPending(player) -> boolean Whether the player has an unresolved attempt on any tracker that shares this TeleportService.
tracker:cancel(player) Cancels this tracker’s retry and deadline jobs for the player and drops its callback. Releases the player at once only when the last call returned with a known failure; otherwise ownership stays until failure or departure.
tracker:destroy() Cancels every player this tracker sent, then leaves the hub. Later send calls return false, "Destroyed". Idempotent.
Callback Description
onFailure(player, reason, result?, message?) Runs on its own thread after the player is released. reason: an ending result name, TeleportFailed or TeleportTimedOut. Skipped when the player left, the attempt was cancelled, or the tracker was destroyed.

TeleportRetry publishes nothing on the Bus. Its callers do: Matchmaking forwards every failure to Matchmaking.TeleportFailed. The hub consumes two engine events.

Event Used for
TeleportService.TeleportInitFailed(player, result, message, placeId, options) Matched by player, place id and attempt token, then handled by result as in the table above.
Players.PlayerRemoving(player) Releases the player’s attempt without a callback.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.