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.
Run code when a player joins or leaves
Section titled “Run code when a player joins or leaves”-- src/Server/Bootstrap.luau receives the kernel after boot, before startreturn 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)endWhy 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.
Schedule recurring or background work
Section titled “Schedule recurring or background work”-- Background priority: runs only on spare frame budgetKernel.Scheduler:every(30, function() for _, Session in Kernel.Sessions do periodicCheck(Session) endend, Kernel.Priority.Background)
-- one-shot, next frame, high priorityKernel.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 endend, { Name = "Worker" }) -- the name appears in the hot-task profiler as "Process Worker"Proc:start()
Proc:suspend() -- stops stepping; state is keptProc:resume()Proc:kill("owner released") -- closes the coroutine, fires OnExitWhy 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.
Run heavy CPU work in parallel
Section titled “Run heavy CPU work in parallel”Parallel Luau with worker readiness, result routing, and timeouts handled by the pool:
-- PathWorker.luau (ModuleScript): pure function, no shared statereturn function(startPos: Vector3, goalPos: Vector3) return computePathWaypoints(startPos, goalPos)endlocal Pool = ActorPool.new({ Size = 4, WorkerModule = script.PathWorker })local Ok, Waypoints = Pool:dispatch(npcPosition, targetPosition) -- yieldsWhy 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.
Log with scopes and sinks
Section titled “Log with scopes and sinks”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 StudioWhy 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.
Capture and deduplicate script errors
Section titled “Capture and deduplicate script errors”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.
Tune values live without redeploying
Section titled “Tune values live without redeploying”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) endend)
-- 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.
Watch kernel health in live servers
Section titled “Watch kernel health in live servers”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:131is 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 churnedWindow: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.
Reuse instances instead of churning them
Section titled “Reuse instances instead of churning them”local Casings = Pool.new({ Create = function() return makeShellCasing() end, InitialSize = 16,})local Casing = Casings:acquire()Casing.Parent = workspacetask.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.
Ship gameplay analytics
Section titled “Ship gameplay analytics”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.