Skip to content

Add a DelayedCommands helper to support arbitrary delayed commands#23090

Merged
alice-i-cecile merged 15 commits into
bevyengine: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 into
bevyengine:mainfrom
Runi-c:delayed_commands

Conversation

@Runi-c

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

Copy link
Copy Markdown
Contributor

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

@kfc35 kfc35 left a comment

Copy link
Copy Markdown
Contributor

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
Copy Markdown
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`.

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

Copy link
Copy Markdown
Member

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
Copy Markdown
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
Comment thread crates/bevy_time/src/lib.rs Outdated
Comment thread crates/bevy_time/src/lib.rs Outdated
.in_set(TimeSystems)
.ambiguous_with(message_update_system),
)
.add_systems(PreUpdate, delayed_queues_system)

Copy link
Copy Markdown
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
Copy Markdown
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.

Comment thread crates/bevy_time/src/delayed_commands.rs
Comment thread crates/bevy_time/src/delayed_commands.rs
Comment thread crates/bevy_time/src/lib.rs Outdated
Comment thread crates/bevy_time/src/lib.rs Outdated
Comment thread crates/bevy_time/src/lib.rs Outdated
Comment thread crates/bevy_time/src/delayed_commands.rs
Comment thread crates/bevy_time/src/delayed_commands.rs Outdated

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

Copy link
Copy Markdown
Member

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

Comment thread Cargo.toml Outdated
mut commands: Commands,
) {
for (e, mut queue) in queues {
if queue.timer.tick(time.delta()).just_finished() {

Copy link
Copy Markdown

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)

@Runi-c Runi-c Feb 24, 2026

Copy link
Copy Markdown
Contributor Author

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.

Comment thread examples/ecs/delayed_commands.rs Outdated
@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

laundmo commented Feb 26, 2026

Copy link
Copy Markdown
Member

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

Runi-c commented Feb 26, 2026

Copy link
Copy Markdown
Contributor Author

@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>
@laundmo

laundmo commented Apr 29, 2026

Copy link
Copy Markdown
Member

@alice-i-cecile Recently, you asked on discord about my usecases for delayed commands.
(keep in mind, the code blocks are using my original implementation and therefore differ in syntax)

I use them to despawn entities after a certain delay. For one of my projects some UI entities are spawned on startup to show the user its running, because by default its a transparent, frameless, full-screen overlay window.

commands.delayed(
    Duration::from_secs_f32(conf.show_corner_boxes),
    move |mut c| {
        c.entity(corner_boxes).despawn();
    },
);

In the same project, i use them to switch states after a delay. The overlay reacts to some events to display something for a short time by switching to a state, and at the same time starting the delayed command to switch back. Doing this at the same time makes it easier to prevent getting stuck in a state, because the switch back is always done together with the switching to it. This allows me to use OnEnter/OnExit/DespawnOnExit to implement a state which doesn't have any internal logic for switching back. It could possibly be even nicer to start the delayed command OnEnter of the state, but i currently only have 1 place which switches to it, so i haven't bothered.

commands.set_state(OverlayPhase::Displaying);
commands.delayed(Duration::from_secs_f32(conf.close_layout_after), |mut c| {
    c.set_state(AppState::Waiting)
});

@Runi-c

Runi-c commented Apr 29, 2026

Copy link
Copy Markdown
Contributor Author

Tossing in my use-case as well @alice-i-cecile:
Main thing I used delayed commands for in my Bevy Jam 7 entry was easy animation of effects - it's an ASCII grid based game, so it was very convenient to design effects that play out over a period of time using delayed commands, such as this spell that "radiates out" from the player by delaying proportional to the target tile's distance from the caster:

let mut delayer = commands.delayed();
for &tile in &evt.targeted {
    let dist = (tile - evt.caster_pos).as_vec2().length();
    let mut delayed = delayer.secs(dist * 0.05);
    delayed.spawn((
        CellPos(tile),
        Particle { /* snip */ },
        ParticleLifetime::new(0.05),
        Light { /* snip */ },
    ));
    delayed.trigger(SpellDamage {
        origin: evt.origin,
        source_pos: evt.caster_pos.as_vec2(),
        target: SpellTarget::single(tile),
        amount: 20.0,
    });
}

This sort of "delayed spawn" pattern in particular tends to be kinda messy to do manually in my experience. I quite enjoyed not needing any intermediate timer machinery/boilerplate to do stuff like this, so I used it for almost every spell in the game!

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