Skip to content

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.

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 no table.remove shifting.
  • 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 Kernel priority) it checks os.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 xpcall with debug.traceback; a crash fires Scheduler.OnError and the step continues with the next task.

Two properties fall out of the drain order and are deliberate:

  • Kernel priority 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.
  • Background starves first — by design. Under sustained overload the budget dies somewhere in the middle queues, and queue 5 never gets reached. That is the contract: Background means “only on spare budget”. If a background task must eventually run, it belongs at Low instead.

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/1000
Slack = TargetFrameSeconds − Others
Budget = Slack if Slack > BudgetSeconds, else BudgetSeconds

The pieces, and why each exists:

  • Stats.HeartbeatTimeMs is 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 alpha 0.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.PhysicsStepTimeMs is added separately because physics time is not part of HeartbeatTimeMs.
  • 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 to BudgetSeconds. This catches overload and costs the stats do not measure (replication serialization, for example).
  • The budget never drops below BudgetSeconds — default 0.0015s (1.5ms). In frame-aware mode BudgetSeconds is 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.luau
return 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)
end

every(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.

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.

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 order

step(0) is the starvation test: the recurring scan still runs (due ticks enqueue), but nothing but Kernel-priority work executes.

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
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.

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 with resetProfile() over a fixed window to read Time as milliseconds-per-second — this is exactly what kernel:enableDiagnostics() does for its HotTasks attribute and what the debug panel displays.
  • Scheduler.setTaskOrigin(fn, label) names a function explicitly. Process registers each process’s step function as Process <Name> — without that, every resumed process would share the kernel’s resume closure’s script:line and 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).

  • scheduleEntry(entry) exists on the public surface but is an internal allocation-free re-enqueue path for Process stepping (a schedule() 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.
  • OnError receives the traceback string, not the raw error value — tasks run under xpcall(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.