iamthebestts/chrono
Unified time scheduler for Luau — tasks, intervals, scopes, profiling
chrono
Unified time scheduler for Luau on Roblox.
One API for delays, intervals, fixed-rate ticks, and per-frame callbacks — with scoped lifecycle, pause, time-scale, and a built-in profiler.
Why
Roblox gives you task.delay, task.wait, and RunService.Heartbeat. They work, but every non-trivial codebase ends up rebuilding the same things on top: cancellation tokens, scope-based cleanup, pause/resume, time-scaling for slow motion, profiling hot callbacks. chrono is that layer, written once.
local chrono = require(path.to.chrono)
local scope = chrono.scope()
scope:every(5, function()
print("every 5 seconds")
end)
scope:after(2, function()
print("once, in 2 seconds")
end)
scope:frame(function(dt)
camera.CFrame *= CFrame.Angles(0, dt, 0)
end)
-- later: one call cleans up everything
scope:destroy()
Features
| Feature | Description |
|---|---|
| Scoped lifecycle | All tasks belong to a scope. Destroy the scope and every task inside is cancelled instantly. |
after / every | One-shot delays and repeating intervals with automatic first-run for every. |
frame | Per-frame callbacks via RunService.Heartbeat with wall-clock dt. |
tick | Fixed-rate simulation loop (e.g. 60 Hz physics) — fires multiple times per frame to catch up. |
| Pause & resume | Scope-level and handle-level pause. frame still fires with dt = 0 while scope is paused. |
| Time-scale | scope:setTimeScale(0.5) for slow motion, 2 for fast-forward. Affects after, every, tick — not frame. |
| maxCatchup | Cap how many times tick fires per frame after a hitch: scope:tick(60, fn, { maxCatchup = 4 }). |
| Error isolation | A crashing callback never kills other tasks. Optional onError handler per scope. |
| Profiler | chrono.profile() returns per-task count, avg/max/p99 execution time. Ring-buffer backed. |
| Global controls | chrono.pause_all() / chrono.resume_all() — freeze every scope in one call (e.g. pause menu). |
| Trove compatible | Scopes expose Destroy() so Trove can clean them up automatically. |
Installation
Wally
Add to your wally.toml:
[dependencies]
chrono = "iamthebestts/[email protected]"
Then run wally install.
pesde
Add to your pesde.toml:
[dependencies]
chrono = { name = "iamthebestts/chrono", version = "^0.1.0" }
Then run pesde install.
Roblox model
Grab the .rbxm from Releases or the Creator Store and drop it into ReplicatedStorage.
Quick start
local chrono = require(ReplicatedStorage.Packages.chrono)
-- 1. Create a scope
local scope = chrono.scope()
-- 2. Run something once after a delay
scope:after(3, function()
print("3 seconds later!")
end)
-- 3. Run something every N seconds (fires immediately, then repeats)
local handle = scope:every(5, function()
print("heartbeat every 5s")
end)
-- 4. Run every frame
scope:frame(function(dt)
part.Position += Vector3.new(0, speed * dt, 0)
end)
-- 5. Fixed-rate physics at 60 Hz
scope:tick(60, function(dt)
player.Position += player.Velocity * dt
end)
-- 6. Pause / resume
scope:pause() -- freezes all timers; frame fires with dt=0
scope:resume() -- picks up where it left off, no backlog
-- 7. Slow motion
scope:setTimeScale(0.5)
-- 8. Cancel a single task
handle:cancel()
-- 9. Destroy the scope — cancels everything
scope:destroy()
API
Full documentation at iamthebestts.github.io/chrono
Module
| Method | Description |
|---|---|
chrono.scope(config?) | Create a new scope. Config: { timeScale: number?, onError: ((err, name) -> ())? } |
chrono.profile() | Returns profiling data for all named tasks. |
chrono.reset_profiler() | Clears all profiler data. |
chrono.pause_all() | Pauses every active scope. |
chrono.resume_all() | Resumes every active scope. |
Scope
| Method | Description |
|---|---|
scope:after(delay, fn, config?) | Schedule fn once after delay seconds. Returns a Handle. |
scope:every(interval, fn, config?) | Schedule fn immediately, then every interval seconds. Returns a Handle. |
scope:frame(fn, config?) | Schedule fn every frame with dt. Returns a Handle. |
scope:tick(hz, fn, config?) | Schedule fn at fixed hz rate. Config accepts maxCatchup. Returns a Handle. |
scope:pause() | Freeze all tasks. frame still fires with dt = 0. |
scope:resume() | Unfreeze all tasks. No backlog. |
scope:setTimeScale(scale) | Multiply time for after/every/tick. Does not affect frame. |
scope:destroy() | Permanently cancel all tasks. Trove-compatible via Destroy(). |
| Property | Type | Description |
|---|---|---|
scope.timeScale | number | Current time multiplier (read-only). |
scope.isPaused | boolean | Whether the scope is paused (read-only). |
scope.isDestroyed | boolean | Whether the scope has been destroyed (read-only). |
scope.elapsedTime | number | Accumulated scaled time (read-only). |
Handle
| Method | Description |
|---|---|
handle:cancel() | Remove task from schedule. Handle becomes invalid. |
handle:pause() | Freeze this task individually. Cumulative with scope pause. |
handle:resume() | Unfreeze this task. |
handle:setInterval(interval) | Change interval (every/tick only). Resets accumulator. |
handle:isPaused() | Returns handle-level pause state (not scope). |
handle:isCancelled() | Returns whether the handle has been cancelled. |
Config
All scheduling methods accept an optional config table:
scope:after(1, fn, { name = "respawn_timer" })
scope:tick(60, fn, { name = "physics", maxCatchup = 4 })
| Field | Applies to | Description |
|---|---|---|
name | all | Label for the profiler. Default: "<anonymous>". |
maxCatchup | tick | Max fires per frame during catch-up. Default: unlimited. |
Profiler
local data = chrono.profile()
for name, entry in data do
print(name, "avg:", entry.avgMs, "max:", entry.maxMs, "p99:", entry.p99Ms)
end
-- entry shape:
-- {
-- name: string,
-- count: number,
-- avgMs: number,
-- maxMs: number,
-- p99Ms: number,
-- totalMs: number,
-- }
chrono.reset_profiler() -- clear all data
Integration
With Trove
local trove = Trove.new()
local scope = chrono.scope()
trove:Add(scope) -- Trove calls scope:Destroy() on cleanup
With Signals
local scope = chrono.scope()
scope:every(5, function()
mySignal:Fire("tick")
end)
Alongside RunService
chrono uses a single RunService.Heartbeat connection internally. Your own connections work independently — no conflicts.
Contributing
See CONTRIBUTING.md for setup, conventions, and workflow.
License
Released under the MIT License.