Skip to content

Core concepts

The primitives everything else is built on (kernel, services, sessions, hooks, the Bus and scopes) and how the server closes.

Six primitives carry the whole framework. Networking, persistence, kits and anti-exploit are all built out of them, and game code uses them directly. Each section below gives one primitive and the rule that matters most for it. The close phase that ends a server comes after them, then one player action traced through all six.

A kernel boots once per machine: the server boots one and every client boots its own. It owns the Scheduler, the Hooks registry, the Bus and the service manager. ServerKernel wraps the shared core and adds sessions, the network driver (kernel:net(), built on first use), the close phase and everything privileged. It lives in ServerScriptService, so it never reaches a client.

-- src/Server/Main.server.luau already does this; shown for orientation
local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Kernel = ServerKernel.boot()
-- src/Server/Bootstrap.luau loads the Game layer here, which registers services
Kernel:start()
-- anywhere else on the server, after boot:
local Kernel = ServerKernel.current()

Booting a second time on the same machine errors and points at current(). Kernel.Version is "0.9.1" and appears in the boot line. Deep dive: Kernel and boot.

A service is a module table with a Name, optional Dependencies, and init, start and stop functions. On the client the same thing is called a controller. Both register with the kernel’s service manager before start(). In the workspace, Game/init.luau registers every module listed in Game/Registry.luau; outside it, call kernel:registerService(definition).

src/Server/Game/Services/QuestService.luau
--!strict
-- QuestService: active quests per session.
local QuestService = {
Name = "QuestService",
Dependencies = { "InventoryService" },
}
function QuestService.init(self: any, kernel: any)
self.Kernel = kernel
self.Active = {}
end
function QuestService.start(self: any)
-- InventoryService's init and start have both run by now
self.Inventory = self.Kernel:getService("InventoryService")
end
function QuestService.stop(self: any)
table.clear(self.Active)
end
return QuestService

Boot order follows Dependencies. Every init runs before any start, so start can call any service, and a dependency’s own start has already run. A cycle errors at boot with the full chain (A -> B -> C -> A), and a dependency on an unregistered service errors too. If an init or start throws, the kernel runs stop on every service it had already attempted and rethrows, so stop must tolerate start never having run. init stores references and builds local state; it does not call other services or yield. Deep dive: Services and Services and controllers.

The server creates one session per player on join and destroys it on leave. Anything bound to a session is torn down with it: connections, instances and cleanup functions.

Kernel:onSession(function(session)
session.Data.Streak = 0 -- per-visit scratch
session:bind(session.Player.CharacterAdded:Connect(onSpawn)) -- disconnected on leave
session:bind(function()
print("left:", session.Player.Name, session.ExitReason)
end)
end)

onSession also runs for players already in the game, so a system that registers late never misses anyone. session.Data lasts one visit. session.Profile is the persistent record once a DataDriver is attached, and session.ProfilePending is true while it loads; anything that must be saved waits for Kernel.ProfileLoaded. A session also carries JoinData (read once from Player:GetJoinData()), User where the engine exposes it, and ExitReason, which is the Enum.PlayerExitReason from PlayerRemoving and the third argument a Kernel.SessionEnd subscriber receives. session:bindLast(fn) runs after every binding; the persistence layer puts the final save there, so leave-time writes made in ordinary bindings reach it. Deep dive: Sessions.

Hooks are named validation chains. Each handler gets a mutable context and cancels the chain by returning false. They are fail-closed: a handler that throws cancels the chain unless the hook point was defined FailOpen. Every network intent passes through one named Intent.<Channel>.

local Off = Kernel.Hooks:on("Intent.BuyItem", function(context)
if context.Session.Profile.Data.Coins < Prices[context.Args[1]] then
return false -- rejected; the handler never runs
end
end, 10) -- lower numbers run earlier; the default is 100
local Passed, Context, Traceback = Kernel.Hooks:fire("Intent.BuyItem", { Session = Session, Args = { ItemId } })
Off() -- on() returns an unsubscribe function

Registry installs a channel’s declarative Validate rules at priority 5, ahead of game validators. When a handler throws, fire returns its traceback as the third value, which is how the fuzz gate reports a crashing validator. Deep dive: Hooks. Every built-in hook point: Hooks and Bus topics.

The Bus is topic pub/sub, so systems communicate without requiring each other. A trailing wildcard subscribes to a whole family. Subscribers receive the topic first, then the published arguments.

local Connection = Kernel.Bus:subscribe("Combat.Kill", function(topic, killer, victim) end)
Kernel.Bus:subscribe("Combat.*", function(topic, ...) end) -- whole family
Kernel.Bus:publish("Combat.Kill", KillerSession, VictimSession)
Connection:disconnect()

Dependencies between services point one way. When two services need each other, one publishes a topic and the other subscribes. Deep dive: Bus, built on the Signal primitive. Every built-in topic: Hooks and Bus topics.

A scope gives related resources one lifetime. kernel:createScope() returns a child of the kernel’s root scope; add connections, Instances, scheduler handles, objects with a destroy or disconnect method, or plain cleanup functions, and destroy() cleans them in reverse registration order.

local Lifetime = Kernel:createScope()
Lifetime:add(Kernel.Bus:subscribe("Inventory.Changed", function(_topic, session)
refreshInventory(session)
end))
Lifetime:add(Kernel.Scheduler:after(0.2, refreshSummary))
Lifetime:tween(Panel, TweenInfo.new(0.3), { BackgroundTransparency = 0 })
local PanelLifetime = Lifetime:child()
PanelLifetime:add(CloseButton.Activated:Connect(function()
PanelLifetime:destroy() -- closing a child early also detaches it from its parent
end))
-- in the owning service's stop(), or when the view closes:
Lifetime:destroy()

Every connection, timer, thread and created instance in game code belongs to a session, a scope, a component’s cleanup or a service’s stop. Cleanup callbacks must not yield, and one failing cleanup is logged without stopping the rest. Kernel shutdown closes any scope still open after the service stops, as a backstop. Deep dive: Scope.

On the server, ServerKernel.boot() binds one game:BindToClose. When the server closes, the kernel:

  1. Publishes Kernel.Closing(reason) on the Bus, where reason is the engine’s Enum.CloseReason.
  2. Runs every service stop() in reverse boot order. Each one runs isolated: a stop that throws logs a warning and the next one still runs.
  3. Publishes Kernel.Stopped(reason) when the last stop returns.

The phase has a 25 second budget (ServerKernel.CloseBudgetSeconds) under the engine’s 30 second close window. A stop() may yield briefly for a final flush such as Boards:destroy(). If the phase is still running at 25 seconds, the kernel logs a warning and returns to the engine.

Sessions and profiles are left to the DataDriver, whose own close drain runs in parallel. It waits for Kernel.Stopped (up to 5 seconds) before the final saves, so profile writes made in stop() are saved, and it finishes within 27 seconds of the close.

src/Server/Game/Services/PayoutService.luau
--!strict
-- PayoutService: banks round earnings into the profile on leave and on server close.
local PayoutService = {
Name = "PayoutService",
}
function PayoutService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Pending = {} :: { [any]: number }
self.Accepting = true
end
function PayoutService.start(self: any)
self.Scope:add(self.Kernel:onSession(function(session: any)
session:bind(function()
self:pay(session) -- bindings run before the profile's final save
end)
end))
self.Scope:add(self.Kernel.Bus:subscribe("Kernel.Closing", function(_topic, _reason)
self.Accepting = false -- no new earnings once shutdown begins
end))
end
function PayoutService.earn(self: any, session: any, amount: number)
if self.Accepting and session.Active then
self.Pending[session] = (self.Pending[session] or 0) + amount
end
end
function PayoutService.pay(self: any, session: any)
local Amount = self.Pending[session]
self.Pending[session] = nil
local Profile = session.Profile
if Amount and Profile and not session.ProfilePending then
Profile.Data.Coins += Amount
end
end
function PayoutService.stop(self: any)
self.Scope:destroy()
-- Writes made here reach the final saves: the data drain waits for Kernel.Stopped.
for Session in self.Pending do
self:pay(Session)
end
end
return PayoutService

kernel:shutdown() is the full teardown used by specs and in-place reruns: it ends every session, destroys the network driver, stops services in reverse boot order, closes remaining scopes, kills kernel-owned processes, and destroys the schedulers, the Bus and the hooks. Deep dive: Kernel and boot.

One player action touches all six. A client fires UseItem. Packet charges the payload against the player’s transport byte and message budgets before decoding it against the channel schema. The NetDriver checks that a live session exists, takes a token from the channel’s per-player rate limit, rejects the payload outright if the channel has no validator and is not Open, and fires the Intent.UseItem hook chain with the session in the context. Validators check server state. The handler changes session.Profile.Data, publishes on the Bus, and writes the new value to a replica. The write marks the replica dirty, and its delivery runs at the replica’s next scheduler deadline under the frame budget. The service that set this up owns its subscriptions through a scope, and its stop() runs in the close phase. Nothing on that path trusted a byte the client sent.

The security consequences of this shape are in Architecture and security.

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