Skip to content

SoftShutdown

Moves players through a reserved transit server and back into a fresh public server when a server closes, and announces scheduled restarts ahead of time.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.SoftShutdown
Since
v0.1.0
Source
SoftShutdown.luau

SoftShutdown keeps players in the game through an update. When Roblox closes a server, its players teleport into a reserved transit server of the same place and bounce from there into a fresh public server running the new version, instead of landing back in the app. Paired with the DataDriver shutdown drain, their data saves on the way out and loads on the way in. The flow keeps no persistent state: the only coordination between the two stages is a ChloeKernelTransit flag in the teleport data, checked against the source place Roblox reports. Every teleport goes through TeleportRetry, the tracker Matchmaking also uses, so a shutdown never overlaps a match teleport.

src/Server/Game/Services/ShutdownService.luau
--!strict
-- ShutdownService: transit-server migration on close and before scheduled restarts.
local ServerScriptService = game:GetService("ServerScriptService")
local SoftShutdown = require(ServerScriptService.ChloeKernelServer.SoftShutdown)
local ShutdownService = {
Name = "ShutdownService",
}
function ShutdownService.init(self: any, kernel: any)
self.Shutdown = SoftShutdown.attach(kernel, { MigrateLeadSeconds = 60 })
end
function ShutdownService.stop(self: any)
if self.Shutdown then
self.Shutdown.destroy()
end
end
return ShutdownService

In Studio attach does nothing and returns a handle whose migrate() answers false, "Studio": there is nothing to migrate and Studio cannot teleport.

attach wires two stages into the same place. Every server runs stage 1. Reserved servers also run stage 2.

attach binds one game:BindToClose callback. When Roblox closes the server:

  1. A moderation shutdown (Enum.CloseReason.Moderation) returns at once. Roblox disables teleports for those.
  2. The deadline is set 25 seconds out. Roblox runs every BindToClose callback in parallel and shuts the server down 30 seconds after the close starts; the 5-second margin is headroom.
  3. With nobody on the server, there is nothing to migrate and the callback moves to the last step.
  4. Kernel.SoftShutdown(playerCount) publishes on the Bus before anything yields. It is the window to tell players what is about to happen.
  5. TeleportService:ReserveServerAsync(game.PlaceId) reserves a transit server of the same place. If it fails, the module warns [SoftShutdown] ReserveServerAsync failed; players will disconnect normally and moves to the last step.
  6. One TeleportOptions carries the access code and the teleport data { ChloeKernelTransit = true }. Players go in batches of 50 (the TeleportAsync group limit), each batch sent through the shared tracker with the close deadline. No new batch starts after the deadline. The tracker retries each player on their own, so a late failure never teleports someone whose first attempt already succeeded. A player whose teleport fails for good is warned about and disconnects normally when the server closes.
  7. The callback waits for the players to leave. It listens to Players.PlayerRemoving, checks the player count one deferred step after each removal (the leaver is still listed while the event fires), and returns as soon as the server is empty or the deadline passes. Nothing polls.
  8. The tracker is destroyed.

The transit server runs the same place and the same code, so the same attach runs there. It recognizes a reserved server by game.PrivateServerId ~= "" with game.PrivateServerOwnerId == 0, which excludes player-owned private servers. For every player already present and every player who joins:

  1. Player:GetJoinData() is read. A player bounces only when SourcePlaceId equals this place and the teleport data carries ChloeKernelTransit. Roblox sets SourcePlaceId; teleport data alone could come from another place. Ordinary reserved servers, such as Matchmaking arenas, run this branch too, and the flag keeps it inert there.
  2. A 2-second grace delay runs on the kernel Scheduler, so a kernel shutdown cancels a pending bounce. A player who already left is skipped.
  3. The tracker sends TeleportAsync(game.PlaceId, { player }) with no reserved code. That is an unpinned place teleport: Roblox’s matchmaker places the player in a public server, which by now runs the new build, and a GameFull or GameEnded result retries so the engine can pick another server.
  4. If the bounce fails for good (three failed calls, the 30-second deadline, or a result no retry can fix), the module warns and kicks the player with This server has restarted. Please rejoin. Nobody else can join a reserved server, so staying there would strand them. An attempt whose outcome is still unknown does not kick.

Stage 2 does not return early. A deploy can close a transit server too, so it also has the stage 1 callback, and a player caught mid-transit rides another hop.

A restart scheduled from the Creator Hub or Open Cloud, or for Roblox maintenance, fires DataModel.ServerRestartScheduled ahead of time. SoftShutdown probes for that event (older engines lack it) and turns it into Kernel.RestartScheduled(restartTime, source, attributes): restartTime is a DateTime, source an Enum.CloseReason. A game can show a countdown from it.

With MigrateLeadSeconds set, SoftShutdown also schedules a migration that many seconds before restartTime on the Scheduler. A second announcement replaces the earlier job. The early migration runs the same steps 3 to 6 as a close with its own 25-second deadline, but does not wait for departures: the server keeps running, and the close handler catches anyone still there when it finally shuts down.

The handle’s migrate() does the same on demand. This version of the service migrates at the end of a round when a restart is pending, instead of at a fixed lead time:

src/Server/Game/Services/ShutdownService.luau
--!strict
-- ShutdownService: transit-server migration on close, or at the next round end before a scheduled restart.
local ServerScriptService = game:GetService("ServerScriptService")
local SoftShutdown = require(ServerScriptService.ChloeKernelServer.SoftShutdown)
local ShutdownService = {
Name = "ShutdownService",
}
function ShutdownService.init(self: any, kernel: any)
self.Kernel = kernel
self.Shutdown = SoftShutdown.attach(kernel)
self.RestartPending = false
end
function ShutdownService.start(self: any)
local function notify()
self.Net.ServerNotice.broadcast("Updating. Sending you to the new version.")
end
self.Connections = {
self.Kernel.Bus:subscribe("Kernel.RestartScheduled", function()
self.RestartPending = true
end),
self.Kernel.Bus:subscribe("Round.PhaseChanged", function(_, phaseName)
if self.RestartPending and phaseName == "Results" then
task.spawn(self.Shutdown.migrate) -- yields on ReserveServerAsync; refusals: NoPlayers, ReserveFailed
end
end),
-- An early migration (migrate() above, or MigrateLeadSeconds)
self.Kernel.Bus:subscribe("Kernel.SoftShutdown", notify),
-- The close: published before the stop phase that disconnects these
self.Kernel.Bus:subscribe("Kernel.Closing", function(_, reason)
if reason ~= Enum.CloseReason.Moderation then
notify()
end
end),
}
end
function ShutdownService.stop(self: any)
for _, Connection in self.Connections or {} do
Connection:disconnect()
end
if self.Shutdown then
self.Shutdown.destroy()
end
end
return ShutdownService

Three BindToClose callbacks share the close, and Roblox runs them in parallel inside one 30-second window:

  • The server kernel’s close phase publishes Kernel.Closing(reason), runs every service stop() in reverse boot order within 25 seconds, then publishes Kernel.Stopped.
  • DataDriver waits for Kernel.Stopped (up to 5 seconds), then drains every profile with a deadline 27 seconds after the close began, so writes made in stop() reach the final saves.
  • SoftShutdown teleports the players out and waits up to 25 seconds for them to leave.

The teleport removes each player, which runs the ordinary leave path: session cleanup, then the profile’s release save. Most profiles save that way; the DataDriver drain catches whatever has not finished. When the player lands on the transit server and again on the public server, the load is an ordinary rejoin. A load that arrives before the previous server released its session lock uses the DataDriver’s lock handoff: the joining server asks the holder to release, and waits up to LockWaitSeconds (default 15) before failing closed.

Kernel.SoftShutdown publishes before ReserveServerAsync yields, so a subscriber can push a message that reaches clients before the teleport. That works for an early migration while services run. At close it may not: Roblox runs the kernel’s close phase and SoftShutdown’s close callback in parallel, so the stop phase can run your service’s stop(), and disconnect its subscriptions, before SoftShutdown publishes. The kernel publishes Kernel.Closing(reason) before its stop phase begins, which is why the service above listens to both. It broadcasts on a State channel declared in Game.Channels:

src/Shared/Game/Channels.luau (excerpt)
ServerNotice = { Kind = "State", Schema = { "String" } },

Roblox shows its own teleport screen on both hops. SoftShutdown has no transit UI. To show your own while a player sits on the transit server, check the teleport data on the client. The bounce teleport does not carry the flag, so it is present only on the transit server:

src/Client/Game/Controllers/TransitController.luau
--!strict
-- TransitController: covers the seconds a player spends on a SoftShutdown transit server.
local TeleportService = game:GetService("TeleportService")
local TransitController = {
Name = "TransitController",
Dependencies = { "UIController" },
}
function TransitController.init(self: any, kernel: any)
self.Kernel = kernel
end
function TransitController.start(self: any)
local Data = TeleportService:GetLocalPlayerTeleportData()
if type(Data) == "table" and Data.ChloeKernelTransit == true then
self.Kernel:getService("UIController"):open("Transit") -- a Screens/Transit module and its ScreenGui
end
end
return TransitController

Teleport data is client-visible and spoofable, which is fine for a loading screen. The stay is short: the bounce starts 2 seconds after arrival.

The first real update deploy is this module’s acceptance test. Run it on purpose:

  1. Confirm the server boots SoftShutdown (from a service’s init) and a DataDriver that is attached. The shutdown drain comes from the driver, not from SoftShutdown.
  2. Publish the update.
  3. Shut down one server from the Creator Hub server list, not every server, and watch its players come back on new servers.
  4. Read the server log for the warnings, each of which means a fallback fired: [SoftShutdown] ReserveServerAsync failed, [SoftShutdown] transit teleport failed for <name>, [SoftShutdown] could not bounce <name> back to a public server, and the DataDriver’s drain warnings.
  5. Check one migrated player’s data on the new server.
  6. Migrate the rest. Migrate To Latest Update also runs BindToClose on each server, so the same flow covers it.
Member Description
SoftShutdown.attach(kernel, options?) -> handle Wires stage 1 (the close handler), stage 2 on reserved servers, and the restart announcement. In Studio it wires nothing and returns an inert handle. Attach once per server.
handle.migrate() -> (ok, reason?) Migrates everyone to a transit server now, with a 25-second deadline, without waiting for departures. Yields on ReserveServerAsync. Refusals: "Studio", "NoPlayers", "ReserveFailed".
handle.destroy() Disconnects the restart listener and cancels a pending lead-time migration. The close handler and the transit bounce stay in place.

attach options:

Option Default Description
MigrateLeadSeconds nil Seconds before a scheduled restart to migrate everyone. nil migrates only on close.
Players, Teleport, Game, Retry, Studio engine services Test injection points: the Players service, TeleportService, the DataModel (PlaceId, PrivateServerId, PrivateServerOwnerId, BindToClose, ServerRestartScheduled), a TeleportRetry-shaped tracker, and the Studio check.
Topic Payload When
Kernel.SoftShutdown playerCount A migration is about to reserve a transit server: on close, at the lead time, or from migrate().
Kernel.RestartScheduled restartTime, source, attributes DataModel.ServerRestartScheduled fired. restartTime is a DateTime, source an Enum.CloseReason. Needs SoftShutdown.attach.
  • Every failure falls back to what would have happened without the module. A failed reservation or a failed transit teleport leaves players to disconnect normally when the server closes. The one exception is the transit server itself, where a failed bounce kicks with a rejoin message.
  • The transit flag rides teleport data, which clients can forge. The bounce also requires Roblox’s SourcePlaceId to be this place, and all the flag can do is send its carrier from a reserved server to a public one. Nothing trusts it for anything of value.
  • Player-owned private servers never bounce: stage 2 requires PrivateServerOwnerId == 0.
  • A player in the middle of a Matchmaking teleport keeps that destination. The shared tracker skips them instead of starting a second teleport. Teleports that game code sends straight through TeleportService are not covered.
  • The transit hop is also a version fence. By the time the bounce fires, the old servers are draining, and the public servers Roblox picks run the new build.

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