Skip to content

Clocks

A clock decides what each frame’s delta time is. The engine holds exactly one, supplied at construction and replaceable through engine.setClock, and it is the only thing that decides how much simulated time a frame is worth.

interface Clock {
delta(nowMs: number): number | null;
}
ParameterMeaning
nowMsThe host timestamp for this tick, in milliseconds, on the same time base as performance.now.

The result is the frame’s delta in milliseconds, or null when this tick is not a frame. A null leaves the simulation untouched and the frame counter unchanged.

A clock is called once per tick, and a tick is either a host frame callback under engine.run or one step of engine.advance. A clock that ignores nowMs therefore produces the same sequence of deltas under both, which is what lets a validator step a scenario synchronously and a reviewer watch the same scenario play.

ClockConstructorDeltaSkips
WallClocknew WallClock(maxDeltaMs?)Real elapsed time since the previous frame, floored at 0 and clamped to maxDeltaMs.Never.
PacedClocknew PacedClock(fps, options?)One frame interval.Ticks arriving before the next grid slot.
ConstantClocknew ConstantClock(stepMs)stepMs, every frame.Never.
SequenceClocknew SequenceClock(stepsMs)The next entry, cycling.Never.
JitterClocknew JitterClock(minMs, maxMs, seed)A seeded draw from [minMs, maxMs].Never.

WallClock and PacedClock read nowMs. The other three ignore it.

class WallClock implements Clock {
constructor(maxDeltaMs?: number);
delta(nowMs: number): number;
}
ParameterDefaultMeaning
maxDeltaMs100The longest delta a frame may report.

The first frame reports 0, because a delta needs a previous frame to measure from. A timestamp behind the previous one reports 0 as well, so the simulation advances by nothing rather than running backwards.

The clamp bounds what a single frame can be worth. A tab that stops receiving frames resumes as though the game paused for the gap, which keeps a long absence from handing the simulation a step it was never written to survive.

class PacedClock implements Clock {
constructor(fps: number, options?: PacedClockOptions);
delta(nowMs: number): number | null;
}
interface PacedClockOptions {
resyncAfter?: number;
}
ParameterDefaultMeaning
fps—Target frames per second. Finite and positive.
resyncAfter4Intervals behind the grid at which the clock abandons the missed slots and restarts from the current tick.

Frame n is due at t0 + n * 1000 / fps. A tick before the next due time returns null; a tick at or after it returns one interval and moves the grid on by one. A frame that overruns therefore shortens the wait for the next one, so the cadence holds its average rate instead of drifting by the overrun on every frame.

Every delivered frame is worth exactly one interval, whatever the tick’s real arrival time. That keeps the delta the game integrates against equal to the delta the pacing targets, and it is what makes a paced run reproducible.

resyncAfter bounds the catch-up. Once the grid falls further behind than that, the missed slots are dropped and the grid restarts from the current tick, so a long stall costs the game a pause rather than a burst of frames replaying time the player did not experience.

The display’s refresh rate bounds what pacing can deliver. A target above it yields a frame per tick, and a target it does not divide evenly yields the nearest slot to each ideal instant.

class ConstantClock implements Clock {
constructor(stepMs: number);
delta(): number;
}

Every frame is worth stepMs, which must be finite and positive. Advancing n frames therefore adds exactly n * stepMs of simulated time.

class SequenceClock implements Clock {
constructor(stepsMs: number[]);
delta(): number;
}

The deltas are delivered in order and the list repeats. It needs at least one entry, each finite and positive.

This is how an uneven but reproducible frame pattern is expressed: a long frame every so often, a stutter, a burst of short frames.

class JitterClock implements Clock {
constructor(minMs: number, maxMs: number, seed: number);
delta(): number;
}
ParameterMeaning
minMsThe shortest delta. Finite and positive.
maxMsThe longest delta. Finite, positive, and at least minMs.
seedSeeds the draw. Finite.

A delta is a function of the seed and the frame index alone, so frame i under seed s has one answer however that frame was reached. The seed is required, because a claim that a build is delta-time independent is worth making only when the failing case replays exactly.

Each constructor rejects its arguments where they are supplied, naming the offending value.

ConditionResult
WallClock with a maxDeltaMs that is not finite and positiveRangeError
PacedClock with an fps that is not finite and positiveRangeError
PacedClock with a resyncAfter below 1RangeError
ConstantClock with a stepMs that is not finite and positiveRangeError
SequenceClock with no stepsRangeError
SequenceClock with a step that is not finite and positiveRangeError naming the value and its index
JitterClock with bounds that are not finite and positiveRangeError naming both bounds
JitterClock with maxMs below minMsRangeError naming both bounds
JitterClock with a non-finite seedRangeError

Clock, PacedClockOptions, WallClock, PacedClock, ConstantClock, SequenceClock, and JitterClock are exported from @clockwyrks/simple-2d.