Bevy systems ordering with .after() vs .chain()

Summary

Bevy 0.18 provides two primary ways to enforce execution order between systems:

  • .after() / .before() constraints – add a dependency edge in the schedule graph.
  • .chain() – creates a system set that is implicitly ordered as a single linear chain.

Although both achieve ordering, they differ in command flushing, dependency visibility, and duplicate‑system handling.


Root Cause

  • .after() / .before() only adds a graph edge; the two systems remain independent nodes.

    • The scheduler may interleave other systems between them.
    • Commands are not automatically flushed after the first system; they are flushed only at the end of the schedule or when a system explicitly calls Commands::apply_deferred.
  • .chain() builds a temporary system set that the scheduler treats as a single ordered block.

    • Internally the set is split into separate stages, each followed by an implicit flush of deferred commands.
    • The chain guarantees that no other system runs between the chained members, and that the command buffer is flushed after each member.

These semantics arise from how Bevy’s Schedule builds its DAG and how CommandQueue flushing is tied to stage boundaries.


Why This Happens in Real Systems

  • Command buffering is purposely lazy to reduce synchronization overhead.
  • When you only add a dependency edge, Bevy assumes you may want to share state (e.g., reading a component written by the predecessor) without forcing a flush.
  • A chain is interpreted as a pipeline where each step depends on the side‑effects of the previous step, so Bevy flushes after each step to preserve those side‑effects.

Real-World Impact

  • Performance

    • .after() can be more parallelizable because the scheduler may run unrelated systems concurrently with the first system.
    • .chain() forces a stricter ordering, potentially reducing parallel execution and increasing frame time.
  • Correctness

    • If a system relies on commands created by the previous system (e.g., spawning entities that the next system immediately queries), .chain() ensures they are visible, while .after() may yield stale data unless you manually flush.
  • Debugging complexity

    • Implicit flushing in chains hides the fact that commands are being applied early, which can lead to surprising behavior when swapping to .after().

Example or Code (if necessary and relevant)

app.add_systems(FixedUpdate, (spawn_enemies, assign_ai).chain());
app.add_systems(FixedUpdate, spawn_enemies)
   .add_systems(FixedUpdate, assign_ai.after(spawn_enemies));

How Senior Engineers Fix It

  • Choose the tool that matches the intent:

    • Use .chain() when the second system must see the results of the first (e.g., commands that create entities/components).
    • Use .after() / .before() when you only need a happens‑before relationship without requiring immediate command visibility.
  • Make flushing explicit when using .after() and you need the commands:

    fn spawn_enemies(mut commands: Commands) { … }
    fn assign_ai(mut commands: Commands, query: Query) {
        commands.apply_deferred(); // forces flush before reading
    }
  • Avoid duplicate system registration:

    • Keep a single source of truth for each system (e.g., a module‑level fn system_a()).
    • When you need the same logic in multiple chains, extract the shared logic into a helper function and call it from distinct wrapper systems.
  • Leverage system sets for higher‑level ordering:

    #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
    struct Gameplay;
    
    app.configure_set(FixedUpdate, Gameplay);
    app.add_systems(FixedUpdate, system_a.in_set(Gameplay));
    app.add_systems(FixedUpdate, system_b.after(system_a).in_set(Gameplay));
  • Audit schedules with app.world.run_schedule_debug(FixedUpdate) to visualize graph edges and ensure no unintended duplication.


Why Juniors Miss It

  • Assume ordering constraints are equivalent because both appear to “run A then B”.
  • Overlook the command‑flush semantics, leading to bugs where entities/components created by the first system are invisible to the second.
  • Add the same system multiple times without realizing that Bevy treats each registration as a distinct node, causing hidden duplicate execution and ambiguous ordering.
  • Ignore the schedule’s DAG nature, treating it like a simple list instead of a graph where other systems can be interleaved.

Key takeaway: Understand the difference between a dependency edge and a forced chain; the former is lightweight and parallel‑friendly, the latter guarantees immediate command visibility but at a potential performance cost.

Leave a Comment