Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

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.

@alice-i-cecile alice-i-cecile added this to the 0.18 milestone Jul 16, 2025
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! S-Needs-Help The author needs help finishing this PR. 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
5 participants