| Tool | Use it for | Execution |
|---|---|---|
hi.std.Thread | Explicit OS threads and long-lived native services | Actual parallelism; you own synchronization and shutdown |
hi.jobs | Bounded CPU work on game-owned data | Existing worker pool; no World/render/UI access from jobs; join within the owning frame |
| Actor events/messages | Notify another actor or component | World dispatcher delivery; messaging itself is not concurrency |
hi.Routine | Straight-line gameplay spread over simulation ticks | Cooperative fibers on the World thread; no worker thread is occupied while waiting |
Use Flow/Timer for tiny caller-driven state and delays. Use routines when keeping ordinary locals, loops, and nested calls makes gameplay code easier to write. Events can update state that a routine observes; they do not create threads or fibers. Thread/job APIs retain their existing contracts.
Start, wait, cancel
const hi = @import("hikari_game");
fn pulse(routine: *hi.Routine) !void {
const actor = routine.actor(); // Weak handle; safe to keep across waits.
const original = hi.world().scale(actor);
defer hi.world().setScale(actor, original);
hi.world().setScale(actor, .{ 2, 2, 2 });
try routine.wait(0.5);
hi.world().setScale(actor, .{ 0.5, 0.5, 0.5 });
try routine.wait(0.5);
}
// Inside component Data:
state: struct { pulse: hi.RoutineHandle = .invalid } = .{},
pub fn onEnable(self: *@This(), actor: hi.ActorContext) void {
self.state.pulse = actor.startRoutine(pulse) catch |err| {
hi.std.log.warn("pulse could not start: {s}", .{@errorName(err)});
return;
};
}try routine.nextTick() suspends until a later simulation tick. Conditions use
ordinary loops: while (!ready(routine.actor())) try routine.nextTick();.
Nested helpers can take *hi.Routine and wait too. Coordinate exclusive ownership
when multiple routines write the same property; the pulse example assumes that
it owns scale until its cleanup runs.
hi.world().cancelRoutine(handle) requests cancellation; routineStatus(handle)
reports queued/running/sleeping/cancelling/completed/cancelled/failed/expired.
Handles are weak: a completed status remains readable until its pool slot is
reused, then the old handle expires. No retained result allocation or release is
required. hi.world().startRoutine(.invalid, function) explicitly starts work
without an actor owner; full scene unload still cancels it.
Timing and lifetime
- Start queues work; it never calls the function inline. Execution is after the
World's
.updategroup and before animation/.post_update. wait(seconds)arms a deadline at suspension.wait(0)means next tick; negative/non-finite values returnerror.InvalidWait. Sim pause freezes both time and execution. Routine cadence is independent of component tick LOD.- Cancellation wakes a sleeping routine; its wait returns
error.Cancelled. Propagate that error withtrysodeferruns. Do not catch and retry it. An unstarted routine can be cancelled without entering its function. - Actor disable/destruction cancels actor-owned routines. Starts are actor-owned,
even from a component: cancel the stored handle in
onDestroyif removal of that component should also stop its routine. Re-enable does not restart work automatically; the example authors that behavior inonEnable. - Full scene unload, World teardown, and module reload drain cleanup before
releasing the affected state/code. Play Stop drains in bounded retirement
passes. An unload cannot safely discard a suspended stack containing
defer. - Never retain
ActorContext, sibling pointers, tick-arena memory, or held locks across a wait. Keep copied values, owned resources, and weak actor handles. Cancelled cleanup must tolerate a missing actor. Do not switch threads, block on I/O, or wait for a job while holding a gameplay borrow.
Routines are cooperative: a long loop without a wait still stalls the World thread, and swallowing cancellation can prevent teardown. Fixed-update waits, unscaled waits, child joins, and direct event/operation subscriptions are not part of this initial API. Running stacks are transient, not scene/save data.
Capacity and scaling
Configure on the World owner thread before starting work (for example in
onWorldAttach, with that World bound):
try hi.world().configureRoutines(.{
.capacity = 10_000,
.stack_bytes = 64 * 1024,
.resumes_per_tick = 1024,
});The default pool is created lazily on first start: 256 slots, 64 KiB stack budget
per slot, 1,024 resumptions per tick. Explicit configuration avoids that setup
cost in a gameplay callback. Pools cannot be reconfigured while active. Starts
beyond capacity return error.RoutineCapacity; they never silently grow the pool.
After reservation, start/wait/cancel use pooled slots and intrusive ready queues without heap allocation. An indexed deadline heap parks sleepers and removes cancelled timers without leaving tombstones. Ready work rotates under the budget; 100,000 continuously ready routines with a 1,024-resume budget need roughly 98 simulation ticks to service everyone once. This is not 100,000 updates per frame.
routineStats() exposes active/sleeping/ready counts, last-pass resumptions,
capacity, and configured stack bytes. Stack bytes exclude guard pages, metadata,
and platform rounding; they are not resident RAM. Each suspended fiber retains
its stack. macOS arm64 uses guarded virtual mappings; Windows x86-64 uses native
Win32 fibers. Linux routines currently report unavailable.
Validation from src/hikari: zig build test-routines -Dgame-src=<abs>,
zig build check-routines-windows -Dgame-src=<abs>, and
zig build routines-bench -Dgame-src=<abs> -Doptimize=fast.
See the benchmark results for measured limits.