Shared time utilities for gameplay, session services, and host clocks. Implementation lives under src/hikari/sdk/src/temporal/. Exports: hi.temporal plus aliases hi.Timer / hi.Interval / hi.Cooldown / hi.Stopwatch / hi.Deadline / hi.Flow / hi.Timers / hi.nowNs / hi.wallTimeMs / hi.wallTimeSec / hi.wallTimeNs (and the *Io variants).
Goal
Delays, rate limits, intervals, multi-step routines, and clock helpers under hot-path constraints: plain values, no per-frame heap, poll edges — no callback graphs on the hot path.
Non-goals
- Managing suspended call stacks. Use hi.Routine for engine-scheduled stackful gameplay routines;
Flow/Timerremain small caller-driven helpers. - Host-mediated scene-load waits (use
world.sceneLoadSnapshot/ readiness stages). - Calendar / timezone / wall-clock scheduling.
- Replacing Motion Kit duration/delay fields on tweens.
Timer / Cooldown / Interval cover single delays and rate limits. Flow is the multi-step story: explicit phases on caller-owned state, short update steps, no hidden suspension or heap iterators. Coming from another engine's coroutine model? See Migration.
Where it sits
entity / GameSubsystem state
│
▼
temporal.* (Timer, Interval, Cooldown, Flow, Stopwatch, Deadline, Timers bus)
│
└─► tick with TickContext.dt / total_time each frameWall/mono “now” lives in sdk/src/time.zig (nowNs, wallTimeMs / Sec / Ns, Io variants; backed by std.Io from the hikari_std module). Temporal re-exports those as hi.nowNs / hi.wallTimeMs / … and as hi.temporal.nowNs. Session sim dt stays session-owned Time — do not call Zig std.Io.Timestamp from product code.
Core types
| Type | Role |
|---|---|
Timer | One-shot countdown; after, tick, pulse (finish edge) |
Interval | Repeating period; tick → fire count (catch-up) |
Cooldown | Rate limit; ready / trigger / tick |
Flow | Multi-step routine; tick then wait / waitUntil / waitFrame / onEnter / advance |
Stopwatch | Accumulator; start/pause/unpause |
Deadline | Absolute threshold vs total_time |
Timers | Dense keyed one-shot bus |
Usage
Single delay / cooldown
// Inside component Data. Runtime state is not authored or serialized.
state: struct {
door_timer: hi.Timer = .after(0.5),
fire_cd: hi.Cooldown = .init(0.35),
} = .{},
pub fn update(self: *@This(), _: hi.ActorContext, ctx: *const hi.TickContext) void {
if (self.state.door_timer.pulse(ctx.dt)) { /* open */ }
self.state.fire_cd.tick(ctx.dt);
if (hi.world().actionPressed("fire") and self.state.fire_cd.trigger()) { /* shoot */ }
}Multi-step routine (Flow)
const Phase = enum(u32) { windup, sound, wait_player, open, _ };
state: struct { flow: hi.Flow = .begin() } = .{},
pub fn update(self: *@This(), actor: hi.ActorContext, ctx: *const hi.TickContext) void {
const f = &self.state.flow;
if (!f.running()) return;
f.tick(ctx.dt);
_ = f.wait(@intFromEnum(Phase.windup), 0.5);
if (f.onEnter(@intFromEnum(Phase.sound))) {
// play sound
f.advance();
}
// Guard expensive predicates: ordinary Zig arguments evaluate eagerly.
if (f.isAt(@intFromEnum(Phase.wait_player))) {
_ = f.waitUntil(@intFromEnum(Phase.wait_player), playerNear(actor));
}
if (f.onEnter(@intFromEnum(Phase.open))) {
// open door
f.stop();
}
}Keyed session delays: hi.Timers (after / tick / pulse / cancel).
Timing and lifetime contracts
Call flow.tick(dt) exactly once per owning update, before polling. wait(step, seconds) no longer accepts dt. A flow belongs to one clock and one update lane; do not drive the same value from both update and fixedUpdate.
- The first active positive-duration wait consumes the tick's elapsed-time budget, even if it does not finish. Repeated polls and subsequent waits cannot spend it again. Excess elapsed time is discarded, not carried to the next phase. A newly armed first wait may consume the current tick; this is a tick-budget helper, not an absolute-deadline scheduler.
- Zero/negative/non-finite durations complete immediately. They do not consume elapsed time. Positive waits capture their duration when armed; changing the argument while waiting does not retime them.
onEntercan perform setup before a wait on the same phase. Each phase uses one wait kind; changing between timed/frame/condition waits requiresgotooradvance.waitFramecompletes only after a latertick, regardless of repeated polling. It means the next owner update, which may be throttled, and is not a rendered-frame fence.tick(0)still establishes a new owner update; it freezes positive timed waits but does not suppress frame waits or conditions. Skip the entire flow update to pause all progress. Ordinary gameplay callbacks already respect the World simulation gate.advancefrom the last usable phase stops. The reservedFlow.stoppedvalue cannot execute actions or waits.start,reset, andgotoreset phase entry and pending waits, but do not replenish elapsed time already spent this tick.Flowdoes not manage owner lifetime or run cleanup. Components must author disable/re-enable policy and resource cleanup in lifecycle hooks. Never retainActorContextor borrowed sibling pointers in flow state. Loops over immediate phases are caller-owned and must be bounded.
Timer.pulse(dt) reports an active-to-finished edge only: an immediate timer is already done and never pulses. Use done() for level readiness. The keyed Timers bus deliberately retains one completion pulse, including immediate timers, until consumed, replaced, or cancelled. Timers.active() counts running countdowns, not pending pulses. Completed slots retain their keys until release/cancel; both operations recycle without allocating. after may allocate at capacity boundaries; reserve the maximum concurrent keys before hot-path use. Lookup and ticking remain linear in admitted slots; this is a small keyed table, use hi.Routine for engine-scheduled suspended routines.
Negative and non-finite deltas are ignored by countdowns, intervals, cooldowns, and stopwatches. Non-finite/negative durations normalize to zero. Interval clamps invalid or sub-epsilon periods to floatEps(f32), computes catch-up arithmetically, and retains a double-precision fractional remainder. Its count saturates at maxInt(u32) and discards excess whole periods; game code must still bound work performed for returned counts. Stopwatches saturate at the largest finite f32. Deadline expects a finite advancing absolute clock; its delay follows the duration normalization rule.
Validation
From src/hikari, run zig build test-temporal -Dgame-src=<absolute-game-src>. This suite is also included in test-sdk and test. It covers tick-budget reuse, immediate waits, repeated frame polling, phase/stop transitions, invalid time, interval catch-up, and timer allocation failures.
Files
src/hikari/sdk/src/temporal/
temporal.zig barrel
clock.zig unit helpers
timer.zig Timer, Interval, Cooldown, Stopwatch, Deadline
flow.zig Flow
bus.zig Timers