Lesson 13

A frame in detail

One progress() from start to finish: runs of systems, sync points, multi-threaded systems splitting their rows over the workers and joining, and the hooks and observers that fire inside each merge.

The last few lessons each took one piece of a frame: systems and phases, commands, where sync points go, what batching drops and hooks and observers. Keeping all of it in your head at once is the hard part. This lesson puts it on one timeline: one world.progress(), recorded from real flecs, run by run and sync by sync.

The whole frame

Ten goblins walk about. Four imps stand in lava. An orc, nearly dead, is already burning. Ignite sets the imps on fire, Burn takes damage off everything that burns and marks the dead, and Reap removes them and drops loot. When something starts burning, the Smolder observer adds Smoke.

Loading flecs…

Each run (a column; on a phone, a band down the page) is systems that flecs runs back to back without merging. The striped bar after it is its sync point, with the number of commands it merged. Each thread has its own lane: main is stage 0, and worker 1 and worker 2 are the other two stages of world.set_threads(3). Step through it, or press play. Click any system or sync bar to see what it did.

What to look for:

  • The first run is flecs’ own. flecs.script.ProgressTasks is an immediate system in PreUpdate, added by the script add-on (part of a default build). Because it’s immediate, it always gets a run of its own, so every frame has a sync point there.
  • Runs, not phases. Six runs: the built-in one, Input, Move and Ignite together, Burn, Reap, Render. Move and Ignite share a run because they’re the same kind and neither matches anything an earlier system in the run declared it may queue (.write<T>()).
  • Workers only work in multi-threaded runs. During Input, Reap and Render the workers are idle.
  • Every table is split. Move gets rows 0–3 of the goblins’ table on main, 4–6 on worker 1 and 7–9 on worker 2. The hero’s table has one row, so only main gets it.
  • Each stage queues on its own. Ignite queues set<Burning> for the four imps: 2 · 1 · 1 commands on the three stages. The merge flushes main’s queue, then worker 1’s, then worker 2’s.
  • Chains run before the next command. A chain is what a hook or observer queues during a merge. Here, an imp moves to a table with Burning, the on_add hook runs, then Smolder. Smolder queues add<Smoke>, and that’s flushed straight away, as a nested flush, before the next imp’s command. Each imp moves twice.
  • The merge changed what Burn sees. The imps moved into the orc’s table, so Burn now finds 5 rows and splits them 2 · 2 · 1.
  • Immediate systems apply on the spot. Reap’s it.world().entity().set<Loot>() runs right there: ShowLoot fires inside Reap’s block. Its destruct() goes through the entity handle, so it’s queued and flushed as soon as Reap returns.

Why is there a sync here?

Click a sync bar. The reasons are traced from flecs’ pipeline builder (flecs_pipeline_build), at the line where it decides each merge, not from rules written for this page. There are only a few:

Reason Example in this frame
An earlier system in the run declared it may queue a component this system matches on its own entities (.write<T>()) Burn matches Burning, and Ignite declared .write<Burning>()
A system reads something from elsewhere (a singleton, .read<T>()) that may be queued (none here)
The threading kind changes Input → Move, Burn → Reap
An immediate system starts or ends before Reap, before Render

Sync points and threads has the full rules. Now change things and watch the runs move:

  • Switch to No .write<Burning>(). The sync before Burn goes, Burn joins Move and Ignite, and it finds only the orc: 1 row. The imps start burning, but they take no damage until frame 2.
  • Switch to All single-threaded. With no kind changes, Input, Move and Ignite share one run, and there’s one sync point fewer.
  • Set the threads to 1. The schedule doesn’t change: the same six runs and the same sync points. A run’s kind comes from the systems, not from the thread count, so a multi_threaded system still starts a new run with one thread.
  • Click Input, make it multi_threaded, and the sync between it and Move disappears.

Burn doesn’t need .write<Dead>(): Reap is immediate, so there’s a merge before it anyway, and the orc’s add<Dead> lands there.

Rows to workers

A multi-threaded system runs once per stage, and each stage takes a contiguous slice of every table the system matches. flecs divides the rows equally and gives the remainder to the first stages. The table panel shows the slices in each stage’s colour.

Here, Tick changes Health in place, and Cool and Settle queue remove<Hot> and remove<Warm>. Change the thread count.

Loading flecs…

  • With 3 threads, the 10 a rows split 4 · 3 · 3, the 4 b rows 2 · 1 · 1, and the boss’s single row goes to main only. With 4 threads it’s 3 · 3 · 2 · 2 and 1 · 1 · 1 · 1.
  • Tick, Cool and Settle get the same slices: the split depends only on how many rows a table has, so within a run an entity stays on the same thread.
  • The queues fill up per stage (9 · 5 · 5 commands with 3 threads), and the merge drains them in stage order. b0’s two removes are batched into one move, because they’re in the same queue.

Stage order has a visible consequence. All 14 a and b entities end up in [Health]. With one thread they arrive in the order a0…a9, b0…b3. With three, it’s a0–a3, b0, b1, a4–a6, b2, a7–a9, b3, and Calmed is called in that order too. The order is still deterministic, but it depends on the thread count.

Inside a merge

This one has no threads. Hit queues four things in this order: set<Burning> on A (which doesn’t have it), set<Health> on B and then on A (both have it, so the value is written straight away and only the OnSet is queued), and destruct() on C. a, b and c are handles captured from setup, so they belong to the world, and flecs 4.2 doesn’t queue operations on the world. .stage(it) rebinds them to the system’s stage. Alarm does the same with camp.

Loading flecs…

  • A batch per entity. A’s first command applies both of A’s commands at once: one move, to [Health, Burning, Struck].
  • Chains are depth-first. Smolder queues add<Smoke> on A, which is flushed at once and fires Alarm, which queues a set<Alert> on the camp, which is flushed inside that. That’s two levels of nesting, all before B’s command.
  • OnSet comes in queue order. A’s Health was queued after B’s, so WatchHealth sees B before A, even though A’s batch ran first.
  • Deleting runs the OnRemove observers, then the on_remove hooks, as in the observers lesson.

Three more setups:

  • With on_validate. Health gets an on_validate hook that rejects values below 5. A’s set<Health>({1}) is rejected: no on_set hook, no WatchHealth. The value is still in the column, though. on_validate decides whether anyone is told, not whether the write happened.
  • With on_replace. on_replace is the one event hook that runs while the system is still running: B’s and A’s set<Health> call it as they’re made, inside Hit’s block, because the old value is about to be overwritten in place. The other event hooks wait for the merge.
  • Observer writes through the world. Smolder uses it.world() instead of the entity it was given, so the move happens inside the observer, while flecs 4.2 has A’s table locked. This page runs a release build, so the move goes ahead, but the timeline marks where a debug build would stop with ECS_LOCKED_STORAGE: the move out of A’s locked table (the second marker is the same move’s row delete). Use the entity handle (which goes through the stage) and the same work runs as a chain instead.

Immediate systems in the timeline

Back in the first frame, Reap shows everything special about immediate systems in one block:

  • a sync point before it (the kind changes), so everything queued so far has landed;
  • world operations applied inside the system, with their hooks and observers inside too;
  • its own queue flushed when it returns, before the next run;
  • no workers. An immediate system always runs on the main thread.

A debug build won’t always stop you from moving the rows you’re iterating: flecs locks the iterated table only for systems with a C callback (.callback in ecs_system_desc_t), not for C++ .each() and .run() systems. Either way, adding or removing rows in a table you’re iterating, for any entity, has to go through the stage. Other changes can go through the world.

Two pipelines

progress() runs the default pipeline. You can make your own and run it yourself, for example physics at a fixed step, a few times a frame. Each pipeline has its own schedule, its own runs and its own sync points.

Loading flecs…

Use the tabs to step through the two physics passes and then the frame. Integrate and Collide belong to the physics pipeline (they’re created with .kind<PhysicsSystem>(), which tags them with PhysicsSystem instead of a phase), so progress() never runs them, and the physics pipeline never runs PlaySound or Render.

  • A custom pipeline orders systems by creation, not by phase. Integrate is multi-threaded and Collide isn’t, so that’s two runs.
  • Collide queues add<Bounced> for Ball1 in the first pass. The pass ends with a merge, like a frame does, so by the second pass Ball1 is in its own table (1 row, so only main integrates it).
  • Nothing waits for the next progress(): every pipeline run ends with everything merged.

Where this build differs from yours

Takeaways

  • A frame is a list of runs. Each run ends with a merge, and that merge is also where the threads wait for each other.
  • A new run starts when a system matches or reads something an earlier system in the run declared it may queue, when the threading kind changes, and around every immediate system. Phases don’t add any.
  • A multi-threaded system splits every table it matches into contiguous slices, one per stage. Each stage has its own queue, and merges go stage by stage.
  • Hooks and observers run during merges and inside immediate systems. In an ordinary run only on_replace (and the copy that writes the value) can run, when a set overwrites a value. What hooks and observers queue is flushed straight away, depth-first, before the next command.
  • Custom pipelines work the same way and end merged.

Checked against flecs v4.2.0 (commit 6566d4b, 4 Nov 2026). If your flecs version behaves differently, trust the docs for your version, then ask.