15 min read

Entities, components and multiplayer

Table of Contents

Spawning entities is the first trivial thing that any developer working with Bevy does outside of the "hello world" example. When we insert components, we attach data to spawned entities, and we read/write that data in systems to drive our game logic.

Normally, there's not much to explore on how to add components to entities, but with multiplayer things get can get a bit more complex. How should we replicate common components? How to deal with client-only ones? How can we replicate components more efficiently?

The text bellow contains the answers (allegedly).

Spawning entities in Bevy

If you are a Bevy user, then surely you already know how to spawn entities. But let's put up a simple example anyway, to have something to build upon further.

First, let's imagine what kind of game we are creating and what data we want. Say, we are developing a 2D grid top-down game, and we need to define our player components.

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Health(pub u32);

#[derive(Component)]
struct Position(pub IVec2);

These components should be enough for some very basic gameplay and movement. Now, we want to spawn the player (note that this is our server code):

fn spawn_player(
    trigger: Trigger<OnAdd, Connected>,
    mut commands: Commands,
    client_query: Query<&RemoteId, With<ClientOf>>,
) {
    let Ok(client_id) = client_query.get(trigger.target()) else {
        return;
    };

    commands.spawn((
        // Our game components:
        Player,
        Health(100),
        Position(IVec2::ZERO),
        // Lightyear components to let our players replicate properly:
        Replicate::to_clients(NetworkTarget::All),
        PredictionTarget::to_clients(NetworkTarget::All),
        ControlledBy {
            owner: entity,
            lifetime: Default::default(),
        },
    ));
}

Assuming this is how we want players represented in the server application, where we don't need any visual components (such as meshes and materials), this simple system should be enough.

Replicating entities with lightyear

What's lightyear?

Lightyear is a networking library for multiplayer games powered by Bevy. It supports multiple transport backends, notably UDP and WebTransport, which makes it a great choice for cross-platform games that will also work in Wasm. And most importantly, it provides replication features that make world synchronization much easier.

This blogpost assumes some familiarity with netcode concepts, such as replication. If you find yourself confused by lightyear-specific code, the lightyear book would be a good place to get yourself more familiar with the library.

â„šī¸

Lightyear isn't the only networking library available in the Bevy ecosystem. That's the one I personally have most experience with, but I'd also like to mention bevy_replicon, which is quite a prominent crate as well. Btw, don't hesitate to check out the Bevy Assets list of networking plugins.

Regardless of your networking plugin of choice, the knowledge should be transferrable, so feel free to read on!

Let's replicate đŸĨ°

Replicating in lightyear is as easy as registering components in the game plugin:

// Our marker component can never really be updated,
// so we replicate it only once.
app.register_component::<Player>()
    .add_prediction(PredictionMode::Once);

// In our example, we'll assume only simple prediction for simplicity.
app.register_component::<Health>()
    .add_prediction(PredictionMode::Simple);
app.register_component::<Position>()
    .add_prediction(PredictionMode::Simple);

With this short declaration, we'll make our server broadcast player entities to clients. And that's it for the common (server and client) components!

Non-replicated components

Normal way

To display our players in game, we need to add some client-specific components. When a player gets created due to replication, we want to insert additional components such as 2D mesh and material.

In our extraordinarily sophisticated fiction, a player will be a white circle.

#[cfg(feature = "client")]
fn insert_client_player_components(
    trigger: Trigger<OnAdd, Player>,
    player_query: Query<(), Without<Confirmed>>,
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    // Note that lightyear also spawns a duplicated player entity
    // with the `Confirmed` component, but we don't want to duplicate
    // visual components.
    if !player_query.contains(trigger.target()) {
        return;
    }

    let mesh = meshes.add(Circle::new(10.0));
    let color = materials.add(Color::WHITE));
    commands
        .entity(trigger.target())
        .insert((Mesh2d(mesh), MeshMaterial2d(material)));
}
â„šī¸

Note that we've wrapped the system with #[cfg(feature = "client")]. While it can also be extracted into a separate client crate, the main idea is that we don't want to compile render related stuff into our server binary.

Ok, so that works. But what's good of having simple game logic, when we can imagine more complex scenarios and rationalize ourselves into going the full gang-of-four mode? 😈

✨ Enterprise ✨ flavoured way (with factories)

You might have got worried already, but stay calm, this section will be about a bit weird (justifiably so) but relatively simple factory pattern.

What if we wanted to add some shared components that every player should have, but their contents would be irrelevant for replication?

For the sake of this example, let's imagine we want to have an index of objects nearby. Where a spatial hash solves just that, we also want to cache objects nearby for each player inside a component (for reasons). A client would be able to use this index to optimize rendering, and a server would be able to implement interest management with it, or some kind of proximity chat, where a player would talk to rocks...., trunks.........., or maybe other players.

#[derive(Component, Default)]
struct ObjectsNearby(pub HashSet<Entity>);

Since we want to store entities there, whose internal ids don't match between server and clients, that makes the component unsuitable for replication (needless to say, it can be computed locally without the need to send it over the network ever).

An easy solution would be just to replicate the component once with ComponentReplicationConfig::replicate_once, but we also want to avoid sending extra bytes on spawning players, deriving serialization traits and dealing with all the associated overhead.

Isn't there a simpler way?

Before going the full gang-of-four mode, let's look at the most obvious approach to this problem: surely, we can just insert the component in both functions.

So let's add the component in our server:

fn spawn_player(
    // ...
) {
    // ...
    commands.spawn((
        // Game and lightyear components...,
        ObjectsNearby::default(),
    ));
}

And here we do the same for replicated players on the client side:

fn insert_client_player_components(
    // ...
) {
    // ...
    commands
        .entity(trigger.target())
        .insert((
            // Client-only components...,
            ObjectsNearby::default(),
        ));
}

One would argue, it's already perfect, and I do advise to stick to this approach without complicating things, especially if you have a simple game.

But once your project grows, you may want to avoid repeating yourself, or might eventually get annoyed by the bugs where you'd forget to spawn a component for either a client or a server.

There is already a tool for that: required components

Indeed! 👏

A very succinct solution is to use Bevy's required components:

#[derive(Component)]
#[require(ObjectsNearby)]
struct Player;

#[derive(Component, Default)]
struct ObjectsNearby(pub HashSet<Entity>);

This will make sure that every time the Player component gets added, we also add ObjectsNearby.

Though you might still be getting the same "meh" feeling as me... (Or you might not, which is also fine.)

But what's wrong, doesn't it solve just the problem we brought up? Weeell... Something made me wake up at 1am and rewrite the whole spawning logic in my pet project for the following 2 reasons:

  • There are already 3 sources of truth, where we declare which components we spawn: the server spawn system, the client insert system and the required components
  • Constructing required components is very limited (you can't initialize them based on other data from the game world)

Ok, so tell me about your factories then

So let's start thinking about the factories. This far, we've established that our pattern will have to address the following requirements:

  • Spawn shared components separate of client-only ones
  • Don't bring client-only dependencies into the server binary
  • Have input arguments (even though we haven't made use of that yet, we want to be able to spawn players at different positions for example)

Without thinking of the exact implementation, let's imagine how such trait would look like:

pub trait EntityFactory<'w, 's> {
    type SharedDependencies;
    type ClientDependencies;
    type Input;

    fn insert_shared_components(
        entity_commands: &mut EntityCommands,
        shared_dependencies: &mut Self::SharedDependencies,
        input: &Self::Input,
    );

    #[cfg(feature = "client")]
    fn insert_client_components(
        entity_commands: &mut EntityCommands,
        shared_dependencies: &mut Self::SharedDependencies,
        client_dependencies: &mut Self::ClientDependencies,
        input: &Self::Input,
    );

    fn insert_components(
        entity_commands: &mut EntityCommands,
        shared_dependencies: &mut Self::SharedDependencies,
        _client_dependencies: &mut Self::ClientDependencies,
        input: &Self::Input,
    ) {
        Self::insert_shared_components(entity_commands, shared_dependencies, input);
        #[cfg(feature = "client")]
        Self::insert_client_components(
            entity_commands,
            shared_dependencies,
            _client_dependencies,
            input,
        );
    }
}

You might have noticed lifetimes (😱), we'll need those for our dependencies (system parameters). Speaking of which, what will be our dependencies for constructing a player? Well, no shared ones this time, but we do have client ones.

Just before we get our hands dirty with declaring those, let's have a quick word about system parameters.

Every system argument has to implement the following trait: SystemParam. As you might have guessed/known, this trait is already implemented for resources, queries, Commands and the sorts.

But it can also be derived and used for custom system params, which we will use for our factory:

#[cfg(feature = "client")]
#[derive(SystemParam)]
struct PlayerFactoryClientDependencies<'w, 's> {
    meshes: ResMut<'w, Assets<Mesh>>,
    materials: ResMut<'w, Assets<ColorMaterial>>,
    _p: PhantomData<&'s ()>,
}

Now, we can finally implement our player factory:

// (Clippy will complain when checking the server binary.)
#[cfg_attr(not(feature = "client"), allow(clippy::needless_lifetimes))]
impl<'w, 's> EntityFactory<'w, 's> for PlayerFactory {
    type SharedDependencies = ();

    #[cfg(feature = "client")]
    type ClientDependencies = PlayerFactoryClientDependencies<'w, 's>;
    #[cfg(not(feature = "client"))]
    type ClientDependencies = ();

    type Input = IVec2;

    fn insert_shared_components(
        entity_commands: &mut EntityCommands,
        _shared_dependencies: &mut Self::SharedDependencies,
        input: &Self::Input,
    ) {
        // We don't want to overwrite existing components
        // when spawning them on the client end as other players
        // that have been playing for some time before the client has
        // joined might have non-default values for health,
        // hence the `insert_if_new` usage.
        entity_commands.insert_if_new((
            Player,
            Health(100),
            Position(input),
            ObjectsNearby::default(),
        ));
    }

    #[cfg(feature = "client")]
    fn insert_client_components(
        entity_commands: &mut EntityCommands,
        _shared_dependencies: &mut Self::SharedDependencies,
        client_dependencies: &mut Self::ClientDependencies,
        input: &Self::Input,
    ) {
        let mesh = client_dependencies.meshes.add(Circle::new(10.0));
        let color = client_dependencies.materials.add(Color::WHITE));
        entity_commands
            .insert((Mesh2d(mesh), MeshMaterial2d(material)));
    }
}

And when I declared to my design
Like Frankenstein's monster
"I am your father, I am your god
And you the magic that I conjure"

- A totally appropriate KGLW quote

The last thing left to do is to rewrite our systems for spawning.

fn spawn_player(
    trigger: Trigger<OnAdd, Connected>,
    mut commands: Commands,
    client_query: Query<&RemoteId, With<ClientOf>>,
) {
    let Ok(client_id) = client_query.get(trigger.target()) else {
        return;
    };

    PlayerFactory::insert_components(
        &mut commands.spawn_empty(),
        &mut (),
        &mut (),
        &IVec2::ZERO,
    )
    .insert((
        Replicate::to_clients(NetworkTarget::All),
        PredictionTarget::to_clients(NetworkTarget::All),
        ControlledBy {
            owner: entity,
            lifetime: Default::default(),
        },
    ));
}

You may have noticed that we still spawn lightyear components outside of the player factory. This is just how I prefer doing it in my own project, to keep replication components strictly in the server crate. If you find that it defeats the purpose of the refactor (to ultimately reduce the number of places where we insert components), you can easily extend the factory to support server-only dependencies and components, so consider it an optional exercise for the reader.

Now, let's see what the client system will look like:

#[cfg(feature = "client")]
fn insert_client_player_components(
    trigger: Trigger<OnAdd, Player>,
    player_query: Query<(), Without<Confirmed>>,
    mut commands: Commands,
    mut client_dependencies: PlayerFactoryClientDependencies,
) {
    // Note that lightyear also spawns a duplicated player entity
    // with the `Confirmed` component, but we don't want to duplicate
    // visual components.
    if !player_query.contains(trigger.target()) {
        return;
    }

    PlayerFactory::spawn_components(
        &mut commands.spawn_empty(),
        &mut (),
        &mut client_dependencies,
        &Default::default(),
    );
}

Well, here you have it! With the ingeniously employed factory pattern, our systems have just become ✨ DRY-er ✨ and less error-prone.

One thing that might cause some eye sore is the fact that we need to pass inputs on the client end as well, when the position component already has its actual value from replication. Having to pass a default value might be annoying but arguably harmless (thanks to insert_if_new). On the bright side, you can use the same factory in yet another (third) case: to spawn players in the singleplayer mode, while reusing the same implementation.

Synchronizing heavy components

At this point, we know many ways how to spawn entities and insert components. In this section, we shall explore how to optimize replication of heavy components.

Let's say we want to build a player inventory. If it's a survival game, or an RPG game, inventories can get pretty large, with a lot of items and available inventory slots. Ideally, we don't want to resend the whole inventory with each update, so we need to come up with something more efficient.

#[derive(Clone, PartialEq, Serialize, Deserialize)]
struct InventorySlot {
  // The exact fields are left out, but we can imagine something
  // like item kind, count, and maybe some additional metadata
  // depending on the game, like durability, etc.
}

#[derive(Component, Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
struct Inventory {
    // `None` would represent an empty inventory slot.
    pub slots: Vec<Option<InventorySlot>>,
}

Delta compression

Lightyear provides delta compression as something we can practically use out of the box. The repo contains the following example that showcases how we can delta-compress positions: examples/delta_compression/src/protocol.rs.

So there are two things that we need to do:

  1. Implement the Diffable trait for Inventory.
#[derive(Default, Clone, Serialize, Deserialize)]
struct InventoryDiff {
    // A pair of an item slot index and a containing item itself.
    // The `u16` type was chosen as something that should
    // suffice for the max inventory size of most games.
    inserted: Vec<(u16, InventorySlot)>,
    removed: Vec<u16>,
}

impl Diffable for Inventory {
    type Delta = InventoryDiff;

    fn base_value() -> Self {
        Self::default()
    }

    fn diff(&self, new: &Self) -> Self::Delta {
        let mut delta = InventoryDiff::default();

        for i in 0..self.slots.len().max(new.slots.len()) {
            match (
                self.slots.get(i).cloned().flatten(),
                new.slots.get(i).cloned().flatten(),
            ) {
                // Empty slot in the old inventory,
                // non-empty in the new one.
                (None, Some(slot)) => {
                    delta.inserted.push((i as u16, slot));
                }
                // Non-empty slot in the old inventory,
                // empty in the new one.
                (Some(_), None) => {
                    delta.removed.push(i as u16);
                }
                // Different item slots.
                (Some(old_slot), Some(new_slot))
                    if old_slot != new_slot =>
                {
                    delta.inserted.push((i as u16, new_slot));
                }
                // Equal item slots, do nothing.
                _ => {}
            }
        }

        delta
    }

    fn apply_diff(&mut self, delta: &Self::Delta) {
        for (i, slot) in &delta.inserted {
            self.slots.resize(self.slots.len().max(*i as usize), None);
            self.slots[*i as usize] = Some(slot.clone());
        }
        for i in &delta.removed {
            self.slots.resize(self.slots.len().max(*i as usize), None);
            self.slots[*i as usize] = None;
        }
    }
}
  1. Register the component.
app.register_component::<Inventory>()
    .add_delta_compression()
    .add_prediction(PredictionMode::Simple);

The key thing here is that we mark the component with add_delta_compression as delta-compressed, and we also use the simple prediction mode.

Messages/events/transactions

I thought any of those words worked really. So I guess just pick one based on who you dreamt to become in your childhoold more: postman, astronomer, or a banker. 🤷

Delta compression can be fun and games, but that works ok only until we need to compare really large components.

â„šī¸

As we briefly discussed it with the lightyear developer, @cBournhonesque noted that the delta compression performance is something they'd like to address (here's an open issue).

If the comparison logic gets tricky to implement, or it becomes inefficient in terms of CPU, we can always stick to [your professional word of choice]. In the game code, we always know when an inventory gets a new item, loses it, or items get swapped, so at exactly that moment we can also send an update.

Each of these actions can be defined as follows:

#[derive(Default, Clone, Serialize, Deserialize)]
struct AddItem {
    pub index: u16,
    pub slot: InventorySlot,
}

#[derive(Default, Clone, Serialize, Deserialize)]
struct RemoveItem {
    pub index: u16,
}

#[derive(Default, Clone, Serialize, Deserialize)]
struct SwapItems {
    pub left: u16,
    pub right: u16,
}

These structs describe just the inventory actions, while bearing no info about a player issuing them, a target container (is it a player's inventory, or a chest?), so keep in mind that a real-world implementation might have to be more detailed than that.

The purpose of this example is to simply show how we can save on traffic while broadcasting these atomic inventory transactions, and how we can avoid costly diff operations. Here, I won't delve into sending lightyear messages, but I'll leave some basic references on how to

In practice, communicating player actions and broadcasting them to other peers can easily get much more nuanced. How to integrate the messaging code while keeping implementation of shared gameplay systems relatively sane? How do we manage interest and avoid sending data to peers that shouldn't receive it? And there are possibly even more questions, so this topic might also deserve a separate blog-post in the future.

Wrapping up

As a recap, here we've covered spawning shared and client-only components, replication, code reuse with required components and the factory pattern, and delta compression.

This is my frist ever blogpost about Bevy. I'll try to get new posts out at least once in 1-2 months - we'll see how that goes so no promises yet. But if you want to support me, please use this ✨ Patreon ✨ link, there will be early access to my Bevy blogs and game progress, private Discord channels and more.