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.
1. Kernel
Section titled “1. Kernel”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 orientationlocal ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)local Kernel = ServerKernel.boot()-- src/Server/Bootstrap.luau loads the Game layer here, which registers servicesKernel: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.
2. Services
Section titled “2. Services”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).
--!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 QuestServiceBoot 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.
3. Sessions
Section titled “3. Sessions”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.
4. Hooks
Section titled “4. Hooks”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 endend, 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 functionRegistry 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.
5. The Bus
Section titled “5. The Bus”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 familyKernel.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.
6. Scopes
Section titled “6. Scopes”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 parentend))
-- 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.
The close phase
Section titled “The close phase”On the server, ServerKernel.boot() binds one game:BindToClose. When the server closes, the kernel:
- Publishes
Kernel.Closing(reason)on the Bus, wherereasonis the engine’sEnum.CloseReason. - 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. - 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.
--!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 = trueend
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 endend
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 endend
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) endend
return PayoutServicekernel: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.
How they compose
Section titled “How they compose”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.