Skip to content

SystemRunner param and macro syntax#21811

Open
ecoskey wants to merge 12 commits intobevyengine:mainfrom
ecoskey:feature/system_runner
Open

SystemRunner param and macro syntax#21811
ecoskey wants to merge 12 commits intobevyengine:mainfrom
ecoskey:feature/system_runner

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 :).

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.

/// 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.

/// ```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

@ecoskey ecoskey force-pushed the feature/system_runner branch from cf12de9 to 6443d66 Compare November 23, 2025 21:08
@ecoskey ecoskey added the S-Blocked This cannot move forward until something else changes label Nov 24, 2025
@ecoskey ecoskey force-pushed the feature/system_runner branch 2 times, most recently from c8cfcc1 to 8ac61d3 Compare November 28, 2025 05:29
@ecoskey ecoskey added this to the 0.18 milestone Dec 1, 2025
@ecoskey ecoskey force-pushed the feature/system_runner branch 2 times, most recently from 6256c5d to 814e327 Compare December 9, 2025 02:11
@alice-i-cecile alice-i-cecile removed this from the 0.18 milestone Dec 10, 2025
@ecoskey ecoskey force-pushed the feature/system_runner branch from 814e327 to 49eae03 Compare December 14, 2025 02:43
@ecoskey ecoskey force-pushed the feature/system_runner branch from 49eae03 to 50f366e Compare December 23, 2025 02:53
@kfc35 kfc35 self-requested a review February 8, 2026 02:54
github-merge-queue bot pushed a commit that referenced this pull request Feb 11, 2026
# Objective

- Enable constructing systems from param builders without world access
- Required for #21811, which requires constructing systems from builders
without world access

## Solution

- Add `BuilderSystem`, a wrapper which defers state construction until
the first run
- in addition to `(...params).build_state(&mut world).build_system(||
{})`, now you can also call `(...params).build_system(|| {})` directly

## Testing

- compiles

(will add more tests while fixing review nits)

---------

Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
@cart cart added this to ECS Feb 17, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in ECS Feb 17, 2026
@ecoskey ecoskey force-pushed the feature/system_runner branch from 50f366e to 0cc8a93 Compare February 17, 2026 20:52
@ecoskey ecoskey added S-Needs-Review Needs reviewer attention (from anyone!) to move forward and removed S-Blocked This cannot move forward until something else changes labels Feb 17, 2026
@ecoskey ecoskey removed the M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide label Feb 17, 2026
@ecoskey
Copy link
Contributor Author

ecoskey commented Feb 17, 2026

rebased and ready for review! :)

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.

Oh, yay!

Before I approve, a few of my comments from my earlier review still seem to apply:

https://github.com/bevyengine/bevy/pull/21811/changes#r2518521366
https://github.com/bevyengine/bevy/pull/21811/changes#r2518563988
https://github.com/bevyengine/bevy/pull/21811/changes#r2518608196

I don't mean for any of them to be blocking, but since you thumbs-upped some of them I thought you intended to make some changes there.

pub fn run_with(&mut self, input: In::Inner<'_>) -> Result<Out, RunSystemError> {
// SAFETY:
// - all accesses are properly declared in `init_access`
unsafe { self.system.validate_param_unsafe(self.world)? };
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, another option here is to validate the inner param inside SystemRunner::validate_param. So if the inner system takes a Single or If<Res> then we'd skip the outer system.

I think that would be more powerful in general. If you want to run the outer system unconditionally, that's still possible by taking system: Result<SystemRunner<_>, SystemParamValidationError> and then doing system?.run()?.

But the existing combinators all want lazy validation, so maybe nobody will ever want eager validation. And that Result type is verbose and hard to discover. So maybe we should leave it like this to make lazy validation convenient, even if it makes eager validation impossible.

Copy link
Contributor

@kfc35 kfc35 left a comment

Choose a reason for hiding this comment

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

I don’t feel particularly qualified to land an approval, but happy to promote this for reviews once chescock’s review comments have been all resolved.

I read and learned some things, and didn’t find any glaring issues with any of the non macro code

ecoskey and others added 2 commits February 18, 2026 21:45
Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
ecoskey and others added 2 commits February 19, 2026 11:08
Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
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-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

SystemRunner param - run systems inside other systems

4 participants