Maciej Główka
Blog Games Contact

← Return to Blog Index

action_queue.png
April 1, 2023

Bevy roguelike tutorial / devlog part 3 - Action queue

rust bevy gamedev

In the previous part of this series we have managed to display, move and do some basic animation of the player character. This time we will start thinking about adding some NPC units. However before we do that we have to figure some turn related mechanics first.

Beware though, I believe that's the most tricky aspect of doing turn-based games (at least for me so far). It is much more complex than I initially realised.

The entire code for this instalment is available here: https://github.com/maciekglowka/hike_deck/tree/part_03

At the moment, we can move our player in a continuous manner. If we quickly press W and then D key, the sprite will move directly to the upper-right tile - instead of first going up and only afterwards right (tile by tile). This is not exactly what we want in a turn-based game. What we do want is actions that are executed in a defined order one by one. Also, because we are animating stuff, we want the next action to wait for the previous one to stop (visually).

For example, only one unit should be seen moving at a time. So first we see the player animated, than NPC_1, than NPC_2 and so on. But it can get more complicated. Imagine that we have two actions chained: (1) we attack a NPC unit (2) the NPC dies as a result. Obviously we want to see the death animation after the attack one has finished - not both played simultaneously.

Action objects

One way of approaching this problem is with the help of the Command pattern (read more here) Each individual action in the game will be a separate object (struct instance actually, since it's Rust). It can be constructed beforehand and stored in a queue or another resource (to wait for the animation's end, for instance). The action object will hold all the necessary parameters required for it's execution. All the actions will also provide an execute method, that would allow to apply it to the game world (eg. move a character).

It might sound complex at first, but I will try to show this process in small steps, and hopefully it will get clearer.

Let us start by defining a trait:


pub trait Action: Send + Sync {
    fn execute(&self, world: &mut World) -> bool;
}

As you can see it is very simple and contains only one method :) You might notice that we provide here mutable World access as a parameter. The interface has to be shared by all the possible game actions, so we cannot predict what each individual mechanic would require (plus the queries would get massive). By including the world reference, we will get most flexibility.

There is one downside: we would need an exclusive system to perform our actions. Exclusive systems in Bevy can access the entire world, but cannot be parallelized - so they can hurt the performance. Since it is a turn-based game I am not worried about that though :)

You might also notice, that the trait name is followed by the Send + Sync attribute. It means that every struct that implements our Action trait must also implement Send and Sync . It is called a supertrait in Rust. (you can find more info on that eg. in the RustBook). We need those two extra traits in order to make our action objects thread safe - it is required by Bevy if we want to store them in eg. resources (and we do).

The return type of our execute method is a bool. We will set it to false when the action is invalid and cannot be performed. (which also means we do not plan at the moment to validate the actions beforehand - keep that in mind).

Ok, so let's now see some first concrete example - the walk action:


pub struct WalkAction(pub Entity, pub Vector2Int);
impl Action for WalkAction {
    fn execute(&self, world: &mut World) -> bool {
        let Some(board) = world.get_resource::<CurrentBoard>() else { return false };
        if !board.tiles.contains_key(&self.1) { return false };

        let Some(mut position) = world.get_mut::<Position>(self.0) else { return false };
        position.v = self.1;
        true
    }
}

This action is a tuple-like struct, that contains two fields: an entity (character being moved) and a vector (target location on the board). In other words, when we plan the action (construct the object) we decide which unit will move and where - as simple as that.

In the execute method, at the beginning we see some very basic validation. We simply check if the target position exists as a board tile. If it does, we query for the entities position component and change it's value. That's all.

Actor component

It's time to implement the above to our game! First things first, we want to give certain entities a possibility to perform actions. We'll do it the ECS way - by creating a specialized component for that (I've put it in the pieces module):


#[derive(Component, Default)]
pub struct Actor(pub Option<Box<dyn Action>>);

The component, apart from being a marker, can hold the entity's action (if any is planned). Because our actions will be of different Rust types in the future (WalkAction, HitAction, PickItemAction etc.) we do not specify a concrete field type here. We use something called a trait object - a special Rust construct that allows us to specify only a required trait for the field (instead of a strict type). This way we can store different action types within our component. Since they all must implement the Action trait, later on we will be able to call the execute method on them. The Box part is a smart pointer type - but it's beyond the scope of our little tutorial :) (again it's in the Rust Book).

We need to spawn this component on our player entity.

Action Queue

I expect the action logic to get pretty complex, as we keep developing the game. That's why we are going to keep it bundled in another separate actions module:


// actions/mod.rs
use bevy::prelude::*;
use std::collections::VecDeque;

pub mod models;
mod systems;

pub struct ActionsPlugin;

impl Plugin for ActionsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<ActorQueue>()
            .add_event::<TickEvent>()
            .add_event::<NextActorEvent>()
            .add_event::<ActionsCompleteEvent>()
            .add_event::<InvalidPlayerActionEvent>()
            .add_system(
                systems::process_action_queue.run_if(on_event::<TickEvent>())
            );
    }
}

pub trait Action: Send + Sync {
    fn execute(&self, world: &mut World) -> bool;
}

#[derive(Default, Resource)]
pub struct ActorQueue(pub VecDeque<Entity>);

pub struct TickEvent;
pub struct NextActorEvent;
pub struct ActionsCompleteEvent;
pub struct InvalidPlayerActionEvent;

As you can see, for now, it mostly contains some event definitions. I believe events are a really handy way to control parts of the game flow, mostly because a single event can have many subscribers, which gives us a nice extendable architecture.

For example when a player will attempt to execute an illegal move we are going to emit the InvalidPlayerActionEvent. One system would receive it, roll back the action and switch the game state to listen for another input from the player. That's the primary use. Later on though, when we eg. implement sound effects, we can play a clip to indicate the wrong move, by subscribing to the very same event. No extra code would have to be added to the action queue logic. The action systems can know nothing about the existence of the sound layer.

Perhaps the most interesting event here is the TickEvent. It is going to be our trigger for the game logic. As we said earlier, the game logic in turn-based games often has to wait for eg. animations to finish. Therefore it's update cannot be executed at every single frame. We are going to trigger the logic steps by emitting this event - based on some other things happening in our game (like detecting that an animation has ended).

There is a single resource as well - the ActorQueue which we will populate at the beginning of every turn. It will hold all the entities that are allowed to perform an action (as queried by the Actor component).

You can also see two submodules here. The first one is models.rs which for now contains our WalkingAction definition. In the future we are going to put all our specific action types there.

The systems.rs module contains our queue processing logic inside of a single exclusive system:


// actions/systems.rs
use bevy::prelude::*;

use crate::pieces::components::Actor;
use crate::player::Player;

use super::{ActorQueue, ActionsCompleteEvent, InvalidPlayerActionEvent, NextActorEvent};

pub fn process_action_queue(world: &mut World) {
    let Some(mut queue) = world.get_resource_mut::<ActorQueue>() else { return };
    let Some(entity) = queue.0.pop_front() else {
        world.send_event(ActionsCompleteEvent);
        return;
    };
    let Some(mut actor) = world.get_mut::<Actor>(entity) else { return };
    let Some(action) = actor.0.take() else { return };

    if !action.execute(world) && world.get::<Player>(entity).is_some() {
        world.send_event(InvalidPlayerActionEvent);
        return;
    }
    world.send_event(NextActorEvent);
}

For now, the steps are rather simple here:

Game States

It seems clear now that we are going to have two different states during our gameplay:

Since there are no handy nested states in Bevy now (otherwise I'd nest it under MainState::Game) - I am going to create an independent parallel state:


#[derive(Clone, Debug, Default, Hash, Eq, States, PartialEq)]
pub enum GameState {
    #[default]
    None,
    PlayerInput,
    TurnUpdate
}

Now, we can change our input system to run only during OnUpdate(GameState::PlayerInput

We are also going to change it in a way that it will not directly manipulate player's Position component. We are going to queue an action instead.


// input/mod.rs
use bevy::prelude::*;
use std::collections::VecDeque;

use crate::actions::{
    ActorQueue,
    models::WalkAction
};
use crate::board::components::Position;
use crate::pieces::components::Actor;
use crate::player::Player;
use crate::states::GameState;
use crate::vectors::Vector2Int;


pub struct InputPlugin;

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<PlayerInputReadyEvent>()
            .add_system(player_position.in_set(OnUpdate(GameState::PlayerInput)));
    }
}

pub struct PlayerInputReadyEvent;

const DIR_KEY_MAPPING: [(KeyCode, Vector2Int); 4] = [
    (KeyCode::W, Vector2Int::UP), (KeyCode::S, Vector2Int::DOWN),
    (KeyCode::A, Vector2Int::LEFT), (KeyCode::D, Vector2Int::RIGHT),
];

fn player_position(
    keys: ResMut<Input<KeyCode>>,
    mut player_query: Query<(Entity, &Position, &mut Actor), With<Player>>,
    mut queue: ResMut<ActorQueue>,
    mut ev_input: EventWriter<PlayerInputReadyEvent>
) {
    let Ok((entity, position, mut actor)) = player_query.get_single_mut() else { return };
    for (key, dir) in DIR_KEY_MAPPING {
        if !keys.just_pressed(key) { continue; }
        let action = WalkAction(entity, position.v + dir);
        actor.0 = Some(Box::new(action));
        queue.0 = VecDeque::from([entity]);
        ev_input.send(PlayerInputReadyEvent);
    }
}

After detecting a mapped key press, we crate a new WalkAction instance and inject it into player's Actor component. Then we let everybody else know that input is ready by sending yet another event

Game manager

Now we just have to connect all this events and modules together. Perhaps I am splitting the code into too many plugins - but let's define another one (the last one for now, promise!). I call it the manager since it will be the most top-level logic orchestrating other modules.


// manager/mod.rs
use bevy::prelude::*;

use crate::actions::{TickEvent, ActionsCompleteEvent, InvalidPlayerActionEvent};
use crate::graphics::GraphicsWaitEvent;
use crate::input::PlayerInputReadyEvent;
use crate::states::{GameState, MainState};

pub struct ManagerPlugin;

impl Plugin for ManagerPlugin {
    fn build(&self, app: &mut App) {
        app.add_system(game_start.in_schedule(OnEnter(MainState::Game)))
            .add_system(game_end.in_schedule(OnExit(MainState::Game)))
            .add_system(turn_update_start.run_if(on_event::<PlayerInputReadyEvent>()))
            .add_system(turn_update_end.run_if(on_event::<ActionsCompleteEvent>()))
            .add_system(turn_update_cancel.run_if(on_event::<InvalidPlayerActionEvent>()))
            .add_system(tick.in_set(OnUpdate(GameState::TurnUpdate)));
    }
}

fn game_start(
    mut next_state: ResMut<NextState<GameState>>
) {
    next_state.set(GameState::PlayerInput);
}

fn game_end(
    mut next_state: ResMut<NextState<GameState>>
) {
    next_state.set(GameState::None);
}

fn turn_update_start(
    mut next_state: ResMut<NextState<GameState>>,
    mut ev_tick: EventWriter<TickEvent>
) {
    next_state.set(GameState::TurnUpdate);
    ev_tick.send(TickEvent);
}

fn tick(
    mut ev_wait: EventReader<GraphicsWaitEvent>,
    mut ev_tick: EventWriter<TickEvent>
) {
    if ev_wait.iter().len() == 0 {
        ev_tick.send(TickEvent);
    }
}

fn turn_update_end(
    mut next_state: ResMut<NextState<GameState>>
) {
    next_state.set(GameState::PlayerInput);
}

fn turn_update_cancel(
    mut next_state: ResMut<NextState<GameState>>
) {
    next_state.set(GameState::PlayerInput);
}

What we see mostly here is a bunch of systems that handles state transitions. I like to keep the state logic together - it is easier this way to avoid some quirky behaviour.

We also send the TickEvent from here when appropriate. At first, when the player has selected their input and we enter the TurnUpdate state. Subsequently every time when we do not have to wait for the animation. I have slightly modified the update_piece_position system for that. Whenever it is updating entities' transforms it emits a wait event letting our manager know that the animations are not done yet. (see the code at the bottom).

In the tick system we check at every frame whether any wait event is present (there might be more than one in the future as we will add other animation systems). If there is none, we can safely ask for the logic layer update by emitting the TickEvent.

Finally the modified position update system:


pub fn update_piece_position(
    mut query: Query<(&Position, &mut Transform), With<Piece>>,
    time: Res<Time>,
    mut ev_wait: EventWriter<super::GraphicsWaitEvent>
) {
    let mut animating = false;
    for (position, mut transform) in query.iter_mut() {
        let target = super::get_world_position(&position, PIECE_Z);
        let d = (target - transform.translation).length();
        if d > POSITION_TOLERANCE {
            transform.translation = transform.translation.lerp(
                target,
                PIECE_SPEED * time.delta_seconds()
            );
            animating = true;
        } else {
            transform.translation = target;
        }
    }
    if animating {
        ev_wait.send(super::GraphicsWaitEvent);
    }
}

If we apply all of the above (consult the Github repo if needed) and run our game we won't actually see much of a difference :D There is still only the player sprite there, which we can move via the key presses. Note two differences however: we can't go outside of the map any more (since the WalkAction validates itself) and we cannot execute more than one movement at a single time.

This episode was more about some internal changes, but finally we are ready to add some NPC units in the next part!

← Bevy roguelike tutorial / devlog part 2 - The player Bevy roguelike tutorial / devlog part 4 - NPC units→