Scheduler & Stages

#include <safi/ecs/ecs.h>
#include <safi/ecs/phases.h>

SafiEngine runs four flecs pipelines per frame, in a fixed order. Each pipeline selects systems by the phase they depend on, and two of them are gated by the current editor mode.

┌────────────────────────── safi_app_tick ───────────────────────────┐
│                                                                    │
│  1. Variable pipeline   (wall-clock dt, always)                    │
│       EcsOnLoad → EcsPreUpdate → EcsOnUpdate                       │
│                  → EcsOnValidate → EcsPostUpdate                   │
│                                                                    │
│  2. Fixed pipeline      (looped N times, dt = SafiTime.fixed_delta)│
│       SafiFixedUpdate                              [if mode=PLAY]  │
│                                                                    │
│  3. Game pipeline       (wall-clock dt)                            │
│       SafiGamePhase                                [if mode=PLAY]  │
│                                                                    │
│  4. Render pipeline     (wall-clock dt, always)                    │
│       EcsPreStore → EcsOnStore                                     │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

The engine does not use the default flecs pipeline. It builds four custom pipelines in safi_ecs_create, each matching a specific set of phases, and safi_app_tick calls ecs_run_pipeline on them in order. The fixed and game pipelines are skipped entirely when SafiEditorState.mode != PLAY.

Assigning a system to a stage

Systems opt into a stage by depending on any phase that pipeline matches.

Variable-rate

ecs_system(world, {
    .entity   = ecs_entity(world, { .name = "camera_follow",
                                    .add  = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
    .callback = camera_follow,
});

Runs once per frame at wall-clock dt. Use this for input-driven logic, camera smoothing, gameplay that should feel smooth regardless of frame rate, and (when step 2 lands) transform propagation on EcsPostUpdate.

Fixed-rate

ecs_system(world, {
    .entity   = ecs_entity(world, { .name = "physics_step",
                                    .add  = ecs_ids(ecs_dependson(SafiFixedUpdate)) }),
    .callback = physics_step,
});

Runs N times per frame at SafiTime.fixed_delta (default 1/60 s). Use this for physics and any deterministic simulation where stable dt matters. SafiFixedUpdate is intentionally not chained to any default flecs phase, so it is invisible to the variable pipeline — it only runs from the fixed-step accumulator loop. Skipped when editor mode is not PLAY.

Gameplay (variable-rate, pausable)

ecs_system(world, {
    .entity   = ecs_entity(world, { .name = "player_controls",
                                    .add  = ecs_ids(ecs_dependson(SafiGamePhase)) }),
    .callback = player_controls,
});

Runs once per frame at wall-clock dt, but only when SafiEditorState.mode == PLAY. Use this for user gameplay systems that should freeze in Edit mode (player movement, AI, scripted events). Engine-owned variable systems — input pumping, camera updates, transform propagation — stay on EcsOnUpdate so the Inspector and viewport remain responsive while the game is paused.

Render

ecs_system(world, {
    .entity   = ecs_entity(world, { .name = "my_overlay",
                                    .add  = ecs_ids(ecs_dependson(EcsOnStore)) }),
    .callback = my_overlay,
});

Runs last in the frame, after both the variable and fixed stages have settled. Use this for draw systems.

Fixed-timestep accumulator

Each frame the engine adds dt to an accumulator and then runs SafiFixedUpdate for as long as the accumulator has a full step in it, capped at SafiApp.fixed_max_steps per frame to prevent the spiral-of-death:

if (mode == PLAY) {
    fixed_accumulator += dt;
    steps = 0;
    while (fixed_accumulator >= fixed_delta && steps < fixed_max_steps) {
        run_pipeline(safi_fixed_pipeline, fixed_delta);
        fixed_accumulator -= fixed_delta;
        steps++;
    }
} else {
    /* Edit / Paused: drain the accumulator so resuming Play doesn't
     * try to catch up on minutes of paused time. */
    fixed_accumulator = 0.0f;
}

If the cap is hit during Play, the leftover time is discarded rather than carried into the next frame. SafiTime.fixed_overshoot exposes the surviving remainder for interpolation.

Editor mode gate

The fixed and game pipelines are gated on SafiEditorState.mode. The full tick:

ecs_run_pipeline(world, safi_ecs_variable_pipeline(), dt);      /* always */

const SafiEditorState *ed = ecs_singleton_get(world, SafiEditorState);
bool game_active = (ed && ed->mode == SAFI_EDITOR_MODE_PLAY);

if (game_active) {
    /* fixed-step loop above */
    ecs_run_pipeline(world, safi_ecs_game_pipeline(), dt);
}

ecs_run_pipeline(world, safi_ecs_render_pipeline(), dt);        /* always */

Toggle the mode at runtime via safi_editor_set_mode (see Editor State). The gltf_viewer demo binds F1 to Edit ↔ Play so you can watch the falling cube freeze and resume.

Pipeline accessors

ecs_entity_t safi_ecs_variable_pipeline(void);
ecs_entity_t safi_ecs_fixed_pipeline(void);
ecs_entity_t safi_ecs_game_pipeline(void);
ecs_entity_t safi_ecs_render_pipeline(void);

Exposed for tests and for apps that want to step individual stages manually. Normal code doesn't touch these — safi_app_tick handles them.

:::warning WIP — scheduling gaps

  • Startup stage — no one-shot startup system registration API; initialization runs in main() before safi_app_run
  • Higher-level ordering API — only raw flecs ecs_dependson phase ordering; no explicit before/after system dependencies :::

See also