Skip to content

Add a timer-powered DelayedCommand for delayed ECS actions#20155

Closed
alice-i-cecile wants to merge 1 commit intobevyengine:mainfrom
alice-i-cecile:delayed-commands
Closed

Add a timer-powered DelayedCommand for delayed ECS actions#20155
alice-i-cecile wants to merge 1 commit intobevyengine:mainfrom
alice-i-cecile:delayed-commands

Conversation

@alice-i-cecile
Copy link
Member

@alice-i-cecile alice-i-cecile commented Jul 15, 2025

Objective

Solution

Create a DelayedCommand command, which wraps another command, moves it into the world as an entity, and then ticks down until it's time to evaluate, despawning itself.

An equivalent DelayedEntityCommand is also provided to allow for easier usage with entity commands.

TODO

  • get to a functioning proof of concept
    • we can't send arbitrary Boxed commands, because Commands::queue requires HandleError
    • HandleError is not dyn compatible, so we cannot simply add it to the box via a helper trait
    • I didn't want to just pass around FnOnce closures, because then you can't use this for commands that store data
    • maybe we can make HandleError dyn compatible?
    • maybe we can somehow just not handle errors at all?
    • we could add a generic instead of boxing, and then split apart the command from the timer? You need the split to be able to tick all these timers in a single system
    • a generic would also want a dedicated queue_delayed API for usability
  • make sure this works for both fixed and virtual time
  • add an EntityCommand variant
  • beef up the docs
  • add this to the Bevy book
  • write draft release notes

@alice-i-cecile alice-i-cecile 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! A-Time Involves time keeping and reporting X-Uncontroversial This work is generally agreed upon S-Needs-Help The author needs help finishing this PR. labels Jul 15, 2025
@alice-i-cecile alice-i-cecile marked this pull request as draft July 15, 2025 23:18
@alice-i-cecile
Copy link
Member Author

alice-i-cecile commented Jul 15, 2025

Having written up a problem description, I'm reasonably confident that the generic is the least fragile and complex option. And also the most likely to work! It is extremely annoying that boxed commands were broken by error handling (or maybe they never worked), but that's not this PR's problem to solve TBH.

Comment on lines +29 to +34
impl Command for DelayedCommand {
/// Spawns a new entity with the [`DelayedCommand`] as a component.
fn apply(self, world: &mut World) {
world.spawn(self);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels weird to me. Why not have users spawn/insert it like other components? We don't impl Command for Observer, for example.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, that's an interesting API idea 🤔

@ItsDoot
Copy link
Contributor

ItsDoot commented Jul 15, 2025

We'll also want to support delayed commands in both Update and FixedUpdate. Currently this seems to only cover Update.

impl Command for DelayedCommand {
/// Spawns a new entity with the [`DelayedCommand`] as a component.
fn apply(self, world: &mut World) {
world.spawn(self);
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a perf cost to this; maybe we could have a pre-existing entities that gets re-used?
(and eventually it would be on a resource when resource-as-components lands)

Copy link
Member Author

Choose a reason for hiding this comment

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

I think I'm alright with the performance cost here. Better inspectability + cancellation + a simpler implementation is worth it IMO.

I don't expect this to be super hot, and users can comfortably add their own abstraction if it is.

) {
let delta = time.delta();
for (entity, mut delayed_command) in delayed_commands.iter_mut() {
delayed_command.delay -= delta;
Copy link

@tacuna tacuna Jul 16, 2025

Choose a reason for hiding this comment

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

In my similar implementation it does not tick down but has the delay as a point in the future (initialized as: timer.elapsed() + duration). So instead of ticking down it just compares

let current_time = time.elapsed();
for (entity, delayed_command) in &delayed_commands {
    if delayed_command.delay <= current_time {
        commands.entity(entity).queue(EvaluateDelayedCommand);
    }
}

upside, you can have the query as none mut and have no math operations. Not sure there are any downsides.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh that's super clever. Good idea!

Copy link
Contributor

Choose a reason for hiding this comment

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

If you make it an immutable component, you could even index it using a priority queue so that you only need to check the time of the next delayed command! Although if we're expecting these commands to frequently be despawned or have their timers changed then you might have to remove them from the queue early.

@chescock
Copy link
Contributor

  • we can't send arbitrary Boxed commands

In case it helps: you can convert an impl Command into a Box<dyn FnOnce> by calling apply in a closure:

fn command_to_boxed_fn(command: impl Command) -> Box<dyn FnOnce(&mut World) + Send + 'static> {
    Box::new(|world| command.apply(world))
}

And Box<dyn FnOnce> is a Command.

It is extremely annoying that boxed commands were broken by error handling (or maybe they never worked)

I think they never worked, and the issue is that Command::apply takes self by value. Rust uses some unstable magic to make Box<dyn FnOnce> work.

The blanket impl<C: Command> HandleError for C means commands that return () already impl HandleError, so error handling shouldn't have changed anything.

@laundmo
Copy link
Contributor

laundmo commented Dec 31, 2025

I've had a look at this, and i don't really like the API it provides. Having to manually instantiate and pass command structs to it is a bit clunky.

Instead, i came up with a draft which allows using the same Commands API people are used to. This is currently working in one of my projects.

The idea stems from the fact that all commands which Commands creates are written to a command queue, so if we make a new Commands with our own queue, and then store it somewhere (a component, in this case), its very easy to later just... append all those stored commands to the current command queue. Theoretically its possible to do this with a chained API as well, like commands.delayed(duration).spawn(), but that makes it basically impossible to delay multiple commands, some of them entity-commands, for the same duration (a feture which the usage of CommandQueue gets us for free).

Usage:

let ent = commands.spawn_empty();
commands.delayed(Duration::from_secs(5), move |mut c|{
    c.entity(ent).despawn();
});

Implementation:

struct DelayedCommands<C: FnOnce(Commands) + Send + Sync + 'static> {
    delay: Duration,
    write_commands: C,
}
trait DelayedCommandsExt {
    fn delayed(
        &mut self,
        delay: Duration,
        write_commands: impl FnOnce(Commands) + Send + Sync + 'static,
    );
}
impl DelayedCommandsExt for Commands<'_, '_> {
    fn delayed(
        &mut self,
        delay: Duration,
        write_commands: impl FnOnce(Commands) + Send + Sync + 'static,
    ) {
        self.queue(DelayedCommands {
            delay,
            write_commands,
        });
    }
}
impl<C: FnOnce(Commands) + Send + Sync + 'static> Command for DelayedCommands<C> {
    fn apply(self, world: &mut World) {
        let mut command_queue = CommandQueue::default();
        (self.write_commands)(Commands::new(&mut command_queue, world));
        world.spawn(DelayedCommandQueue(
            Timer::new(self.delay, TimerMode::Once),
            command_queue,
        ));
        println!("spawned delayed command queue");
    }
}

#[derive(Component)]
struct DelayedCommandQueue(Timer, CommandQueue);

fn command_after(
    cmds: Query<(Entity, &mut DelayedCommandQueue)>,
    time: Res<Time>,
    mut commands: Commands,
) {
    for (e, mut cmd) in cmds {
        if cmd.0.tick(time.delta()).just_finished() {
            commands.append(&mut cmd.1);
            commands.entity(e).despawn();
        }
    }
}

@alice-i-cecile alice-i-cecile added S-Adopt-Me The original PR author has no intent to complete this work. Pick me up! M-Release-Note Work that should be called out in the blog due to impact and removed S-Needs-Help The author needs help finishing this PR. labels Dec 31, 2025
@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.

@laundmo
Copy link
Contributor

laundmo commented Dec 31, 2025

@alice-i-cecile are the changed labels a way to ask/nudge me to make a new PR with my version or...?

@alice-i-cecile
Copy link
Member Author

@alice-i-cecile are the changed labels a way to ask/nudge me to make a new PR with my version or...?

This would be nice! It's also a way to signal that I'm not immediately going to return to this work, so others are welcome to pick it up.

pull bot pushed a commit to psy-repos-rust/bevy that referenced this pull request Feb 25, 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 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-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-Adopt-Me The original PR author has no intent to complete this work. Pick me up! X-Uncontroversial This work is generally agreed upon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a TimedCommands SystemParam for easier delayed operations

6 participants