TL;DR - check the complete working code here: https://github.com/maciekglowka/bevy_turn_based
If you have ever used Bevy engine you probably already know that the order in which it's systems are executed is somewhat tricky and not exactly deterministic. This might be especially painful in a turn-based game scenario, where game logic updates should follow a predefined sequence.
Additionally, by default, all the systems in Bevy run together at every frame. This is also very unlikely the desired behaviour for the logic handling systems. For example, if the game would include some sort of movement animation, you'd want to wait for the animation to finish before executing the next move. Possibly like so:
Previously, firing a system only once-in-a-while in Bevy required a heavy usage of states
and run conditions
. Which was not always very clean and readable - especially when the code base grew. Fortunately, with the appearance of the one-shot
system mechanics those days are over.
Basically, one-shot
systems are not registered in the Bevy's schedule (via the 'add_systems' method). Instead they can be fired freely by other systems - only when needed.
[ You can learn more about them in the Bevy Cheatbook: https://bevy-cheatbook.github.io/programming/one-shot-systems.html ]
So now, we could have one scheduled animation system running every frame, updating our visuals. The system would loop through all current animations and progress them. Only when the animation list became empty, it would fire the update logic system (which otherwise would not run at all).
Let's see how this could work with some simple code example. First, we need to ensure that in each turn of our game every single unit (actor) moves - once and only once. Therefore at the beginning of each turn we are going to populate an actor queue, which is a standard Bevy resource:
#[derive(Default, Resource)]
struct ActorQueue(VecDeque<Entity>);
Then we can create a populating system that would run only when the turn is starting (the queue also ensures that the player moves first):
fn collect_actor_queue(
query: Query<Entity, (With<Npc>, Without<Player>)>,
player_query: Query<Entity, With<Player>>,
mut queue: ResMut<ActorQueue>,
) {
queue.0 = query.iter().collect();
if let Ok(player) = player_query.get_single() {
queue.0.push_front(player);
}
}
Now we need another system to handle this queue by taking actor entities one by one and executing their actions. Note that if the current actor is a player character, we might not be able to progress yet (as we have to wait for player's input). In such case we simply just return early an wait for another execution of this system.
fn handle_actor_queue(world: &mut World) {
// if the queue is empty, start a new turn by collecting actors again
let Some(&entity) = world.resource::<ActorQueue>().0.front() else {
let _ = world.run_system(world.resource::<QueueSystems>().collect_actor_queue);
return;
};
// check if player has an intent buffered
if let Some(mut player) = world.get_mut::<Player>(entity) {
if let Some(target) = player.0.take() {
if let Some(action) = get_action_at(entity, target, world) {
world.resource_mut::<ActionQueue>().0.push_back(action);
world.resource_mut::<ActorQueue>().0.pop_front();
}
}
return;
}
// otherwise handle npc actor
world.resource_mut::<ActorQueue>().0.pop_front();
if let Some(action) = npcs::get_npc_action(entity, world) {
world.resource_mut::<ActionQueue>().0.push_back(action);
}
}
As you might notice, the unit's actions are not executed immediately here. Rather than that, they are put into yet another queue. This allows us to handle situations where an action would create some chain of events, rather than a single movement.
For example, imagine a combat situation:
An action queue let's us keep track of those secondary actions and run them in a sequence (we do not want to play death animation before the attack is done ;). Each action can push another one to the queue. (so hit
pushes damage
, damage
pushes kill
, kill
is final and pushes nothing).
As you'd expect we need another system to walk through it (again one action at a time):
fn handle_action_queue(world: &mut World) {
if let Some(action) = world.resource_mut::<ActionQueue>().0.pop_front() {
if action.is_valid(world) {
let result = action.execute(world);
if let Some(result) = result {
world.resource_mut::<ActionQueue>().0.push_back(result);
}
}
} else {
// no more actions to execute - progress the actor queue
let _ = world.run_system(world.resource::<QueueSystems>().handle_actor_queue);
}
}
This system actually comes first in our game logic structure and calls handle_actor_queue
only when there are no more current actions to process. Therefore it is also the one initially called by the graphics system - when the animations are idle.
It could be called directly, like the previous ones, but as I like to keep my logic separate from the presentation I decided to use an event trigger this time. So, we do register it within the schedule, with a run condition:
// include in the app / plugin setup
.add_systems(Update, handle_action_queue.run_if(on_event::<GameTick>()));
// fire event from the animation system
pub fn handle_animations(
mut commands: Commands,
mut query: Query<(Entity, &mut Animation, &mut Transform)>,
time: Res<Time>,
mut events: EventWriter<GameTick>,
) {
let mut is_animating = false;
for (entity, mut animation, mut transform) in query.iter_mut() {
// cut: update animations here
}
if !is_animating {
events.send(GameTick);
}
}
So, this is basically it. We didn't use any single state, on_enter
, on_exit
etc. We have used only one simple run conditions (we didn't have to though). I think this approach is much cleaner and a great improvement in Bevy overall. Also, as the systems call directly each other, it is much easier to predict their order of execution.
I didn't discuss here everything necessary to run (for example what the actions are and how they execute). However the complete working code can be found in a Github repo - mentioned at the beginning of the post.