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.

version License: MIT CI


Install with Wally    Get on Creator Store


Documentation · API Reference · Changelog


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

FeatureDescription
Scoped lifecycleAll tasks belong to a scope. Destroy the scope and every task inside is cancelled instantly.
after / everyOne-shot delays and repeating intervals with automatic first-run for every.
framePer-frame callbacks via RunService.Heartbeat with wall-clock dt.
tickFixed-rate simulation loop (e.g. 60 Hz physics) — fires multiple times per frame to catch up.
Pause & resumeScope-level and handle-level pause. frame still fires with dt = 0 while scope is paused.
Time-scalescope:setTimeScale(0.5) for slow motion, 2 for fast-forward. Affects after, every, tick — not frame.
maxCatchupCap how many times tick fires per frame after a hitch: scope:tick(60, fn, { maxCatchup = 4 }).
Error isolationA crashing callback never kills other tasks. Optional onError handler per scope.
Profilerchrono.profile() returns per-task count, avg/max/p99 execution time. Ring-buffer backed.
Global controlschrono.pause_all() / chrono.resume_all() — freeze every scope in one call (e.g. pause menu).
Trove compatibleScopes 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

MethodDescription
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

MethodDescription
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().
PropertyTypeDescription
scope.timeScalenumberCurrent time multiplier (read-only).
scope.isPausedbooleanWhether the scope is paused (read-only).
scope.isDestroyedbooleanWhether the scope has been destroyed (read-only).
scope.elapsedTimenumberAccumulated scaled time (read-only).

Handle

MethodDescription
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 })
FieldApplies toDescription
nameallLabel for the profiler. Default: "<anonymous>".
maxCatchuptickMax 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.