Kernel & Boot
Boots one kernel per machine, starts and stops its services, runs the server close phase and publishes health diagnostics.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel - Since
- v0.1.0
- Source
- init.luauinit.luau
Each machine boots exactly one kernel: the server runs one and every client runs its own. The kernel owns the
Scheduler, the hook registry, the Bus and the
service manager, and it holds no game rules. The design rests on a two-layer split. The shared
microkernel in ReplicatedStorage.ChloeKernel replicates to every client and holds only generic primitives.
ServerScriptService.ChloeKernelServer never replicates and wraps the core with everything privileged:
sessions, networking, validators, rate limits and the close phase. Where code lives decides who can
read it, which is covered in Architecture and security. Lifetimes below the
kernel belong to scopes and processes.
Mental model
Section titled “Mental model”A kernel goes through a fixed sequence of moments. The gap between the first two is where the game exists.
Kernel.boot()builds the kernel: a scheduler bound toHeartbeat(it sleeps while it has no work), a hook registry, a Bus and an empty service manager. It connectsScheduler.OnErrorto a Logger warning,[ChloeKernel] scheduled task errored:, so a crashing task is never silent. The instance is a per-VM singleton, and a secondboot()errors withChloeKernel is already booted on this machine; use Kernel.current().- The registration window is the time between
boot()andstart().Main.server.luauandMain.client.luaucall a siblingBootstrapModuleScript with the booted kernel, and the shipped Bootstraps load theGamelayer, which registers every service or controller. Nothing registered runs yet. kernel:start()runs the two-phase service boot (everyinit, then everystart, in dependency order, see Services). It prints one line such as[ChloeKernel 0.9.1] Server booted 4 services in 1.3msand sets aReplicatedStorageattribute (ChloeKernelServerorChloeKernelClient) to the version, so external tooling can observe the kernel without sharing its VM.- The close phase runs on the server when the engine shuts it down:
Kernel.Closing, every servicestop()in reverse boot order, thenKernel.Stopped. See Close phase. kernel:shutdown()is the full teardown used by specs and in-place re-runs. A live server never calls it. See Shutdown.
Kernel.Version is "0.9.1". kernel.Role is "Server" or "Client", decided by RunService:IsServer().
The boot sequence
Section titled “The boot sequence”Both Main scripts ship with the framework. Game work goes in the Game layer (src/Server/Game,
src/Client/Game); Main and Bootstrap stay as shipped unless a framework driver must attach before services. The
layer is described in Workspace overview.
Server (src/Server/Main.server.luau):
- In Studio only, the spec gate runs the Shared and Server spec suites, then the Game suite when
ServerScriptService.Game.Testsexists. A failing suite raises and stops the script before the kernel boots. The play session keeps running so the Output window stays readable. ServerKernel.boot()boots the shared core, creates a session for every player already in the game, connectsPlayerAddedandPlayerRemoving, and binds the close hook. In Studio this runs as the gate’sBootstep.- The sibling
Bootstrapis called with the kernel. It requiresServerScriptService.Game, which builds the channel handles fromGame.ChannelswithRegistry.serverand registers every service listed inGame/Registry.luau, each with the handles injected asself.Net. kernel:start()runs the service boot. When a NetDriver was built, every intent or request channel that has no validator and is notOpengets a[ChloeKernelServer]warning, because it will reject every payload.enableDiagnostics(1)runs in Studio, and on any server where theReplicatedStorage.ChloeKernelDebugattribute istrue, feeding the debug panels.- In Studio only, a deferred
Auditstep fails on any High finding fromsecurityAudit, and aFuzzstep drives hostile payloads through every channel validator once the first session exists. Run mode has no players, so the fuzz step is not expected there. The fuzz step runs validators only and fails when one throws. Details are on Fuzz and audit.
Client (src/Client/Main.client.luau):
- In Studio only, client game specs under
StarterPlayerScripts.Game.Testsrun first, and a failure asserts before the kernel boots. Kernel.boot(). The client runs the shared microkernel directly; there is no privileged layer.- The sibling
BootstraprequiresStarterPlayerScripts.Game, which builds client handles withRegistry.clientand registers every controller. The framework’s own test place also attaches the simulation panel here; a real game deletes those two lines. kernel:start().Panel.attach(kernel)attaches the F8 debug panel, which stays inert outside Studio unlessChloeKernelDebugis set.
Boot configuration
Section titled “Boot configuration”boot(config?) takes a KernelConfig. Every field is optional, and the server kernel passes the same table through
to the core.
| Option | Type | Default | Effect |
|---|---|---|---|
BudgetSeconds |
number? |
0.0015 |
Without TargetFrameSeconds this is a fixed per-step slice. With it, this is the floor the frame-aware budget never drops below. |
TargetFrameSeconds |
number? |
0.0165 on the server when neither budget option is set, otherwise nil |
Enables frame-aware budgeting: each step spends the slack left under this target after the engine’s measured share of the frame. |
ProfileTasks |
boolean? |
true |
false skips per-task clock reads, origin attribution and the deferred-queue scan. enableDiagnostics turns profiling back on. |
StaggerRecurring |
boolean? |
false |
true offsets the first deadline of each positive-interval every() job by a fraction of its interval, so jobs created together do not fire in the same frame. |
Frame-aware mode turns on when TargetFrameSeconds is set, or when neither budget option is set on the server. Passing
only BudgetSeconds gives a fixed slice on either machine.
- Server default, frame-aware. Each step reads
Stats.HeartbeatTimeandStats.PhysicsStepTime(seconds; engines without them fall back to the deprecated*Msproperties), subtracts the engine’s share of the frame and spends the slack under the 16.5 ms target, never less than the 1.5 ms floor. An idle server drains backlog fast and a physics-heavy one backs off by itself. The formula is on the Scheduler page. - Client default, a fixed 1.5 ms slice.
HeartbeatTimeexcludes render time on the client, so a frame-aware budget would read a mostly empty frame and starve rendering.
-- Main.server.luau owns the real boot call; shown for orientationlocal ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Booted = ServerKernel.boot({ TargetFrameSeconds = 0.020, -- frame-aware against a 50 fps target BudgetSeconds = 0.002, -- never below 2 ms, even on late frames ProfileTasks = false, -- lean production boot; diagnostics turn it back on})Game systems are modules in the Game layer, listed in its Registry.luau. The Bootstrap registers them between
boot() and start().
--!strict-- ScoreService: per-visit score totals.
local ScoreService = { Name = "ScoreService",}
function ScoreService.init(self: any, kernel: any) self.Kernel = kernel self.Totals = {} :: { [Player]: number }end
function ScoreService.start(self: any) self.SessionConnection = self.Kernel:onSession(function(session) self.Totals[session.Player] = 0 session:bind(function() self.Totals[session.Player] = nil end) end)end
function ScoreService.add(self: any, player: Player, amount: number) if self.Totals[player] then self.Totals[player] += amount endend
function ScoreService.stop(self: any) if self.SessionConnection then self.SessionConnection:disconnect() endend
return ScoreService--!strict-- Every server service, one require per module in Services.
return { require(script.Parent.Services.ComponentService), require(script.Parent.Services.ScoreService),} :: { any }--!strict-- PingController: publishes the local player's ping every two seconds.
local Players = game:GetService("Players")
local PingController = { Name = "PingController",}
function PingController.init(self: any, kernel: any) self.Kernel = kernelend
function PingController.start(self: any) -- Low priority: on a full frame the sample waits for a later step self.Sampler = self.Kernel.Scheduler:every(2, function() local Seconds = Players.LocalPlayer:GetNetworkPing() self.Kernel.Bus:publish("Ping.Sampled", math.round(Seconds * 1000)) end, self.Kernel.Priority.Low)end
function PingController.stop(self: any) if self.Sampler then self.Sampler:cancel() endend
return PingController--!strict-- Every client controller, one require per module in Controllers.
return { require(script.Parent.Controllers.ComponentController), require(script.Parent.Controllers.UIController), require(script.Parent.Controllers.PingController),} :: { any }Anywhere else after boot:
local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Score = ServerKernel.current():getService("ScoreService")API reference
Section titled “API reference”Kernel (shared, ReplicatedStorage.ChloeKernel)
Section titled “Kernel (shared, ReplicatedStorage.ChloeKernel)”| Member | Description |
|---|---|
Kernel.boot(config: KernelConfig?): Kernel |
Once per VM. Builds and binds the scheduler and creates the hooks, Bus and service manager. Errors if a kernel is already booted. |
Kernel.current(): Kernel |
The booted instance. Asserts ChloeKernel has not booted yet before boot. |
Kernel.Version |
"0.9.1". |
Kernel.Priority |
{ Kernel = 1, High = 2, Normal = 3, Low = 4, Background = 5 }. Lower runs first. |
kernel.Role |
"Server" or "Client". |
kernel.Scheduler, kernel.Hooks, kernel.Bus, kernel.Services |
The core subsystems. |
kernel.Booted, kernel.ShuttingDown, kernel.Shutdown |
Lifecycle flags. |
kernel.Processes |
Weak-keyed set of live processes created by spawnProcess; shutdown() kills them. |
kernel:registerService(definition) |
Forwards to Services:register, then publishes Kernel.ServiceRegistered(name) on the Bus. |
kernel:start() |
Two-phase service boot. Errors kernel already started on a second call and asserts kernel is shut down after shutdown. |
kernel:getService(name: string) |
The service table. Errors on an unknown name. |
kernel:createScope(): Scope |
A child of the kernel’s root scope, closed by shutdown() if nothing closed it first. See Scope. |
kernel:spawnProcess(fn, options?): Process |
options = { Name: string?, Priority: number? }. Binds the process to the kernel scheduler and tracks it in kernel.Processes. See Processes. |
kernel:phaseScheduler(phase): Scheduler |
Lazily builds and caches a scheduler bound to another engine phase. See Phase schedulers. |
kernel:stats() |
{ Version, Role, Scheduler = { LastStepSeconds, TasksRun, TasksDeferred }, ServiceTimings }. |
kernel:enableDiagnostics(intervalSeconds?, extend?) |
Publishes health attributes every interval (default 5 s) and raises Kernel.Overload. Idempotent. |
kernel:shutdown() |
Full teardown; clears the boot singleton so a fresh boot() works. Idempotent. |
kernel:api(extensions?) |
Frozen restricted surface for userspace code. See The restricted api() surface. |
ServerKernel (ServerScriptService.ChloeKernelServer)
Section titled “ServerKernel (ServerScriptService.ChloeKernelServer)”The server kernel wraps the core. It forwards the shared members and adds the privileged ones.
| Member | Description |
|---|---|
ServerKernel.boot(config?): ServerKernel |
Boots the core with the same config, wires sessions and binds the close hook. Errors ChloeKernelServer is already booted; use ServerKernel.current() on a second call. If wiring fails it shuts down what it built and rethrows. |
ServerKernel.current(): ServerKernel |
Asserts ChloeKernelServer has not booted yet before boot. |
ServerKernel.CloseBudgetSeconds |
25. How long the close hook waits for the stop phase. |
ServerKernel.Version, ServerKernel.Priority |
The core’s values. |
kernel.Core |
The underlying shared kernel. The server kernel has no Role field; read kernel.Core.Role. |
kernel.Scheduler, kernel.Hooks, kernel.Bus, kernel.Services |
The core’s subsystems. |
kernel.Sessions |
Live map of Player to Session. |
kernel.SessionCount |
Number of live sessions. |
kernel.OnSessionStart |
Signal fired with each new session. |
kernel.Closing |
true once the close hook has run. |
kernel:registerService, kernel:start, kernel:getService, kernel:spawnProcess, kernel:phaseScheduler |
Forward to the core. start asserts server kernel is shut down after shutdown and adds the unguarded-channel warnings. |
kernel:createScope(): Scope |
Asserts server kernel is shut down once server shutdown begins, then forwards to the core. |
kernel:onSession(fn): Connection |
Runs fn for current and future sessions. See Sessions. |
kernel:getSession(player): Session? |
nil once the player has left. |
kernel:net(): NetDriver |
Lazy singleton: a game that never touches networking never builds the driver. |
kernel:stats() |
The core snapshot plus Sessions (count) and Net (a copy of the driver’s counters, when built). |
kernel:enableDiagnostics(intervalSeconds?) |
Core diagnostics extended with session and net counters, plus the debug-gated log mirror. |
kernel:securityAudit(options?): { AuditFinding } |
options = { Silent: boolean? }. One pre-ship sweep; findings sorted most severe first. |
kernel:shutdown() |
Disconnects the join and leave signals, ends every session, removes the log mirror sink, destroys the NetDriver, shuts down the core and clears the server singleton. |
kernel:api() |
The core surface extended with createScope, onSession and getSession. |
The server kernel sets Services.Kernel to itself, so a service’s init(self, kernel) receives the server kernel with
the session API the bare core lacks.
Close phase
Section titled “Close phase”The server kernel binds one game:BindToClose callback at boot. When the engine closes the server:
- The kernel publishes
Kernel.Closing(reason)on the Bus.reasonis theEnum.CloseReasonthe engine passes (ServerEmpty,DeveloperUpdate,Moderationand so on), ornil. - It runs
Services:stopAll()on its own thread: everystop()in reverse boot order, each isolated, so a stop that throws logs a[ServiceManager]warning and the rest still run. - When the stops finish it publishes
Kernel.Stopped(reason). - The close callback returns to the engine when the stop phase finishes or after
CloseBudgetSeconds(25 s), whichever comes first. On the budget it warns[ChloeKernelServer] close stop phase still running after 25sand returns while the stops keep running.
The engine waits 30 seconds for close callbacks, so 25 leaves headroom. A second close call is ignored, and so is a close
after shutdown().
The close hook leaves sessions and profiles alone. DataDriver binds its own close drain, which runs
in parallel: it waits for Kernel.Stopped (up to 5 s) so profile writes made in a stop() reach the final saves, then
finishes within 27 s of the close. That is why a stop() may still pay out an end-of-round reward:
function RoundService.stop(self: any) -- Close phase: the DataDriver drain waits for Kernel.Stopped, so these writes are saved for Session, Points in self.Points do local Profile = Session.Profile if Profile then Profile.Data.Coins += Points end end table.clear(self.Points) if self.Timer then self.Timer:cancel() endendThe close phase does not call shutdown(). Scopes created with createScope, processes and the scheduler stay alive
until the server process ends, so anything that must be released at close belongs to a service’s stop().
Phase schedulers
Section titled “Phase schedulers”The main scheduler steps on Heartbeat. kernel:phaseScheduler(phase) builds one more scheduler per engine phase on
first use and caches it:
| Phase | Use | Notes |
|---|---|---|
"Heartbeat" |
Default | Returns the main scheduler itself. |
"PreSimulation" |
Physics-coupled work before the solver | Fixed budget. |
"PostSimulation" |
Work that reads solved physics | Fixed budget. |
"PreRender" |
Camera work | Client only. Binding it on the server errors PreRender schedulers are client-only. |
A phase scheduler has a fixed budget equal to the main scheduler’s BudgetSeconds. A frame-aware one would measure the
same slack the Heartbeat scheduler already spent and claim it twice. It copies the main scheduler’s ProfileTasks and
StaggerRecurring settings when it is created, gets its own [ChloeKernel] <phase> task errored: warning sink, and
sleeps while idle like the main one. On the server kernel phaseScheduler forwards to the core (added in 0.9.0).
Start per-step work on the first item that needs it and cancel it on the last:
local Hovering: { [BasePart]: number } = {}local HoverJob: any = nil
local function hover(part: BasePart, height: number) Hovering[part] = height if HoverJob then return end HoverJob = kernel:phaseScheduler("PreSimulation"):every(0, function() for Part, Height in Hovering do local Velocity = Part.AssemblyLinearVelocity Part.AssemblyLinearVelocity = Vector3.new(Velocity.X, (Height - Part.Position.Y) * 4, Velocity.Z) end end, kernel.Priority.High)end
local function drop(part: BasePart) Hovering[part] = nil if next(Hovering) == nil and HoverJob then HoverJob:cancel() -- no hovering parts: the phase scheduler goes back to sleep HoverJob = nil endendstats() and diagnostics
Section titled “stats() and diagnostics”kernel:stats() is a cheap point snapshot: scheduler health plus per-service boot timings.
kernel:enableDiagnostics(intervalSeconds?) is the continuous version. It turns task profiling on, attaches
GcWatch (Roblox exposes no GC pause timings, so allocation rate, collection cadence and reclaim size
are the signals), and every interval (default 5 s) writes a health snapshot as attributes on a per-role Configuration
under ReplicatedStorage: ChloeKernelDiagServer or ChloeKernelDiagClient. The debug panels
and external tooling read them without sharing the kernel’s VM. The task runs at Kernel priority, because health
reporting has to survive the overload it reports on.
The attribute names are short and live on the child container. About 29 long-prefixed attributes written on
ReplicatedStorage itself errored the diagnostics task in real games (fixed in 0.6.2). String attribute values are
capped at 50 characters. Step and deferral figures are windowed over the interval: a point sample phase-locks against
recurring work, and deferral comes in bursts that a point sample between bursts reads as zero.
| Attribute | Meaning |
|---|---|
BudgetMs |
Budget of the last step. Changes per frame in frame-aware mode. |
StepMs / StepMaxMs |
Average and longest scheduler step in the window. |
TaskMaxMs / TaskMaxKey |
Longest single task in the window and the last 50 characters of its script:line origin. |
FrameBusyMs / PhysicsMs |
Engine heartbeat compute and physics time read by the last step. |
FrameTargetMs |
The frame-aware target, 0 in fixed mode. |
TasksRun |
Tasks run since boot. |
Deferred |
Tasks deferred, summed over the window. |
DeferredKeys |
The five most deferred origins as 12x Module.luau:40, comma-separated, cut to 50 characters. |
Services |
Services in the boot order. |
LuaHeapMb |
gcinfo() for this VM. Studio shares one heap between server and client; production does not. |
MemoryMb |
Stats:GetTotalMemoryUsageMb(). Published everywhere, since it does not depend on memory category tracking. |
Instances / Primitives / MovingPrimitives / Contacts |
Engine instance and simulation counters. |
ReplKbpsIn / ReplKbpsOut |
Replication bandwidth. |
PhysKbpsIn / PhysKbpsOut |
Physics bandwidth. Reads 0 once Improved Physics Replication is active (global since 2026-06-15). |
GcAllocKbS / GcCyclesMin / GcReclaimKb |
GcWatch: allocation rate, collection cadence, last reclaim size. |
HotTasks1 / HotTasks2 / HotTasks3 |
Top three profiler entries as X.XXms/s Module.luau:40. |
Uptime |
Seconds since diagnostics started. |
NetAccepted / NetRejected / NetRateLimited / NetTransportFloods / NetTransportFloodBytes |
Server only, once a NetDriver exists. |
The session count is not a diagnostics attribute. The server kernel keeps it on the ChloeKernelSessions attribute of
ReplicatedStorage at every join and leave.
After three consecutive windows with deferred tasks, the kernel publishes Kernel.Overload on the Bus
with the stats snapshot, and again every window while deferral continues. Since 0.9.0 the check runs before any
attribute write, so a failing write cannot suppress the alert.
local Logger = require(game:GetService("ReplicatedStorage").ChloeKernel.Logger)
local Log = Logger.scope("Health")
kernel.Bus:subscribe("Kernel.Overload", function(_, snapshot) Log.warn("sustained frame budget overruns", snapshot.Scheduler.TasksDeferred)end)The shared enableDiagnostics takes an extend(snapshot) callback that adds fields before publishing. The server
kernel uses it to add Sessions and Net. A throwing extension logs [ChloeKernel] diagnostics extension errored:.
On the server, diagnostics also wire a log mirror when running in Studio or when ChloeKernelDebug is true. A
Logger sink keeps the newest 32 lines. The first line after each flush arms one
Scheduler:after(0.5, flush) at Background priority, so lines go out at most every half second and an idle log
schedules nothing (0.9.0; it used to poll every 0.5 s). The flush JSON-encodes the lines
and sends them over the CKDBG_LogTail packet, one FireClient per player that NetTap.debugAllowed accepts, because
logs can carry anything. With no allowed player connected the lines are dropped.
Shutdown
Section titled “Shutdown”kernel:shutdown() exists for specs and in-place re-runs. It sets ShuttingDown, then releases everything in this
order, each step isolated so one failure cannot stop the rest:
- The diagnostics task and GcWatch.
Services:stopAll(): every service not yet stopped, in reverse boot order.- The root scope, which closes every scope from
createScope()still open. - Every process from
spawnProcessstill alive, killed with reason"kernel shutdown". - The phase schedulers, the main scheduler, the Bus and the hook registry.
It then clears the boot singleton and the ChloeKernel<Role> attribute, so a fresh Kernel.boot() works. If any step
failed, shutdown() raises kernel shutdown failed: with every collected traceback after finishing the rest.
start(), createScope() and spawnProcess() assert once shutdown has begun, and shutdown() asserts
cannot shut down while services are booting when called from inside a service’s init or start.
On the server call shutdown() on the server kernel. It ends every session first (the hooks, the Bus and services are
still available to session-end handlers), then shuts down the core. Calling kernel.Core:shutdown() directly skips
that and leaves sessions and the NetDriver behind.
The restricted api() surface
Section titled “The restricted api() surface”kernel:api(extensions?) returns a frozen table for userspace code: scripts that should schedule work and talk on the
Bus without reaching scheduler internals or registering on kernel-reserved hooks.
| Member | Forwards to |
|---|---|
Version, Role, Priority |
Constants. |
schedule(fn, priority?, ...) |
Scheduler:schedule: one-shot on the next step. |
every(intervalSeconds, fn, priority?) |
Scheduler:every. |
after(delaySeconds, fn, priority?) |
Scheduler:after: one-shot on a deadline. |
createScope() |
kernel:createScope(). On the server facade this goes through the server kernel, so it is rejected as soon as server shutdown begins. |
spawnProcess(fn, options) |
kernel:spawnProcess. |
publish(topic, ...) / subscribe(topic, fn) |
The Bus. |
onHook(name, fn, priority?) |
Hooks:on. A name starting with Kernel. errors hook point "<name>" is kernel-reserved. |
getService(name) |
Services:get. |
onSession(fn) / getSession(player) |
Server facade only. |
Members are dot-called. Privileged layers pass extensions to add members before the freeze, which is how the server
kernel adds its three. Because the table is frozen, userspace cannot swap members out from under other consumers.
local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Api = ServerKernel.current():api()local Lifetime = Api.createScope()Lifetime:add(Api.every(1, function() Api.publish("Game.Tick")end, Api.Priority.Background))securityAudit()
Section titled “securityAudit()”kernel:securityAudit({ Silent? }?) sweeps the security surface in one call and returns { Severity, Kind, Name, Detail }
findings, most severe first. Unless Silent, it prints [ChloeKernelServer] security audit: clean or logs a warning
listing every finding, one line each as [Severity] Kind (Name): Detail, for example
[High] UnguardedChannel (intent Grant): has no validator ....
| Kind | Severity | Meaning |
|---|---|---|
UnguardedChannel |
High | An intent or request channel has no validator and is not Open, so every payload rejects. |
FailOpenGate |
High | An Intent. or Request. hook point is FailOpen, so a crashing validator would pass the payload. |
OpenChannel |
Medium | Open = true on a channel with a handler: any client payload reaches it unvalidated. |
DefaultRateLimit |
Info | A channel with a handler rides the default rate limit instead of an explicit one. |
Without a NetDriver the sweep still checks hook points for the fail-open gate. The Studio gate fails the play test on any High finding. The fuzzer, which exercises what the audit only reads, is on Fuzz and audit.
Hooks and bus topics
Section titled “Hooks and bus topics”| Name | Type | Fired |
|---|---|---|
Kernel.ServiceRegistered |
Bus topic | After each successful registerService, with the service name. |
Kernel.SessionStart / Kernel.SessionEnd |
Bus topics | Player join and leave. SessionEnd carries (session, exitReason?). See Sessions. |
Session.Start / Session.End |
Hook points | Fired synchronously before the matching Bus topic. |
Kernel.Closing |
Bus topic | Server close begins, with the close reason. Service stop() runs right after. |
Kernel.Stopped |
Bus topic | Every service stop() finished during close, with the close reason. |
Kernel.Overload |
Bus topic | Three or more consecutive diagnostic windows saw deferred tasks; carries the stats snapshot. |
The full cross-module list lives at Hooks and bus topics.