Kernel & Boot
The kernel is the one object each machine boots — server and client each run exactly one. It owns the Scheduler, the hook registry, the Bus, and the service manager, and it contains no game rules. The defining design decision is the two-layer split: the shared microkernel in ReplicatedStorage.ChloeKernel replicates to every client and holds only generic primitives, while ServerScriptService.ChloeKernelServer never replicates and holds everything privileged — sessions, networking, validators, rate limits. Code placement is the attack surface; see Architecture & security.
Mental model
Section titled “Mental model”Kernel.boot() is construction, kernel:start() is ignition, and the gap between them is the registration window.
- boot builds the kernel: a Heartbeat-bound scheduler, a hook registry, a bus, an empty service manager. It also connects
Scheduler.OnErrorto a warning sink, so a crashing task never takes the frame down silently. The result is stored in a module-level singleton — a secondboot()on the same machine errors withChloeKernel is already booted on this machine; use Kernel.current(). - the registration window is where your game exists.
Main.server.luauandMain.client.luauboth look for a siblingBootstrapModuleScript and call it with the booted kernel. Everything you register — services, drivers, channels — happens here, before any of it runs. - start runs the two-phase service boot (every
initbefore anystart, dependency-ordered — see Services), prints one line ([ChloeKernel 0.5.0] Server booted N services in Xms), and stamps the kernel version on aReplicatedStorageattribute (ChloeKernelServerorChloeKernelClient) so external tooling can observe kernel state without sharing its VM.
The version is Kernel.Version = "0.5.0". 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 and already do this — you edit Bootstrap, not Main.
Server (src/Server/Main.server.luau):
- In Studio only: TestKit runs every spec under the shared and server
Tests/folders. A failing spec fails the play test before the kernel exists. ServerKernel.boot()— boots the shared core underneath, wraps it, and wires session creation toPlayers.PlayerAdded/PlayerRemoving(including players already present).- The sibling
BootstrapModuleScript is required and called with the booted kernel. kernel:start()— two-phase service boot. If a NetDriver was built during bootstrap, every intent or request channel that has a handler but no validator and is notOpengets a boot warning: it will reject every payload.- In Studio only:
enableDiagnostics(1)feeds the F8 debug panel, a deferredsecurityAuditasserts on any High-severity finding, and the fuzzer drives hostile payloads through every channel once the first session exists. Both gates fail the play test loudly.
Client (src/Client/Main.client.luau):
Kernel.boot()— the client runs the shared microkernel directly; there is no privileged layer to add.- The sibling
Bootstrapis required and called. kernel:start().Panel.attach(kernel)— the debug panel, Studio-only unless theChloeKernelDebugattribute is set.
Boot configuration
Section titled “Boot configuration”boot(config?) takes a KernelConfig with two optional numbers. They configure how much of each frame the scheduler may spend; the full mechanics live on the Scheduler page.
| Option | Type | Default | Effect |
|---|---|---|---|
BudgetSeconds |
number? |
0.0015 |
Without TargetFrameSeconds this is a fixed per-step slice. With it, it is the floor the frame-aware budget never drops below. |
TargetFrameSeconds |
number? |
0.0165 on the server when neither 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. |
The rule, exactly as the source decides it: frame-aware mode turns on when TargetFrameSeconds is set explicitly, or when neither option is set on the server. Passing only BudgetSeconds gives a fixed slice on either machine.
- Server default — frame-aware. Each step reads
Stats.HeartbeatTimeMsandStats.PhysicsStepTimeMs, subtracts the engine’s share (minus the scheduler’s own smoothed step cost, so idle frames the scheduler fills do not read as busy), and spends the slack under the 16.5ms target — never less than the 1.5ms floor. An idle server drains backlog fast; a physics-heavy one backs off automatically. Late frames back off to the floor regardless of engine stats, which catches overload and costs the stats miss (replication serialization, for one). - Client default — fixed 1.5ms.
HeartbeatTimeMsexcludes render time on the client, so a frame-aware budget would read an empty frame and starve the render thread. The client stays on a flat slice unless you explicitly opt in.
-- 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 50fps target BudgetSeconds = 0.002, -- and never below 2ms even on late frames})Registration happens in Bootstrap, which receives the booted kernel between boot() and start():
-- src/Server/Bootstrap.luaureturn function(kernel) kernel:registerService({ Name = "Score", init = function(self, booted) self.Kernel = booted self.Totals = {} end, start = function(self) self.Kernel:onSession(function(session) self.Totals[session.Player] = 0 session:bind(function() self.Totals[session.Player] = nil end) end) end, })end-- src/Client/Bootstrap.luaureturn function(kernel) kernel:registerService({ Name = "Minimap", init = function(self, booted) self.Kernel = booted end, start = function(self) local function redraw() -- rebuild minimap dots from workspace state end -- Low priority: redraw waits when the frame is already full self.Kernel.Scheduler:every(0.25, redraw, self.Kernel.Priority.Low) end, })endAnywhere else, after boot:
local Kernel = require(game:GetService("ServerScriptService").ChloeKernelServer).current()local Score = Kernel:getService("Score")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; errors if already booted. Builds and binds the scheduler, hooks, bus, services. |
Kernel.current(): Kernel |
The booted instance; asserts ChloeKernel has not booted yet before boot. |
Kernel.Version |
"0.5.0". |
Kernel.Priority |
{ Kernel = 1, High = 2, Normal = 3, Low = 4, Background = 5 } — lower runs first. |
kernel.Role |
"Server" or "Client". |
kernel.Scheduler, .Hooks, .Bus, .Services |
The core subsystems, directly reachable. |
kernel:registerService(definition) |
Forwards to Services:register; see Services. |
kernel:start() |
Two-phase service boot; errors kernel already started on a second call. |
kernel:getService(name: string) |
The service table; errors on unknown names. |
kernel:spawnProcess(fn, options?): Process |
options = { Name: string?, Priority: number? }; binds the process to the kernel scheduler. See Processes. |
kernel:phaseScheduler(phase): Scheduler |
Lazily builds a scheduler bound to another engine phase. |
kernel:stats() |
{ Version, Role, Scheduler = { LastStepSeconds, TasksRun, TasksDeferred }, ServiceTimings }. |
kernel:enableDiagnostics(intervalSeconds?, extend?) |
Publishes health attributes every interval (default 5s); idempotent. |
kernel:shutdown() |
Full teardown; clears the boot singleton so a fresh boot() works. |
kernel:api(extensions?) |
Frozen restricted surface for userspace code. |
ServerKernel (ServerScriptService.ChloeKernelServer)
Section titled “ServerKernel (ServerScriptService.ChloeKernelServer)”Everything above, wrapped, plus:
| Member | Description |
|---|---|
ServerKernel.boot(config?): ServerKernel |
Boots the shared core underneath and wires sessions. Same config, own singleton. |
ServerKernel.current(): ServerKernel |
Asserts ChloeKernelServer has not booted yet before boot. |
kernel.Core |
The underlying shared kernel. |
kernel.Sessions |
Live map of Player to Session. |
kernel.OnSessionStart |
Signal fired with each new session. |
kernel:onSession(fn): Connection |
Fires for current and future sessions; see Sessions. |
kernel:getSession(player): Session? |
nil after the player leaves. |
kernel:net(): NetDriver |
Lazy singleton — games that never touch networking never build the driver. |
kernel:stats() |
Core snapshot plus Sessions (count) and Net (a clone of the driver’s counters, when built). |
kernel:enableDiagnostics(intervalSeconds?) |
Core diagnostics extended with session and net counters, plus a debug-gated log mirror. |
kernel:securityAudit(options?): { AuditFinding } |
options = { Silent: boolean? }. One pre-ship sweep; findings sorted most severe first. |
kernel:api() |
The core surface extended with onSession and getSession. |
The server kernel also points Services.Kernel at itself, so a service’s init(self, kernel) receives the server kernel — session API included — not the bare core.
Phase schedulers
Section titled “Phase schedulers”The main scheduler steps on Heartbeat. kernel:phaseScheduler(phase) lazily builds and caches one scheduler per additional engine phase:
| 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 on the server errors PreRender schedulers are client-only. |
Phase schedulers are deliberately fixed-budget (they inherit the main scheduler’s BudgetSeconds, not its frame-target): a frame-aware phase scheduler would measure the same frame slack Heartbeat already spent and double-claim it. Each gets its own OnError warning sink naming the phase.
local Physics = Kernel:phaseScheduler("PreSimulation")Physics:every(0, function() -- runs every physics step, before the solver, under a 1.5ms slice applyCustomForces()end, Kernel.Priority.High)stats() 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: every interval (default 5s) it publishes a health snapshot to ReplicatedStorage attributes, where the debug panel and external tooling read them without sharing the kernel’s VM. The diagnostics task runs at Kernel priority — health reporting must survive overload, when Background work has already stopped running.
Calling it also attaches GcWatch: Roblox exposes no GC pause timings, so allocation rate, collection cadence, and reclaim size are the actionable signals instead.
Attributes are named ChloeKernelDiag + role + suffix (so ChloeKernelDiagServerStepMs on the server). Step and deferral figures are windowed over the interval — a point sample phase-locks against recurring work and reads whatever frame it aligns with, and deferral is bursty enough that a point sample between bursts reads zero during genuine overload.
| Suffix | Meaning |
|---|---|
BudgetMs |
Last step’s budget (varies per frame in frame-aware mode). |
StepMs / StepMaxMs |
Windowed average / max scheduler step time. |
FrameBusyMs / PhysicsMs |
Engine heartbeat compute and physics time, broken out so frame time is attributable per source. |
FrameTargetMs |
The frame-aware target (0 when fixed). |
TasksRun |
Cumulative tasks executed. |
Deferred |
Tasks deferred, summed over the window. |
Services |
Booted service count. |
LuaHeapMb |
gcinfo() for this VM (shared with the client heap in Studio, separate in production). |
MemoryMb / Instances / Primitives / MovingPrimitives / Contacts |
Engine memory and simulation counters (MemoryMb only where memory tracking is available). |
ReplKbpsIn / ReplKbpsOut / PhysKbpsIn / PhysKbpsOut |
Replication and physics bandwidth — the only ungated engine signals on live builds. |
GcAllocKbS / GcCyclesMin / GcReclaimKb |
GcWatch: allocation rate, GC cadence, last reclaim size. |
HotTasks |
Top 3 profiler entries as ms-per-second name, pipe-separated. |
Uptime |
Seconds since diagnostics started. |
On the server the snapshot is extended with Sessions and Net, and the net counters publish under role-less names: ChloeKernelDiagNetAccepted, NetRejected, NetRateLimited, NetTransportFloods, NetTransportFloodBytes.
Overload alerting: after three consecutive diagnostic windows with deferred tasks, the kernel publishes Kernel.Overload on the Bus with the stats snapshot:
Kernel.Bus:subscribe("Kernel.Overload", function(_, snapshot) Log.warn("sustained frame budget overruns", snapshot.Scheduler.TasksDeferred)end)The shared enableDiagnostics accepts an extend callback that mutates the snapshot before publishing — that is how the server layer grafts Sessions and Net on. A crashing extension warns instead of failing silently. The server variant additionally wires a log mirror when running in Studio or when ChloeKernelDebug is set: Logger lines buffer in a 32-line ring and flush every 0.5s over a CKDBG_LogTail packet — sent per recipient, never broadcast, and only to allowlisted debuggers, because logs can carry anything.
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 firing kernel-reserved hooks.
| Member | Forwards to |
|---|---|
Version, Role, Priority |
Constants. |
schedule(fn, priority?, ...) |
Scheduler:schedule — one-shot, next step. |
every(intervalSeconds, fn, priority?) |
Scheduler:every. |
spawnProcess(fn, options) |
kernel:spawnProcess. |
publish(topic, ...) / subscribe(topic, fn) |
The Bus. |
onHook(name, fn, priority?) |
Hooks:on, with a guard: any name starting with Kernel. errors — those hook points are kernel-reserved. |
getService(name) |
Services:get. |
Privileged layers pass extensions to graft their own members on before the freeze — the server kernel adds onSession and getSession this way. Because the table is frozen, userspace cannot swap members out from under other consumers.
local Api = ServerKernel.current():api()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, sorted most severe first. Unless Silent, it prints security audit: clean or a warning block listing every finding.
| Kind | Severity | Meaning |
|---|---|---|
UnguardedChannel |
High | A channel has a handler but no validator and is not Open — every payload rejects. |
FailOpenGate |
High | An Intent. or Request. hook point is FailOpen — a crashing validator would pass the payload. |
OpenChannel |
Medium | Open = true: any client payload reaches the handler unvalidated. |
DefaultRateLimit |
Info | The channel rides the default rate limit instead of an explicit one. |
Without a NetDriver the sweep still checks hook gates for the fail-open misconfiguration. The Studio boot gate asserts no High finding survives a play test. Full treatment — including the fuzzer that exercises what the audit only reads — is on Fuzz & Audit.
Hooks and bus topics
Section titled “Hooks and bus topics”| Name | Type | Fired |
|---|---|---|
Kernel.Overload |
Bus topic | Three consecutive diagnostic windows saw deferred tasks; carries the stats snapshot. |
Kernel.SessionStart / Kernel.SessionEnd |
Bus topics | Player join/leave, published by the server kernel; carries the session. See Sessions. |
Session.Start / Session.End |
Hook points | Fired synchronously before the matching bus topic. See Sessions. |
The full cross-module list lives at Hooks & bus topics.