Time
Time.attach(kernel) sets kernel.Time to one object exposing every clock a game actually needs: monotonic wall time, the shared server clock, raw accumulated frame time, and a scaled game-time clock that honors pause and slow motion. The design decision that defines it: one clock surface means a designer’s “pause the game” or “half-speed bullet time” request is a single pause()/setScale() call, not an audit of every system that independently reads os.clock().
Mental model
Section titled “Mental model”Time.attach(kernel, options?) builds one stateful instance and stores it on kernel.Time. It tracks two running accumulators — FrameSeconds and ScaledSeconds — plus a Scale (default 1) and a Paused flag (default false). Unless options.SkipLoop is set, it connects to RunService.Heartbeat and drives itself every frame:
function Time.step(self, dt) self.FrameSeconds += dt if not self.Paused then self.ScaledSeconds += dt * self.Scale endendThat’s the entire engine. Two clocks are pure passthroughs — now() returns self.Clock() (the injectable clock, os.clock by default) and server() returns workspace:GetServerTimeNow() directly, no accumulator involved. The other two, frame() and scaled(), just read the accumulators step maintains.
frame() never pauses
Section titled “frame() never pauses”FrameSeconds accumulates unconditionally — step adds dt to it every call regardless of Paused. There’s no branch that skips it. That makes frame() the clock for things that must keep advancing through a pause (menu animations, a paused-game debug overlay’s own elapsed-time display); scaled() is the one that freezes.
scaled() integrates, and that’s why it never rewinds
Section titled “scaled() integrates, and that’s why it never rewinds”ScaledSeconds is not elapsedRawTime * Scale recomputed from a stored total — it’s an accumulator that adds dt * Scale each frame, using whatever Scale was in effect for that specific frame. That distinction matters: a naive implementation that multiplies total elapsed time by the current scale would jump backward the instant scale decreases (dropping Scale from 1 to 0.5 would suddenly halve a value gameplay code already read and displayed). Because Time instead adds a new, always-non-negative increment every frame — dt >= 0 from Heartbeat, and setScale asserts scale >= 0 — ScaledSeconds can only ever hold steady (Scale = 0 or paused) or climb. Changing Scale changes the rate of future increments; it never touches the total already banked.
The step(dt) spec seam
Section titled “The step(dt) spec seam”options.SkipLoop = true skips the Heartbeat:Connect entirely, leaving step(dt) uncalled by anything — the caller drives it manually. This is how specs get deterministic time without waiting on real frames:
local Clock = Time.attach(Kernel, { SkipLoop = true })Clock:step(1) -- advance exactly 1 second, on demandClock:setScale(0.5)Clock:step(1) -- ScaledSeconds is now 1 + 1*0.5 = 1.5options.Clock is the equivalent seam for now() — inject a fake clock so wall-time reads are deterministic too.
-- src/Server/Bootstrap.luaulocal Time = require(game:GetService("ReplicatedStorage").ChloeKernel.Time)
return function(kernel) Time.attach(kernel)
local RoundEndsAt = kernel.Time:scaled() + 60 kernel.Bus:subscribe("Round.Tick", function() local Remaining = RoundEndsAt - kernel.Time:scaled() if Remaining <= 0 then kernel.Bus:publish("Round.Ended") end end)
-- Pause menu: freeze every scaled() reader with one call kernel.Bus:subscribe("Menu.Opened", function() kernel.Time:pause() end) kernel.Bus:subscribe("Menu.Closed", function() kernel.Time:resume() end)
-- Slow-motion ability: half speed for every scaled() reader kernel.Bus:subscribe("Ability.BulletTime", function(_, seconds) kernel.Time:setScale(0.5) task.delay(seconds, function() kernel.Time:setScale(1) end) end)endThe countdown, the pause menu, and the slow-motion ability never coordinate with each other directly — they all just read kernel.Time:scaled(), and the two control calls (pause/resume, setScale) change what every reader sees at once.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Time.attach(kernel, options?) → TimeService |
Builds the instance, sets kernel.Time, and connects RunService.Heartbeat to drive step unless options.SkipLoop. |
time:now() → number |
self.Clock() — monotonic wall time, os.clock by default. |
time:server() → number |
workspace:GetServerTimeNow() — the server-synchronized clock. The same primitive Rewind uses by default for its claim-timestamp Clock, so a claim stamped with time:server() lines up with the clock the server validates against. |
time:frame() → number |
Seconds accumulated across every step, paused or not. Never freezes. |
time:scaled() → number |
Seconds accumulated as dt * Scale, skipped entirely while paused. This is the clock gameplay timers should read. |
time:setScale(scale: number) |
Asserts scale is a finite number >= 0. Publishes Time.ScaleChanged on every call, even to the same value. |
time:getScale() → number |
Current Scale. |
time:pause() |
Sets Paused = true and publishes Time.Paused — only if it was not already paused. |
time:resume() |
Sets Paused = false and publishes Time.Resumed — only if it was actually paused. |
time:isPaused() → boolean |
Current Paused state. |
time:step(dt: number) |
Advances both accumulators. Called automatically off Heartbeat unless SkipLoop; call it directly in specs. |
time:destroy() |
Disconnects the Heartbeat connection (if any) and clears kernel.Time if it still points at this instance. |
Options (Time.attach(kernel, options?), both optional):
| Field | Default | Description |
|---|---|---|
Clock |
os.clock |
Injectable source for now(). |
SkipLoop |
false |
Skip the automatic Heartbeat connection; the caller drives time via step(dt). |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Time.ScaleChanged |
(scale) |
Every setScale call — unguarded, even if the new value equals the old one. |
Time.Paused |
() |
pause(), only on the transition from running to paused. |
Time.Resumed |
() |
resume(), only on the transition from paused to running. |
Design notes
Section titled “Design notes”Before a single clock surface exists, a countdown timer, a pause menu, and a slow-motion ability each tend to keep their own timing state — a countdown reads os.clock() at start and compares against it each tick, a slow-mo ability scales its own dt locally, and “pause” means each of those systems separately learning to check a global paused flag before advancing. Add a second slow-motion ability, or a spectator mode that needs the same paused-aware clock, and every one of those call sites needs the same fix independently — that’s the “hunt through every timer” the module description names.
With kernel.Time, every gameplay-facing timer reads scaled() and nothing else. pause()/resume()/setScale() change what scaled() returns for every reader simultaneously — the countdown, the ability cooldowns, the VFX timelines — without any of them knowing pause or slow motion exist as concepts. now() and frame() stay available for the code that deliberately wants to ignore pause and scale (the pause menu’s own UI animation, or wall-clock logging).