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.
--!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() endend
return ShutdownServiceIn Studio attach does nothing and returns a handle whose migrate() answers false, "Studio": there is nothing to
migrate and Studio cannot teleport.
Mental model
Section titled “Mental model”attach wires two stages into the same place. Every server runs stage 1. Reserved servers also run stage 2.
Stage 1: the closing server
Section titled “Stage 1: the closing server”attach binds one game:BindToClose callback. When Roblox closes the server:
- A moderation shutdown (
Enum.CloseReason.Moderation) returns at once. Roblox disables teleports for those. - The deadline is set 25 seconds out. Roblox runs every
BindToClosecallback in parallel and shuts the server down 30 seconds after the close starts; the 5-second margin is headroom. - With nobody on the server, there is nothing to migrate and the callback moves to the last step.
Kernel.SoftShutdown(playerCount)publishes on the Bus before anything yields. It is the window to tell players what is about to happen.TeleportService:ReserveServerAsync(game.PlaceId)reserves a transit server of the same place. If it fails, the module warns[SoftShutdown] ReserveServerAsync failed; players will disconnect normallyand moves to the last step.- One
TeleportOptionscarries the access code and the teleport data{ ChloeKernelTransit = true }. Players go in batches of 50 (theTeleportAsyncgroup 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. - 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. - The tracker is destroyed.
Stage 2: the transit server
Section titled “Stage 2: the transit server”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:
Player:GetJoinData()is read. A player bounces only whenSourcePlaceIdequals this place and the teleport data carriesChloeKernelTransit. Roblox setsSourcePlaceId; 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.- 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.
- 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 aGameFullorGameEndedresult retries so the engine can pick another server. - 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.
Scheduled restarts
Section titled “Scheduled restarts”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:
--!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 = falseend
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() endend
return ShutdownServiceData flush ordering
Section titled “Data flush ordering”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 servicestop()in reverse boot order within 25 seconds, then publishesKernel.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 instop()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.
Messaging players
Section titled “Messaging players”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:
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:
--!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 = kernelend
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 endend
return TransitControllerTeleport data is client-visible and spoofable, which is fine for a loading screen. The stay is short: the bounce starts 2 seconds after arrival.
Deploy-day runbook
Section titled “Deploy-day runbook”The first real update deploy is this module’s acceptance test. Run it on purpose:
- 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. - Publish the update.
- Shut down one server from the Creator Hub server list, not every server, and watch its players come back on new servers.
- 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. - Check one migrated player’s data on the new server.
- Migrate the rest. Migrate To Latest Update also runs
BindToCloseon each server, so the same flow covers it.
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| 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. |
Gotchas and design notes
Section titled “Gotchas and design notes”- 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
SourcePlaceIdto 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.