Skip to content

Schedule

Stages run in a fixed order each tick: startup → pre → update → post → render. startup runs once on the first tick. The other four run every tick in insertion order.

import { schedule } from "@f0rbit/forge";
const sch = schedule();
sch.add("startup", spawn_level, "level.setup");
sch.add("update", player_input, "player.input");
sch.add("update", movement, "movement");
sch.add("post", collisions, "collisions");
sch.add("render", draw_overlays, "ui.overlays");
sch.tick(world, ctx); // pumps input + runs every stage in order

schedule.add accepts either a positional name string (the common case) or an AddOpts bag for periodic gating:

type AddOpts = {
every?: number; // run on every Nth tick (default 1)
phase?: number; // tick offset for the gate (default 0)
name?: string; // system name (default `__sys_<n>`)
};
schedule.add(
stage: Stage,
system: System,
opts?: AddOpts | string,
): Schedule;

Three forms in practice:

sch.add("update", player_input); // unnamed, every tick
sch.add("update", movement, "movement"); // named, every tick
sch.add("update", regen, { every: 60, phase: 30, name: "regen" }); // every 60 ticks, offset 30

The wrapper fires when ctx.time.tick % every === phase % every. every: 1 is equivalent to no gate. Use phase to interleave two periodic systems with the same period instead of bunching them on tick 0.

every must be a positive integer; phase a non-negative integer. The schedule throws on registration if either is invalid (programmer-error guard, not a recoverable runtime error).

For grid-game tile movement, pair with ticks_per_step(cells_per_second, fixed_dt) so movement speed stays decoupled from grid resolution.

  1. startup runs once.
  2. ctx.input.advance(world, ctx) — drains the input source, refreshes action state, fires pre_advance and on_advance listeners (replay hooks live here).
  3. pre, update, post, render run in that order. After the render stage, ctx.debug.frame() is automatically drained (so debug commands always render in the same tick they’re issued).

sch.run(stage, world, ctx) runs one stage in isolation — useful inside the harness tick for headless tests that don’t want PIXI render systems to fire.

The schedule fires input.advance before stages and debug.frame() after render automatically. You don’t add input or debug systems by hand — the schedule does it.

The startup_done flag is per-schedule() instance; if you clear() the world via snapshotter.restore, startup will not re-fire (this matches “loaded a save → don’t re-spawn the level”). Recreate the schedule if you want a fresh startup pass.

schedule.add_periodic(stage, sys, { every, phase? }, name?) is removed. Pass the same opts to add instead:

// v0.2.0
sch.add_periodic("update", movement, { every: 10 }, "movement");
// v0.3.0
sch.add("update", movement, { every: 10, name: "movement" });

The string-name positional shorthand is preserved for the common non-periodic case.