Skip to content

Services

Registers game systems and boots them in dependency order, every init before any start, then stops them in reverse and unwinds a boot that fails.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.ServiceManager
Since
v0.1.0
Source
ServiceManager.luau

Services are the game’s systems: plain tables registered with the kernel before start(), booted in dependency order and stopped in reverse. The boot has two phases. Every service’s init runs before any service’s start, so by the time a start runs, every service it depends on is fully built and safe to call. There is no lazy loading and no proxy object. A boot that fails partway unwinds the services it already touched, and on the server the stop phase runs when the server closes. The kernel side is on Kernel & Boot; per-player state belongs to sessions and grouped cleanup to scopes. How a game lays out its services and controllers is covered in Services and controllers.

The ServiceManager is a registry, a topological sort and a list of services that have begun booting.

  1. Registration. Between boot() and start(), kernel:registerService(definition) records the definition by name and publishes Kernel.ServiceRegistered(name) on the Bus. In the workspace, Game/init.luau registers every entry of Game/Registry.luau after injecting the channel handles as self.Net. Nothing runs yet.
  2. Order. kernel:start() resolves the boot order with a depth-first walk over Dependencies. Root names are visited in alphabetical order, so the order is the same on every run. The walk tracks its active path, so a cycle errors with the full chain (service dependency cycle: A -> B -> C -> A).
  3. Init. Each service joins the active list just before its init(self, kernel) runs, in boot order. A service builds its own state here.
  4. Start. Every start(self) runs in the same order. A service reaches for other services here: getService on a dependency returns a table whose init already ran.
  5. Stop. stopAll() pops services off the active list, last booted first, and calls each stop(self) in isolation. It runs on server close (see Stop on close), during kernel:shutdown(), and when a boot fails.

init and start run under xpcall with debug.traceback. A service that throws aborts the boot with its name and traceback in the error. A game that half-booted is worse than one that refuses to boot.

Since 0.8.0 a boot that fails unwinds before it rethrows, so a half-built game does not leave connections, timers or instances behind:

  • init throws: every service whose init was attempted, including the one that threw, is stopped in reverse order. An init may have allocated resources before it failed. Services whose init never ran are not touched.
  • start throws: every init already ran, so every service is stopped in reverse boot order, including services whose start never ran.

The boot then rethrows service "X" failed during init: or service "X" failed during start: with the traceback. kernel:start() propagates it, the kernel never marks itself booted and the boot line never prints. After the unwind the manager’s BootOrder and Timings are empty and it can boot again.

Because of this, stop(self) must tolerate init or start never having finished. Check every field before releasing it:

function QuestService.stop(self: any)
if self.Connection then
self.Connection:disconnect()
self.Connection = nil
end
if self.Lifetime then
self.Lifetime:destroy()
self.Lifetime = nil
end
end

On a live server the kernel binds one BindToClose at boot. When the server closes it publishes Kernel.Closing, runs the stop phase in reverse boot order within ServerKernel.CloseBudgetSeconds (25 s), then publishes Kernel.Stopped. Before 0.9.0 service stop() never ran on a live server close. The sequence and the budget are on Kernel & Boot.

A stop() may yield briefly for a final flush, such as a leaderboard drain. Profile writes made in stop() reach the final saves, because the DataDriver close drain waits for Kernel.Stopped (up to 5 s).

A service stops at most once. stopAll() removes each service from the active list before calling its stop, so a close followed by shutdown(), or a stop that calls stopAll() again, never stops a service twice, even when the first call threw.

One service per module, listed in Game/Registry.luau:

src/Server/Game/Services/InventoryService.luau
--!strict
-- InventoryService: per-visit item lists.
local InventoryService = {
Name = "InventoryService",
}
function InventoryService.init(self: any, kernel: any)
self.Kernel = kernel
self.Items = {} :: { [Player]: { string } }
end
function InventoryService.give(self: any, player: Player, itemId: string)
local Items = self.Items[player]
if not Items then
Items = {}
self.Items[player] = Items
end
table.insert(Items, itemId)
end
return InventoryService
src/Server/Game/Services/QuestService.luau
--!strict
-- QuestService: rewards quest completions through InventoryService.
local QuestService = {
Name = "QuestService",
Dependencies = { "InventoryService" },
}
function QuestService.init(self: any, kernel: any)
self.Kernel = kernel
end
function QuestService.start(self: any)
-- Safe: InventoryService's init already ran
self.Inventory = self.Kernel:getService("InventoryService")
self.Connection = self.Kernel.Bus:subscribe("Quest.Completed", function(_topic, player: Player, questId: string)
self.Inventory:give(player, `Reward_{questId}`)
end)
end
function QuestService.stop(self: any)
-- Reverse boot order: runs before InventoryService's stop
if self.Connection then
self.Connection:disconnect()
end
end
return QuestService

Registration order does not matter. QuestService may be listed before InventoryService; only Dependencies orders the boot.

A service is its definition table, so any extra field or method on it is the service’s public surface, as give is above:

local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Inventory = ServerKernel.current():getService("InventoryService")
Inventory:give(SomePlayer, "Sword")
export type ServiceDefinition = {
Name: string,
Dependencies: { string }?,
init: ((self: any, kernel: any) -> ())?,
start: ((self: any) -> ())?,
stop: ((self: any) -> ())?,
[any]: any,
}
Field Description
Name Required, non-empty, unique per kernel. Game services end in Service and client controllers in Controller.
Dependencies Names of services whose init and start must run before this one’s. Every listed name must be registered.
init(self, kernel) Phase 1. Build your own state; do not call other services or yield. kernel is the booted kernel, and on the server it is the server kernel with the session API.
start(self) Phase 2. Connect events, bind channel handlers and use other services.
stop(self) Reverse boot order on close, on shutdown and when a boot unwinds. Must tolerate init or start not having finished.
Net Injected by Game/init.luau before registration: the typed channel handles from Game.Channels.
anything else Yours. The definition table is the service instance; getService returns it directly.

All three lifecycle functions are optional. A service with only a Name is valid.

Game code uses the kernel wrappers (kernel:registerService, kernel:start(), kernel:getService). The manager itself is kernel.Services.

Member Description
ServiceManager.new(kernel): ServiceManager Built by the kernel at boot.
manager:register(definition: ServiceDefinition) Records a definition. Errors on a missing Name, a duplicate, registration after boot and registration during a lifecycle transition.
manager:get(name: string): ServiceDefinition The service table. Errors unknown service "name"; never returns nil.
manager:boot() Resolves the order, runs every init, then every start. Unwinds and rethrows on failure. Called by kernel:start().
manager:stopAll() Stops every active service in reverse order, each at most once. Clears BootOrder and Timings.
manager.Definitions { [string]: ServiceDefinition }, every registered service.
manager.BootOrder { string }, the resolved order. Empty before boot and after stopAll().
manager.ActiveOrder Services whose init began and that have not been stopped yet.
manager.Timings Per service { Init: number, Start: number } in seconds.
manager.Booted true after a successful boot, false again after stopAll().
manager.Booting, manager.Stopping true while a boot or a stop phase runs.
manager.Kernel The kernel passed to every init. The server kernel sets it to itself.

Every failure has its own message. Exact strings from the source:

Situation Result
Definition without a Name service definition requires a Name (assert at registration).
Duplicate name service "X" is already registered.
Registration after boot cannot register service "X" after boot.
Registration from inside an init or start cannot register service "X" during a lifecycle transition.
Dependency cycle service dependency cycle: A -> B -> C -> A, the full chain in path order.
Dependency on an unregistered name service "A" depends on unregistered service "Ghost".
init throws Attempted services stop in reverse, then service "X" failed during init: plus the traceback.
start throws Every service stops in reverse, then service "X" failed during start: plus the traceback.
stop throws Logs [ServiceManager] service "X" failed during stop: with the traceback and keeps stopping the rest.
boot() twice services already booted (kernel:start() reports kernel already started first).
boot() from inside a boot or stop services are already transitioning.
stopAll() from inside a boot cannot stop services while boot is in progress.
get with an unknown name unknown service "name".

boot() clocks each phase of each service. manager.Timings[name] holds { Init, Start } in seconds, and the table is ServiceTimings in kernel:stats(). It is the first place to look when boot time grows:

for Name, Timing in kernel:stats().ServiceTimings do
print(string.format("%s: init %.2fms, start %.2fms", Name, Timing.Init * 1000, Timing.Start * 1000))
end

The boot line ([ChloeKernel 0.9.1] Server booted N services in Xms) is the total. The timings are cleared when the services stop.

Topic Arguments Published
Kernel.ServiceRegistered name After each successful registration. A plain Bus topic: a subscriber added later does not receive earlier registrations, so read kernel.Services.Definitions for those.
Kernel.Closing closeReason? Server close begins; the stop phase follows.
Kernel.Stopped closeReason? The server close stop phase finished.

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