Skip to content

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.

  1. React to events. Bus topics, session lifecycle (kernel:onSession, session:bind), GetPropertyChangedSignal, GetAttributeChangedSignal, CollectionService signals through TagWatch, and Kernel.ProfileLoaded through Milestones, which also replays profiles that loaded before you subscribed.
  2. 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.
  3. Run continuous work only while something is active. Start the job on the first item and cancel it on the last.
  4. 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.

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:

  1. 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.
  2. Otherwise it disconnects the phase signal. If a timer exists, it arms one task.delay for the earliest deadline, set to fire 34 ms early so the phase signal is attached before the deadline arrives.
  3. 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 now
local 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 frame
local 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 go
local 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.

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
end
end, { Name = "Beacon" })
Beacon:start()
-- the owner's stop(), or kernel shutdown, ends it
Beacon: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.

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 expiry
self.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)
end

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

src/Server/Game/Services/CaptureService.luau
--!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 = 0
end
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))
end
end
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
end
end
function CaptureService.stop(self: any)
self.Scope:destroy()
end
return CaptureService

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

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.