Skip to content

Services

Services are your game’s systems: plain tables registered on the kernel before start(), booted in dependency order, stopped in reverse. The defining design decision is the two-phase boot — every service’s init runs before any service’s start — so by the time any start runs, every dependency is fully initialized and safe to use. There is no lazy loading, no proxy objects, no partially-constructed state to reason about.

The ServiceManager is a registry plus a topological sort.

  1. Registration window. Between boot() and start() (in practice: inside your Bootstrap — see Kernel & Boot), kernel:registerService(definition) records definitions by name. Names must be unique; registration after boot is an error. Nothing runs yet.
  2. Order resolution. kernel:start() resolves the boot order with a depth-first walk over Dependencies. Root names are visited in alphabetical order, so the order is deterministic across runs — two independent services always boot in the same relative order. The walk tracks its active path, so a cycle errors with the full chain (service dependency cycle: A -> B -> C -> A), not just the closing edge.
  3. Init phase. Every init(self, kernel) runs in boot order. This is where a service builds its own state.
  4. Start phase. Every start(self) runs in the same order. This is where a service reaches for other services — getService on any dependency is guaranteed to return a fully-initialized table, because all inits already ran.
  5. Shutdown. The kernel’s shutdown() (kernel.Core:shutdown() on the server) calls stop(self) on each service in reverse boot order, so a service’s dependencies outlive it.

Both init and start are wrapped in xpcall with debug.traceback: a throwing service aborts the boot with the service named and the traceback attached. Boot failures are loud by design — a game half-booted is worse than a game that refuses to boot.

-- src/Server/Bootstrap.luau
return function(kernel)
kernel:registerService({
Name = "Inventory",
init = function(self, booted)
self.Kernel = booted
self.Items = {}
end,
})
kernel:registerService({
Name = "Quests",
Dependencies = { "Inventory" },
init = function(self, booted)
self.Kernel = booted
self.Active = {}
end,
start = function(self)
-- guaranteed initialized: Inventory's init already ran
self.Inventory = self.Kernel:getService("Inventory")
end,
stop = function(self)
-- reverse boot order: runs before Inventory's stop
table.clear(self.Active)
end,
})
end

Registration order does not matter — Quests may register before Inventory. Only Dependencies orders the boot.

A service is just its definition table, so any extra fields you put on it are the service’s public surface:

kernel:registerService({
Name = "Wallet",
init = function(self)
self.Balances = {}
end,
-- extra members become the service API
getBalance = function(self, player: Player): number
return self.Balances[player] or 0
end,
})
-- later, anywhere after boot:
local Wallet = Kernel:getService("Wallet")
print(Wallet:getBalance(SomePlayer))
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 string, unique per kernel. Asserted at registration.
Dependencies Names of services that must init and start before this one. Every listed name must be registered.
init(self, kernel) Phase 1. Build own state. kernel is the booted kernel — on the server, the ServerKernel (session API included), not the bare core.
start(self) Phase 2. Wire into other services; every dependency is fully initialized.
stop(self) Reverse boot order on shutdown.
anything else Yours. The definition table is the service instance — getService returns it directly.

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

Game code normally uses the kernel wrappers (kernel:registerService, kernel:start(), kernel:getService); the manager is reachable at kernel.Services.

Member Description
ServiceManager.new(kernel): ServiceManager Built by the kernel at boot; you never construct one.
manager:register(definition: ServiceDefinition) Records a definition. Errors on missing Name, duplicates, or post-boot registration.
manager:get(name: string): ServiceDefinition The service table. Errors unknown service "name" — no silent nil.
manager:boot() Resolves order, runs all inits, then all starts. Called by kernel:start().
manager:stopAll() Stops in reverse boot order. Called by the kernel’s shutdown().
manager.BootOrder { string } — the resolved order, available after boot.
manager.Timings Per-service { Init: number, Start: number } seconds.
manager.Booted true after boot(), false again after stopAll().

Every failure mode has a distinct, loud error. Exact strings, from 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.
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 Boot aborts: service "X" failed during init: plus the traceback. Later services never init.
start throws Boot aborts: service "X" failed during start: plus the traceback. Every init already ran.
stop throws Warns (service "X" failed during stop:) and keeps stopping the rest — shutdown never gets stuck behind one broken service.
get with an unknown name unknown service "name".

boot() clocks each service’s phases. manager.Timings[name] holds { Init, Start } in seconds, and the whole table surfaces as ServiceTimings in kernel:stats() — 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.5.0] Server booted N services in Xms) is the sum; ServiceTimings is the breakdown.