Skip to content

Time

One clock surface for wall time, server time, frame time and a scaled game clock, so pause and slow motion are one call.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Time
Since
v0.7.0
Source
Time.luau

Time.attach(kernel) sets kernel.Time to one object with every clock a game needs: monotonic wall time, the shared server clock, frame time, and a scaled game clock that honors pause and slow motion. The point is the scaled clock. When every gameplay timer reads kernel.Time:scaled(), “pause the game” is one pause() and “half-speed bullet time” is one setScale(0.5), instead of an audit of every system that reads os.clock() on its own. VfxSuite follows the same switch, so engine particles slow and freeze with the game.

Time keeps no per-frame state and connects to no frame event. Since 0.9.0 it computes frame() and scaled() from stored anchors when you read them; before, a Heartbeat connection added to two accumulators every frame. The values are the same.

  • now() returns the injected Clock, os.clock by default.
  • server() returns workspace:GetServerTimeNow().
  • frame() returns the seconds since attach, read from the engine’s frame-quantized time() (or the FrameClock option), plus anything added through step(dt). It never pauses and ignores the scale.
  • scaled() returns AnchorScaled + (frame() - AnchorFrame) * Scale, or AnchorScaled alone while paused.

setScale, pause and resume each re-anchor before they change anything: the span since the last anchor is folded into AnchorScaled at the old rate, and AnchorFrame moves to now. So a change applies from the moment it is made and never rewrites time already passed. Dropping the scale from 1 to 0.5 slows scaled() from then on; it never jumps backwards, which is what happens if you multiply total elapsed time by the current scale. scaled() can only hold still (paused, or scale 0) or climb.

A scale change while paused re-anchors without adding anything and takes effect when the game resumes.

SkipLoop = true leaves out the frame clock entirely, so frame time moves only through step(dt). Clock injects the source for now(), and FrameClock (added in 0.9.0) injects the source for frame():

local Clock = Time.attach(Kernel, { SkipLoop = true })
Clock:step(1) -- frame() is 1, scaled() is 1
Clock:setScale(0.5)
Clock:step(1) -- frame() is 2, scaled() is 1.5

Attach in a service’s init, so every service that depends on it can read kernel.Time in start:

src/Server/Game/Services/ClockService.luau
--!strict
-- ClockService: owns kernel.Time and the bullet-time ability.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Time = require(ReplicatedStorage.ChloeKernel.Time)
local ClockService = {
Name = "ClockService",
}
function ClockService.init(self: any, kernel: any)
self.Kernel = kernel
self.Clock = Time.attach(kernel)
end
function ClockService.start(self: any)
local Kernel = self.Kernel
self.Connection = Kernel.Bus:subscribe("Ability.BulletTime", function(_topic, seconds: number)
self.Clock:setScale(0.5) -- every scaled() reader runs at half rate from now
if self.Restore then
self.Restore:cancel()
end
-- Scheduler deadlines count real seconds; the scale does not stretch them
self.Restore = Kernel.Scheduler:after(seconds, function()
self.Restore = nil
self.Clock:setScale(1)
end)
end)
end
function ClockService.stop(self: any)
if self.Restore then
self.Restore:cancel()
end
if self.Connection then
self.Connection:disconnect()
end
self.Clock:destroy()
end
return ClockService

A timer that pauses with the game stores a scaled deadline and compares when it is used:

local EndsAt = kernel.Time:scaled() + 60
local function secondsLeft(): number
return math.max(EndsAt - kernel.Time:scaled(), 0)
end

The countdown, a pause menu calling pause() and resume(), and the ability above never coordinate. They all read scaled(), and the control calls change what every reader sees at once. now() and frame() stay available for code that must ignore pause and scale, such as the pause menu’s own animation or wall-clock logging.

Member Description
Time.attach(kernel, options?) -> TimeService Builds the clock, sets kernel.Time, and returns it. Connects nothing.
time:now() -> number The Clock option, os.clock by default.
time:server() -> number workspace:GetServerTimeNow(). Rewind validates claims against the same clock by default.
time:frame() -> number Seconds since attach from the frame clock, plus step time. Never pauses.
time:scaled() -> number Game seconds at the current scale, frozen while paused. The clock gameplay timers read.
time:setScale(scale) Re-anchors, sets the scale and publishes Time.ScaleChanged on every call, even with the same value. Asserts a number that is not NaN and not negative.
time:getScale() -> number The current scale.
time:pause() Re-anchors and publishes Time.Paused, only when running.
time:resume() Re-anchors and publishes Time.Resumed, only when paused.
time:isPaused() -> boolean Whether the clock is paused.
time:step(dt) Adds dt to frame time. With SkipLoop it is the only way frame time moves.
time:destroy() Clears kernel.Time if it still points at this clock.

Options, all optional:

Field Default Description
Clock os.clock Source for now().
FrameClock time Source for frame(). Ignored with SkipLoop. Added in 0.9.0.
SkipLoop false No frame clock: frame time moves only through step(dt).
Topic Payload Fired
Time.ScaleChanged (scale) Every setScale call, including a repeat of the current value.
Time.Paused () pause(), on the change from running to paused.
Time.Resumed () resume(), on the change from paused to running.

VfxSuite reads kernel.Time when it attaches and follows these topics: it sets the TimeScale of live emitters, fire, smoke, sparkles and explosions to their authored value times the kernel scale, clamped to 0 to 1, and 0 while paused. Attach Time before VfxSuite.

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