Adding networked multiplayer to my game with Bevy Replicon

Published: March 19, 2025.

Last weekend, I worked on adding networked multiplayer to a video game that I'm building in my spare time. My game engine of choice, Bevy, has a lively ecosystem so there were options.

I went with bevy_replicon for its high-level API and rock-solid documentation and maintenance. I was so impressed by it that I just had to share my enthusiasm and write a blog post about how cool I think it is.

Entity Component Systems

Bevy is a game engine that uses the Entity Component System (ECS) pattern.

The ECS pattern encourages you to design your game logic around systems that manage entities and their components. That sounds quite abstract, so let's break it down and give some examples.

In the examples, I'll model an object that moves randomly across a grid. First, I'll define its components, then set up a system to spawn it as an entity, and finally create an update system to handle its random movement.

Components

A component is simply a piece of data. That's it. The following Rust code defines two components:

/// This is a component that models a discrete 2D position.
#[derive(Component)]
struct Position {
  pub x: isize,
  pub y: isize,
}

/// This is a *marker component* that does not in fact hold
/// data, but can be used as a marker to distinguish the
/// holder of the component from others.
#[derive(Component)]
struct RandomWalker;

Systems

So what do you do with these components? You use them in systems, where you can instantiate them, and attach them to entities:

/// This is a system that spawns an entity with a Position
/// component and a RandomWalker component.
fn setup_walker(mut commands: Commands) {
  // spawn a new entity, and attach components to it
  commands.spawn((
    // attach a position
    Position { x: 0, y: 0 },
    // attach a RandomWalker marker
    RandomWalker,
  ));
}

You can also add query arguments to systems. This tells Bevy to inject the query into your system, giving you access to the query results at runtime. You can even modify the values in the query, as shown here:

/// This is a system that iterates over ALL entities that
/// have a Position component and are marked as RandomWalker.
fn move_random_walkers(
  mut q_walkers: Query<&mut Position, With<RandomWalker>>
) {
  for mut walker_position in q_walkers.iter_mut() {
    // move the x and y position by a random amount
    walker_position.x += pick_from(vec![-1, 0, 1]);
    walker_position.y += pick_from(vec![-1, 0, 1]);

    info!(
      "walker moved to ({}, {})",
      walker_position.x,
      walker_position.y
    );
  }
}

The implementation of pick_from (that picks a value from the given iterable at random) is left as an exercise for the reader.1

To wire everything up, we must configure the systems to run at the appropriate times:

fn main() {
  App::new()
    // add minimal plugins & logging
    .add_plugins((MinimalPlugins, LogPlugin::default()))
    // schedule `setup_walker` to run once on startup
    .add_systems(Startup, setup_walker)
    // schedule `move_random_walkers` to run every update loop
    .add_systems(Update, move_random_walkers)
    .run();
}

Running this Bevy app prints the following output:

walker moved to (0 ,  0)
walker moved to (-1, -1)
walker moved to (-2, -1)
walker moved to (-2,  0)
walker moved to (-1, -1)
walker moved to (-2, -2)
... and a whole bunch more

As you can see, our setup system successfully initialised the walker, and the other function seems to properly mutate and log the walker's position every loop.

Entities

In the text above, I've also introduced entities. Think of entities as things with their own identity—basically organized collections of components that work together.

It is important to note that you can't attach multiple components of the same type to a single entity—for example, you can't add two Position components to one entity.

Resources and events

Bevy provides two additional core concepts beyond entities, components, and systems: resources and events.

Resources function as "singleton components" not tied to any entity—you can think of them as global data where only one instance of each type can exist at a time.

Events are temporary pieces of data that aren't attached to entities. They're ephemeral, lasting for just one update cycle before disappearing, making them perfect for communicating short-lived information across your game.

Networked multiplayer with Bevy Replicon

We're making good progress! We've learned how to model our game state using Bevy's ECS. But what happens when we need to share this state between players? That's where Bevy Replicon comes in...

Bevy Replicon allows you to replicate state between several Bevy apps over the network. 2 In this setup, you'll run one Bevy app as the authoritative server and separate Bevy apps for each client. The authoritative server can be a dedicated (headless) server, or it can be one of the clients.

Components are only replicated from the server to the clients, and never the other way around.

By default, nothing is replicated. For a component to be replicated to clients, two things must be true:

  1. The component type must be marked as replicated with app.replicate::<MyComponent>().
  2. The component must be attached to an entity that also has the Replicated component.

(I had to read that a few times before it clicked.)

Bevy Replicon also supports a single-player mode, which doesn't do any networking, but emulates the architecture in a single Bevy app so you can easily play a game in offline mode as well.

Client state and commands

The client manages its own state and adds components like 3D meshes and materials to entities that it replicated from the server, giving them visual presence on screen.

When a player takes an action in the game (like clicking the mouse), a trigger is sent to the server so it can properly update the game state in a controlled way.

To be able to use a client trigger, both the server and the client must register it:

app
  .add_client_trigger::<MyClientTrigger>(ChannelKind::Ordered);

Then, it can be used from a client system this:

commands.client_trigger(MyClientTrigger { msg: "Hi" });

And picked from a server system like this:

fn process_trigger(
  trigger: Trigger<FromClient<MyClientTrigger>>,
) {
  info!(
    "{} said '{}'",
    trigger.client_entity,
    trigger.msg,
  );
}

These triggers can also be set up the other way (from the server to the client).

Client-server game state management

In broad strokes, the functionality that Bevy Replicon provides for me can be illustrated as follows:

a diagram that illustrates that entities are replicated from server to client and that triggers can go both ways

With this, I have all I need: I can have my dedicated (or emulated) server manage the shared game state, and have only the necessary data replicated to the client(s).

In my game, when a client connects, the server spawns a character entity and marks it as owned by that client with an OwnedBy(Entity) component that I created. This way, the server can verify that commands for the character come from the right client.

Then—because I tagged the character with the Replicated marker and registered my Position component as a replicated component—the character automatically replicates to all active clients, where they also gain a visual presence on the screen.

Conclusion

Learning all this was quite a journey, but once everything clicked, I was amazed by how much heavy lifting Bevy Replicon handles behind the scenes.

The ECS pattern is simply so flexible and powerful that it allows for state synchronisation solutions like this.

I'm excited to watch Bevy's ecosystem continue to grow, and I'm eager to be part of this journey too.

If you want to stay updated about my progress with this game, you can follow me on Mastodon, Bluesky (I share videos / screenshots there) or subscribe to my web feed (I might write more here).

Footnotes

  1. Ever since working through mathematics text books I've always wanted to write this.
  2. Technically, it doesn't do any I/O without configuring a messaging backend & transport, but it provides the replication logic and a powerful API to be used from your Bevy app.