Scheduler
The Scheduler is the kernel’s answer to “when does work run”: five priority queues drained once per engine step, under a time budget measured with os.clock(). The defining decision is that work is budgeted, not free — every frame the scheduler computes how much time it may spend, runs tasks in priority order until the deadline, and defers the rest to the next step instead of blowing the frame. Kernel boot creates one, binds it to Heartbeat, and everything else in the framework — Processes, diagnostics, kit loops — runs through it.
Mental model
Section titled “Mental model”Internally the Scheduler is small and allocation-averse:
- Five FIFO queues, one per priority:
Kernel = 1,High = 2,Normal = 3,Low = 4,Background = 5. Lower runs first. Each queue is a ring-style array with a head index, so dequeue is O(1) with notable.removeshifting. - One recurring list for
every()entries, scanned at the start of each step; due entries are enqueued into their priority queue as ordinary tasks. - A step drains queue 1 through 5 in order. Before each task (except at
Kernelpriority) it checksos.clock()against the deadline; once the budget is spent, everything still queued is counted as deferred and waits for the next step. - Queue depth is snapshotted at the start of each queue’s drain, so work scheduled from inside a task always runs on the next step — a task that reschedules itself cannot livelock the frame.
- Errors are isolated. Every task runs under
xpcallwithdebug.traceback; a crash firesScheduler.OnErrorand the step continues with the next task.
Two properties fall out of the drain order and are deliberate:
Kernelpriority is budget-exempt. The deadline check is skipped for queue 1, because deferring the kernel’s own work (diagnostics, health reporting, process bookkeeping) would stall the thing doing the deferring. Kernel-priority work always runs, even on a saturated frame.Backgroundstarves first — by design. Under sustained overload the budget dies somewhere in the middle queues, and queue 5 never gets reached. That is the contract:Backgroundmeans “only on spare budget”. If a background task must eventually run, it belongs atLowinstead.
The frame budget
Section titled “The frame budget”The budget per step is resolved in this order: an explicit step(budgetSeconds) argument wins; otherwise a configured TargetFrameSeconds enables frame-aware mode; otherwise the fixed BudgetSeconds slice is used.
Server default: frame-aware. Each step spends the slack left under a 0.0165s (16.5ms) frame target — slightly under 1/60 to leave headroom for engine work:
Others = max(HeartbeatTimeMs/1000 − StepEma, 0) + PhysicsStepTimeMs/1000Slack = TargetFrameSeconds − OthersBudget = Slack if Slack > BudgetSeconds, else BudgetSecondsThe pieces, and why each exists:
Stats.HeartbeatTimeMsis the engine’s measured compute share of the frame — but it already includes the scheduler’s own previous work. The scheduler’s smoothed step cost (StepEma, an EMA with alpha0.1, matched to the stat’s own smoothing) is subtracted back out. Without that correction, a scheduler that fills idle frames would read as a busy engine and shrink its own budget in a feedback loop.Stats.PhysicsStepTimeMsis added separately because physics time is not part ofHeartbeatTimeMs.- Late frames back off to the floor. If the time since the last step reaches
1.25 ×the target, the engine stats are not trusted and the budget drops toBudgetSeconds. This catches overload and costs the stats do not measure (replication serialization, for example). - The budget never drops below
BudgetSeconds— default0.0015s (1.5ms). In frame-aware modeBudgetSecondsis the floor, not the slice.
The result: an idle server drains backlog at up to ~15ms per frame; a physics-heavy server backs off automatically; a starved server still makes 1.5ms of progress every frame.
Client default: fixed 1.5ms slice. The client deliberately does not get frame-aware mode by default, because HeartbeatTimeMs excludes the render thread — the most expensive thing a client does is invisible to the stat. A frame-aware client budget would see a mostly “idle” frame, fill it with task work, and starve rendering. A fixed slice cannot.
Passing BudgetSeconds explicitly (with no TargetFrameSeconds) opts into fixed mode on either side; passing TargetFrameSeconds opts into frame-aware mode with that target. Both spec-verified in Scheduler.spec.luau.
Everything below works in a Bootstrap; kernel.Scheduler is the Heartbeat scheduler and kernel.Priority re-exports Scheduler.Priority.
-- src/Server/Bootstrap.luaureturn function(kernel) -- One-shot: runs on the next step, at Normal priority by default. kernel.Scheduler:schedule(function() rebuildNavMesh() end, kernel.Priority.High)
-- Extra arguments pass through to the task. kernel.Scheduler:schedule(function(a, b) print(a + b) end, nil, 2, 3)
-- Recurring: every 30 seconds, only on spare frame budget. local Sweep = kernel.Scheduler:every(30, function() for _, Session in kernel.Sessions do periodicCheck(Session) end end, kernel.Priority.Background)
-- Handles cancel both kinds. Sweep:cancel()
-- Task crashes are isolated; they land here instead of killing the step. kernel.Scheduler.OnError:connect(function(err, name) warn("task crashed:", name, err) end)endevery(0, fn) is the legitimate per-frame case. Recurring cadence is drift-free: the next due time is anchored to the previous deadline (NextTime += Interval), and only when the scheduler falls more than a whole interval behind does it snap forward to now + Interval instead of burst-firing the missed ticks.
Phase schedulers
Section titled “Phase schedulers”The default scheduler steps on Heartbeat. Work that must align with physics or the camera gets its own scheduler on another engine step, via the kernel:
-- Physics-coupled work: runs before each simulation step.local PreSim = kernel:phaseScheduler("PreSimulation")PreSim:every(0, function() applyCustomForces()end)
-- Camera work, client only: runs before each render.local PreRender = kernel:phaseScheduler("PreRender")PreRender:every(0, updateCameraRig)Phase values are "Heartbeat", "PreSimulation", "PostSimulation", and "PreRender". "PreRender" is client-only — binding it on the server errors. Phase schedulers are created lazily, cached per phase, and always run with a fixed budget equal to the main scheduler’s BudgetSeconds: a frame-aware phase scheduler would double-claim the same slack the Heartbeat scheduler already spent.
Driving a scheduler manually
Section titled “Driving a scheduler manually”step(budget?) is public precisely so tests can drive time deterministically — no binding, no waiting:
local Scheduler = require(game:GetService("ReplicatedStorage").ChloeKernel.Scheduler)
local Sched = Scheduler.new()local Order = {}Sched:schedule(function() table.insert(Order, "normal")end, Scheduler.Priority.Normal)Sched:schedule(function() table.insert(Order, "kernel")end, Scheduler.Priority.Kernel)Sched:step(1) -- one-second budget: everything drains-- Order is { "kernel", "normal" } regardless of schedule orderstep(0) is the starvation test: the recurring scan still runs (due ticks enqueue), but nothing but Kernel-priority work executes.
API reference
Section titled “API reference”Constructor & statics
Section titled “Constructor & statics”| Member | Description |
|---|---|
Scheduler.new(config?) → Scheduler |
config = { BudgetSeconds: number?, TargetFrameSeconds: number?, Phase: Phase? }. Server default: frame-aware, target 0.0165s, floor 0.0015s. Client default: fixed 0.0015s. Explicit BudgetSeconds alone selects fixed mode; explicit TargetFrameSeconds selects frame-aware. Phase defaults to "Heartbeat" |
Scheduler.Priority |
{ Kernel = 1, High = 2, Normal = 3, Low = 4, Background = 5 } — lower runs first; re-exported as Kernel.Priority |
Scheduler.setTaskOrigin(fn, label) |
Names fn in the hot-task profiler instead of its script:line (Process does this per process: Process <Name>) |
Scheduler.setMicroProfilerZones(enabled) |
Enables/disables named MicroProfiler zones around every task. Default: enabled in Studio only |
Scheduler.getMicroProfilerZones() → boolean |
Current zone state |
Instance
Section titled “Instance”| Member | Description |
|---|---|
scheduler:schedule(fn, priority?, ...) → TaskHandle |
One-shot on the next step. priority defaults to Normal and is clamped to 1..5; extra arguments pass through to fn |
scheduler:every(intervalSeconds, fn, priority?) → TaskHandle |
Recurring at intervalSeconds cadence (0 = every step). Errors on NaN/negative/infinite intervals. No argument pass-through |
scheduler:step(budgetSeconds?) |
Runs one drain. Explicit budget overrides both modes — this is the test hook |
scheduler:bind() / scheduler:unbind() |
Connect/disconnect the configured phase signal (idempotent) |
scheduler:takeStepWindow() → (time, steps, max, deferred) |
Drains the accumulated diagnostics window (sums/max since the last call) and resets it |
scheduler:queueDepths() → ({ number }, number) |
Current depth per priority queue plus the recurring-entry count; debug-only |
scheduler:topTasks(count) → { HotTask } |
Profiler readout, hottest first; HotTask = { Key, Time, Calls, Max } |
scheduler:resetProfile() |
Clears profiler accumulation (pair with topTasks over a fixed window) |
scheduler:destroy() |
Unbinds, destroys OnError, clears queues and recurring entries |
scheduler.OnError |
Signal fired (err, taskName?) when a task crashes; err carries a traceback. The kernel connects a default [ChloeKernel] warn handler at boot |
scheduler.BudgetSeconds |
The fixed slice (fixed mode) or the floor (frame-aware mode) |
scheduler.TargetFrameSeconds |
Frame-aware target, or nil in fixed mode |
TaskHandle is { Cancelled: boolean, cancel: (self) → () }. Cancelling a queued one-shot prevents it from running; cancelling a recurring entry removes it on the next scan. Cancelled tasks never run — spec-verified.
scheduler.Stats is updated every step:
| Field | Meaning |
|---|---|
LastStepSeconds |
Wall time of the last step |
LastBudgetSeconds |
The budget the last step actually ran under (in frame-aware mode this changes per frame) |
LastFrameBusySeconds |
Stats.HeartbeatTimeMs / 1000 snapshot taken at step start |
LastPhysicsSeconds |
Stats.PhysicsStepTimeMs / 1000 snapshot taken at step start |
TasksRun |
Cumulative tasks executed |
TasksDeferred |
Tasks pushed past the deadline in the last completed step — mid-step reads see the previous step’s value, so overload never reports as zero |
StepEma |
Smoothed step cost (alpha 0.1) — the frame-aware self-correction term |
WindowTime / WindowSteps / WindowMax / WindowDeferred |
Accumulators drained by takeStepWindow() |
Deferral is bursty; a point sample between bursts reads zero during genuine overload. That is why diagnostics report the window sum — and why kernel:enableDiagnostics() publishes Kernel.Overload on the Bus only after deferred work shows up in three consecutive windows.
The hot-task profiler
Section titled “The hot-task profiler”Profiling is always on, not a debug mode — at ~33ns per task it is cheap enough to leave running on live servers (see Benchmarks). Every task’s wall time is attributed to the script:line where its function was defined:
debug.info(fn, "sl")runs once per function identity; origins are cached in a weak-keyed table, so steady-state attribution is a table lookup.topTasks(n)returns the hottest origins by total time, with call counts and per-call max. Pair it withresetProfile()over a fixed window to readTimeas milliseconds-per-second — this is exactly whatkernel:enableDiagnostics()does for itsHotTasksattribute and what the debug panel displays.Scheduler.setTaskOrigin(fn, label)names a function explicitly. Process registers each process’s step function asProcess <Name>— without that, every resumed process would share the kernel’s resume closure’sscript:lineand the profiler would be blind.
MicroProfiler zones are a separate, heavier layer: debug.profilebegin/profileend around every task, named by the origin’s Module:line label, so per-task cost is legible in the engine timeline (Ctrl+F6). Zones cost roughly a microsecond per task pair — measured at 388ns vs 354ns per schedule+step in the benchmarks — so they are Studio-only by default. A live server opts in with Scheduler.setMicroProfilerZones(true).
Design notes & gotchas
Section titled “Design notes & gotchas”scheduleEntry(entry)exists on the public surface but is an internal allocation-free re-enqueue path for Process stepping (aschedule()call would cost two tables per process per frame). Its contract — never queue one entry twice concurrently — is owned by the caller. Game code should not use it.OnErrorreceives the traceback string, not the raw error value — tasks run underxpcall(fn, debug.traceback).- Frame stats (
HeartbeatTimeMs,PhysicsStepTimeMs) are read once per step, so the budget computation and the diagnostics attributes always describe the same frame snapshot.