General architecture
Lightyear
essentially provides 2 plugins that will handle every networking-related concern for you: a ClientPlugin
and a ServerPlugin
.
The plugins will define various resources and systems that will handle the connection to the server. Some of the notable resources are:
- the TickManager:
lightyear
usesTicks
to handle synchronization between the client and server. TheTick
is basically the fixed-timestep unit of simulation, it gets incremented by 1 every time the FixedUpdate schedule runs. TheTickManager
has the tick method to return the current client or server tick. (depending on which plugin you are using) - the ClientConnectionManager or ServerConnectionManager which are used to send messages to the remote.
- the ClientConnection or
ServerConnection which
handle the general io connection. You can use them to get the current
ClientId
or to check that the connection is still alive. - the InputManager lets you send inputs from the client to the server
There are many different sub-plugins but the most important things that lightyear
handles for you are probably:
- the sending and receiving of messages.
- automatic replication of the World from the server to the client
- handling the inputs from the user.
Example code organization
In the most basic setup, you will run 2 separate apps: one for the client and one for the server.
(You can also run both in the same app in what is called HostServer
mode, but we will not cover that in this
tutorial.)
The simple_box
example has the following structure:
main.rs
: this is where we read the settings file fromassets/settings.ron
and create the client or server app depending on the passed CLI arguments.settings.rs
: here we parse thesettings.ron
file and have helpers to create theClientConfig
andServerConfig
structs which are all that is required to build aClientPlugin
or aServerPlugin
protocol.rs
: here we define a shared protocol, which is basically the list of messages, components and inputs that can be sent between the client and server.shared.rs
: this is where we define shared behaviour between the client and server. For example some simulation logic (physics/movement) should be shared between the client and server.client.rs
: this is where we define client-specific logic (input-handling, client-prediction, etc.)server.rs
: this is where we define server-specific logic (spawning players for newly-connected clients, etc.)
Defining a protocol
First, you will need to define a protocol for your game. (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 components 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)] pub struct PlayerPosition(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.register_component::<PlayerId>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Once) .add_interpolation(ComponentSyncMode::Once); app.register_component::<PlayerPosition>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Full) .add_interpolation(ComponentSyncMode::Full) .add_linear_interpolation_fn(); app.register_component::<PlayerColor>(ChannelDirection::ServerToClient) .add_prediction(ComponentSyncMode::Once) .add_interpolation(ComponentSyncMode::Once); } } }
Message
Similarly, the MessageProtocol
is an enum containing 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:
/// 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);
/// Again, you need to use the macro `message_protocol` to define a message protocol.
#[message_protocol(protocol = "MyProtocol")]
pub enum Messages {
Message1(Message1),
}
impl Plugin for ProtocolPlugin{
fn build(&self, app: &mut App) {
// Register messages
app.add_message::<Message1>(ChannelDirection::Bidirectional);
// Register components
...
}
}
Inputs
Lightyear handles inputs (the user actions that should be sent to the server) for you, you just need to define the list of possible inputs (like the message or component protocols).
(it is recommended to use the leafwing
feature to handle inputs with leafwing-input-manager
, but we will not cover
that in this tutorial)
Let's define our inputs:
#![allow(unused)] fn main() { /// The different directions that the player can move the box #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct Direction { pub(crate) up: bool, pub(crate) down: bool, pub(crate) left: bool, pub(crate) right: bool, } /// The `InputProtocol` needs to be an enum of the various inputs that the client can send to the server. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum Inputs { Direction(Direction), Delete, Spawn, /// NOTE: we NEED to provide a None input so that the server can distinguish between lost input packets and 'None' inputs None, } impl Plugin for ProtocolPlugin{ fn build(&self, app: &mut App) { // Register inputs app.add_plugins(InputPlugin::<Inputs>::default()); // Register messages ... // Register components ... } } }
Inputs have to implement the UserAction
trait, which means that they must be Send + Sync + 'static
and can be serialized.
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: do we want to increase the priority of some messages in case the network is congested?
/// A channel is basically a ZST (Zero Sized Type) with the `Channel` derive macro.
#[derive(Channel)]
pub struct Channel1;
pub(crate) struct ProtocolPlugin;
impl Plugin for ProtocolPlugin {
fn build(&self, app: &mut App) {
// channels
app.add_channel::<Channel1>(ChannelSettings {
mode: ChannelMode::OrderedReliable(ReliableSettings::default()),
..default()
});
// register messages, inputs, components
...
}
}
We create a channel by simply deriving the Channel
trait on an empty struct.
Protocol
The complete protocol looks like this:
#![allow(unused)] fn main() { pub(crate) struct ProtocolPlugin; impl Plugin for ProtocolPlugin { fn build(&self, app: &mut App) { // messages app.add_message::<Message1>(ChannelDirection::Bidirectional); // inputs app.add_plugins(InputPlugin::<Inputs>::default()); // components app.register_component::<PlayerId>(ChannelDirection::ServerToClient) .add_prediction::<PlayerId>(ComponentSyncMode::Once) .add_interpolation::<PlayerId>(ComponentSyncMode::Once); app.register_component::<PlayerPosition>(ChannelDirection::ServerToClient) .add_prediction::<PlayerPosition>(ComponentSyncMode::Full) .add_interpolation::<PlayerPosition>(ComponentSyncMode::Full) .add_linear_interpolation_fn::<PlayerPosition>(); app.register_component::<PlayerColor>(ChannelDirection::ServerToClient) .add_prediction::<PlayerColor>(ComponentSyncMode::Once) .add_interpolation::<PlayerColor>(ComponentSyncMode::Once); // channels app.add_channel::<Channel1>(ChannelSettings { mode: ChannelMode::OrderedReliable(ReliableSettings::default()), ..default() }); } } }
Summary
We now have a complete Protocol
that defines:
- the 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 Plugins.