Event-driven execution
Work runs when state changes or a deadline arrives, continuous jobs run only while something is active, and an idle system costs nothing per frame.
ChloeKernel schedules work when something changes or a deadline arrives. Attaching a system does not give it a
permanent frame callback. Nothing polls for a change that has an event, and nothing sweeps a table every frame to find
an expiry that could have been a deadline. Some duties are continuous by nature (simulating a projectile, following a
target, autosaving, renewing a session lock), and those run only while their owner has work: the job starts with the
first item and is cancelled with the last. 0.8.0 rebuilt the Scheduler around a deadline heap
and put the gameplay systems to sleep when empty; 0.9.0 converted the kits, persistence, live-ops and client
presentation modules. The same rules bind game code: they are golden rule 3 in AGENTS.md, and the
directive linter enforces them. Performance has the numbers.
The rules
Section titled “The rules”- React to events. Bus topics, session lifecycle (
kernel:onSession,session:bind),GetPropertyChangedSignal,GetAttributeChangedSignal, CollectionService signals through TagWatch, andKernel.ProfileLoadedthrough Milestones, which also replays profiles that loaded before you subscribed. - Use deadlines for time. A timer is
kernel.Scheduler:after(seconds, fn), and its handle is cancelled on cleanup. An expiry is one deadline per item, or a timestamp checked when the value is used. - Run continuous work only while something is active. Start the job on the first item and cancel it on the last.
- Give everything an owner. Every connection, timer, thread and created instance belongs to a session, a
scope, a component’s cleanup or a service’s
stop.
The linter turns the first three into failures. CK002 rejects wait, spawn, delay and tick everywhere, and CK004
rejects task.wait inside a while or repeat loop in any non-test code. In game code (src/*/Game and
templates/), CK005 rejects every task.wait and CK006 rejects per-frame engine connections such as RunService
frame events.
| Need | Use |
|---|---|
| Run once later | kernel.Scheduler:after(seconds, fn, priority?); keep the handle and :cancel() it on cleanup |
| Expire something (buffs, cooldowns, invites) | One after deadline per item, or a stored timestamp checked when used. Never a sweep loop. |
| Coalesce bursts of changes | A dirty flag plus one pending after handle |
| Continuous work while active | kernel.Scheduler:every(interval, fn) started on the first item, cancelled on the last |
| Per-frame camera or physics coupling | kernel:phaseScheduler("PreRender") (client) or "PreSimulation", only while active |
| Long-lived behavior you can pause or kill | kernel:spawnProcess(fn); inside it, Process.yield() or Process.sleep(seconds) |
| Group cleanups | kernel:createScope(), scope:add(connectionOrInstanceOrFn), scope:tween(instance, info, goals), scope:destroy() |
| Per-player lifetime | kernel:onSession(fn) and session:bind(connectionOrFn) |
Scheduled callbacks run inside the kernel step. If one must yield (an *Async call), wrap the yielding part in
task.spawn with a busy flag so the step is not held up. DataDriver’s autosave job does exactly that.
How the Scheduler sleeps
Section titled “How the Scheduler sleeps”kernel.Scheduler is bound to Heartbeat at boot. Binding enables automatic execution; it does not keep a frame
connection. After every change to its queues or timers, the scheduler decides whether it has work:
- It is runnable when a priority queue holds a task, an
every(0, ...)job exists, or the earliest timer is due within 34 ms. A runnable scheduler connects to its phase signal if it is not connected already, and steps every frame until it runs out of work. - Otherwise it disconnects the phase signal. If a timer exists, it arms one
task.delayfor the earliest deadline, set to fire 34 ms early so the phase signal is attached before the deadline arrives. - An empty scheduler has no phase connection and no delayed thread at all.
Timers (after and every with a positive interval) live in a binary min-heap keyed by due time, so cancelling a
handle removes its entry directly and nothing scans the list each frame. The 34 ms window exists because reconnecting
around every short interval costs a connect, a disconnect and a delayed thread per wake. Before 0.9.0 a short-interval
job paid that on every tick; at 34 ms, a 30 Hz job (33.3 ms apart) keeps the scheduler attached.
The first frame-aware step after an idle gap budgets the frame’s real slack: the target (16.5 ms on the server) minus
Stats.HeartbeatTime and Stats.PhysicsStepTime, because the scheduler did no work in the frame it slept through.
Later steps subtract the scheduler’s own smoothed step cost from HeartbeatTime instead, and a frame that arrives
1.25 times the target or later drops to the 1.5 ms floor.
Recurring jobs follow three rules:
- A recurring callback never overlaps itself. Its next deadline is inserted after the callback returns, so a callback that yields is not started again while it is suspended.
- Missed intervals do not build a backlog. The next deadline is the previous one plus the interval, and a job that fell a whole interval behind snaps to one interval from now instead of firing the missed ticks back to back.
- Cancelling prevents further runs. It does not undo side effects a running callback already made.
A callback that yields leaves the step it ran in. If newer steps ran while it was suspended, the resumed step stops without recording its stale elapsed time, deferrals or window maximum (a 0.9.0 fix; before it, a yield leaked into the frame budget and the overload statistics).
-- One-shot: runs once, about 0.2 s from nowlocal Refresh = Kernel.Scheduler:after(0.2, function() recalculateVisibleStats()end, Kernel.Priority.Normal)Refresh:cancel() -- when the owner goes away first
-- Recurring: uses the same deadline heap; only every(0, ...) keeps the scheduler awake every framelocal Sweep = Kernel.Scheduler:every(5, pruneStaleEntries, Kernel.Priority.Background)Sweep:cancel()after and every reject NaN, negative and infinite delays at the call site. unbind() stops automatic wakeups and
leaves a manual step() available (specs drive time this way); destroy() cancels every owned handle and rejects new
work.
Two boot options change recurring work. StaggerRecurring = true delays each positive-interval job’s first run by a
fraction of its interval (golden-ratio spaced), so jobs created in the same frame do not all fire together; later runs
keep their cadence, and one-shot deadlines are never staggered. Leave it off if game rules depend on periodic jobs
starting in the same frame. ProfileTasks = false skips the per-callback clock reads and origin attribution and keeps
the aggregate budget statistics; it defaults to true, and kernel:enableDiagnostics() turns it back on.
-- src/Server/Main.server.luau boots with no options; this is where they would golocal Kernel = ServerKernel.boot({ ProfileTasks = false, StaggerRecurring = true })Phase schedulers from kernel:phaseScheduler("PreSimulation") or "PreRender" (client only) follow the same sleep
rules on their own engine phase, with a fixed budget. They are created on first use and cached per phase.
Processes that sleep
Section titled “Processes that sleep”A process is a coroutine the scheduler steps. Process.yield() hands the thread back and
resumes it on a later step, which re-queues the process every step it is alive. Process.sleep(seconds) parks it on
one scheduler deadline instead, so a process that waits costs nothing until the deadline. SpellKit channels use it.
local Process = require(game:GetService("ReplicatedStorage").ChloeKernel.Process)
local Beacon = Kernel:spawnProcess(function() while true do pulseBeacon() Process.sleep(4) -- one deadline, no per-step resume endend, { Name = "Beacon" })Beacon:start()
-- the owner's stop(), or kernel shutdown, ends itBeacon:kill("owner stopped")A process body may also wait on an engine call (task.wait, WaitForChild, an *Async method). Since 0.9.0 the
engine resumes it when that call completes, and its next Process.yield() hands it back to the scheduler. Before
0.9.0 the scheduler resumed such a body early with nil. kill() cancels a pending engine resume.
Patterns
Section titled “Patterns”The three shapes below cover most game timing. Each one does work only when there is something to do.
An expiry is a deadline, or a timestamp read when it matters. The timestamp version needs no timer at all:
-- Cooldown: the validator reads a stored ready time, the handler sets it. Nothing runs in between.Kernel.Hooks:on("Intent.Dash", function(context) return os.clock() >= (context.Session.Data.DashReadyAt or 0)end, 10)
self.Net.Dash.on(function(session: any) session.Data.DashReadyAt = os.clock() + 3 performDash(session)end)When the expiry must do something (remove a shield, end a buff), give each item its own deadline and cancel it with the item’s owner:
function ShieldService.grant(self: any, session: any, seconds: number) local Previous = self.Expiries[session] if Previous then Previous:cancel() end session.Data.Shielded = true self.Expiries[session] = self.Kernel.Scheduler:after(seconds, function() self.Expiries[session] = nil session.Data.Shielded = false end)end
-- in start(): the session owns its pending expiryself.Scope:add(self.Kernel:onSession(function(session: any) session:bind(function() local Pending = self.Expiries[session] self.Expiries[session] = nil if Pending then Pending:cancel() end end)end))Bursts of changes coalesce behind one pending handle. Ten inventory changes in one frame produce one rebuild:
function ScoreboardService.markDirty(self: any) if self.Pending then return -- a rebuild is already queued for this burst end self.Pending = self.Kernel.Scheduler:after(0.25, function() self.Pending = nil self:rebuild() end)endContinuous work starts with the first active item and stops with the last. This capture point scores once a second, but only while someone stands in it; an empty hill leaves the scheduler free to sleep.
--!strict-- CaptureService: scores time on the hill, with a job that runs only while the hill is held.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local Log = Logger.scope("CaptureService")
local CaptureService = { Name = "CaptureService",}
function CaptureService.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope() self.Holders = {} :: { [Player]: boolean } self.Count = 0end
function CaptureService.start(self: any) local Volume = workspace:FindFirstChild("HillZone") if not (Volume and Volume:IsA("BasePart")) then Log.warn("expected a trigger part at workspace.HillZone") return end local Regions = self.Scope:add(Zones.attach(self.Kernel)) -- Zones publishes the leave itself when a holder's session ends Regions:add("Hill", Volume, { OnEnter = function(player: Player) self:enter(player) end, OnLeave = function(player: Player) self:leave(player) end, })end
function CaptureService.enter(self: any, player: Player) if self.Holders[player] then return end self.Holders[player] = true self.Count += 1 if self.Count == 1 then self.Tick = self.Scope:add(self.Kernel.Scheduler:every(1, function() for Holder in self.Holders do local Session = self.Kernel:getSession(Holder) if Session then Session.Data.HillSeconds = (Session.Data.HillSeconds or 0) + 1 end end end)) endend
function CaptureService.leave(self: any, player: Player) if not self.Holders[player] then return end self.Holders[player] = nil self.Count -= 1 if self.Count == 0 and self.Tick then self.Scope:remove(self.Tick) -- hand the handle back so the scope does not collect dead ones self.Tick:cancel() self.Tick = nil endend
function CaptureService.stop(self: any) self.Scope:destroy()end
return CaptureServicePolling is still right when a dependency exposes no reliable change event, or when missed notifications must be reconciled: DataStore autosaves and lock renewal, LiveConfig’s reconciliation behind its MessagingService invalidations, Matchmaking’s reads of the shared queue, Leaderstats (profile tables have no change events), and a replica whose interest depends on player positions. Each of those still runs only while its owner has work.
What the framework converted
Section titled “What the framework converted”0.8.0 changed the machinery that every job runs on:
| Area | Before | Since 0.8.0 |
|---|---|---|
| Scheduler | Scanned every recurring job each frame; stayed connected to its phase | Deadline heap with one earliest-deadline wakeup; an idle bound scheduler disconnects. after added for one-shot work. |
| Projectiles, Sweeps, Hazards, Effects, Zones | Update jobs ran at 30, 30, 8, 10 and 4 Hz from attach | Each job starts when work becomes active and is cancelled when the last item is removed. Untimed effects without tick callbacks need no sweep. |
| Replica delivery | Per-cadence traversal of every replica | Dirty-driven: writes, roster changes and interest invalidation wake delivery. Manual interest is available for rules that have their own events. |
| Packet | Heartbeat pump always connected; channel and profile readiness polled | The pump disconnects after 1 s without traffic. Readiness waits on events with a timeout. |
0.9.0 converted the rest of the framework:
| Area | Now |
|---|---|
| Scheduler | Stays attached for deadlines under 34 ms and wakes one window early for longer ones. Process.sleep parks a process on one deadline. |
| Spatial and Simulation | Spatial.characters follows CharacterAdded, CharacterRemoving and PlayerRemoving and samples roots only while one is tracked (it polled every player forever). Simulation.server waits on the kernel’s boot attribute instead of polling every frame. |
| Anti-exploit | Movement (5 Hz) and Rewind (20 Hz) sample only while at least one session is tracked. Forensics records only while players are tracked. |
| Networking | NetGovernor samples ping only while sessions exist and probes only clients that finished the ready handshake. Poll-mode replica interest sleeps on an empty server, and joins no longer invalidate Manual-interest replicas. |
| Persistence | DataDriver waits for in-flight saves and local handoffs on events. Receipts waits for a joining buyer on Kernel.ProfileLoaded or their departure. |
| Live ops | Matchmaking polls the shared queue only while this server has local queued players. SoftShutdown waits for departures on PlayerRemoving with one deadline and runs its transit bounce on the kernel Scheduler. Leaderboards and Analytics flush only while work is pending, outside the scheduler step. Leaderstats sweeps only while sessions exist. |
| LiveConfig | LiveConfig publishes a MessagingService invalidation after set and update, so servers re-read at once; its MemoryStore poll relaxes to 120 s as reconciliation while invalidations arrive (30 s without MessagingService). Source = "ConfigService" refreshes on ConfigSnapshot.UpdateAvailable and never polls. |
| World | Stages unloads an empty stage exactly Linger seconds after the last occupant leaves, with a per-stage timer that re-entry cancels; the 1 s loop is gone. Zones skips its per-part overlap queries on a server with no characters or tracked instances when every zone is already empty. NPC despawn is one scheduler deadline. |
| Kits | CraftingKit arms one timer per timed craft (was a 4 Hz sweep). ProceduralKit arms one retry per model inside its cooldown (was 10 Hz). PartyKit expires invites lazily (was a 5 s sweep). RoundKit arms one deadline per phase. The MeleeKit timeline (20 Hz) runs only while a combatant is mid-action, SpellKit regenerates mana (1 Hz) only while someone is below MaxMana after a cast, and CompanionKit follows (4 Hz) only while a companion is summoned. |
| Client networking | HazardClient and ProjectileClient step on PreRender only while entries are live; static hazards set their CFrame once. RagdollClient waits for every cut joint’s Enabled change event under one 0.5 s deadline. |
| Client presentation | Impact and CameraKit bind a render step only while a shake lives or the camera is held. AudioKit steps only while fades, reverb zones, or handles with cue points or occlusion exist. AnimKit steps IK only during a weight fade. CutsceneKit, MaterialKit, BonePhysics, VfxSuite and Dissolve connect only while they have work. Time computes frame() and scaled() on read with no frame connection. The DeviceBench governor pauses while the window is unfocused. |
| Debug surface | The F8 debug panels run no render tasks, samplers, log sink or NET capture while hidden. Client NET capture runs only while a NET window is open. The server NetTap mirror and log mirror arm one flush when an entry arrives and encode nothing without an allowlisted debugger. ImGui listens for input movement only during a drag. |
Gait is the one client module that keeps a stepper whenever it has work: one PreSimulation connection
while any rig is bound, because it samples continuously.