Skip to content

Add a DelayedCommands helper to support arbitrary delayed commands#23090

Merged
alice-i-cecile merged 15 commits intobevyengine:mainfrom
Runi-c:delayed_commands
Feb 25, 2026
Merged

Add a DelayedCommands helper to support arbitrary delayed commands#23090
alice-i-cecile merged 15 commits intobevyengine:mainfrom
Runi-c:delayed_commands

Conversation

@Runi-c
Copy link
Contributor

@Runi-c Runi-c commented Feb 21, 2026

Objective

Solution

  • Build off the work in Add a timer-powered DelayedCommand for delayed ECS actions #20155 (comment), especially @laundmo's comment.
  • Add a DelayedCommands helper obtainable via commands.delayed() that owns CommandQueues and hands out new Commands bound to them.
  • When the DelayedCommands helper is dropped, push spawn commands onto the host Commands to spawn the queues as DelayedCommandQueue entities.
  • The entities are ticked by a new system added by TimePlugin. When the timer fires, the queue is submitted onto that system's Commands.

Testing

  • Added a new test in bevy_time and it seems to work.
  • I'm not very familiar with doing hacky things like using Drop like this and would therefore appreciate careful review and guidance if changes are requested.

Showcase

fn my_cool_system(mut commands: Commands) {
    // fairly unobtrusive one-line delayed spawn
    commands.delayed().secs(0.1).spawn(DummyComponent);

    // the DelayedCommands can be stored to reuse more tersely
    let mut delayed = commands.delayed();
    // allocation happens immediately so you can even queue
    // further operations on entities that aren't spawned yet
    let entity = delayed.secs(0.5).spawn_empty().id();
    delayed.secs(0.7).entity(entity).insert(DummyComponent);

    // `delayed.secs` and `delayed.duration` both simply return a
    // `Commands` rebound to the stored `CommandQueue`, so you can additionally
    // just store that and reuse it to queue multiple commands with the same delay
    let mut in_1_sec = delayed.duration(Duration::from_secs_f32(1.0));
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
}

@kfc35 kfc35 added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events A-Time Involves time keeping and reporting S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Feb 21, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in ECS Feb 21, 2026
@kfc35 kfc35 added D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes D-Straightforward Simple bug fixes and API improvements, docs, test and examples and removed D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes labels Feb 21, 2026
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.

The API is quite nice IMO. I don’t see any issues with the code. Pretty straightforward to understand as well.

I think maybe one thing you can add to your test is that you can still use the same original commands to spawn some things to take effect immediately after using commands.delayed() to spawn some delayed things, just to show that it’s can still be used after doing some delayed shenanigans.

I also am not too familiar with doing weird things with Drop, but I don’t think what you wrote is problematic.

@alice-i-cecile alice-i-cecile added the M-Release-Note Work that should be called out in the blog due to impact label Feb 23, 2026
@github-actions
Copy link
Contributor

It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note.

Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes.

}
}

/// Returns a [`Commands`] that pushes commands to the provided queue instead of the one from `self`.
Copy link
Member

@alice-i-cecile alice-i-cecile Feb 23, 2026

Choose a reason for hiding this comment

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

Fancy. This needs more docs! We need to cover:

  1. Why you might want to do this.
  2. What the semantic meaning of the returned Commands object is.
  3. The fact that you don't seem to need to do anything with the returned Commands object: the magic seems to be done via Drop.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to clarify, there's only Drop magic on the DelayedCommands, the Commands returned by this rebind function should be used to write commands or else there's no point in calling it (the queue will remain empty)

@alice-i-cecile alice-i-cecile added D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes and removed D-Straightforward Simple bug fixes and API improvements, docs, test and examples labels Feb 23, 2026
.in_set(TimeSystems)
.ambiguous_with(message_update_system),
)
.add_systems(PreUpdate, delayed_queues_system)
Copy link
Member

Choose a reason for hiding this comment

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

This will only operate on Time<Virtual> because of the location of this system.

I'm fine with that limitation for an initial PR, but I'm also happy to help advise you on how to lift it (DelayedCommands + DelayedCommandQueue should have a generic, copy the strategy from Time).

If we don't lift that limitation in this PR, we should document it clearly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As we talked about on Discord there's a few complications here that make this quite tricky:

  • DelayedCommands doesn't know what schedule it's running in or what the current default clock (Time<()>) is set to.
  • We'd rather not have commands.delayed take a generic as it can't have a default and would hurt ergonomics.
  • There doesn't seem to be a reasonable way to inspect the current clock that accounts for custom clocks. (e.g. we could have an enum, but that doesn't account for custom clocks).

So unfortunately this looks like it'll have to be followup work. I added a note to document the limitation.

Copy link
Member

@alice-i-cecile alice-i-cecile left a comment

Choose a reason for hiding this comment

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

Incredibly slick: I really like this approach, and I think that the impact to users is high.

That said, I have a few cleanup items before we merge this:

  1. This code is generally very clever (mixed connotations!). We need more docs and comments throughout to make this extremely clear to future readers.
  2. There's a few code organization nits.
  3. Usage examples are mandatory here. Doc tests at least, but a tiny example in the ecs folder would be nice for discoverability.
  4. I think this is important enough that it deserves a release note :)

@alice-i-cecile
Copy link
Member

@Runi-c and I tried to hash out designs for "make this generic over the clock used in Time", but weren't satisfied with any solution.

Conversation

Details

[15:59]Runi:
DelayedCommands + DelayedCommandQueue should have a generic, copy the strategy from Time
I'm down to try implementing this but I'm unsure what the usage should look like. Perhaps make commands.delayed generic using a phantom marker? and default to () like Time?
[16:00]Runi: does this just mean we have to add a separate ticking system for each Time variant?
[16:06]
Alice 🌹

: Yep, DelayedCommands<T = ()>, just like Time
[16:06]
Alice 🌹

: And precisely, one for Virtual, one for Real, and one for Fixed (this one goes in FixedPreUpdate)
[16:07]
Alice 🌹

: I think that you actually probably want to avoid the generic in the delayed method though, because type defaults won't work there so it'll hurt UX
[16:07]
Alice 🌹

: Instead, commands.delayed() always uses (), and then add something like delayed_with_clock::() for unusual cases
[16:08]Runi: ooh, okay, that's a nice idea
[16:08]Runi: thank you! i should be able to get that to work
[16:08]
Alice 🌹

: Mhmm! Thanks for taking this on; it's one of those "things I would really love to have but never have time for" features
[16:28]Runi: i think this might be more tricky than anticipated - the meaning of DelayedCommandQueue<()> depends on the schedule you created it in but we don't have access to that from within Commands
[16:30]
Alice 🌹

: Can we add a hook to the generated component to annotate the correct schedule in some form once it's spawned?
[16:30]
Alice 🌹

: You might need to introduce a CurrentClock enum resource that you update
[16:31]
Alice 🌹

: Splitting it into DelayedCommandQueue + a marker component is probably right to avoid moving it twice
[16:31]Runi: yeah i don't think there's a way to inspect the current schedule, the tracking resource is an idea but it's a little hostile to custom clocks
[16:31]Runi: unless the tracking resource is just also generic i guess
[16:31]Runi: that might work
[16:32]Runi: ...no, i have no idea how that would work
[16:33]Runi: reflection would do it but it's currently optional for bevy_time and should likely be kept that way
[16:34]
Alice 🌹

: You would need trait queries
[16:35]
Alice 🌹

: Actually, what if we do:

pub struct CurrentClock {
active_marker_component: Box<dyn Component + Default>,
}

[16:36]Runi: what would the marker component be?
[16:36]
Alice 🌹

: Clock, Clock, Clock
[16:37]
Alice 🌹

: A little elaborate, but nothing crazy
[16:37]
Alice 🌹

: I think you'll also need to specify the storage type, but eh that's fine
[16:37]Runi: alright, that might work, I'll try it out and see where it leads
[16:51]Runi: oh, that's an early fail
Image
[16:57]
Alice 🌹

: Can you specify the storage type? That used to work
[16:57]
Alice 🌹

: And would be a fine sacrifice here
[16:57]Runi: i don't see a way to - it's not an associated type
[16:57]Runi: unless there's some magic syntax i dont know about here
[16:58]
Alice 🌹

: Ah, it used to be 😅
[16:58]
Alice 🌹

: Okay hmm
[16:58]
Alice 🌹

: I am out of ideas for now lol
[16:58]
Alice 🌹

: Let's just leave a note and make an issue lol
[16:59]Runi: alrighty, that sounds good lol

We should make DelayedCommands play nicely with Fixed time in a follow-up PR to manage complexity and avoid stalling this out.

mut commands: Commands,
) {
for (e, mut queue) in queues {
if queue.timer.tick(time.delta()).just_finished() {
Copy link

Choose a reason for hiding this comment

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

Consider my comment on the Alice's original MR, #20155 (comment)

Copy link
Contributor Author

@Runi-c Runi-c Feb 24, 2026

Choose a reason for hiding this comment

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

Good callout, I switched it over.

I also considered @chescock's suggestion in that thread of using a priority queue index to check only the next queue that should be submitted per frame, but I'm unfamiliar with engine performance work. So long as there's no major performance issues, I'd prefer to save that kind of complexity for when we have a benchmark and real-world usage patterns to work from.

@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Feb 24, 2026
@alice-i-cecile alice-i-cecile moved this from Needs SME Triage to SME Triaged in ECS Feb 24, 2026
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Feb 24, 2026
Merged via the queue into bevyengine:main with commit 1fdf426 Feb 25, 2026
44 checks passed
@github-project-automation github-project-automation bot moved this from SME Triaged to Done in ECS Feb 25, 2026
@laundmo
Copy link
Contributor

laundmo commented Feb 26, 2026

Damn, I really wish my phone dind't break a few days ago, causing me to miss this (apparently, Thunderbird does not deal with subdirectories used as inboxes well). Turns out I really ended up loving the callback-style API in my original snippet - not because its particularily more useful, but because of the very clear conceptual and visual distinction between delayed and non-delayed code. The simple act of having it in a block, to me, feels much less immediately confusing and prone to things like bad naming of the delayed commands than this style.

Not that i dont see the advantages of this style as well. its far easier to delay a few commands interspersed in more complex non-delayed code - but thats also the downside imo.

Ah well, its not a big enough deal to me to make me submit a PR to change it.

@Runi-c
Copy link
Contributor Author

Runi-c commented Feb 26, 2026

@laundmo Darn, I was hoping to get your thoughts too!

For what it's worth, I used your exact snippet in my submission to Bevy Jam 7, which ended up containing a significant number of delayed commands, and it worked great! The main thing that bothered me about the callback style was having to move things into it, meaning any data borrowed from components or resources in the system had to be pre-emptively cloned or copied into the callback to work correctly. By all means manageable, but it was a rough edge I really wanted to figure out how to get rid of by the end there just to cut down on ownership boilerplate.

Runi-c added a commit to Runi-c/bevy that referenced this pull request Feb 28, 2026
…evyengine#23090)

# Objective

- A generalized mechanism for "doing something later" is desirable for
many games, especially when it comes to gameplay logic and VFX.
- Fixes bevyengine#15129
- Closes bevyengine#20155

## Solution

- Build off the work in
bevyengine#20155 (comment),
especially @laundmo's comment.
- Add a `DelayedCommands` helper obtainable via `commands.delayed()`
that owns `CommandQueue`s and hands out new `Commands` bound to them.
- When the `DelayedCommands` helper is dropped, push spawn commands onto
the host `Commands` to spawn the queues as `DelayedCommandQueue`
entities.
- The entities are ticked by a new system added by `TimePlugin`. When
the timer fires, the queue is submitted onto that system's `Commands`.

## Testing

- Added a new test in `bevy_time` and it seems to work.
- I'm not very familiar with doing hacky things like using `Drop` like
this and would therefore appreciate careful review and guidance if
changes are requested.

---

## Showcase

```rust
fn my_cool_system(mut commands: Commands) {
    // fairly unobtrusive one-line delayed spawn
    commands.delayed().secs(0.1).spawn(DummyComponent);

    // the DelayedCommands can be stored to reuse more tersely
    let mut delayed = commands.delayed();
    // allocation happens immediately so you can even queue
    // further operations on entities that aren't spawned yet
    let entity = delayed.secs(0.5).spawn_empty().id();
    delayed.secs(0.7).entity(entity).insert(DummyComponent);

    // `delayed.secs` and `delayed.duration` both simply return a
    // `Commands` rebound to the stored `CommandQueue`, so you can additionally
    // just store that and reuse it to queue multiple commands with the same delay
    let mut in_1_sec = delayed.duration(Duration::from_secs_f32(1.0));
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
}
```

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Runi-c added a commit to Runi-c/bevy that referenced this pull request Mar 4, 2026
…evyengine#23090)

# Objective

- A generalized mechanism for "doing something later" is desirable for
many games, especially when it comes to gameplay logic and VFX.
- Fixes bevyengine#15129
- Closes bevyengine#20155

## Solution

- Build off the work in
bevyengine#20155 (comment),
especially @laundmo's comment.
- Add a `DelayedCommands` helper obtainable via `commands.delayed()`
that owns `CommandQueue`s and hands out new `Commands` bound to them.
- When the `DelayedCommands` helper is dropped, push spawn commands onto
the host `Commands` to spawn the queues as `DelayedCommandQueue`
entities.
- The entities are ticked by a new system added by `TimePlugin`. When
the timer fires, the queue is submitted onto that system's `Commands`.

## Testing

- Added a new test in `bevy_time` and it seems to work.
- I'm not very familiar with doing hacky things like using `Drop` like
this and would therefore appreciate careful review and guidance if
changes are requested.

---

## Showcase

```rust
fn my_cool_system(mut commands: Commands) {
    // fairly unobtrusive one-line delayed spawn
    commands.delayed().secs(0.1).spawn(DummyComponent);

    // the DelayedCommands can be stored to reuse more tersely
    let mut delayed = commands.delayed();
    // allocation happens immediately so you can even queue
    // further operations on entities that aren't spawned yet
    let entity = delayed.secs(0.5).spawn_empty().id();
    delayed.secs(0.7).entity(entity).insert(DummyComponent);

    // `delayed.secs` and `delayed.duration` both simply return a
    // `Commands` rebound to the stored `CommandQueue`, so you can additionally
    // just store that and reuse it to queue multiple commands with the same delay
    let mut in_1_sec = delayed.duration(Duration::from_secs_f32(1.0));
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
    in_1_sec.spawn(DummyComponent);
}
```

---------

Co-authored-by: Alice Cecile <alice.i.cecile@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 A-Time Involves time keeping and reporting C-Feature A new feature, making something new possible D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes M-Release-Note Work that should be called out in the blog due to impact S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add a TimedCommands SystemParam for easier delayed operations

5 participants