Debug Panels
The debug panel is an immediate-mode (ImGui-style) overlay split into separate windows — CLIENT, SERVER, NET, and optionally SIM CONTROL — so each machine’s stats read separately and the wire gets its own view. The defining decision: it is Studio-only by design. It auto-attaches in Studio and refuses to exist in production unless an explicit attribute and a per-user allowlist say otherwise, because the NET window and log mirror carry every player’s decoded traffic and the full server log.
Opening the panel
Section titled “Opening the panel”F8 toggles every window at once (F9 is Roblox’s own dev console). In Studio there is nothing to set up — Main.client.luau attaches the panel after boot:
local Panel = require(ReplicatedStorage.ChloeKernel.Debug.Panel)Panel.attach(Booted) -- Studio-only unless ChloeKernelDebug is trueThe SERVER window needs the server to publish diagnostics; Main.server.luau does this in Studio:
Booted:enableDiagnostics(1) -- feeds the F8 debug panel, 1s intervalWithout it, the SERVER window says so and tells you the call to make. Title bars drag each window independently; a clean click on the title bar collapses it (a drag past 4 pixels never counts as a collapse click). While the overlay is closed, the entire cost is one boolean check per frame.
Mental model
Section titled “Mental model”Three modules cooperate:
Debug/ImGui.luauis the widget layer — immediate mode over retained Instances. Each render pass rebuilds the window declaratively; Instances are reused keyed by call position, so a 10Hz re-render costs property writes with no Instance churn.Debug/Panel.luaurenders the CLIENT and SERVER windows on aScheduler:every(0.1, ...)task atPriority.Low— the panel’s own render task shows up in its own hot-tasks list, deliberately ungraded.Debug/NetTap.luauis a passive recorder both VMs share: a 64-entry ring of every kernel channel message, plus running counters. The server’s ring drains to allowlisted clients over a debug-onlyCKDBG_NetTapmirror packet;Debug/NetPanel.luaurenders the merged view at 5Hz.
Server stats reach the client two ways: numeric health data as ChloeKernelDiag* attributes on ReplicatedStorage (written by enableDiagnostics, readable by anyone, harmless), and the sensitive streams — log tail, wire capture — as per-recipient packets sent only to allowlisted debuggers, never broadcast.
The CLIENT window
Section titled “The CLIENT window”Reads this machine directly. Sections top to bottom:
- Frame & network — kernel version, uptime, FPS with frame time, ping, replication kbps in/out, physics solver rate.
- Memory — total memory (Studio only — see the aside below), Lua heap, Instance count, script memory, GC alloc (Lua allocation rate, the lever that drives GC cost), and GC cycles (sweep cadence and reclaim sizes). Heap climbing with reclaims near zero is a leak; heap stable with busy cycles is churn.
- Frame — decomposes measured frame compute against the frame target: engine + other scripts (heartbeat phase minus the kernel), physics, render CPU, and the kernel share. When a frame is slow, the red row names whose problem it is before you reach for the MicroProfiler.
- Scheduler — the frame budget bar (consumed vs permitted; at 100% remaining tasks defer to the next frame), a step-time sparkline, tasks/sec, Deferred (green only at 0), live queue depths per priority (
K/H/N/L/B), recurring-task count, and live Process count. - Hot tasks — the heaviest scheduled origins this second in ms/s, attributed to the exact defining
script:lineviaScheduler:topTasks(5). “Something is lagging” becomes “Systems.Combat:131is eating 4.2 ms/s, open it.” - Kernel internals — booted services, distinct bus topics (+wildcards), hook points and handler counts. Each is graded against its own leak pattern.
- Log tail — the last 6 Logger lines via a sink; hover a row for the full untruncated line. A Dump button prints a stats snapshot to the output console where text can actually be copied.
Every graded stat colors green/orange/red with thresholds tuned to its meaning, and every row tooltips a diagnosis — not just what the number means, but where to look when it is wrong.
The SERVER window
Section titled “The SERVER window”Mirrors the server through the ChloeKernelDiag* attributes, so it works in a real multi-machine session:
- Status — sessions, services, uptime, and a VERSION SKEW warning that only renders when server and client kernels report different versions (a mid-rolling-deploy server or a stale client).
- Memory — server Lua heap, GC alloc/cycles, Instances, and a Total bar against Roblox’s 6400 MB hard cap (the server crashes at the cap, taking every player with it).
- Engine — the counters that survive production’s disabled memory tracking: replication and physics repl bandwidth (all players summed), primitives (total and awake), contacts. Replication CPU has no public timing stat — when frame compute reads green and the server still lags, the replication row is the suspect.
- Frame and Scheduler — the same decomposition as the client, plus step max (worst single step in the window) and Deferred summed since the last diagnostics tick, because deferral is bursty and a point sample reads zero during overload. Sustained deferral also fires
Kernel.Overloadon the bus. - Hot tasks — the heaviest server origins, mirrored as an attribute string.
- Log tail — server log lines, mirrored per-recipient over
CKDBG_LogTailonly whileenableDiagnosticsis on. - Net — intents accepted/rejected, rate-limited sends, and transport floods (payloads dropped before channel decoding), with counts and bytes.
The NET window
Section titled “The NET window”A live wire inspector for kernel channel traffic from both machines — server entries arrive via the debug-only mirror, which does not exist in production. It surfaces rejected, rate-limited, and dropped messages as first-class rows, not just delivered ones.
Totals up top, computed over 1-second windows from counter deltas:
| Counter | Meaning |
|---|---|
| In / Out | Kernel channel messages per second, both machines combined. |
| Wire | Payload KB/s computed from each channel’s schema widths — exact for fixed-width wire types and buffers, measured for Any. |
| Rejected | Failed validation (hook chain returned false), arrived without a session, or hit an Unguarded channel — a handler-bearing channel with no validator rejects every payload and records the Unguarded verdict. |
| Rate limited | Dropped by per-channel token buckets before validation ran. Honest players hitting this means the channel’s RateLimit is below real gameplay — see Rate limiting. |
| Dropped | Transport-level losses: payloads past Packet’s 8 KB/heartbeat flood cap, or unreliable sends over the ~900-byte limit. |
The tail lists newest first. Each row reads: direction arrow (↑ client→server, ↓ server→client — logical flow, the same on either machine), channel, snt/rcv (which half of the wire this row is), decoded arguments, payload bytes, processing time, and the verdict. One delivered message appears twice — the sender’s snt row and the receiver’s rcv row; a snt with no matching rcv never arrived (or, for simulation replicas, was never on the real wire — the load harness uses fake packet factories on purpose).
Pause capture freezes the tail for inspection (messages still flow — only recording stops), Clear resets ring and counters (useful for discarding boot-time spec traffic), and a checkbox hides replica noise.
Click any row to pin it in the Inspector, which shows the message both ways:
- the decoded argument values (pre-serialization),
- the per-argument wire types, with
Anyexpanded to the best-fit encoding the type engine actually picked (Any:U8,Any:F64,Any:Str+U8len…), - the serialized payload as hex — the exact bytes written for those values (first 32 bytes), plus the note that one channel-id byte rides along and Packet batches messages per frame.
Server-side times are gate+validators+handler; client-side request times are round trips. The whole capture layer is spec-verified — Tests/NetTap.spec.luau pins the byte estimates, hex previews, and Any best-fit descriptions down to exact strings (encodePreview({ "NumberU16" }, 258) is "02 01"; the wire is little-endian).
The SIM CONTROL window
Section titled “The SIM CONTROL window”Debug/SimPanel.luau (attached from this place’s client Bootstrap) is a remote control for load testing, sharing the same gate and F8 toggle:
- Server load — buttons for
HighIntensity,Overload,SmallGame, andShowcase, fired as a realSimControlkernel intent so the command itself shows up in the NET window and runs the full validation pipeline. The current mode reads back from theChloeKernelSimulationattribute; Stop clears it. - Client load (this machine) — runs
Debug/SimLoad.luaurigs locally:HighIntensity(150 entity processes, 400 bus msgs/s, 240 hook fires/s, Serde round trips, pooled part churn, a deliberate 2 ms/tick burn, and 60 realSimNoiseintents/s — deliberately above that channel’s 50/s rate limit so RateLimited verdicts visibly fire in the NET window) orSmallGame(~1/10 intensity, no burn). Client and server loads start and stop independently.
Start a load, then watch the CLIENT/SERVER windows react: hot tasks name the simulation’s own origins, the budget bar climbs, Deferred grades. Modes, intensities, and what each rig exercises are covered on Simulation.
Building your own panels with ImGui
Section titled “Building your own panels with ImGui”Debug/ImGui.luau is a small immediate-mode layer you can use for any debug window. Rendering is declarative — call render as often as you like and describe the whole window each time:
-- Client Bootstrap: a self-contained custom debug windowlocal Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ImGui = require(ReplicatedStorage.ChloeKernel.Debug.ImGui)
return function(kernel) local Gui = Instance.new("ScreenGui") Gui.Name = "CombatDebug" Gui.ResetOnSpawn = false Gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
local Window = ImGui.window("Combat", { Size = UDim2.fromOffset(320, 240), Position = UDim2.fromOffset(12, 560), Parent = Gui, })
local PhysicsHistory = {} local Shots = 0 local DrawAimLines = false
kernel.Scheduler:every(0.1, function() table.insert(PhysicsHistory, workspace:GetRealPhysicsFPS()) if #PhysicsHistory > 40 then table.remove(PhysicsHistory, 1) end
Window:render(function(ui) ui:header("Weapons") ui:value("Shots fired", Shots) ui:bar("heat", (Shots % 20) / 20, `{Shots % 20}/20`, nil, "Fires 20 shots to fill the bar.") ui:sparkline("physics Hz", PhysicsHistory, 240) ui:separator() if ui:button("Fire") then Shots += 1 end DrawAimLines = ui:checkbox("Draw aim lines", DrawAimLines) end) end, kernel.Priority.Low)endHow it stays cheap: each widget call claims a slot keyed by call position. Same call order + same widget type = same Instance, updated only when a value actually changed (every widget diffs against cached state before touching a property). Slots past the last call of a pass are destroyed, so conditional trailing rows just disappear. Buttons and checkboxes return their event on the next render pass — the classic immediate-mode handshake — and a relabeled slot is treated as a different logical widget, so its pending click drops instead of firing on the wrong button.
ImGui API
Section titled “ImGui API”| Member | Description |
|---|---|
ImGui.window(title: string, options?: { Size: UDim2?, Position: UDim2?, Parent: Instance? }) |
Creates a draggable, collapsible window. Defaults: 380×440 at (16, 16). |
Window:render(build: (ui: Ui) -> ()) |
Runs one immediate-mode pass. Skipped entirely while collapsed. |
Window:destroy() |
Destroys the window and disconnects its drag listener (which outlives the window otherwise). |
ImGui.Theme |
The shared palette — Good/Warn/Bad/Accent/Text/TextDim/… — for consistent health-grade colors. |
The ui object inside render:
| Widget | Description |
|---|---|
ui:header(text) |
Section header row. |
ui:label(text, color?, hint?) |
One line of text; truncates, hover shows hint. |
ui:value(label, value, color?, hint?) |
Key/value row — the workhorse. |
ui:separator() |
Horizontal rule. |
ui:button(text): boolean |
Returns true on the pass after a click. |
ui:checkbox(text, checked): boolean |
Returns the new value; assign it back to your state. |
ui:selectable(text, selected, color?, hint?): boolean |
List row with selection highlight; returns true when clicked. |
ui:bar(label, fraction, text?, color?, hint?) |
Progress/budget bar with overlay text; fraction clamps to 0–1. |
ui:sparkline(label, values, max?, hint?) |
40-bar history graph; auto-scales to the peak when max is nil; bars grade orange past 60% and red past 85%. |
Every hint renders as a tooltip to the window’s right side — the built-in panels use them for full diagnostic text, and yours can too.
Production access control
Section titled “Production access control”Who can open any of this is decided in one place, NetTap.debugAllowed(player), and it fails closed:
- Studio — always allowed.
- Live,
ChloeKernelDebugattribute nottrue— denied. The panel refuses to attach, the mirrors never fire,NetTap.Activeis false and recording costs one boolean check. - Live, flag on — hardcoded owner UserIds (the
OwnerUserIdstable at the top ofNetTap.luau— edit it for your deployment) are always allowed. Everyone else is checked against theChloeKernelDebugUserIdsattribute: a comma-separated UserId list, or"*"/"all"/"ALL"to open a test place to every joiner. - Flag on but allowlist unset or empty — denied for everyone but owners and Studio, with a one-time warning explaining exactly that. Failing open here would leak every player’s decoded traffic and the full server log to anyone.
The sensitive mirrors respect the same gate per recipient: CKDBG_NetTap and CKDBG_LogTail are fired client-by-client to allowlisted debuggers only, never broadcast. The SimControl intent validates the sender with the same function, so flagging a live server does not hand load controls to every player.
| Attribute (ReplicatedStorage) | Meaning |
|---|---|
ChloeKernelDebug: boolean |
Enables the debug surface outside Studio. Without it, nothing attaches. |
ChloeKernelDebugUserIds: string |
Comma-separated UserId allowlist, or "*"/"all"/"ALL" wildcard. Missing/empty = owners and Studio only. |
ChloeKernelSimulation: string |
Current server simulation mode; empty stops. Set directly or via SIM CONTROL. |
ChloeKernelDiag* |
Numeric server diagnostics written by enableDiagnostics — step times, budgets, heap, GC, replication, net counters. Read by the SERVER window. |