Skip to content

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.

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 to Heartbeat (it sleeps while it has no work), a hook registry, a Bus and an empty service manager. It connects Scheduler.OnError to a Logger warning, [ChloeKernel] scheduled task errored:, so a crashing task is never silent. The instance is a per-VM singleton, and a second boot() errors with ChloeKernel is already booted on this machine; use Kernel.current().
  • The registration window is the time between boot() and start(). Main.server.luau and Main.client.luau call a sibling Bootstrap ModuleScript with the booted kernel, and the shipped Bootstraps load the Game layer, which registers every service or controller. Nothing registered runs yet.
  • kernel:start() runs the two-phase service boot (every init, then every start, in dependency order, see Services). It prints one line such as [ChloeKernel 0.9.1] Server booted 4 services in 1.3ms and sets a ReplicatedStorage attribute (ChloeKernelServer or ChloeKernelClient) 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 service stop() in reverse boot order, then Kernel.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().

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):

  1. In Studio only, the spec gate runs the Shared and Server spec suites, then the Game suite when ServerScriptService.Game.Tests exists. A failing suite raises and stops the script before the kernel boots. The play session keeps running so the Output window stays readable.
  2. ServerKernel.boot() boots the shared core, creates a session for every player already in the game, connects PlayerAdded and PlayerRemoving, and binds the close hook. In Studio this runs as the gate’s Boot step.
  3. The sibling Bootstrap is called with the kernel. It requires ServerScriptService.Game, which builds the channel handles from Game.Channels with Registry.server and registers every service listed in Game/Registry.luau, each with the handles injected as self.Net.
  4. kernel:start() runs the service boot. When a NetDriver was built, every intent or request channel that has no validator and is not Open gets a [ChloeKernelServer] warning, because it will reject every payload.
  5. enableDiagnostics(1) runs in Studio, and on any server where the ReplicatedStorage.ChloeKernelDebug attribute is true, feeding the debug panels.
  6. In Studio only, a deferred Audit step fails on any High finding from securityAudit, and a Fuzz step 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):

  1. In Studio only, client game specs under StarterPlayerScripts.Game.Tests run first, and a failure asserts before the kernel boots.
  2. Kernel.boot(). The client runs the shared microkernel directly; there is no privileged layer.
  3. The sibling Bootstrap requires StarterPlayerScripts.Game, which builds client handles with Registry.client and registers every controller. The framework’s own test place also attaches the simulation panel here; a real game deletes those two lines.
  4. kernel:start().
  5. Panel.attach(kernel) attaches the F8 debug panel, which stays inert outside Studio unless ChloeKernelDebug is set.

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.HeartbeatTime and Stats.PhysicsStepTime (seconds; engines without them fall back to the deprecated *Ms properties), 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. HeartbeatTime excludes 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 orientation
local 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().

src/Server/Game/Services/ScoreService.luau
--!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
end
end
function ScoreService.stop(self: any)
if self.SessionConnection then
self.SessionConnection:disconnect()
end
end
return ScoreService
src/Server/Game/Registry.luau
--!strict
-- Every server service, one require per module in Services.
return {
require(script.Parent.Services.ComponentService),
require(script.Parent.Services.ScoreService),
} :: { any }

Anywhere else after boot:

local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local Score = ServerKernel.current():getService("ScoreService")

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.

The server kernel binds one game:BindToClose callback at boot. When the engine closes the server:

  1. The kernel publishes Kernel.Closing(reason) on the Bus. reason is the Enum.CloseReason the engine passes (ServerEmpty, DeveloperUpdate, Moderation and so on), or nil.
  2. It runs Services:stopAll() on its own thread: every stop() in reverse boot order, each isolated, so a stop that throws logs a [ServiceManager] warning and the rest still run.
  3. When the stops finish it publishes Kernel.Stopped(reason).
  4. 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 25s and 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:

src/Server/Game/Services/RoundService.luau
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()
end
end

The 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().

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
end
end

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.

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:

  1. The diagnostics task and GcWatch.
  2. Services:stopAll(): every service not yet stopped, in reverse boot order.
  3. The root scope, which closes every scope from createScope() still open.
  4. Every process from spawnProcess still alive, killed with reason "kernel shutdown".
  5. 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.

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))

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.

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.

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