Skip to content

Services and controllers

The shape of a game system, how the kernel boots and stops it, how it gets its channel handles, and how systems use kits and talk to each other.

A system is a service on the server (src/Server/Game/Services) or a controller on the client (src/Client/Game/Controllers). Both are plain module tables registered with the kernel’s ServiceManager through Registry.luau. The kernel boots them in dependency order, runs every init before any start, and on the server runs every stop in reverse order when the server closes. Channels arrive already built as self.Net. Make a new system only when a feature has its own state, its own lifetime and its own client requests. Behavior on many tagged instances is a component, constants are config, and stateless helpers are plain modules next to their only user.

src/Server/Game/Services/VoteService.luau
--!strict
-- VoteService: one map vote per player during intermission, and the winner announcement.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Maps = require(ReplicatedStorage.Game.Config.Maps)
local VoteService = {
Name = "VoteService",
Dependencies = { "RoundKit" },
}
function VoteService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Votes = {} :: { [any]: number } -- session to map index
self.IsOpen = false
end
function VoteService.start(self: any)
-- Round rule, after the Range rule from Channels.luau (priority 5)
self.Scope:add(self.Kernel.Hooks:on("Intent.VoteMap", function(_context: any): boolean?
if not self.IsOpen then
return false
end
return nil
end, 10))
self.Net.VoteMap.on(function(session: any, mapIndex: number)
self.Votes[session] = mapIndex
end)
self.Net.GetMyVote.handle(function(session: any): number
return self.Votes[session] or 0
end)
-- RoundKit started first and already announced its first phase: read it once, then follow the topic
local Phase = self.Kernel:getService("RoundKit"):current()
self.IsOpen = Phase ~= nil and Phase.Name == "Intermission"
self.Scope:add(self.Kernel.Bus:subscribe("Round.PhaseChanged", function(_topic: string, phaseName: string)
if phaseName == "Intermission" then
self.IsOpen = true
elseif self.IsOpen then
self:decide()
end
end))
self.Scope:add(self.Kernel:onSession(function(session: any)
if session.Active then
session:bind(function()
self.Votes[session] = nil
end)
end
end))
end
-- Closes voting and announces the map with the most votes. Ties go to the earlier map.
function VoteService.decide(self: any)
self.IsOpen = false
local Counts = table.create(#Maps, 0)
for _, MapIndex in self.Votes do
Counts[MapIndex] += 1
end
local Winner = 1
for Index, Count in Counts do
if Count > Counts[Winner] then
Winner = Index
end
end
table.clear(self.Votes)
self.Net.VoteDecided.broadcast(Maps[Winner].Name)
self.Kernel.Bus:publish("Vote.Decided", Winner)
end
function VoteService.stop(self: any)
if self.Scope then
self.Scope:destroy()
end
end
return VoteService
Field Rule
Name Equals the file (or folder) name and ends in Service or Controller. CK010 fails the check otherwise.
Dependencies Names of systems that must boot first. Optional.
init(self, kernel) Stores references and builds local state. Does not call other systems and does not yield.
start(self) Connects events, binds channel handlers through self.Net, registers hooks and starts work. Other systems are usable here.
stop(self) Releases what start created. Must work when start never ran.

self is the module table. The ServiceManager passes the registered definition to every lifecycle function, so there is one instance of each system per server or per client, and its state lives in fields on the module (self.Votes). The module’s top level holds only requires, constants and functions: no connections, instances or yields at require time.

kernel:start() hands the registered systems to the ServiceManager, which:

  1. Sorts the names alphabetically and walks each one’s Dependencies depth first. A system always lands after everything it depends on, and independent systems keep a stable order across runs.
  2. Errors on a cycle with the full chain, for example service dependency cycle: RoundService -> VoteService -> RoundService, and on a missing dependency with service "VoteService" depends on unregistered service "RoundKit".
  3. Runs every init in that order, then every start in the same order.

Because both phases follow the same order, a dependency has finished its start before a dependent’s start begins. VoteService above depends on RoundKit for that reason: RoundKit.start announces the first phase on the Bus immediately, before VoteService could subscribe, so VoteService reads current() once and then follows Round.PhaseChanged. A Bus topic reaches only the subscribers present when it is published.

A boot that fails rolls back. When an init or start throws, the ServiceManager calls stop on every system whose init it attempted, newest first, including the one that failed, then rethrows service "<Name>" failed during init (or start) with the traceback. That is why stop must tolerate a missing start and even a partial init: check fields before using them.

init runs synchronously inside kernel:start(), so a yield there holds every later system. Work that must yield (a Request, an *Async call) runs in start inside task.spawn, and re-checks what it depends on after the yield.

On the server, ServerKernel.boot binds one BindToClose handler. When the server closes:

  1. The kernel publishes Kernel.Closing(reason) on the Bus.
  2. stop runs for every system in reverse boot order. Each call is isolated: an error is logged as service "<Name>" failed during stop and the next system still stops.
  3. The kernel publishes Kernel.Stopped(reason).

The close handler returns to the engine when the stop phase ends or after 25 seconds (ServerKernel.CloseBudgetSeconds), whichever comes first, and warns close stop phase still running after 25s in the second case. The engine allows 30. A stop may yield briefly for a final flush such as Boards:destroy().

DataDriver runs its own close drain in parallel. Its final saves wait for Kernel.Stopped for up to 5 seconds and the drain ends 27 seconds after the close starts, so a profile write made in stop reaches the final save. The same stop phase also runs on kernel:shutdown() (on the server, after every session has ended) and during a boot rollback. The client has no close phase: controllers stop on shutdown and rollback only.

Game/init.luau sets Net on every module listed in Registry.luau before registering it. The table maps each channel name in Game.Channels to a handle:

Side Handle methods
Server on (Intent, PredictedIntent), send and broadcast (State, Unreliable), handle (Request)
Client fire (Intent), predict (PredictedIntent), on (State, Unreliable), invoke (Request)

Every system on a side shares one table, and the server defines every channel before any init, so handlers bound in start are live for the first message. A system registered some other way (for example straight from the Bootstrap) has no self.Net. Channels covers what each method does and how messages are gated.

A controller follows the same contract on the client kernel. The client kernel has the same Scheduler, Bus, Hooks, createScope and getService, and no sessions.

src/Client/Game/Controllers/VoteController.luau
--!strict
-- VoteController: sends map votes and closes the vote screen when the server announces the winner.
local VoteController = {
Name = "VoteController",
Dependencies = { "UIController" },
}
function VoteController.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
end
function VoteController.start(self: any)
local UI = self.Kernel:getService("UIController")
self.Scope:add(self.Net.VoteDecided.on(function(mapName: string)
UI:close("Vote")
self.Kernel.Bus:publish("Vote.Decided", mapName)
end))
end
function VoteController.vote(self: any, mapIndex: number)
self.Net.VoteMap.fire(mapIndex)
end
-- Yields for the round trip. 0 means no vote, a rejection or a timeout.
function VoteController.myVote(self: any): number
return self.Net.GetMyVote.invoke()
end
function VoteController.stop(self: any)
if self.Scope then
self.Scope:destroy()
end
end
return VoteController

Dependencies = { "UIController" } guarantees UIController has started (and so has found its screen modules) before this controller can call close. Screens reach controllers through their context (context.Controllers("VoteController"), see Screens).

That split also decides who listens to server pushes. A screen can be built after the server’s pushes went out (a ScreenGui that arrives at first spawn), so it never listens to a State channel itself for data it needs at once. A controller created at boot keeps that data (an InventoryClient, a SettingsClient, a Replica), and the screen reads the controller’s cache or fetches through a Request.

Framework kits are configured, never copied. They come in two shapes, and the shape decides where the kit is wired.

Kit.attach(kernel, config) returns an object the owning game service keeps. Call it from that service’s init, with the config in Game/Config. InventoryKit, ShopKit, LootKit, CraftingKit, MeleeKit, NPCKit, PartyKit, TeamKit, CompanionKit, DialogueKit and AchievementKit use this shape, as do systems such as Zones, Hazards, Effects, Projectiles, Stages, Leaderstats and Leaderboards.

src/Server/Game/Services/EconomyService.luau
--!strict
-- EconomyService: the inventory and the shops.
local ServerScriptService = game:GetService("ServerScriptService")
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)
local Items = require(script.Parent.Parent.Config.Items)
local ShopKit = require(ServerScriptService.ChloeKernelServer.Kits.ShopKit)
local Shops = require(script.Parent.Parent.Config.Shops)
local EconomyService = {
Name = "EconomyService",
Dependencies = { "DataService" },
}
function EconomyService.init(self: any, kernel: any)
self.Kernel = kernel
self.Inventory = InventoryKit.attach(kernel, { Items = Items })
self.Shops = ShopKit.attach(kernel, { Shops = Shops, Inventory = self.Inventory })
end
function EconomyService.start(_self: any) end
function EconomyService.stop(_self: any) end
return EconomyService

Kit.service(config) returns a complete system definition. WeaponKit, SpellKit, QuestKit, RoundKit, CheckpointKit, InteractionKit and ProceduralKit use this shape. Add the result to Registry.luau as an entry:

src/Server/Game/Registry.luau
--!strict
-- Every server service, one require per module in Services. ServiceManager
-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local RoundKit = require(ServerScriptService.ChloeKernelServer.Kits.RoundKit)
return {
require(script.Parent.Services.ComponentService),
require(script.Parent.Services.VoteService),
RoundKit.service(require(script.Parent.Config.Rounds)),
} :: { any }
src/Server/Game/Config/Rounds.luau
--!strict
-- Rounds: the RoundKit phase cycle.
return {
Phases = {
{ Name = "Intermission", Duration = 20, MinPlayers = 2 },
{ Name = "Round", Duration = 180 },
{ Name = "Results", Duration = 10 },
},
}

A kit service’s Name is the kit’s name ("RoundKit", "WeaponKit"), which is what other systems list in Dependencies and pass to getService. CK010 checks only the require(script.Parent.Services.<Name>) entries, so kit entries do not need a Service suffix. Kits define their own channels (SH_Buy, WK_Fire, and so on). Extend a kit through its hooks, Bus topics and config, not new channels. The Kits overview introduces every kit.

One concern per file. When a system passes about 300 lines or picks up a second concern, turn FooService.luau into a folder:

src/Server/Game/Services/VoteService/
init.luau the system table: Name, Dependencies, init, start, stop
Tally.luau pure vote counting, with a spec in Game/Tests
Types.luau shared type definitions

Child modules receive what they need as arguments and never require init.luau, so there is no require cycle and each child can be tested alone. The registry entry stays require(script.Parent.Services.VoteService), which resolves to the folder’s init.luau; CK010 accepts either form. tools/scaffold.py refuses to create VoteService.luau when the folder form already exists.

Two mechanisms, with a direction rule:

Mechanism Use it when Example
kernel.Bus:publish(topic, ...) and kernel.Bus:subscribe(topic, fn) The publisher does not care who reacts, or the reaction runs the other way from an existing dependency VoteService publishes Vote.Decided; a MapService subscribes and loads the map
self.Kernel:getService("Name") One system needs an answer from or an action by another VoteService reads RoundKit:current()

Dependencies point one way. If two systems need each other, one publishes a Bus topic and the other subscribes. getService returns the other system’s module table (so :method() calls work) and errors with unknown service "<Name>" for an unregistered name. It works for any registered system, but only a declared dependency is guaranteed to have run its start first, so list the system in Dependencies whenever start calls it. Bus subscribers receive (topic, ...). Bus covers wildcards and delivery.

Per-player state is keyed by session and released at session end through session:bind, as VoteService does. Setup that needs saved data waits for the profile with Milestones.on(kernel, "Kernel.ProfileLoaded", fn), which also replays profiles that loaded before the subscription (see Milestones and Sessions).

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