Skip to content

Conversation

@ecoskey
Copy link
Contributor

@ecoskey ecoskey commented Nov 12, 2025

Objective

resolves #16680

Add a new system param for running systems inside other systems. Also, I've included some macros for nice syntax on top.

I'm pretty proud of how nice I was able to make this, but there's still a bit of work to do, especially around generic code :)

Testing

  • Ran examples
  • Tried to migrate existing pipe and map impls, but failed. This PR is pretty robust for most use cases but isn't fully ready for generic code yet. In particular, it's difficult to make sure the input types match (often have to wrap with StaticSystemInput) and ReadOnlySystem bound is inferred correctly when using for run conditions. These only really matter for combinators like pipe and map though, since otherwise they're run exactly the same.

Showcase

Click to view showcase
fn count_a(a: Query<&A>) -> u32 {
    a.count()
}

fn count_b(b: Query<&B>) -> u32 {
    b.count()
}

let get_sum = (
    ParamBuilder::system(count_a),
    ParamBuilder::system(count_b)
)
.build_system(
    |mut run_a: SystemRunner<(), u32>, mut run_b: SystemRunner<(), u32>| -> Result<u32, RunSystemError> {
        let a = run_a.run()?;
        let b = run_b.run()?;
        Ok(a + b)
    }
);

let get_sum = compose! {
    || -> Result<u32, RunSystemError> {
        let a = run!(count_a)?;
        let b = run!(count_b)?;
        Ok(a + b)
    }
}

@ecoskey ecoskey force-pushed the feature/system_runner branch from bbc70c7 to e0c905c Compare November 12, 2025 01:23
@ecoskey ecoskey added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide M-Release-Note Work that should be called out in the blog due to impact labels Nov 12, 2025
@ecoskey ecoskey requested a review from chescock November 12, 2025 01:28
Copy link
Contributor

@chescock chescock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really cool!

I think this will close #16680, so you might want to link that in the PR description.

I left a lot of comments, but they're mostly style nitpicks and brainstorming for future possibilities. The only thing that I think really needs attention is the map_err(RunSystemError::Skipped) part, since that will silently ignore missing resources. And the CI failure :).

Comment on lines +222 to +224
pub fn system<'w, 's, In, Out, Marker, Sys>(
system: Sys,
) -> impl SystemParamBuilder<SystemRunner<'w, 's, In, Out, Sys::System>>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the lifetimes here matter, or can they all be 'static?

Suggested change
pub fn system<'w, 's, In, Out, Marker, Sys>(
system: Sys,
) -> impl SystemParamBuilder<SystemRunner<'w, 's, In, Out, Sys::System>>
pub fn system<In, Out, Marker, Sys>(
system: Sys,
) -> impl SystemParamBuilder<SystemRunner<'static, 'static, In, Out, Sys::System>>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was just following the other methods tbh, I can make those static though 👍

Uninitialized {
builder: Builder,
func: Func,
meta: SystemMeta,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this here only to support with_name? (It's also used in get_last_run/set_last_run, but I think those are only supposed to be valid on initialized systems.) It might be better to just store a DebugName. If the builder is large, this could wind up being the largest variant and taking up space even after the system is built.

/// )
/// .build_state(&mut world)
/// .build_system(
/// |mut run_a: SystemRunner<(), u32>, mut run_b: SystemRunner<(), u32>| -> Result<u32, RunSystemError> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the CI failure is because this is using the default of dyn System instead of inferring the FunctionSystem types for count_a and count_b.

Suggested change
/// |mut run_a: SystemRunner<(), u32>, mut run_b: SystemRunner<(), u32>| -> Result<u32, RunSystemError> {
/// |mut run_a: SystemRunner<(), u32, _>, mut run_b: SystemRunner<(), u32, _>| -> Result<usize, RunSystemError> {

Alternately, you might want a builder method for dyn System, like

    pub fn dyn_system<In, Out, Marker, Sys>(
        system: Sys,
    ) -> impl SystemParamBuilder<SystemRunner<'static, 'static, In, Out, dyn System<In = In, Out = Out>>>
    where
        In: SystemInput + 'static,
        Out: 'static,
        Sys: IntoSystem<In, Out, Marker>,
    {
        SystemRunnerBuilder::boxed(Box::new(IntoSystem::into_system(system)))
    }

/// ParamBuilder::system(count_b)
/// )
/// .build_state(&mut world)
/// .build_system(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other CI failure is because it can't tell whether the return type is u32 or Result<u32>. You can supply it like

Suggested change
/// .build_system(
/// .build_system::<_, u32, _, _>(

But it might look nicer to run the system and annotate the result, like

/// let result: usize = world.run_system_once(get_sum).unwrap();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does add_systems infer which to use?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does add_systems infer which to use?

It just always requires Out = () for systems and Out = bool for conditions.

Copy link
Contributor Author

@ecoskey ecoskey Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah true. And the other combinators can set it explicitly, sounds good.

}

/// Create a [`System`] from a [`SystemParamBuilder`]
fn build_system<Marker, Func, Out>(self, func: Func) -> BuilderSystem<Marker, Self, Func, Out>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that compose! systems aren't usable with run_system_cached because they don't have a ZST IntoSystem. That never seemed important for system builders in the past, because they generally used some dynamic data and would never be a ZST, but compose! systems won't always capture state.

If we want to allow them to be used with run_system_cached, I think there are two pieces:

  • We need an IntoBuilderSystem. It would hold builder and func but not meta, so it could be a ZST if the builder is.
  • We need SystemRunnerBuilder to store an impl IntoSystem instead of a Box<impl System>, and then call into_system inside of build. That makes the BoxedSystem case a little more awkward, but I think it's still possible.

/// ```ignore
/// let system_a = |world: &mut World| { 10 };
/// let system_b = |a: In<u32>, world: &mut World| { println!("{}", *a + 12) };
/// compose! {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to accept a function definition instead of or in addition to a closure? That would make it easier for these systems to have names. Something like

compose! {
    fn system_name()  -> Result<(), RunSystemError> {
        run!(system_a)
    }
}

For that matter, would it make sense to use an attribute macro, like

#[compose]
fn system_name() -> Result<(), RunSystemError> {
    run!(system_a)
}

?

... Oh, maybe not, because what you'd really want that to expand to is const system_name: impl System = const { ... };, but there's no way to write the type for the const.

@chescock
Copy link
Contributor

  • Tried to migrate existing pipe and map impls, but failed. This PR is pretty robust for most use cases but isn't fully ready for generic code yet. In particular, it's difficult to make sure the input types match (often have to wrap with StaticSystemInput) and ReadOnlySystem bound is inferred correctly when using for run conditions. These only really matter for combinators like pipe and map though, since otherwise they're run exactly the same.

I think I got pipe and and mostly working on this branch! I had to add lots of 'static annotations, but I think those are mostly harmless.

As you mentioned, the big problem is that the resulting systems take StaticSystemInput<In> instead of In, which means they can't be used in the schedule because that expects In = () and not In = StaticSystemInput<()>. Maybe we could create a wrapper type that converts a System<In = StaticSystemInput<In>> to a System<In = In>?

pub trait IntoSystem<In: SystemInput, Out, Marker>: Sized {
// ...
    fn pipe2<B, BIn, BOut, MarkerB>(
        self,
        system: B,
    ) -> impl System<In = StaticSystemInput<'static, In>, Out = BOut>
    where
        Out: 'static,
        B: IntoSystem<BIn, BOut, MarkerB> + 'static,
        for<'a> BIn: SystemInput<Inner<'a> = Out> + 'static,
        In: 'static,
        BOut: 'static,
        Marker: 'static,
        MarkerB: 'static,
        Self: 'static,
    {
        compose_with!(
            |StaticSystemInput(input): StaticSystemInput<In>| -> Result<BOut, RunSystemError> {
                let value = run!(self, input)?;
                run!(system, value)
            }
        )
    }
pub trait SystemCondition<Marker, In: SystemInput = ()>:
// ...
    fn and2<M: 'static, C: SystemCondition<M, In> + 'static>(
        self,
        and: C,
    ) -> impl ReadOnlySystem<In = StaticSystemInput<'static, In>, Out = bool>
    where
        for<'a> In: SystemInput<Inner<'a>: Copy> + 'static,
        Self: 'static,
        Marker: 'static,
    {
        compose_with!(
            |StaticSystemInput(input): StaticSystemInput<In>| -> Result<bool, RunSystemError> {
                Ok(run!(self, input)? && run!(and, input)?)
            }
        )
    }

@ecoskey
Copy link
Contributor Author

ecoskey commented Nov 12, 2025

Thanks for the input! I'll spin up a few PRs for BuilderSystem and RemapInputSystem (or whatever we call it) and start cleaning things up tonight or tomorrow

Also I realized SystemInput::unwrap is probably unnecessary since you can just destructure StaticSystemInput :P

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ECS Entities, components, systems, and events C-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide M-Release-Note Work that should be called out in the blog due to impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SystemRunner param - run systems inside other systems

2 participants