Skip to content

Infrastructure & Ops

The layer under the gameplay: per-player lifecycles that clean up after themselves, work scheduled under a frame budget, long-lived coroutines with a lifecycle, parallel dispatch, structured logs, deduplicated errors, fleet-wide config flips, and the instrumentation to watch all of it running live.

-- src/Server/Bootstrap.luau receives the kernel after boot, before start
return function(kernel)
kernel:onSession(function(session)
session.Data.JoinedAt = os.clock()
session:bind(session.Player.CharacterAdded:Connect(onSpawn)) -- auto-disconnected
session:bind(function()
print(session.Player.Name, "played for", os.clock() - session.Data.JoinedAt)
end)
end)
end

Why it works: onSession also fires for players already in the game, so late-registering systems never miss anyone. Everything passed to session:bind — connections, instances, functions — is cleaned up on leave. That one habit is most of leak prevention: per-player state lives on the session, and the session’s teardown owns it.

See Sessions for the session object, session.Data vs session.Profile, and the end-of-session ordering.

-- Background priority: runs only on spare frame budget
Kernel.Scheduler:every(30, function()
for _, Session in Kernel.Sessions do
periodicCheck(Session)
end
end, Kernel.Priority.Background)
-- one-shot, next frame, high priority
Kernel.Scheduler:schedule(rebuildNavMesh, Kernel.Priority.High)

Why it works: the scheduler drains queues in priority order under a per-frame budget. On the server the budget is frame-aware by default: each step spends the slack left under a 16.5ms frame target after the engine’s measured share of the frame (Stats.HeartbeatTimeMs), so an idle server drains backlog fast and a physics-heavy one backs off automatically — never below a 1.5ms floor. The client defaults to a fixed 1.5ms slice (the render thread’s cost is invisible to the frame stat, so “fill the frame” would starve rendering). Background runs only on spare budget; Kernel priority can’t be starved. Task errors are isolated and reported on Scheduler.OnError.

See Scheduler for the budget math, priority levels, and hot-task profiling.

Run long-lived behaviors you can pause or kill

Section titled “Run long-lived behaviors you can pause or kill”

A Process is a coroutine with a lifecycle (PID, state machine, exit signal) stepped by the scheduler — for any logic that runs across frames:

local Process = require(ReplicatedStorage.ChloeKernel.Process)
local Proc = Kernel:spawnProcess(function()
while true do
stepWork()
Process.yield() -- resumes on a later scheduler step; never blocks the frame
end
end, { Name = "Worker" }) -- the name appears in the hot-task profiler as "Process Worker"
Proc:start()
Proc:suspend() -- stops stepping; state is kept
Proc:resume()
Proc:kill("owner released") -- closes the coroutine, fires OnExit

Why it works: Process.yield() hands control back to the scheduler instead of blocking, so a process spreads its work across frames under the same budget as everything else. Proc.OnExit fires with the final state (Completed/Crashed/Killed); crashes carry a traceback in .Result. Because the work runs inside the process’s own coroutine, profiler time is attributed to Process Worker — not to the kernel’s resume line.

See Processes for the state machine and how processes interact with the Scheduler.

Parallel Luau with worker readiness, result routing, and timeouts handled by the pool:

-- PathWorker.luau (ModuleScript): pure function, no shared state
return function(startPos: Vector3, goalPos: Vector3)
return computePathWaypoints(startPos, goalPos)
end
local Pool = ActorPool.new({ Size = 4, WorkerModule = script.PathWorker })
local Ok, Waypoints = Pool:dispatch(npcPosition, targetPosition) -- yields

Why it works: the worker module is a pure function — arguments in, result out, no shared state — which is exactly the shape Parallel Luau parallelizes safely. The pool owns the ugly parts: waiting for actors to signal readiness before the first dispatch, routing each result back to its caller, and timing out a hung worker instead of blocking forever. dispatch yields the calling thread until the result returns.

See ActorPool for worker lifecycle, sizing, and timeout behavior.

local Log = Logger.scope("Shop")
Log.info("purchase", player.Name, itemId)
Log.warn("stock low", itemId)
-- pipe everything to your analytics service without touching call sites:
Logger.addSink(function(entry)
Analytics:track(entry.Scope, entry.Message, entry.Args)
end)
Logger.setLevel(Logger.Level.Debug) -- in Studio

Why it works: scopes tag every entry with the system that emitted it, and sinks receive structured entries (Scope, Level, Message, Args) instead of formatted strings — so shipping logs to analytics, a webhook, or the debug panel’s log tail is one addSink call, not a find-and-replace across call sites. Sinks are crash-isolated — a broken sink can’t break the caller.

See Logger for levels, the console sink, and entry structure.

local Watcher = ErrorWatch.attach(Kernel)
Kernel.Bus:subscribe("Kernel.ScriptError", function(_, message, trace, source, count)
Analytics:track("script_error", { message = message, source = source, count = count })
end)

Why it works: identical errors log once per window with an occurrence count instead of flooding — a crash loop becomes one line with (x4000), not four thousand lines. Deduplicated errors publish on Kernel.ScriptError with the message, traceback, source, and running count, so the same stream feeds logs, Analytics (which can bridge it automatically with WatchErrors = true), or an external alerting sink.

See GcWatch & ErrorWatch for windows, counts, and the memory-side twin.

local Config = LiveConfig.new(Kernel, {
Defaults = { BossHealth = 100000, DoubleXP = false, NewShopEnabled = true },
})
local Health = Config:get("BossHealth")
Kernel.Bus:subscribe("Config.Changed", function(_, key, value)
if key == "DoubleXP" then announceEvent(value) end
end)
-- from any server (or the dev console) — reaches the whole fleet within a poll:
Config:set("DoubleXP", true)
-- mid-incident kill switch:
Config:set("NewShopEnabled", false)

Why it works: Defaults is the source-controlled truth; set() writes an override into a shared MemoryStore document that every server polls, so a flip reaches the fleet within one poll interval and Config.Changed fires only on actual changes. Reverting a key falls back to the default — overrides are a layer, not a replacement. LiveConfig also polls once at boot, so a server that starts mid-event picks up the overrides immediately instead of one interval late.

See LiveConfig for override semantics, poll cadence, and the driver seam.

Kernel:enableDiagnostics(5) -- publishes to ReplicatedStorage attributes every 5s
Kernel.Bus:subscribe("Kernel.Overload", function(_, snapshot)
Log.warn("sustained frame budget overruns", snapshot.Scheduler.TasksDeferred)
end)
print(Kernel:stats()) -- { Scheduler = {...}, ServiceTimings, Sessions, Net }

Why it works: diagnostics publish the kernel’s own numbers — scheduler health, service timings, session counts, net counters — to ReplicatedStorage attributes on a cadence, which is what the debug panel’s SERVER window mirrors and what an external watchdog can read without a network channel. Kernel.Overload fires after sustained over-budget frames, so alerting is a bus subscription, not a metrics pipeline.

See Debug Panels for the visual consumer of these numbers and Scheduler for what the health fields mean.

Watch everything live with the debug panel

Section titled “Watch everything live with the debug panel”
-- already wired in Main.client.luau; server side feeds it with:
Kernel:enableDiagnostics(1)

An immediate-mode (ImGui-style) debug overlay, split into three windows — CLIENT, SERVER, and NET — so each machine’s stats read separately and the wire has its own view. Client: FPS, ping, network kbps, physics rate, memory breakdown, scheduler health with hot-task profiling, kernel internals, live log tail. Server (mirrored via diagnostics attributes): sessions, services, scheduler health, hot tasks, net counters. NET: live kernel channel traffic from both machines — direction arrows, decoded arguments, payload bytes, processing/round-trip time, and the verdict (Rejected/RateLimited/Dropped); click any row to pin it in the Inspector, which shows the decoded values and the serialized payload as hex. F8 toggles everything.

Reading it, in brief:

  • Colors are health grades — green = good, orange = worth watching, red = act now.
  • The Frame sections decompose the whole frame into engine + other scripts, physics, the kernel share, and (client) render CPU — when a frame is slow, the red row names WHOSE problem it is.
  • Hot tasks name the exact offender by script:line (ms/s), so “something is lagging” becomes “Systems.Combat:131 is eating 4.2ms/s, open it.”
  • Hover any stat for a diagnostic tooltip — not just what the number means, but where to look when it’s wrong.
  • GC rows tell leaks and churn apart: heap climbing + reclaims near zero = a leak; heap stable + busy cycles = garbage churn.

Studio-only by design — it auto-attaches in Studio and refuses to exist in production unless ReplicatedStorage has the ChloeKernelDebug attribute explicitly set to true. On a live server, also set ChloeKernelDebugUserIds (comma-separated UserIds) to restrict the whole surface to those users: the panel only attaches for them, the NET/log mirrors fire per-recipient (never broadcast — they carry every player’s decoded traffic and the full server log), and the simulation controls reject everyone else.

Build your own debug windows with the same library:

local ImGui = require(ReplicatedStorage.ChloeKernel.Debug.ImGui)
local Window = ImGui.window("Combat", { Parent = screenGui })
-- re-render as often as you like; instances are reused, not churned
Window:render(function(ui)
ui:header("Weapons")
ui:value("Active projectiles", count)
ui:sparkline("damage/s", DamageHistory, 500)
if ui:button("Give rifle") then ... end
DebugAimLines = ui:checkbox("Draw aim lines", DebugAimLines)
end)

See Debug Panels for the full panel-reading guide — every graded stat, the NET inspector, and the leak-hunting workflow.

local Casings = Pool.new({
Create = function() return makeShellCasing() end,
InitialSize = 16,
})
local Casing = Casings:acquire()
Casing.Parent = workspace
task.delay(2, function() Casings:release(Casing) end)

Why it works: Instance.new/Destroy churn fragments memory and stutters frames; pools turn both into table operations. acquire() hands back a parked instance (or creates one when the pool runs dry), release() parks it again — the instance count stays flat no matter how fast effects fire. ProjectileClient uses one internally.

See Pool & Preload for pool lifecycle and sizing.

local Events = Analytics.attach(Kernel, {
MaxPerMinute = 120, -- protects Roblox's custom-event budget
Sample = { PositionPing = 0.1 }, -- keep 10% of chatty events
WatchErrors = true, -- bridge Kernel.ScriptError automatically
})
Events:track("RoundFinished", winner, roundNumber)
Events:track("BossKilled", killer, 1, { CustomField01 = bossName })

Why it works: track() is a queue insert; a background flush batches to AnalyticsService:LogCustomEvent — funnels and retention with zero external infrastructure — or to an injected Destination (webhook, warehouse). Over-cap and sampled-out events drop and are counted in Stats, never silently; a crashing destination loses only its own batch. With WatchErrors = true, deduplicated script errors from ErrorWatch arrive as ScriptError events for free.

See Analytics for sampling, the rate budget, and the destination seam.