Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

For a quick tutorial about how to use the crate, click here.

What is lightyear?

Lightyear is a networking library for games written in Bevy. It uses a client-server networking architecture, where the server is authoritative over the game state.

It is heavily inspired by naia.

What is this book about?

This book serves several purposes:

  • It contains some explanations of game networking concepts, as well as how they are implemented in this crate
  • provide some examples of how to use the crate
  • explain some of the design decisions that were made

This book does not aim to be a polished document, or a comprehensive reference for lightyear. It is more of a collection of notes and thoughts that I had while developing the crate; a networking-related wiki that I could reference later.

Tutorial

This section will teach you how to quickly setup networking in your bevy game using lightyear.

You can find many examples in the examples folder.

In this tutorial, we will reproduce the simple box example that showcases how to:

  • setup a basic client/server app
  • replicate an entity from server to clients
  • add prediction and interpolation

General architecture

lightyear is split up into multiple workspace crates under the repository’s crates/ directory, grouped by the networking facet they provide. The main crate lightyear provides an easy way of importing all the other crates and settings up the necessary plugins. In particular it provides 2 plugin groups that set up the various systems needed for multiplayer app: ClientPlugins and ServerPlugins.

There are many different sub-plugins that handle most of the complexities of networking, such as:

  • Sending and receiving of messages
  • Automatic replication of the World from the server to the client
  • Syncing the timelines of the client and the server
  • Handling the inputs from the user

Example code organization

In a basic setup, you will run 2 separate apps: one for the client and one for the server.

The simple_box example has the following structure:

  • main.rs: Creation of the client or server app depending on the passed CLI mode
  • protocol.rs: Defines shared protocol, which is essentially the list of messages, components and inputs that can be sent between the client and server
  • shared.rs: Defines shared behaviour between the client and server. For example,simulation logic like physics/movement should be shared between the client and server to ensure consistency.
  • client.rs: Defines client-specific logic (input-handling, client-prediction, etc)
  • server.rs: Defines server-specific logic (spawning players for newly-connected clients, etc)

Adding the lightyear plugins

You will have to add the ClientPlugins and ServerPlugins to your app, depending on whether you are building a client or a server.

Defining a protocol

After which, you will have to define a protocol for your game. The protocol must be added after the ClientPlugins or ServerPlugins are added to the app. (see here in the example)

This is where you define the “contract” of what is going to be sent across the network between your client and server.

A protocol is composed of:

  • Input: Defines the client’s input type, i.e. the different actions that a user can perform (e.g. move, jump, shoot, etc)
  • Message: Defines the message protocol, i.e. the messages that can be exchanged between the client and server
  • Components: Defines the component protocol, i.e. the list of components that can be replicated between the client and server
  • Channels: Defines channels that are used to send messages between the client and server

A Message is any struct that is Serialize + Deserialize + Clone.

Components

The ComponentRegistry is needed for automatic World replication: automatically replicating entities and components from the server’s World to the client’s World. Only the components that are defined in the ComponentRegistry will be replicated.

The ComponentRegistry is a Resource that will store metadata about which components should be replicated and how. It can also contain additional metadata for each component, such as prediction or interpolation settings. lightyear provides helper functions on the App to register components to the ComponentRegistry.

Let’s define our component protocol:

#![allow(unused)]
fn main() {
/// A component that will identify which player the box belongs to
#[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PlayerId(ClientId);

/// A component that will store the position of the box. We could also directly use the `Transform` component.
#[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq, Reflect, Deref, DerefMut)]
pub struct PlayerPosition(pub Vec2);

/// A component that will store the color of the box, so that each player can have a different color.
#[derive(Component, Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct PlayerColor(pub(crate) Color);

pub struct ProtocolPlugin;

impl Plugin for ProtocolPlugin{
    fn build(&self, app: &mut App) {
        app.component::<PlayerId>().replicate();

        app.component::<PlayerPosition>().replicate();

        app.component::<PlayerColor>().replicate();
    }
}
}

Message

Similarly, the MessageRegistry must contain the list of possible Messages that can be sent over the network. When registering a message, you can specify the direction in which the message should be sent.

Let’s define our message protocol:

#![allow(unused)]
fn main() {
/// We don't really use messages in the example, but here is how you would define them.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Message1(pub usize);

impl Plugin for ProtocolPlugin{
  fn build(&self, app: &mut App) {
    app.register_message::<Message1>()
      .add_direction(NetworkDirection::ServerToClient);
  }
}
}

Inputs

As lightyear handles inputs, user actions that should be sent to the server; you have to define the list of possible inputs (e.g message or component protocols)

Let’s define our inputs:

#![allow(unused)]
fn main() {
/// The different directions that the player can move the box
#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq, Clone)]
pub struct Direction {
    pub(crate) up: bool,
    pub(crate) down: bool,
    pub(crate) left: bool,
    pub(crate) right: bool,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Reflect)]
pub enum Inputs {
    Direction(Direction),
}

impl Default for Inputs {
    fn default() -> Self {
        Self::Direction(Direction::default())
    }
}

// All inputs need to implement the `MapEntities` trait
impl MapEntities for Inputs {
    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {}
}

impl Plugin for ProtocolPlugin{
  fn build(&self, app: &mut App) {
    app.add_plugins(input::native::InputPlugin::<Inputs>::default());
  }
}
}

Channels

We can also define some channels that will be used to send messages between the client and server. This is optional, since lightyear already provides some default channels for inputs and components.

A Channel defines some properties of how messages will be sent over the network:

  • Reliability: Can the messages be lost or do we re-send them until we receive an ACK?
  • Ordering: Do we guarantee that the messages are received in the same order that they were sent?
  • Priority: Which messages to send in priority if we have reached the max bandwidth of the network?
#![allow(unused)]
fn main() {
pub struct Channel1;

pub(crate) struct ProtocolPlugin;

impl Plugin for ProtocolPlugin {
    fn build(&self, app: &mut App) {
        app.add_channel::<Channel1>(ChannelSettings {
          mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
          ..default()
        })
        // this will automatically add the ChannelReceiver/ChannelSender on Client/Server entities        
        .add_direction(NetworkDirection::ServerToClient);
    }
}
}

We create a channel by simply deriving the Channel trait on an empty struct.

Summary

We now have a complete Protocol that defines:

  • Data that can be sent between the client and server (inputs, messages, components)
  • How the data will be sent (channels)

We can now start building our client and server.

Setting up the client and server

The client and server will both be bevy Entities to which you can add components to customize their networking behaviour. Here are some of the common components:

  • Link represents an IO link between a local peer and a remote peer that can be used to send and receive raw bytes
  • Transport adds the capability of setting up various Channels that each provide different reliability/ordering guarantees for a group of bytes
  • MessageManager, MessageSender<M>, MessageReceiver<M> are used to send and receive messages over the network. A message is any rust type that can be serialized/deserialize into raw bytes.
  • ReplicationSender is added to a link entity to enable sending replicated entities and components through that connection, and ReplicationReceiver is added to receive them.

The [Link] component is the primary component that represents a connection between two peers. Every network connection is represented by a link. On the server side, you have a Server component which spawns a new entity with a [Link] component every time a new client connects to it. The LinkOf relationship component is added on these entities to help you identify the [Server] that they are connected to.

The link is agnostic to the actual io layer, you will have to pair it with an actual io component (UdpIo, WebTransportIo, etc.) to start sending and receiving bytes.

Connection

Lightyear makes a distinction between a [Link] and a Connection. A Link is a low-level component that represents a raw IO link, which can be used to send and receive bytes. A Connection is a link that has a long-lived identifiers attached to them. The LocalId and RemoteId components are used to store the PeerId of the local and remote peers, respectively. The PeerId is a unique identifier for a peer in the network, which can be used to identify the peer across multiple connections. (a client could get disconnected and reconnect with a different [Link], but still have the same PeerId).

The lifecycle of a connection is controlled by several sets of components.

You can trigger [Connect] to start the connection, and [Disconnect] to stop it.

The [Disconnected], [Connecting], [Connected] components represent the current state of the connection.

On the server, [Start] and [Stop] components are used to control the server’s listening state. The [Stopped], [Starting], [Started] components represent the current state of the connection.

While a client is disconnected, you can update its configuration (ReplicationSender, MessageManager, etc.), it will be applied on the next connection attempt.

Client

A client is simply an entity with a [Link] to which the [Client] marker component is added. The marker component is used in conjunction with the protocol to customize the behaviour of the link entity. For example if a message is added to the protocol with

app.register_message::<Message1>()
  .add_direction(NetworkDirection::ServerToClient);

then a MessageReceiver<Message1> component will automatically be added to any Client entity.

You can also just add the [MessageReceiver<M>] component directly to the client entity to receive messages of type M from the server.

Here is how you can set up a client in your app:

let auth = Authentication::Manual {
    server_addr: SERVER_ADDR,
    client_id: 0,
    private_key: Key::default(),
    protocol_id: 0,
};
let client = commands
    .spawn((
        Client,
        LocalAddr(CLIENT_ADDR),
        PeerAddr(SERVER_ADDR),
        Link::default(),
        ReplicationReceiver,
        NetcodeClient::new(auth, NetcodeConfig::default())?,
        UdpIo::default(),
    ))
    .id();
commands.trigger_targets(Connect, client);

Let’s walk through this:

  • we add the [Client] marker component to the entity to identify it as a client.
  • we manually specify the [LocalAddr] and [PeerAddr] components to define the local and remote addresses of the link.
  • we add the [Link] component to the entity, which will be used to send and receive raw bytes over the network.
  • we add the [ReplicationReceiver] component to the entity, which will be used to receive replicated entities and components from the server.
  • every [Link] needs to use a connection layer; either Netcode or Steam. Here we will use Netcode. For testing purposes we will use the Manual authentication method, where we have to specify the server address and client ID.
  • finally we add the [UdpIo] component to the entity, which will be used to send and receive UDP packets over the network.

Finally we trigger the [Connect] trigger to start the connection process.

(The examples wrap this setup in ExampleClient/ExampleServer helpers in examples/common, but the components are the same ones you see here.)

Server

Similarly, a server is an entity to which the [Server] marker component is added. Everytime a new io link is established with a remote peer, a new entity will be spawned with the [LinkOf] component that will mark that [Link] as being a child of the endpoint owned by the [Server].

let server = commands
    .spawn((
        NetcodeServer::new(NetcodeConfig::default()),
        LocalAddr(SERVER_ADDR),
        UdpEndpoint::default(),
        Server,
    ))
    .id();
commands.trigger_targets(Start, server);

We need to add NetcodeServer because we need a connection layer. This will automatically insert the [Server] component. By default, it uses the server entity’s [LocalAddr] to validate the private address list in incoming connection tokens. The transport updates [LocalAddr] after binding, so this also picks up an OS-assigned port when binding to port 0. We also need to specify the [LocalAddr] component to define the local address of the server. The IO layer we choose is UDP, so we add the [UdpEndpoint] component to the entity, alongside the [Server] role marker that identifies it as an authoritative server rather than a P2P peer endpoint.

For local development, wildcard and loopback addresses of the same family and port are considered equivalent, such as 0.0.0.0:5000 and 127.0.0.1:5000. Address checking can be disabled for an addressless transport by setting NetcodeConfig::server_addr_check to false.

Finally we trigger the [Start] trigger so that the server can start listening for incoming connections.

Next we will start adding systems to the client and server.

Adding basic functionality

What we want to achieve is this:

  • when a client connects to the server, the server spawns a player entity for that client
  • that entity gets replicated to all clients
  • a client can send inputs to move the player entity that corresponds to them

Replicating an entity

As we saw earlier, the [Server] will spawn a new entity whenever a new client connects to it. That entity will have a [Link] component that represents the connection to the client, as well as the [LinkOf] component that links it to the [Server].

However it is your responsibility to customize that connection with extra components, such as [ReplicationSender], to handle the replication and message sending/receiving. This can be done using observers:

pub(crate) fn handle_new_client(trigger: On<Add, LinkOf>, mut commands: Commands) {
    commands.entity(trigger.entity).insert((
        ReplicationSender,
        Name::from("Client"),
    ));
}

How often replication updates go out is controlled separately, with a ReplicationMetadata resource (for example app.insert_resource(ReplicationMetadata::new(SEND_INTERVAL))).

When the [Link] is established (Linked is added) we are still not connected: we will send a few packets to authenticate the client according to the netcode protocol. Only after the authentication is successful will the [Connected] component be added to the client entity.

When that happens we can start adding game behaviour:

pub(crate) fn handle_connected(
    trigger: On<Add, Connected>,
    query: Query<&RemoteId, With<ClientOf>>,
    mut commands: Commands,
) {
    let Ok(client_id) = query.get(trigger.entity) else {
        return;
    };
    let client_id = client_id.0;
    let entity = commands
        .spawn((
            PlayerBundle::new(client_id, Vec2::ZERO),
            // we replicate the Player entity to all clients that are connected to this server
            Replicate::to_clients(NetworkTarget::All),
        ))
        .id();
    info!(
        "Create player entity {:?} for client {:?}",
        entity, client_id
    );
}

We do this by listening to Connected component being added on the entity. We can access the id of the client that connected by using the RemoteId component, which is added to the entity when the connection is established and contains the client’s [PeerId] (the inverse mapping from PeerId to Entity is stored in NetworkingMetadata::peer_map)

Finally we are free to spawn an entity for that player, that we can replicate using the Replicate component. On that component you need to specify the NetworkTarget to which the entity should be replicated.

That’s it! Now all the clients that match that NetworkTarget will receive the entity and its components that were added to the protocol.

(you can learn more in the replicate page)

There are tons of extra components you can added when replicating an entity to control how the replication works.

Timelines

Ticks are the fundamental unit of time in lightyear, and are used to synchronize the client and server. Ticks are incremented by 1 every time the FixedMain schedule runs. LocalTimeline is the application-global resource that contains the current simulation tick. Each client session has a RemoteTimeline component containing its estimate of the remote peer’s timeline.

On a client, the LocalTimelineSync resource compares the local simulation instant with the selected RemoteTimeline. It can shift the local tick or adjust the simulation speed, and it also stores the input delay used to assign ticks to buffered inputs. Input delay affects the synchronization target, but there is no separate input clock. Systems can use SyncedLocalTimeline when they should not run before synchronization is ready. It dereferences to LocalTimeline and also exposes the current input delay, so the synchronization controller does not need to be fetched separately. Systems that require the presentation cursor only after it is ready can use SyncedInterpolationTimeline. A whole-tick correction emits LocalTimelineShift, which updates local input, prediction, and prespawn histories together.

Handle client inputs

In general it is a good idea (for reasons we will see later) to have a shared function between the client and server that handles the inputs.

If we take our Inputs struct from earlier, it can look like this:

pub(crate) fn shared_movement_behaviour(mut position: Mut<PlayerPosition>, input: &Inputs) {
    const MOVE_SPEED: f32 = 10.0;
    let Inputs::Direction(direction) = input;
    if direction.up {
        position.y += MOVE_SPEED;
    }
    if direction.down {
        position.y -= MOVE_SPEED;
    }
    if direction.left {
        position.x -= MOVE_SPEED;
    }
    if direction.right {
        position.x += MOVE_SPEED;
    }
}

(ActionState<Inputs> derefs to Inputs, so both client and server can pass their &ActionState<Inputs> straight into this function.)

Sending inputs

Then we want to be able to handle inputs from the user. Inputs are stored in a component called ActionState<I>.

Note that the inputs are tick-synced, which means that your input for tick T is guaranteed to be processed by the server on tick T. (this is achieved by storing the inputs in a buffer on the server, and processing them only when the correct tick is reached)

We need a system that reads keypresses/mouse movements and converts them into inputs that you will write into the ActionState<I> component.

pub(crate) fn buffer_input(
    mut query: Query<&mut ActionState<Inputs>, With<InputMarker<Inputs>>>,
    keypress: Res<ButtonInput<KeyCode>>,
) {
    if let Ok(mut action_state) = query.single_mut() {
        let mut direction = Direction {
            up: false,
            down: false,
            left: false,
            right: false,
        };
        if keypress.pressed(KeyCode::KeyW) || keypress.pressed(KeyCode::ArrowUp) {
            direction.up = true;
        }
        if keypress.pressed(KeyCode::KeyS) || keypress.pressed(KeyCode::ArrowDown) {
            direction.down = true;
        }
        if keypress.pressed(KeyCode::KeyA) || keypress.pressed(KeyCode::ArrowLeft) {
            direction.left = true;
        }
        if keypress.pressed(KeyCode::KeyD) || keypress.pressed(KeyCode::ArrowRight) {
            direction.right = true;
        }
        action_state.0 = Inputs::Direction(direction);
    }
}

The InputMarker<I> component is used to identify the entity that the local client is controlling. (other clients might replicate to you an entity with the ActionState<I> component but no InputMarker<I> because it can be useful to have access to their inputs. Since the InputMarker<I> is not present, your inputs won’t modify their ActionState<I> component)

On every tick, you can buffer the input for the local client by updating the ActionState<I> component. This has to be done in the FixedPreUpdate schedule and in the InputSystems::WriteClientInputs system set:

app.add_systems(
    FixedPreUpdate,
    buffer_input.in_set(InputSystems::WriteClientInputs),
);

Receiving inputs

On the server, you can simply read the inputs from the ActionState<I> component, and apply game logic based on them. Remember to run this in the FixedUpdate schedule, as inputs are tick-synced!

As a rule of thumb, any simulation system (physics, etc.) must run in the FixedUpdate Schedule to behave correctly.

fn movement(
    mut position_query: Query<(&mut PlayerPosition, &ActionState<Inputs>)>,
) {
    for (position, inputs) in position_query.iter_mut() {
        shared::shared_movement_behaviour(position, inputs);
    }
}

Displaying entities

Finally we can add a system on both client and server to draw a box to show the player entity.

pub(crate) fn draw_boxes(
    mut gizmos: Gizmos,
    players: Query<(&PlayerPosition, &PlayerColor)>,
) {
    for (position, color) in &players {
        gizmos.rect(
            Vec3::new(position.x, position.y, 0.0),
            Quat::IDENTITY,
            Vec2::ONE * 50.0,
            color.0,
        );
    }
}

Now, running the server and client in parallel should give you:

  • server spawns a cube when client connects
  • client can send inputs to the server to control the cube
  • the movements of the cube in the server world are replicated to the client (and to other clients) !

In the next section, we will see some more advanced replication techniques.

Advanced systems

In this section we will see how we can add client-prediction and entity-interpolation to make the game feel more responsive and smooth.

Client prediction

If we wait for the server to:

  • receive the client input
  • move the player entity
  • replicate the update back to the client

We will have a delay of at least 1 RTT (round-trip-delay) before we see the impact of our inputs on the player entity. This can feel very sluggish/laggy, which is why often games will use client-side prediction. Another issue is that the entity on the client will only be updated whenever we receive a packet from the server. Usually the packet send rate is much lower than one packet per frame, for example it can be on the order of 10 packet per second. If the server’s packet_send_rate is low, the entity will appear to stutter.

The solution is to run the same simulation systems on the client as on the server, but only for the entities that the client predicts. This is “client-prediction”: we move the client-controlled entity immediately according to our user inputs, and then we correct the position when we receive the actual state of the entity from the server. (if there is a mismatch)

To do this in lightyear, you will need to change a few things:

  • enable prediction for the component in your protocol;
  • enable the application’s global prediction pipeline with a PredictionManager resource;
  • mark the entity as predicted, either with PredictionTarget on the send-side or by adding Predicted on the receive-side.

First, in your protocol, mark the component as predicted:

app.component::<PlayerPosition>().replicate()
    .predict();

Then, enable the application’s prediction pipeline. ClientPlugins installs the prediction systems, but the systems only run while the application has a PredictionManager resource. The shared example helpers enable it automatically; otherwise insert the resource during application setup:

app.insert_resource(PredictionManager::default());

Finally, choose which replicated entities should be predicted. If the sender knows which clients should predict the entity, add a PredictionTarget component on the send-side. Usually, the client that ‘controls’ the entity will be predicting it.

let entity = commands
        .spawn((
            Replicate::to_clients(NetworkTarget::All),
            PredictionTarget::to_clients(NetworkTarget::Single(client_id)),
        ))
        .id();

If the receiver decides locally that an entity should be predicted, add Predicted on the receive-side instead:

commands.entity(replicated_entity).insert(Predicted);

Once prediction is enabled for an entity, the receive-side entity gets a Predicted marker. There is only one entity: its live components hold the predicted values, and each predicted component also gets two history buffers on the same entity — ConfirmedHistory<C> (authoritative states received from the server) and PredictionHistory<C> (what the client simulated). In most cases you just query the live component value.

The predicted entity lives a few ticks in the future (at least 1 RTT), enough ticks so that the client inputs for tick N have time to arrive on the server before the server processes tick N.

Whenever the player sends an input, we can apply the inputs instantly to the predicted entity; which is the one that we show to the player. After roughly 1 RTT, we receive the actual state of the entity from the server, which lands in the ConfirmedHistory. If it mismatches what we predicted for that tick, we perform a rollback: we reset the entity to the confirmed state, and re-run all the ticks that happened since the last server update was received. In particular, we will re-apply all the client inputs that were added since the last server update.

Then, on the client, you need to make sure that you also run the same simulation logic as the server, for the Predicted entities. This is very important! The client must be ‘predicting’ what the entity will do even though it doesn’t have perfect information because it doesn’t know the inputs of other players. Most of the time the prediction will be correct, and we successfully erased the lag between the user input and the entity movement. Sometimes the prediction will be wrong, in which case lightyear will trigger a rollback and re-run the simulation since the tick that was wrong.

We will add a new system on the client that also applies the user inputs. It is very similar to the server system, we also listen for the InputEvent event. It also needs to run in the FixedUpdate schedule to work correctly.

On the client:

fn player_movement(
    mut position_query: Query<(&mut PlayerPosition, &ActionState<Inputs>), With<Predicted>>,
) {
    for (position, input) in position_query.iter_mut() {
        shared::shared_movement_behaviour(position, input);
    }
}
app.add_systems(FixedUpdate, player_movement);

Now you can see why it’s a good idea to use shared logic between the client and server for the movement system: by using a shared function, we can ensure that the client and server will run the same logic for the player movement, which is crucial for client-side prediction to work correctly.

Now you can try running the server and client again. The predicted cube should move immediately when you send an input on the client, with no waiting for the server round-trip. (In simple_box the client also renders remote players as interpolated copies, so with two clients you’ll see your own player predicted and the other player interpolated.)

Snapshot interpolation

Client-side prediction works well for entities that the player predicts, but what about entities that are not controlled by the player? There are two solutions to make updates smooth for those entities:

  • predict them as well, but there might be much bigger mis-predictions because we don’t have access to other player’s inputs
  • display those entities slightly behind the ‘Confirmed’ entity, and interpolate between the last two confirmed states

The second approach is called ‘interpolation’, and is the one we will use in this tutorial. You can read this Valve article that explains it pretty well.

To do this, there are again two places to update:

In your protocol, register an interpolation function for the component, which specifies how to interpolate between two states:

pub type LerpFn<C> = fn(start: C, other: C, t: f32) -> C;

If your type implements the Ease trait from bevy, you can also call add_linear_interpolation. This is what we will do here.

#[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq, Deref, DerefMut)]
pub struct PlayerPosition(pub Vec2);

impl Ease for PlayerPosition {
    fn interpolating_curve_unbounded(start: Self, end: Self) -> impl Curve<Self> {
        FunctionCurve::new(Interval::UNIT, move |t| {
            PlayerPosition(Vec2::lerp(start.0, end.0, t))
        })
    }
}

app.component::<PlayerPosition>().replicate()
    .predict()
    .add_linear_interpolation();

(In simple_box the component has both .predict() and .add_linear_interpolation(): the owning client predicts it, everyone else interpolates it.)

Then, when replicating the entity, you can also specify which clients should predict the entity by adding a InterpolationTarget component. Usually, the clients that don’t control an entity will be interpolating it.

let entity = commands
        .spawn((
            Replicate::to_clients(NetworkTarget::All),
            InterpolationTarget::to_clients(NetworkTarget::AllExceptSingle(client_id)),
        ))
        .id();

If interpolation is enabled for an entity, it gets an Interpolated marker. Same deal as prediction: one entity, whose live component holds the interpolated value while a ConfirmedHistory<C> on the same entity buffers the authoritative snapshots. Every frame the live value is re-sampled by blending between the last two confirmed states.

The interpolated entity is sampled a few ticks in the past. We want it to live slightly in the past so that we always have at least 2 confirmed states to interpolate between.

Now if you run a server and two clients, each player should see the other’s player slightly in the past, but with movements that are interpolated smoothly between server updates.

Conclusion

We have now covered the basics of lightyear, and you should be able to build a server-authoritative multiplayer game with client-side prediction and entity interpolation!

Examples

This page lists the examples in the examples folder, roughly easiest first. Run the server with cargo run -- server and a client with cargo run -- client -c 1 (add --headless=false for a GUI).

Easy

  • simple_setup: minimal example, just the client and server plugins and a connection.
  • simple_box: the tutorial example. Client/server prediction and interpolation, plus an optional deterministic input-only P2P mode.
  • bevy_enhanced_inputs: integrating lightyear with the bevy_enhanced_input crate for input handling.

Medium

  • delta_compression: replicate a component by sending only the difference when it changes, instead of the full value.
  • network_visibility: only replicate a subset of entities to each player (interest management with rooms).
  • replication_groups: replicate entities that refer to other entities (a component containing an Entity), with entity mapping so the references stay valid on the client.
  • priority: bandwidth management. Cap the bytes per second on a link and let priorities decide which updates go first.

Advanced

  • avian_2d / avian_3d: replicate an Avian physics simulation (2D and 3D).
  • fps: prespawn bullets directly on the predicted timeline, with lag compensation for collisions between predicted and interpolated entities.
  • auth: how a client gets a ConnectToken from a backend to connect to a server.
  • lobby: change the network topology at runtime; any client can become the host instead of the dedicated server.
  • deterministic_replication: lockstep-style deterministic simulation.

There are also two bigger demos in demos: spaceships and projectiles.

Concepts

There are several layers that enable lightyear to act as a games networking library. Let’s list them from the bottom up (closer to the wire):

  • IO: how do we send raw bytes over the network between two peers? The Link component can be added to an entity to interact with the IO layer. Usually you will directly add the io component itself (WebTransportClientIo, UdpIo, CrossbeamIo, etc.), which will add the Link component.

  • Transport: how do provide reliability/ordering guarantees for the bytes we want to send over the Link? The Transport component can be added to provide Channels. These channels can be used to define the send_frequency, priority, ordering, reliability characteristics for the bytes you want to send.

  • Messages: how do you go from raw bytes to rust types? The MessageManager/MessageSender/MessageReceiver components will be required to serialize/deserialize from rust types into raw bytes that you can send over the Link or Transport. It is also responsible for mapping Entities from the remote World to the local World.

  • Connection: how do we get a persistent connection on top of a link? A Link can be ephemeral, for example if it’s simply an UDPSocket. Sometimes you want a more long-term identifier for the different peers that you are linked to. For example so that when a client disconnects and reconnects you can recognize them as the same client even if their socket port changed. Currently we have two layers that can give you a persistent connection: Netcode or Steam.

  • Replication: how do you replicate components between the remote World and the local World

  • advanced replication: prediction, interpolation, etc.

Transport

The bottom of the stack is the IO layer: getting raw bytes from one peer to another.

The [Link] component is the type-erased struct that will send/receive raw bytes. It holds a send queue and a receive queue of raw payloads, plus the link state (Linking, Linked, Unlinked) and some stats. Lightyear systems only ever talk to the Link; they don’t know or care how the bytes actually travel.

How the bytes travel is decided by the IO component you pair with the Link:

  • UdpIo / UdpEndpoint: plain UDP sockets
  • WebTransportClientIo / WebTransportEndpoint: WebTransport (QUIC)
  • WebSocketClientIo / WebSocketEndpoint: WebSocket
  • CrossbeamIo: in-memory channels, used for tests and host-server mode
  • SteamClientIo / SteamEndpoint: Steam sockets

So a UDP client is Link + UdpIo, a WebTransport client is Link + WebTransportClientIo, and so on. Swapping transports means swapping one component.

An accepting transport component requires Endpoint, which owns the collection of per-peer Link entities through LinkOf { endpoint }. It does not imply the Server role: a P2P peer can also own an endpoint. Trigger LinkStart to open its listener.

Native accepting endpoints are available with either the transport crate’s p2p or server feature. lightyear/p2p forwards p2p to whichever optional transports you enable, so a peer can listen without enabling Lightyear’s server feature. For example, select default-features = false and features = ["std", "p2p", "udp"] to use lightyear_udp::endpoint::{UdpEndpoint, UdpEndpointPlugin} without server plugins. For WebSocket, WebTransport, and Steam, the transport’s p2p feature enables Aeronet’s accepting-side support (called server by Aeronet), not Lightyear’s authority role.

When installing an endpoint transport plugin directly, it also installs the shared endpoint lifecycle support: unlinking an endpoint unlinks and despawns its child links, and each new child inherits the endpoint’s receive conditioner. Client-only builds retain the shared Endpoint, LinkOf, and Aeronet bridge types; accepting transport components require p2p or server.

For an authoritative server, add Server alongside the endpoint component and the appropriate connection component. ServerUdpIo is the UDP shorthand that requires both UdpEndpoint and Server. Configure inherited receive conditioning with Endpoint::new(conditioner) rather than on Server; access the fan-out collection through Endpoint.

Serialization

We use postcard to serialize and deserialize messages. It’s a compact, serde-compatible binary format (a bool takes a single byte, integers use varint encoding).

When sending messages, we start by serializing the message early into a Bytes structure.

This allows us to:

  • know the size of the message right away (which helps with packet fragmentation)
  • cheaply copy the message if we need to send it multiple times (for reliable channels) However:
  • it is much more expensive and inefficient to call serialize on each individual message compared with the final packet, and the serialized bytes compress less efficiently

Buffers

We use a Writer (backed by a reusable BytesMut allocation) to serialize messages, so we don’t allocate from scratch for every message.

When we receive a packet, we wrap the bytes in a Reader (a cursor over the shared Bytes, no copy) and deserialize messages from it in order.

Packet

On top of the transport layer (which lets us send some arbitrary bytes) we have the packet layer.

A packet is a structure that contains some data and some metadata (inside the header).

Packet header

The packet header will contain the same data as described in the Gaffer On Games articles:

  • the packet type (single vs fragmented)
  • the packet id (a wrapping u16)
  • the last ack-ed packet id received by the sender
  • an ack bitfield containing the ack of the last 32 packets before last_ack_packet_id
  • the current tick

Packet data

The data will be a list of Messages that are contained in the packet.

A message is a structure that knows how to serialize/deserialize itself.

This is how we store messages into packets:

  • the message get serialized into raw bytes
  • if the message is over the packet limit size (roughly 1200 bytes), it gets fragmented into multiple parts
  • we build a packet by iterating through the channels in order of priority, and then storing as many messages we can into the packet

Connection

Introduction

Our transport layer only allows us to send/receive raw packets to a remote address. But we want to be able to create a stateful ‘connection’ where we know that two peers are connected.

To establish that connection, that needs to be some machinery that runs on top of the transport layer and takes care of:

  • sending handshake packets to authenticate the connection
  • sending keep-alive packets to check that the connection is still open
  • storing the list of connected remote peers
  • etc.

Connection logic lives in components you add to the link entity, next to the IO component.

Multiple implementations are provided:

  • Netcode (NetcodeClient / NetcodeServer)
  • Steam (SteamClientIo / SteamEndpoint; add Server for the authoritative role)
  • Local (CrossbeamIo, in-memory, for tests and host-server mode)

Netcode

This implementation is based on the netcode.io standard created by Glenn Fiedler (of GafferOnGames fame). It describes a protocol to establish a secure connection between two peers, provided that there is an unordered unreliable transport layer to exchange packets.

For my purpose I am using this Rust implementation of the standard.

You use the Netcode connection by adding the NetcodeClient or NetcodeServer component, coupled with any of the available IO components (UdpIo, WebTransportClientIo, etc.)

To connect to a game server, the client needs to send a ConnectToken to the game server to start the connection process.

There are several ways to obtain a ConnectToken:

  • the client can request a ConnectToken via a secure (e.g. HTTPS) connection from a backend server. The server must use the same protocol_id and private_key as the game servers. The backend server could be a dedicated webserver; or the game server itself, if it has a way to establish secure connection.
  • when testing, it can be convenient for the client to create its own ConnectToken manually. You can use Authentication::Manual for those cases.

Currently lightyear does not provide any functionality to let a game server send a ConnectToken securely to a client. You will have to handle this logic yourself.

Steam

This implementation is based on the Steamworks SDK.

Local

Local connections are used when running in host-server mode: the server and the client are running in the same bevy App. No packets are actually sent over the network since the client and server share the same World.

Multi connection

In lightyear, connections are just entities with IO components, so a server can serve several transports at the same time. This means that the server could:

  • open a port to establish steam socket connections
  • open another port for UDP connections
  • open another port for WebTransport connections
  • etc.

and have all these connections running at the same time.

You can therefore have cross-play between different platforms.

Another potential usage is to have a “HostServer” setup where a client acts as the “host”:

  • the Client and the Server run in the same process (this is the HostClient topology; the simple_box example runs it with Mode::HostClient)
  • the in-process client talks to the server over local (crossbeam) channels
  • other clients can still connect to the same server over UDP, WebTransport, etc.

Reliability

In this layer we add some mechanisms to be able to send and receive messages reliably or in a given order.

It is similar to the reliable layer created by Glenn Fiedler on top of his netcode.io code.

This layer introduces:

  • reliability: make sure a packets is received by the remote peer
  • ordering: make sure packets are received in the same order they were sent
  • channels: allow to send packets on different channels, which can have different reliability and ordering guarantees

PacketHeader

Every packet starts with a small header (see packet). It contains:

  • the packet type (data vs fragment, essentially)
  • the packet id (a wrapping u16)
  • the last ack-ed packet id received by the sender
  • an ack bitfield covering the 32 packets before that id (so 33 acks in total)
  • the current tick

This schema is adopted from the GafferOnGames blogpost.

Channels

Lightyear introduces the concept of a Channel to handle reliability.

A Channel is a way to send packets with specific reliability, ordering and priority guarantees.

You register a channel on the app like so (this must be shared between client and server, so it usually lives in the protocol plugin):

pub struct Channel1;

pub(crate) struct ProtocolPlugin;

impl Plugin for ProtocolPlugin {
    fn build(&self, app: &mut App) {
        app.add_channel::<Channel1>(ChannelSettings {
            mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
            ..default()
        })
        .add_direction(NetworkDirection::ServerToClient);
    }
}

Any Send + Sync + 'static struct can be a channel; there is a blanket Channel impl, so no derive needed.

Mode

The mode field of ChannelSettings defines the reliability/ordering guarantees of the channel.

Reliability:

  • Unreliable: packets are not guaranteed to arrive (UnorderedUnreliable, UnorderedUnreliableWithAcks, SequencedUnreliable)
  • Reliable: packets are guaranteed to arrive. We will resend the packet until we receive an acknowledgement from the remote. You can tune how often we resend via the ReliableSettings field (rtt_resend_factor, rtt_resend_min_delay).

Ordering:

  • Ordered: packets are guaranteed to arrive in the order they were sent (client sends 1,2,3,4,5, server receives 1,2,3,4,5)
  • Unordered: packets are not guaranteed to arrive in the order they were sent (client sends 1,2,3,4,5, server receives 1,3,2,5,4)
  • Sequenced: packets are not guaranteed to arrive in the order they were sent, but we will discard packets that are older than the last received packet (client sends 1,2,3,4,5, server receives 1,3,5 (2 and 4 are discarded))

Direction

The direction (NetworkDirection::ClientToServer, ServerToClient or Bidirectional) can be used to restrict a Channel (or a message) to one way of traffic.

Replication

Protocol

Overview

The Protocol module in this library is responsible for defining the communication protocol used to send messages between the client and server.

Key Concepts

It must be shared between client and server (usually a single ProtocolPlugin added to both apps), so that messages can be serialized and deserialized correctly. And it must be added after the ClientPlugins or ServerPlugins.

A protocol is composed of:

  • Inputs: the client’s input type, i.e. the different actions a user can perform (move, jump, shoot, etc). Input handling is added with one of the input plugins, for example app.add_plugins(input::native::InputPlugin::<Inputs>::default()); (there are equivalents for leafwing inputs and bevy-enhanced-inputs).

  • Messages: the messages exchanged between client and server. Any Send + Sync + 'static type works. You register one with:

    app.register_message::<Message1>()
        .add_direction(NetworkDirection::ServerToClient);

    The direction is only used to automatically add MessageReceiver<M>/MessageSender<M> on your Client/Sender entities, but you can also add these components manually.

  • Components: the components that can be replicated from one World to the other. You register a component with:

    app.component::<PlayerId>()
        .replicate()
        .predict()
        .add_linear_interpolation();

    (You specify additional behaviour per component: prediction, interpolation, correction…)

  • Channels: the delivery guarantees used to send messages. You register one with:

    app.add_channel::<Channel1>(ChannelSettings {
        mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
        ..default()
    })
    .add_direction(NetworkDirection::ServerToClient);

Replication

You add the Replicate component to an entity to replicate it from the local World to the remote World.

commands.spawn((
    PlayerBundle::new(client_id, Vec2::ZERO),
    Replicate::to_clients(NetworkTarget::All),
));

Replicate decides who the entity goes to. There are two sibling components:

  • PredictionTarget controls which clients run client-side prediction for the entity (they get a Predicted copy)
  • InterpolationTarget controls which clients interpolate the entity (they get an Interpolated copy)

By default, every component on the entity that was registered with app.component::<C>().replicate() gets replicated, and every change gets sent. The remote copy always converges to a consistent past state of the local entity: same set of components, same values, just delayed.

A few more pieces you can attach to a replicated entity:

  • ControlledBy so the server can track which client owns the entity (the owning client gets a Controlled marker on its copy, which is how it knows where to put its InputMarker)
  • ReplicateLike / DisableReplicateHierarchy to control whether children of the entity are replicated similarly to the parent
  • Per-component behavior is chosen at registration time instead: replicate_once() for insert-only components, replicate_filtered::<With<RigidBody>>() to only replicate on matching entities, replicate_with_priority(n) for bandwidth management

Adding Replicate also adds the required Replicating marker. You can remove Replicating to pause replication without changing the target. This can be useful when you want to despawn the entity on the server without replicating the despawn. (e.g. an entity can be despawned immediately on the server, but needs to remain alive on the client to play a dying animation). Reinsert Replicating to resume replication.

You can find some of the other usages in the advanced_replication section.

Replicating resources

You can also replicate bevy Resources. This is useful when you want to update a Resource on the server and keep synced copies on the client. In Bevy 0.19, resources are components stored on Bevy’s resource entities, and Lightyear relies on Replicon’s resource replication API for this.

To replicate a Resource:

  • Define your resource and register it with Replicon on both peers:
    #![allow(unused)]
    fn main() {
    use bevy_replicon::prelude::AppRuleExt;
    
    #[derive(Resource, Serialize, Deserialize)]
    pub struct MyResource(pub f32);
    
    pub fn plugin(app: &mut App) {
        app.replicate_resource::<MyResource>();
    }
    }
  • Insert the resource on the server:
    #![allow(unused)]
    fn main() {
    commands.insert_resource(MyResource(1.0));
    }

Replicon also provides replicate_resource_once, replicate_resource_as, and diff-based variants. If a client creates a local copy of the same resource before the server replicates it, use Replicon’s resource-entity mapping support to avoid spawning a duplicate resource entity.

Bevy integration

Lightyear is a set of bevy plugins. There are two plugin groups:

  • ClientPlugins { tick_duration } for apps that act as clients
  • ServerPlugins { tick_duration } for apps that act as servers

(An app can add both; that’s host-server mode. The simple_box example does exactly that with Mode::HostClient.)

On top of those, your game adds:

  • a shared protocol plugin (components, messages, inputs, channels) on both sides, added after ClientPlugins/ServerPlugins
  • client-specific systems (input buffering, predicted movement) — see client
  • server-specific systems (spawning players, authoritative movement) — see server

The pages in this section explain how lightyear hooks into bevy’s schedules (system order) and how client time sync works. Messages are covered in the protocol page.

System order

Lightyear provides several SystemSets that you can use to run your systems in the correct order.

The main things to keep in mind are:

  • All packets are read during the PreUpdate schedule. This is also where replication updates are applied to the local world and where rollback happens.
  • Network interpolation runs in the Update schedule (InterpolationSystems::Prepare, then Interpolate).
  • All packets are sent during the PostUpdate schedule (ReplicationSystems::Send). All messages that were buffered are then sent to the remote, and all replication updates (entity spawn, component updated, etc.) are also sent.
  • There are 2 SystemSets that you will interact with most:
    • InputSystems::WriteClientInputs: this is where you should write your inputs (in the FixedPreUpdate schedule) so that they are buffered and sent to the server correctly
    • plain FixedUpdate: this is where all your simulation systems (physics, movement, etc.) should run, so that they interact correctly with client-side prediction, etc.

Here is a simplified version of the system order:

---
title: Simplified SystemSet order
---
stateDiagram-v2

   PreUpdate --> Update
   Update --> FixedUpdate
   FixedUpdate --> PostUpdate
   state PreUpdate {
      Receive --> Rollback
   }
   state Update {
      PrepareInterpolation --> Interpolate
   }
   state FixedPreUpdate {
      WriteClientInputs --> BufferClientInputs
   }
   state FixedUpdate {
      Main: user simulation
   }
   state PostUpdate {
       Send
       FrameInterpolation
   }

Full system order

---
title: SystemSet order
---
stateDiagram-v2

   PreUpdate --> Update
   Update --> FixedUpdate
   FixedUpdate --> PostUpdate
   state PreUpdate {
      Receive --> ReceiveInputMessages
      ReceiveInputMessages --> Rollback
   }
   state Rollback {
       Check --> RemoveDisable
       RemoveDisable --> Prepare
       Prepare --> RollbackStep
       RollbackStep --> EndRollback
   }
   state Update {
      PrepareInterpolation --> Interpolate
   }
   state FixedPreUpdate {
      WriteClientInputs --> BufferClientInputs
      BufferClientInputs --> SnapToConfirmed
   }
   state FixedUpdate {
      Main: user simulation
   }
   state FixedPostUpdate {
      RestoreInputs --> UpdateHistory
      UpdateHistory --> EntityDespawn
   }
   state PostUpdate {
        Send --> PrepareInputMessage
        PrepareInputMessage --> SendInputMessage
        FrameInterpolation
   }

Client

A client is an entity with the Client marker component, a Link, an IO component, a connection component (NetcodeClient), and a ReplicationReceiver.

The client’s jobs, every frame:

  • buffer local inputs in FixedPreUpdate (InputSystems::WriteClientInputs) so they get sent to the server with the right tick
  • run predicted movement in FixedUpdate, same code as the server
  • receive replicated entities/messages in PreUpdate (ReplicationSystems::Receive) and interpolated snapshots in Update
  • send everything out in PostUpdate (ReplicationSystems::Send)

The time sync page explains how the client keeps its tick aligned with the server’s.

Time sync

Ticks are the shared clock. The server’s tick is authoritative; the client continuously estimates it and steers its own timeline to match.

The pieces:

  • LocalTimeline: a resource with the local simulation tick. It advances once per FixedMain run.
  • RemoteTimeline: a component on the link entity holding the estimated tick of the remote peer, built from packet header ticks plus ping measurements.
  • LocalTimelineSync: the controller. It compares the local instant against the remote estimate and either shifts the tick by whole ticks or speeds/slows the simulation. It also owns the input delay: the number of ticks the client waits before applying its own inputs, so they still arrive at the server on time.
  • SyncedLocalTimeline: a system param for gameplay systems that must not run before sync is ready. It derefs to LocalTimeline and also exposes the input delay. Systems holding this SystemParam will be skipped while the local timeline is not synced to the remote.
  • SyncedInterpolationTimeline: same idea for systems that need the interpolation cursor (the slightly-in-the-past sampling point) to be ready.
  • LocalTimelineShift: an event emitted on whole-tick corrections, so input buffers, prediction history and prespawn state all shift together.

Ping (smoothed RTT with outlier rejection) feeds all of this. You mostly don’t touch these types directly; but when a system behaves oddly at startup (entities frozen for the first second), “timeline not synced yet” is the usual cause, and gating that system on SyncedLocalTimeline is the usual fix.

Server

A server is an entity with the Server role marker, a connection component such as NetcodeServer, and an accepting transport component such as UdpEndpoint. ServerUdpIo is shorthand for UdpEndpoint plus Server.

Server requires Endpoint, which owns the per-peer link collection and optional receive conditioner. A P2P peer can own an Endpoint without being a Server.

Every accepted link has LinkOf { endpoint } pointing at its owning endpoint. In an application that also has P2P endpoints, filter for the Server role before applying server-specific behavior:

pub(crate) fn handle_new_client(
    trigger: On<Add, LinkOf>,
    links: Query<&LinkOf>,
    servers: Query<(), With<Server>>,
    mut commands: Commands,
) {
    let Ok(link_of) = links.get(trigger.entity) else {
        return;
    };
    if !servers.contains(link_of.endpoint) {
        return;
    }
    commands.entity(trigger.entity).insert((
        ReplicationSender,
        Name::from("Client"),
    ));
}

At that point the client is only linked, not connected: netcode authentication still has to succeed. Only when the Connected component is added is the client real, and that’s where game behaviour starts (spawn a player, etc.).

The server’s per-frame jobs mirror the client’s: read inputs and step simulation in FixedUpdate, replicate the world in PostUpdate (ReplicationSystems::Send) at the rate set by the ReplicationMetadata resource.

Advanced Replication

Bandwidth management

By default, lightyear sends everything that’s ready every time the replication timer fires, without any regard for the bandwidth available to the client.

But in some situations you might want to limit the bandwidth used by the client or the server, for example to limit server traffic costs, or because the client’s connection cannot handle a very high bandwidth.

This page will explain how to do that. There are several options to choose from.

Limiting the number of replication objects

The simplest thing you can do is to carefully choose which entities and components you need to replicate. For example, rendering-related components (particles, assets, etc.) do not need to spawned on the server and replicated to the client. They can be created on the client and only the necessary information (position, rotation, etc.) can be replicated.

This also saves CPU costs on the server.

Updating the send interval

Another thing you can do is to update the replication interval. The ReplicationMetadata resource controls how often replication updates go out:

app.insert_resource(ReplicationMetadata::new(SEND_INTERVAL));

A longer interval means the Send systems run less often, which saves both bandwidth and server CPU. The tradeoff is that clients see updates less frequently (which is exactly what prediction and interpolation are for).

You can put a hard cap on how many bytes go through a connection by adding a configured Transport to the link entity:

commands.entity(client_link).insert((
    ReplicationSender,
    // limit to 3KB/s
    Transport::new(PriorityConfig::new(3000)),
));

Once the cap is hit, something has to give. That’s where priorities come in.

Prioritizing entities and components

When there are more updates ready than fit in the budget, lightyear sends the most important ones first and defers the rest. Importance comes from two places:

  • per entity, with the ReplicatePriority component (see the priority example, where the middle row updates less often than the edges):
commands.spawn((
    position,
    ReplicatePriority(priority),
    Replicate::to_clients(NetworkTarget::All),
));
  • per component type, at registration: app.component::<C>().replicate_with_priority(n).

Only the relative values matter: an entity with priority 10 is sent twice as often as one with priority 5. Deferred updates aren’t dropped, they just wait for the next send (unlike unreliable messages, entity updates keep being retried until the remote world is consistent).

Replication Logic

This page explains how replication works and what guarantees can be made.

Replication makes a distinction between:

  • Entity Actions (entity spawn/despawn, component insert/remove): these events change the archetype of an entity
  • Entity Updates (component update): these events don’t change the archetype of an entity but simply update the value of some components. Most (90%+) replication messages should be Entity Updates.

Those two are handled differently by the replication system.

Invariants

There are certain invariants/guarantees that we wish to maintain with replication.

Rule #1: we would like a replicated entity to be in a consistent state compared to what it was on the server: at no point do we want a situation where a given component is on tick T1 but another component of the same entity is on tick T2. The replicated entity should be equal to a version of the remote entity in the past. Similarly, we would not want one component of an entity to be inserted later than other components. This could be disastrous because some other system could depend on both components being present together!

Rule #2: prediction and hierarchies need entities to move in lockstep. Two relevant examples:

  • client prediction: for client-prediction, we want to rollback if a received server-state doesn’t match with the predicted history. If predicted entities were on different ticks, we’d have to roll each one back from a different tick. Much easier if all predicted entities share the same tick.
  • hierarchies: some entities have relationships. For example you could have an entity with a component Head, and an entity Body with a component HasParent(Entity) which points to the Head entity. If we want to replicate this hierarchy, we need to make sure that the Head entity is replicated before the Body entity. (otherwise the Entity pointed to in HasParent would be invalid on the client).

The way lightyear (via Replicon) honors this is by sending actions and updates for an entity together: whenever there are entity actions to send, the pending updates for the same entities go in the same message. That way a lost packet can’t leave you with updates for an entity whose spawn you haven’t seen.

Entity Actions

Entity Actions are replicated reliably and in order.

Send

Whenever there are actions to send, they go out together with the updates for the same entities. This is to guarantee consistency; if they went as 2 separate messages, the packet containing the updates could get lost and we would be in an inconsistent state.

Receive

On the receive side, we buffer the EntityActions that we receive, so that we can read them in order. Updates are only applied once the actions they depend on have been applied.

Entity Updates

Send

We gather all updates since the last time we got an ACK from the receiver that the updates were received.

The reason for this is:

  • we could be gathering all the component changes since the last time we sent actions, but then it could be wasteful if the last time we had any actions was a long time ago and many components got updated since.
  • we could be gathering all the component changes since the last time we sent a message, but then we could have a situation where:
    • we send changes for C1 on tick 1
    • we send changes for C2 on tick 2
    • packet for C1 gets lost, and we apply the C2 changes -> the entity is now in an inconsistent state at C2

Receive

Entity Updates are applied in a sequenced way:

  • we only apply updates if we have already applied the EntityActions they were sent with
  • if we received a more recent update that can be applied, we discard the older one (sequencing)
    • for example if the server sends U2 then U3 and we receive U3 first, we discard U2 because it is older than U3

Input handling

Lightyear handles inputs for you by:

  • buffering the last few inputs on both client and server
  • re-using the inputs from past ticks during rollback
  • sending client inputs to the server with redundancy

Client-side

Input handling runs across several schedules. The InputSystems sets involved, in order:

  • ReceiveInputMessages (in PreUpdate, before rollback): receive input messages from other clients (matters for P2P / predicting remote players)
  • WriteClientInputs (in FixedPreUpdate): this is where you write. Put your input-gathering system here; it updates the local ActionState<I> for the current tick
  • BufferClientInputs (in FixedPreUpdate, right after): lightyear moves the ActionState into the input buffer. During rollback, this set instead loads the historical input back into the ActionState, so your simulation re-runs with the right values
  • PrepareInputMessage / SendInputMessage (in PostUpdate): pack the last few ticks of inputs into a message (with redundancy, so lost packets don’t lose inputs) and send it to the server
  • RestoreInputs (in FixedPostUpdate), CleanUp (in PostUpdate): housekeeping so buffers don’t grow forever

Server-side

On the server the inputs arrive as messages, get buffered per client, and are then served tick-by-tick: when the server simulates tick T, it hands your systems the inputs the client buffered for tick T. That’s the tick-sync guarantee: your input for tick T runs on the server at tick T.

The practical consequence is the same as before: read inputs from the ActionState<I> component, and run the simulation that consumes them in the FixedUpdate schedule.

Interpolation

Introduction

Interpolation means that we will store replicated entities in a buffer, and then interpolate between the last two states to get a smoother movement.

See this excellent explanation from Valve: link or this one from Gabriel Gambetta: link

Implementation

In lightyear, interpolation can be automatically managed for you.

When you spawn the entity on the server, add an InterpolationTarget to say which clients should interpolate it:

commands.spawn((
    Replicate::to_clients(NetworkTarget::All),
    InterpolationTarget::to_clients(NetworkTarget::AllExceptSingle(client_id)),
));

This means that all clients except the one with id client_id will interpolate this entity. There is only one entity on the receiving side: it gets an Interpolated marker, its live components hold the interpolated values, and a ConfirmedHistory<C> on the same entity buffers the authoritative snapshots for every interpolated component. Every frame the live value is re-sampled from that buffer, slightly in the past. (The owning client usually gets a Predicted marker instead; see prediction.)

Which components get interpolated

Not every registered component is interpolated, only the ones you opt in at registration:

app.component::<PlayerPosition>()
    .replicate()
    .add_linear_interpolation();

If your component implements bevy’s Ease trait, add_linear_interpolation just works. For anything else, provide the interpolation function explicitly with add_interpolation_with:

app.component::<MyComponent>()
    .replicate()
    .add_interpolation_with(|start, end, t| {
        // your blending logic here
        start.lerp(end, t)
    });

The function signature is LerpFn<C> = fn(start: C, other: C, t: f32) -> C.

Interpolation delay

Sampling “slightly in the past” is what makes interpolation robust to jitter: there are always two confirmed states to blend between. The per-client delay is tracked with an InterpolationDelay component (the server also uses it as an estimate for lag compensation).

Interpolation runs in the Update schedule (InterpolationSystems::Prepare, then Interpolate), after time sync has run, so the sampling point tracks the synchronized timeline.

Custom interpolation

In some cases, the interpolation logic can be more complex than a simple linear blend per component. For example, you might want to interpolate based on multiple components at once (a cubic spline using position, velocity and acceleration).

In those cases, register with InterpolationFns::history_only (which only maintains the history buffer) and add your own systems in InterpolationSystems::Interpolate, which runs after lightyear has prepared the histories.

Client-side Prediction

Introduction

Client-side prediction means that some entities are on the ‘client’ timeline instead of the ‘server’ timeline: they are updated instantly on the client.

The way it works in lightyear: a replicated entity that the client predicts gets a Predicted marker on the receiving side. There is only one entity. Its live components hold the predicted values, and each predicted component carries two history buffers on the same entity:

  • ConfirmedHistory<C>: authoritative states received from the server
  • PredictionHistory<C>: what the client itself simulated

If you do an action on the client (for example move a character), it applies instantly to the live components. Roughly 1 RTT later the server’s authoritative state for that tick arrives in the ConfirmedHistory, and the two get compared.

Wrong predictions and rollback

Sometimes, the client will predict something, but the server’s version won’t match what the client has predicted. For example the client moves their character by 1 unit, but the server doesn’t move the character because it detects that the character was actually stunned by another player at that time and couldn’t move. (the client could not have predicted this because the ‘stun’ action from the other player hasn’t been replicated yet).

In those cases the client will have to perform a rollback. Let’s say the client entity is now at tick T’, but the client is only receiving the server update for tick T. (T < T’) Every time the client receives an update for tick T, it will:

  • check for each updated component if the confirmed state matches what was predicted for tick T
  • if it doesn’t, it will restore all the components to the confirmed state at tick T
  • then the client will replay all the systems for the entity from tick T to T’

State-based vs input-based rollback

There are two things that can prove a prediction wrong, and they trigger different kinds of rollback:

  • State rollback: a newly received authoritative state doesn’t match the predicted history. This is the case above: the server says “at tick T you were actually here”.
  • Input rollback: a newly received input (from another player) doesn’t match the input that was assumed when simulating. The client often simulates remote players by repeating their last known input; when the real input arrives and differs, everything simulated with the guessed input has to be re-done from the tick the input changed.

How each kind behaves is controlled by the RollbackPolicy on the PredictionManager:

pub struct RollbackPolicy {
    pub state: RollbackMode,
    pub input: RollbackMode,
    pub max_rollback_ticks: u16, // upper bound on how far back we go (default 20)
}

Each mode is one of:

  • Check (the default): compare against history, only rollback on mismatch.
  • Always: rollback on every new state/input without comparing. This skips the check cost entirely (for states it also means no PredictionHistory needs storing). It’s also a good stress test: if your game can’t handle the CPU load of constant rollbacks, you’ll find out fast.
  • Disabled: never rollback for that kind. Disable state rollback if you’re doing deterministic replication (inputs only); disable input rollback if there are no remote inputs to receive.

If both kinds mismatch at once, state takes precedence: we rollback from the state mismatch.

One caveat of Always: your game logic has to handle being rolled back at any time. If it can’t (e.g. it plays a sound or spawns an entity as a side effect every time it runs), constant rollbacks will expose that immediately. That’s the point, but be ready for it.

Which components should I predict?

You want to predict components for entities that will live in the Predicted timeline, i.e. the timeline that will see changes immediately based on client inputs. However it doesn’t mean that every component needs to be actively predicted with .predict() (i.e. with rollbacks enabled). A lot of components can be computed from other components and don’t need to be predicted or even sent through the network. Here is a quick explanation of which components should be predicted.

As a rule of thumb, a component should be predicted with .predict() if it meets the following criteria:

  1. Cannot be calculated using the predicted components available within the current tick.
  2. May be modified after creation.

As an example let’s look at the avian3d::position::Position component provided by the physics simulator avian. It describes the position of a 3D object. Its value is modified at the end of each tick by the avian physics simulator so it meets the second criteria. If a system wants to know the value of a Position component during a given tick, it has no way of calculating that value. Instead, the system will query the Position component whose value was calculated in the previous tick by the avian physics simulator. This means that Position also meets the first criteria and so it should be predicted.

A more subtle example is a system that wants to calculate how quickly the length of a ray cast changes. The system would need to know the length of the ray cast in the previous tick in order to compare it to the length of the ray cast in then current tick. This previous length will have to be stored in a component and that component meets the first criteria as the value it stores cannot be calculated in the current tick. The system would then have to save the ray cast’s current length in that component after the calculation so that it can be used in the next tick. This is a modification of the component and so it meets the second criteria as well and should be predicted. Here’s how the the component and system are defined:

#![allow(unused)]
fn main() {
/// Stores the length of the ray cast from the previous tick.
#[derive(Component)]
struct RayCastPrevLength(f32)

fn calculate_ray_cast_speed(time: Res<Time>, mut query: Query<(&mut RayCastPrevLength, &Position)>) {
  for (mut ray_cast_prev_length, position) in &mut query {
    let curr_ray_cast_length = perform_ray_cast();

    // Perform calculation that relies on information from previous tick.
    let ray_cast_speed = (curr_ray_cast_length - ray_cast_prev_length.0) / time.delta_seconds();

    // Do something with ray cast speed.

    // Save current ray cast length to be used in the next tick.
    ray_cast_prev_length.0 = curr_ray_cast_length;
  }
}
}

If you stored the ray cast speed in a component so that it can be used by other systems then the component does not need to be predicted. It is modified every tick so it meets the second criteria, however, it’s value is calculated using predicted components available in the current tick (RayCastPrevLength) and so it does not meet the first criteria and does not need to be predicted.

Edge cases

Component removal on predicted

Client removes a component on the predicted entity, but the server doesn’t remove it. There should be a rollback and the client should re-add that component on the predicted entity.

Status: added unit test. Need to reconfirm that it works.

Component removal on confirmed

Server removes a component, but the predicted entity still had it. There should be a rollback where the component gets removed from the predicted entity.

Status: added unit test. Need to reconfirm that it works.

Component added on predicted

The client adds a component on the predicted entity, but the server doesn’t add it. There should be a rollback and that component gets removed from the predicted entity.

Status: added unit test. Need to reconfirm that it works.

Component added on confirmed

The server adds a new component. If it was not also added on the predicted entity, there should be a rollback, where the component gets added to the predicted entity.

Status: added unit test. Need to reconfirm that it works.

Prespawned entity gets matched

See prespawning. When the server entity arrives and matches a prespawned entity, the prespawned entity becomes the predicted entity.

Status:

  • the prespawned entity gets matched upon server replication: no unit tests but tested in an example that it works.
  • the prespawned entity gets spawned but the server never sends a match, the prespawned entity should get despawned (timeout cleanup): not handled currently.

Server despawns the entity

The client never despawns a replicated entity on its own; the entity gets despawned only when the server despawns it and the despawn is replicated.

When that happens, the predicted entity gets despawned as well.

Status: no unit tests but tested in an example that it works.

Predicted entity gets despawned

There are several options:

OPTION A: Despawn predicted immediately but leave the possibility to rollback and re-spawn it.

We could despawn the predicted entity immediately on the client timeline. If it turns out that the server doesn’t despawn the entity, we then have to rollback and re-spawn the predicted entity with all its components. We can achieve this by using the trait

pub trait PredictionDespawnCommandsExt {
    fn prediction_despawn(&mut self);
}

that is implemented for EntityCommands. Instead of actually despawning the entity, we will just remove all the synced components, but keep the entity and the components’ histories. If it turns out that the server did not despawn the entity, we can then rollback and re-add all the components for that entity.

The main benefit is that this is very responsive: the entity will get despawned immediately on the client timeline, but respawning it (during rollback) can be jarring. This can be improved somewhat by animations: instead of the entity disappearing it can just start a death animation. If the death is cancelled, we can simply cancel the animation.

Status:

  • predicted despawn, server doesn’t despawn, rollback: no unit tests but tested in an example that it works.
    • TODO: this needs to be improved! See note below.
    • NOTE: the way it works now is not perfect. We rely on getting a rollback (where we can see that the confirmed entity does not match the fact that the predicted entity was despawned). However we only initiate rollbacks on receiving server updates, and it’s possible that we are not receiving any updates because the entity is not changing on the server, or because of packet loss! One option would be that predicted_despawn sends a message Re-Replicate(Entity) to the server, which will answer back by replicating the entity again. Let’s wait to see how big of an issue this is first.
  • predicted despawn, server despawns, we should not rollback but instead despawn the entity when the server despawn gets replicated: no unit tests but tested in an example that it works

OPTION B: wait for the server despawn to be replicated

If we want to avoid the jarring effect of respawning the entity, we can instead wait for the server to confirm the despawn. In that case, we will just wait for the server despawn to arrive. When that despawn is propagated, the client entity will be despawned as well.

Status: no unit tests but tested in example.

There is no jarring effect, but the despawn will be delayed by 1 RTT.

OPTION C: despawn predicted immediately and don’t allow rollback

If you don’t care about rollback and just want to get rid of the Predicted entity, you can just call despawn on it normally.

Status: no unit tests but tested in example.

Prespawned entity gets despawned

Same thing as predicted entity getting despawned, but this time we are despawning the prespawned entity before we even received the server’s confirmation. (this can happen if the entity is spawned and despawned soon after)

Status:

  • prespawned despawn before we have received the server’s replication, server doesn’t despawn, rollback:
    • no unit tests but tested in an example that it works
    • TODO: same problem as with normal predicted entities: only works if we get a rollback, which is not guaranteed
  • prespawned despawn before we have received the server’s replication, server despawns, no rollback:
    • the predicted entity should visually get despawned (all components removed). When the server entity gets replicated and matched, it should initiate a rollback, and see at the end of the rollback that the entity should indeed be despawned.
    • no unit tests but tested in an example that it works

Frame interpolation

Game simulation normally runs in Bevy’s fixed schedules so its rate does not depend on rendering FPS. Render frames and fixed ticks do not line up exactly, however: a rendered frame can contain zero, one, or several fixed ticks. Rendering the latest fixed pose directly therefore looks jittery.

Lightyear’s FrameInterpolationPlugin smooths this by rendering one fixed tick behind the simulation:

lerp(previous_fixed_value, current_fixed_value, overstep_fraction)

This is separate from network interpolation. Network interpolation samples buffered server snapshots on an interpolated timeline. Frame interpolation smooths values between local fixed ticks, including values produced by client prediction.

Setup

First register an interpolation rule. Rules registered for network interpolation are reused:

app.component::<Position>()
    .replicate()
    .predict()
    .add_linear_interpolation();

For a local-only component, register a rule directly:

app.interpolate_with::<MyPosition>(
    InterpolationFns::no_history(|start, end, t| {
        MyPosition(start.0.lerp(end.0, t))
    }),
);

Then add the plugin and opt entities in with FrameInterpolate:

app.add_plugins(FrameInterpolationPlugin);

fn enable_frame_interpolation(
    trigger: On<Add, Predicted>,
    mut commands: Commands,
) {
    commands.entity(trigger.entity).insert(FrameInterpolate);
}

FrameInterpolate is type-erased. It enables every applicable registered component or bundle rule on the entity; component-specific interpolation marker types are not needed. Lightyear automatically inserts the corresponding FrameInterpolationHistory<C> components when the marker and live components are both present.

Use SkipFrameInterpolation for a frame in which a discontinuity such as a teleport should not be interpolated.

System order

Frame interpolation has three system sets:

RunFixedMainLoop:
  FrameInterpolationSystems::Restore

FixedPostUpdate, after fixed simulation:
  FrameInterpolationSystems::Update

PostUpdate, after replication send and before transform propagation:
  FrameInterpolationSystems::Interpolate

Restore

PostUpdate temporarily writes visual values into live components. Before any fixed ticks run on the next rendered frame, Restore copies FrameInterpolationHistory<C>::current_value back to the component. Fixed simulation therefore reads canonical state rather than the previous frame’s rendered value.

The restore runs once per rendered frame before the fixed loop, including frames in which the loop executes no fixed tick.

Update

After each fixed simulation tick, Update shifts the existing current_value to previous_value and records the new canonical live value as current_value. With several fixed ticks in one rendered frame, the history finishes with the last two fixed samples.

History updates are skipped during rollback replay. After replay, prediction repairs frame history from corrected prediction history instead of recording every discarded intermediate replay step.

Interpolate

In PostUpdate, Interpolate samples the previous and current fixed values using Time<Fixed>::overstep_fraction(). It runs after replication sends so a temporary visual value is not replicated, and before Bevy transform propagation so rendering sees the smoothed pose.

Frame interpolation updates the live component through Bevy’s change-detecting mutable access. Downstream systems that filter on Changed<C> therefore observe the interpolated value in the same PostUpdate, provided they run after FrameInterpolationSystems::Interpolate. The visual value itself is not replicated: interpolation runs after replication sends, and the canonical current fixed value is restored before the next render schedule.

Interaction with visual correction

Prediction correction smooths the discontinuity caused by rollback. It uses the same interpolation rules and frame history:

frame interpolation -> visual correction -> transform propagation

Frame interpolation first produces the corrected timeline’s visual sample. RollbackSystems::VisualCorrection then adds and decays the difference from the pre-rollback rendered value. Reversing these systems would let frame interpolation overwrite correction.

Because correction shares this pipeline, registering .add_correction() installs FrameInterpolationPlugin automatically. When rollback stores PreviousVisual<C>, its required components add FrameInterpolate to the entity. Add the marker at spawn time only when continuous between-tick smoothing should begin before the first rollback. If rollback should snap immediately, omit correction.

Avian physics

Avian adds synchronization between physics Position/Rotation and Bevy Transform, so its ordering contract is more specific. See Avian physics for the recommended replication mode, exact rollback/fixed/render order, transform authority, and supported visual-correction combinations.

Tradeoffs

Frame interpolation:

  • smooths rendering across variable render frame times;
  • introduces one fixed tick of visual delay;
  • stores previous and current values for each interpolated component;
  • temporarily writes visual values into live components, making restore ordering essential.

An alternative is to simulate an extra partial tick for rendering. That avoids the one-tick delay but costs an additional simulation and must not commit partial-tick state to the canonical timeline.

Avian physics

Lightyear’s lightyear_avian2d and lightyear_avian3d integrations coordinate four versions of a physics pose:

  • Avian’s simulation state: Position and Rotation.
  • Replicated and predicted state.
  • The temporary visual state produced by frame interpolation and rollback correction.
  • Bevy’s local Transform and derived GlobalTransform used for rendering.

The difficult part is ownership. A value written for rendering in PostUpdate must not become the starting point of the next fixed simulation tick.

Use AvianReplicationMode::Position { sync_to_transform: false } unless the application deliberately treats Transform as gameplay state.

ModeReplicated and predictedFrame interpolation and correctionPhysics authorityRecommendation
Position { sync_to_transform: false }Position, RotationPosition, Rotation, LinearVelocity, AngularVelocityPosition, RotationPreferred
Position { sync_to_transform: true }Position, RotationPosition, Rotation, LinearVelocity, AngularVelocitySynchronizes the physics pose to Transform for fixed-tick authoring, then imports editsUse for transform-driven fixed gameplay with compact physics replication
TransformTransformTransformTransform at the application boundary; Avian still uses Position and Rotation internallyUse for transform-driven applications

FrameInterpolate is type-erased: adding the one marker to an entity enables all applicable registered component or bundle rules, so correction and frame interpolation operate directly on Avian’s canonical Position and Rotation, with LinearVelocity and AngularVelocity corrected alongside them using linear error decay.

Position is not always the right choice. Prefer Transform when:

  • local-space transform hierarchy data is the network API;
  • scale or a non-physics translation axis is gameplay state that must be replicated as part of the same component.

Those are authority and data-model choices, not visual-smoothing requirements. A render hierarchy can still follow a body replicated in Position mode because the integration writes the final visual Position and Rotation to Transform before Bevy propagates transforms.

Setup for Position mode

Register the physics components used for replication and prediction. Interpolation rules are reused by both network interpolation and frame interpolation. Correction also uses these rules to sample the corrected visual pose after rollback.

app.component::<Position>()
    .replicate()
    .predict()
    .add_linear_interpolation()
    .add_correction();

app.component::<Rotation>()
    .replicate()
    .predict()
    .add_linear_interpolation()
    .add_correction();

Install the integration and disable Avian’s overlapping synchronization and interpolation plugins:

app.add_plugins(LightyearAvianPlugin {
    replication_mode: AvianReplicationMode::Position {
        sync_to_transform: false,
    },
    ..default()
});

app.add_plugins(
    PhysicsPlugins::default()
        .build()
        .disable::<PhysicsTransformPlugin>()
        .disable::<PhysicsInterpolationPlugin>(),
);

In this mode, spawn and move bodies through Position and Rotation:

commands.spawn((
    RigidBody::Kinematic,
    Position::from_xy(10.0, 20.0),
    Rotation::default(),
));

By default, automatic synchronization is one-way from Position and Rotation to Transform, and it runs once in PostUpdate after frame interpolation and visual correction. A change made only to Transform is intentionally not copied back into physics. This prevents a stale rendered transform from overwriting state restored by rollback.

This also applies to input-driven systems in FixedUpdate. In the default configuration they should update Position/Rotation, velocity, forces, or other Avian state. A Transform change made there is not imported into physics, and the final Position-to-Transform writeback can overwrite it.

To let fixed-tick gameplay author Transform while retaining compact Position/Rotation replication, enable the optional bridge:

app.add_plugins(LightyearAvianPlugin {
    replication_mode: AvianReplicationMode::Position {
        sync_to_transform: true,
    },
    ..default()
});

When sync_to_transform is true, Lightyear synchronizes the restored canonical Position and Rotation to Transform before FixedUpdate. Gameplay can therefore safely read and update Transform during FixedUpdate. Lightyear imports the authored transform in FixedPostUpdate before Avian physics, then writes the simulated pose back afterward. This also works with frame interpolation: the previous frame’s visual transform is replaced with the canonical pose before gameplay can edit it.

This authority rule applies to rigid-body poses. A child collider without its own RigidBody still uses its local Transform/ColliderTransform to describe its offset from the parent body. Its Position and Rotation are derived world state and should not be replicated independently. Replicate physics poses only for entities with their own rigid body:

app.component::<Position>()
    .replicate_filtered::<With<RigidBody>>()
    .predict()
    .add_linear_interpolation()
    .add_correction();

app.component::<Rotation>()
    .replicate_filtered::<With<RigidBody>>()
    .predict()
    .add_linear_interpolation()
    .add_correction();

This excludes child colliders that do not have a RigidBody. Their pose is computed from the rigid-body root and their fixed local Transform, so sending their derived Position and Rotation would duplicate state and could apply samples from a different visual timeline.

To smooth a predicted entity between fixed ticks from the moment it spawns, add the type-erased marker:

fn add_frame_interpolation(
    trigger: On<Add, Predicted>,
    mut commands: Commands,
) {
    commands.entity(trigger.entity).insert(FrameInterpolate);
}

Registering correction installs FrameInterpolationPlugin automatically. The marker does not name Position or Rotation; Lightyear selects all applicable interpolation rules from the entity’s archetype. Correction also adds the marker automatically when a rollback first stores PreviousVisual<C>, but adding it at spawn time enables continuous between-tick smoothing before the first correction. A higher-priority bundle rule can be registered when translation and rotation must be sampled together.

Position mode system order

The following order assumes a client with prediction, frame interpolation, and correction. Pure servers run the fixed simulation and history/replication work but do not create predicted visual corrections.

PreUpdate: receive and rollback

ReplicationSystems::Receive
  -> RollbackSystems::Check
  -> RollbackSystems::Prepare
  -> RollbackSystems::Rollback
  -> RollbackSystems::EndRollback

When a received authoritative value requires rollback:

  1. Prepare stores the pre-rollback rendered value in PreviousVisual<C> for components registered with correction, then restores the rollback state.
  2. Rollback replays the fixed simulation to the current prediction tick.
  3. Frame-history updates are skipped during replay; recording every intermediate replay tick would replace render history with discarded work.
  4. EndRollback repairs FrameInterpolationHistory<C> from the corrected PredictionHistory<C>, samples the corrected visual value with the registered interpolation rule, and creates VisualCorrection from the old visual pose to that corrected sample.
  5. The live component is restored to the corrected canonical value before PreUpdate ends.

RunFixedMainLoop: restore before simulation

FrameInterpolationSystems::Restore
  -> optional Position/Rotation to Transform
  -> optional Transform propagation
  -> zero or more FixedMain iterations

Restore copies each frame history’s current_value back into the live Position and Rotation. The previous frame’s interpolated or visually corrected pose is only a render value and must not enter fixed simulation.

With Position { sync_to_transform: false }, no transform synchronization runs here. With Position { sync_to_transform: true }, the restored canonical physics pose is copied to Transform and propagated before FixedUpdate. This is essential because the transform left by the previous PostUpdate is visual rather than canonical.

RunFixedMainLoop runs once per rendered frame even when it executes zero fixed ticks. The restore therefore also happens on render frames with no fixed simulation. When several fixed ticks are needed, canonical state is restored once before the first and then advanced normally by each tick.

FixedPostUpdate: simulate, then record

For every fixed tick:

optional Transform propagation and Transform to Position/Rotation
  -> PhysicsSystems::StepSimulation
  -> optional Position/Rotation to Transform
  -> { PredictionSystems::UpdateHistory
       FrameInterpolationSystems::Update }

The two history updates both observe the completed physics step. Prediction history stores canonical state for future rollback. Frame history shifts its old current sample to previous_value and records the new canonical value as current_value.

The optional transform-authoring bridge runs its import in PhysicsSystems::Prepare and its writeback in PhysicsSystems::Writeback. It deliberately gives the transform authored during FixedUpdate precedence. Avian’s ordinary conflict resolution cannot be used for this import because frame interpolation correctly marks Position changed after the previous physics tick; Avian would otherwise interpret that visual change as newer physics authority and reject the transform edit.

If several fixed ticks run in one rendered frame, frame history ends with the last two fixed poses. If no fixed tick runs, it remains unchanged.

PostUpdate: construct the rendered pose

ReplicationSystems::Send
  -> FrameInterpolationSystems::Interpolate
  -> RollbackSystems::VisualCorrection
  -> PhysicsSystems::Writeback
  -> TransformSystems::Propagate
  1. Replication sends canonical component state before visual systems temporarily change it.
  2. Frame interpolation writes Position and Rotation between the previous and current fixed samples using Time<Fixed>::overstep_fraction().
  3. Visual correction adds and decays the remaining rollback error on top of that interpolated pose. It runs second so frame interpolation cannot overwrite the correction.
  4. Frame interpolation updates Bevy change detection for Position and Rotation, so Avian’s ordinary writeback observes the final visual physics pose and copies it to Transform. The set ordering makes this work on render frames that contain no fixed tick as well.
  5. Bevy transform propagation updates GlobalTransform and render children.

The live physics components contain visual values after PostUpdate. That is intentional and lasts only until FrameInterpolationSystems::Restore at the start of the next RunFixedMainLoop.

Visual correction setup

There are two related pieces:

  • FrameInterpolationPlugin installs the restore, history-update, and visual-interpolation systems. Correction registration installs it automatically.
  • FrameInterpolate opts an entity into the applicable registered rules and causes its frame-history components to be inserted. PreviousVisual<C> requires this marker, so rollback correction adds it automatically.

Visual correction is built on that same rule, history, and restore pipeline. Calling .add_correction() is therefore sufficient to install the infrastructure needed for correction. Add FrameInterpolate earlier only when the entity should also be smoothed continuously between fixed ticks before its first rollback.

Desired behaviorRegistration and components
Immediate rollback snap; no between-tick smoothingPredict the components, but omit .add_correction() and FrameInterpolate
Between-tick smoothing onlyAdd an interpolation rule, FrameInterpolationPlugin, and FrameInterpolate; omit correction
Rollback correction, enabling frame interpolation on first rollbackAdd an interpolation rule and correction
Between-tick smoothing from spawn plus rollback correctionAdd an interpolation rule and correction, then add FrameInterpolate at spawn

Position mode itself works without frame interpolation or correction. Correction still uses frame history to preserve canonical simulation state while a corrected visual value is rendered, but its registration now installs that machinery automatically.

Transform mode

This mode treats Transform as the replicated application state but synchronizes it into Avian before physics and back out afterward.

FixedPostUpdate:
  propagate Transform -> Transform to Position/Rotation
  -> physics -> Position/Rotation to Transform
  -> prediction history + frame history for Transform

PostUpdate:
  frame-interpolate Transform -> correct Transform -> propagate

Use it only when application systems are intentionally transform-driven. It sends and stores more state than the physics pose and makes local hierarchy semantics part of the replication contract.

Manual synchronization

Setting LightyearAvianPlugin::update_syncs_manually disables the integration’s automatic Position/Rotation/Transform synchronization, including the optional fixed-tick bridge requested by Position { sync_to_transform: true }. The mode still configures history and visual-system ordering and still performs other integration work, such as child-collider position updates.

When synchronization is manual, preserve the same invariant: fixed simulation must read canonical state, replication must send canonical state, and render-only interpolation or correction must be applied after sending and before transform propagation.

Prespawning

Introduction

There are two ways to get a predicted entity on the client:

  • normal (“delayed”) predicted entities: they are spawned on the server and then replicated to the client. The client marks the received entity Predicted and starts simulating it ahead of the server.
  • prespawned entities: the entity is created on the client (in the predicted timeline) and on the server using the same system. When the server replicates the entity back to the client, instead of treating it as a brand-new entity, the client matches it (by hash) with the pre-spawned one and keeps simulating that one.

This section focuses on prespawned entities.

How does it work

You can find an example of prespawning in the fps example, where bullets are prespawned on the client.

Let’s say you want to spawn a bullet when the client shoots. You could just spawn the bullet on the server and wait for it to be replicated + predicted on the client. However that would introduce a delay between clicking on the ‘shoot’ button and seeing the bullet spawned.

So instead you run the same system on the client to prespawn the bullet in the predicted timeline. The only thing you need to do is add the PreSpawned component to the entity spawned (on both the client and server).

commands.spawn((BulletBundle::default(), PreSpawned::default()));

That’s it!

  • The client will assign a hash to the entity, based on its components and the tick at which it was spawned. You can also override the hash (PreSpawned::new(hash)) or add a salt (PreSpawned::default_with_salt(client_id)) to tell apart entities spawned on the same tick by different players.
  • When the client receives the server entity, it matches the signature against its prespawned entities. If it matches, it re-uses the prespawned entity as the Predicted entity instead of spawning a new one. If nothing matches, it just spawns a normal predicted entity.

In-depth

The various pieces for prespawning are:

  • PreSpawned component hook, on_add:

    • Unless a hash is provided, computes the hash of the prespawned entity based on its archetype (only the replicated components) + spawn tick.
  • Matching happens through Replicon’s signature mechanism: the prespawned entity’s signature is compared with incoming server entities. If there is a match, the prespawned entity is kept as the predicted entity (marked Predicted) instead of spawning a fresh one.

  • PreSpawnedReceiver is an app-global resource (not on the link) that tracks locally prespawned entities: their hashes, spawn ticks, and lifecycle. It also shifts them along on LocalTimelineShift so they stay consistent with the timeline.

  • PreSpawnedSystems::CleanUp:

    • removes prespawned entities on the client that never got matched with any server entity (they time out).

One thing to note is that we updated the rollback logic for pre-spawned entities. The normal rollback logic is:

  • we receive a confirmed update
  • we check if the confirmed update matches the predicted history
  • if not, we initiate a rollback, and restore the predicted history to the confirmed state. (Thanks to replication group, all components of all entities in the replication group are guaranteed to be on the same confirmed tick)

However for pre-spawned entities, we do not have any confirmed state yet! So instead we need to rollback to the history of the pre-spawned entity itself.

  • we compute the prediction history of all components during FixedUpdate
  • when we have a rollback, we also rollback all prespawned entities to their history
  • Edge cases:
    • if the prespawned entity didn’t exist at the rollback tick, we despawn it
    • if a component didn’t exist at the rollback tick, we remove it
    • if a component existed at the rollback tick but not anymore, we re-spawn it
    • TODO: if the preentity existed at the rollback tick but not anymore, we re-spawn it This one is NOT handled (or maybe it is via prediction_despawn(), check!)

Caveats

There are some things to be careful of:

  • the entity must be spawned in a system that runs in the FixedMain schedule, because only then are you guaranteed to have exactly the same tick between client and server.
    • If you spawn the prespawned entity in the Update schedule, it won’t be registered correctly for rollbacks, and also the tick associated with the entity spawn might be incorrect.

Mapping Entities

Some messages or components contain references to other Entities. For example:

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct SpawnedEntity {
    entity: Entity,
}

#[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq)]
struct Parent {
    entity: Entity,
}

In this case, we cannot replicate the Component or Message directly, because the Entity is only valid on the local machine. So the Entity that the client would receive from the server would only be valid for the Server World, not the Client’s.

We can solve this problem by mapping the server Entity to the corresponding client Entity.

Bevy’s MapEntities trait does this mapping:

pub trait MapEntities {
    /// Map the entities inside the message or component from the remote World to the local World
    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M);
}

Messages and components implement it as a no-op by default (no mapping). If your type contains entities, implement it yourself:

impl MapEntities for SpawnedEntity {
    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
        self.entity = entity_mapper.get_mapped(self.entity);
    }
}

Then opt the type into mapping at registration. For messages, that’s .add_map_entities() on the message registration:

app.register_message::<SpawnedEntity>()
    .add_direction(NetworkDirection::ServerToClient)
    .add_map_entities();

For components, implementing bevy’s MapEntities is enough; the mapping is applied when the component is received. Without mapping, the inner entities are sent raw and will be meaningless on the other side.

The Entity type itself already implements MapEntities, and so do common containers of entities.

TODOs

  • if we receive a mapped entity but the entity doesn’t exist in the client’s entity map, we currently don’t apply any mapping, but still receive the Message or Component.
    • that could be completely invalid, so we should probably not receive the Message or Component at all ?
    • instead we might to wait for the mapped entity to be created; as soon as it’s present in the map we can then apply the mapping and receive the Message or Component.
      • therefore we need a waitlist of messages that are waiting for the mapped entity to be created

Interest management

Interest management is the concept of only replicating to clients the entities that they need.

For example: in a MMORPG, replicating only the entities that are “close” to the player.

There are two main advantages:

  • bandwidth savings: it is pointless to replicate entities that are far away from the player, or that the player cannot interact with. Those bandwidth savings become especially important when you have a lot of concurrent connected clients.
  • prevent cheating: if you replicate entities that the player is not supposed to see, there is a risk that clients read that data and use it to cheat. For example, in a RTS, you can avoid replicating units that are in fog-of-war.

Implementation

Visibility is per (entity, client-link) pair: the server only replicates an entity through links it is currently visible to. Visibility is cached, so once you mark an entity visible to a client it stays relevant until you change it again.

Replicate’s target composes with visibility as a logical AND: a client outside the target never receives the entity, no matter the visibility. Visibility only narrows things further.

There are two ways to manage it.

Immediate visibility updates

Use the VisibilityExt world methods directly. Here client is the link entity (the one with ReplicationSender):

world.gain_visibility(entity, client);
world.lose_visibility(entity, client);

lose_visibility despawns the remote copy. If you’d rather keep the last-known state on the client without further updates, there are two retaining variants:

  • lose_visibility_retained: only retains the entity if the client has seen it before; otherwise it was never spawned there. Good for last-known-state views or avoiding repeated spawn setup when things move in and out of interest.
  • lose_visibility_always_present: spawns the entity even while hidden, then pauses updates. Good for roster entries or placeholders that must exist before their live state matters. Don’t use it for things that must stay secret, since it reveals existence.

(These map to Replicon’s ScopeLifetime::WhileVisible, AfterFirstVisibility and AlwaysPresent.)

Rooms

For semi-static layouts, rooms are easier than manual per-pair updates. An entity can join one or more rooms, and client links can similarly join one or more rooms. An entity is relevant to a client when they share a room.

This can be useful for games where you have physical instances of rooms:

  • a RPG where you can have different rooms (tavern, cave, city, etc.)
  • a server could have multiple lobbies, and each lobby is in its own room
  • a map could be divided into a grid of 2D squares, where each square is its own room
// setup (once)
app.add_plugins(RoomPlugin);

// allocate rooms and assign them
let room = app.world_mut().resource_mut::<RoomAllocator>().allocate();
commands.spawn((Replicate::to_clients(NetworkTarget::All), Rooms::single(room)));
// ...and put the client link in the same room:
commands.entity(client_link).insert(Rooms::single(room));

To summarize:

  • if a client is in a room but the entity is not (or vice-versa), we will not replicate that entity to that client
  • if the client and entity are both in the same room, we will replicate that entity to that client
  • if a client leaves a room that the entity is in (or an entity leaves a room that the client is in), the entity becomes hidden for that client
  • if a client joins a room that the entity is in (or an entity joins a room that the client is in), we will spawn that entity for that client

You can see rooms in action in the network_visibility example.

Appendix