#Best practices for calling functions across systems in Bevy

20 messages · Page 1 of 1 (latest)

silent pasture
#

I'm working on a card game using Bevy and I have a question about the best practices for calling one system (or function) from another system. Below is the situation:

The draw function

This function handles the logic for drawing cards from a deck. It updates the deck, the zone of the drawn card, and increments the player's card count.

// This function draws `cards_to_draw` cards for a specific player.
pub fn draw(
    mut deck_pile: &mut ResMut<DeckPileResource>,         // The shared deck state
    mut query: &mut Query<(&mut Zone, &mut Hidden), With<Card>>, // Query for card components
    mut query_number_of_cards_in_hand: &mut Query<&mut NumberOfCardsInHand, With<Player>>, // Query for player hand size
    player_who_drawn: Entity, // The player drawing the card
    cards_to_draw: u8,        // Number of cards to draw
) {
    for _ in 0..cards_to_draw {
        if deck_pile.number_of_cards != 0 {
            // Remove a card from the deck and decrease the count
            let card_drawn = deck_pile.deck_order.pop().unwrap();
            deck_pile.number_of_cards -= 1;

            // Update the drawn card's zone and hidden status
            let (mut zone, mut hidden_status) = query.get_mut(card_drawn).unwrap();
            *zone = Zone::Hand(player_who_drawn);
            *hidden_status = Hidden::ForNotOwners(player_who_drawn);

            // Increment the number of cards in the player's hand
            let mut number_of_cards_in_hand = query_number_of_cards_in_hand.get_mut(player_who_drawn).unwrap();
            number_of_cards_in_hand.0 += 1;

            info!("Card drawn!");
        } else {
            info!("No card in deck!");
        }
    }
}
#

The setup_draw system

This is a higher-level system that determines how many cards each player should draw based on turn order. It calls the draw function for each player sequentially.

fn setup_draw(
    turn_order_resource: Res<TurnOrderResource>, // Keeps track of turn order
    mut next_turn_phase: ResMut<NextState<TurnPhaseState>>, // Updates the turn phase
    mut deck_pile: ResMut<DeckPileResource>,    // The shared deck state
    mut query: Query<(&mut Zone, &mut Hidden), With<Card>>, // Query for card components
    mut query_number_of_cards_in_hand: Query<&mut NumberOfCardsInHand, With<Player>>, // Query for player hand size
) {
    // Iterate through players in turn order
    for (index, player) in turn_order_resource.turn_order.iter().enumerate() {
        // Determine how many cards the current player should draw
        let number_of_cards_to_draw: u8 = match index {
            0 => 4,
            1 | 2 => 5,
            3 | 4 => 6,
            5 | 6 => 7,
            _ => 0,
        };

        // Call the `draw` function to draw cards for the current player
        draw(&mut deck_pile, &mut query, &mut query_number_of_cards_in_hand, *player, number_of_cards_to_draw);
    }

    // Update the turn phase after all players have drawn their cards
    next_turn_phase.set(TurnPhaseState::Recover);
}
#

Why I'm not using events

I want this logic to be sequential. For example:

  • The state should not change until all players have finished drawing their cards.
  • If a card has a "when drawn" effect, subsequent cards should wait until that effect is resolved.
#

Why I'm not using system piping

I can't use system piping (chaining systems with in_state or apply_system_buffers) because after the draw phase, I need to:

  1. Transition the game state to a new phase.
  2. Optionally include additional logic directly after the draw logic (e.g., processing effects, custom transitions, etc.).
#

My Question

As you can see, I'm calling the draw function directly within setup_draw. However, this leads to some concerns about maintainability and idiomatic Bevy design:

  • Is there a cleaner way to reuse this logic (e.g., draw) across multiple systems without causing issues with borrowing or breaking Bevy's ECS design principles?
  • Is directly calling one function from another system acceptable in this context, or is there a better pattern to handle this?

Thanks in advance!

dusty remnant
#

Observers are nice here tbh. @ripe briar probably has more tangible advice though

silent pasture
#

Observers? Are they in the Bevy Cheat Book? I couldn't find them

dusty remnant
#

They're quite new, and I don't think they're covered there yet

#

There's an example at least

#

But for sequential logic with very mild performance constraints they can be really nice 🙂

silent pasture
#

I'll take a look at the examples, thanks!

ripe briar
silent pasture
#

No problem, I’m not in a hurry. Thanks!

ripe briar
# silent pasture ### My Question As you can see, I'm calling the `draw` function directly with...

I think the most simple possible implementation of this would be a one-shot system.

#[derive(Resource)]
pub struct CardSystemLibrary {
    draw_id: SystemId<In<(Entity, u8)>>,
}

impl FromWorld for CardSystemLibrary {
    fn from_world(world: &mut World) -> Self {
        CardSystemLibrary {
            draw_id: world.register_system(draw),
        }
    }
}

First, you can register the ID of your draw system like so. Don't forget to then add the resource with app.init_resource::<CardSystemLibrary>();

From now on, instead of your really lengthy draw(&mut deck_pile, &mut query, &mut query_number_of_cards_in_hand, *player, number_of_cards_to_draw);, you can write instead:

fn setup_draw(
  // SNIP
  system_library: Res<CardSystemLibrary>,
  mut commands: Commands,
) {
    // Iterate through players in turn order
    // SNIP

        // Call the `draw` function to draw cards for the current player
        commands.run_system_with_input(system_library.draw_id, (*player, number_of_cards_to_draw));
    }

    // Update the turn phase after all players have drawn their cards
    next_turn_phase.set(TurnPhaseState::Recover);
}
#

You won't need to pass any arguments beyond the player and number of cards, Bevy will fetch the Queries for you. Like all commands, this will run after your system is over, so next_turn_phase.set(TurnPhaseState::Recover); will execute before your draw system. It doesn't seem consequential here, but it's something to keep in mind.

#

Then, if you have special effects, they can be a chain reaction happening all from inside the draw function. Since you don't need to pass parameters around anymore, you have a lot of freedom as to what to do with this.

#

This is not the most performant way of going about it, but in the context of a card game where you're not looking for parallelism anyways, that does not matter much.

#

Oh, and in your draw function, your two arguments here:

    player_who_drawn: Entity, // The player drawing the card
    cards_to_draw: u8,        // Number of cards to draw

would become this:

    In((player_who_drawn, cards_to_draw)): In<(Entity, u8)>,
#

Let me know if this is not solving your question!

molten hedge
#

This was an excellent primer into one shots for me in any case thank you :D (i am not the op)