Over lightyear
Prediction said what to send. bevy_mettle_lightyear sends
it over lightyear: replicas
replicated, requests and replies carried, and who may ask checked.
Turn on the lightyear feature:
[dependencies]
bevy_mettle = { version = "0.1", features = ["lightyear"] }
It’s then bevy_mettle::lightyear, its names are in the prelude, and
MettlePlugins adds MettleNetPlugin too.
Setting up
Both sides add the same plugins, in the same order:
- lightyear’s own plugins:
ServerPluginson the server,ClientPluginson a client; MettlePlugins, withNetRoleset toServerorClient;- your attributes, and
replicate_attributefor each one clients should see; - your behaviours, and
replicate_inputfor each input they take; - the effects and abilities code names, and the same content, as Loading data shows.
use core::time::Duration;
use bevy::prelude::*;
use bevy::state::app::StatesPlugin;
use bevy_mettle::prelude::*;
use lightyear::prelude::client::ClientPlugins;
use lightyear::prelude::server::ServerPlugins;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0, max = MaxLife)]
struct Life;
#[derive(Attribute)]
#[attribute(base = 100.0)]
struct MaxLife;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;
/// Where the fireball is aimed: sent from the client as it is.
#[derive(Component, Clone, Copy, serde::Serialize, serde::Deserialize, bevy::ecs::entity::MapEntities)]
struct Aim(Vec2);
#[derive(Component, Default, Behaviour)]
#[behaviour(input = Aim)]
struct CastFireball;
#[derive(Ability)]
struct Fireball;
const TICK: Duration = Duration::from_millis(16);
/// What both sides add, after lightyear's plugins.
fn mettle(app: &mut App, role: NetRole) {
app.insert_resource(role)
.add_plugins(MettlePlugins)
.add_attribute::<Life>()
.add_attribute::<MaxLife>()
.add_attribute::<Mana>()
.replicate_attribute::<Life>()
.replicate_attribute::<MaxLife>()
.replicate_attribute::<Mana>()
.add_behaviour::<CastFireball>()
.replicate_input::<Aim>()
.add_ability::<Fireball>();
}
let mut server = App::new();
server.add_plugins((MinimalPlugins, StatesPlugin, ServerPlugins { tick_duration: TICK }));
mettle(&mut server, NetRole::Server);
let mut client = App::new();
client.add_plugins((MinimalPlugins, StatesPlugin, ClientPlugins { tick_duration: TICK }));
mettle(&mut client, NetRole::Client);
MettleNetPlugin registers its channel, messages and components with
lightyear, which only works once lightyear’s plugins are there. With the
lightyear feature on, adding MettlePlugins to an app without them
panics. If the same game also runs offline, leave the net plugin out there:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
let mut offline = App::new();
offline.add_plugins(MettlePlugins.build().disable::<MettleNetPlugin>());
Set the NetRole before the game starts. The plugin does what that role
needs, and nothing for the other.
Your actors
Your game replicates its actors. Mettle replicates what’s on them. On the server, the mage its player controls:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
use lightyear::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Ability)] struct Fireball;
fn spawn_mage(commands: &mut Commands, player: Entity) -> Entity {
commands
.spawn((
Base::<Life>::default(),
Base::<Mana>::default(),
Replicate::to_clients(NetworkTarget::All),
ControlledBy {
owner: player,
lifetime: Lifetime::default(),
},
))
.grant_ability(Fireball)
.id()
}
let _ = spawn_mage;
player is the client’s link on the server. ControlledBy is what lets
that client cast the mage’s abilities. Without it, the client sees the mage
but can’t act for it.
What’s replicated
From the server to its clients:
- every active effect’s
EffectReplicaand every granted ability’sAbilityReplica, with theirEffectOfandAbilityOf, to the clientControlledBythe actor alone: it predicts with them. They go and come with the actor (ReplicateLikeit), and a visibility filter keeps them to its owner, following control as it comes, changes hands or goes. An actor no client controls sends them to none. SetMettleNetPlugin { replicas: Replicas::Everyone }to send them to every client that sees the actor, for a party frame showing allies’ buffs; - each actor’s cues, lasting and one-shot, to every client that sees it: what other clients see of its effects and abilities, while its cooldowns and buffs stay its owner’s;
- each actor’s
Base<A>andServed<A>, for the attributes you named withreplicate_attribute.Base<A>is what the client works predictions out on.Served<A>is what it shows. On the server, every actor withBase<A>servesA; - an effect’s context, with its replica and its one-shot cues: its entities mapped to the client’s, those in the game’s own types too. One the client isn’t sent (an arrow only the server has) is an empty entity there, the one it becomes if it’s sent later: check it has what you read;
- each modifier source’s
ModifiersReplica, carrying the actor it’sModifierOf, for the sources your game replicates and opts in (below).
All of it names content by number: effects by EffectId, abilities by
AbilityId, tags by TagId. Nothing carries a name.
On a client, every actor replicated from the server is marked Remote, so
it shows the server’s values rather than its own fold, except while it
predicts. A client shows all this. It never decides it.
Mettle’s own state on an actor, its folded modifiers, isn’t replicated. It’s worked out on each side. If clients read an actor’s owned tags that your game sets, replicate those yourself.
An item’s or a passive’s modifiers reach clients through a
ModifiersReplica: the adapter registers it for replication, and the game
opts each source in by putting an empty one on it, and replicating the source.
The server keeps the replica in step with the source’s Modifiers, its
conditions’ tags by number, and the actor it’s ModifierOf; each client gives
the source its Modifiers and its ModifierOf from it
(Modifiers). ModifierOf itself isn’t
replicated: a client keeps its own on the effects it shows.
use bevy::prelude::*;
use bevy_mettle::prelude::*;
use lightyear::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
/// The mage's ring, on the server: its modifiers reach every client.
fn equip_ring(commands: &mut Commands, mage: Entity) -> Entity {
commands
.spawn((
ModifierOf(mage),
ModifiersReplica::default(),
Replicate::to_clients(NetworkTarget::All),
))
.modifiers([ModifierSpec::flat::<SpellPower>(5.0).owner_has("state.casting")])
.id()
}
let _ = equip_ring;
A client’s reads in context, a hit’s tags say, are its own, worked out from what it has. They’re advisory.
Requests and replies
- Asking. A client’s
AbilityRequests go to the server onMettleChannel, reliable and ordered, and withreplicate_inputits requests with input and its new input, in order after them. An input is sent as it is, so it derivesSerialize,DeserializeandMapEntities(a target entity is mapped to the server’s). - Checking. The server takes a request, or input, only for an actor
ControlledBythe client sending it. A request for any other is rejected back to that client asFailure::NotGranted, with a warning, so its prediction is undone; input for any other is dropped. - Answering. The server’s
AbilityReplygoes to the client controlling the actor, the only one it takes requests from, with the replication tick its state goes out at. Later replies, rejecting a running activation’s input, go the same way. - Ending. A client’s cancels of its own activations go to the server
after the requests they cancel, taken only for actors it controls; the
server’s ends of a client’s activations go to the client controlling the
actor. Both on
MettleChannel.
A reply isn’t ordered with replication: they travel different ways. So a client holds a reply that confirms its prediction until the actor’s state as of that tick has arrived. Then the prediction gives way to the server’s state in the same frame. The cost is never paid twice, or not at all. A confirming reply whose actor never changed has nothing to wait for, so it’s held for 30 frames at most. A rejecting reply is handed over at once, since nothing changed on the server.
A client that disconnects drops the replies it holds and takes back every
prediction still waiting at once, each failing Failure::Unanswered, without
waiting out PredictionTimeout.
Replies and requests are heard in PreUpdate, after lightyear receives its
messages and replication, and before AbilitySystems.
Checking content
Effects, abilities and tags go as their numbers, so a client has to number
content as its server does. The server tells each client how it numbers its
content, a ContentFingerprint of every registry, as it connects, and again
whenever its content changes (a hot reload). The game’s context data types
are in it too: they go by number, in order of name, so add the same ones on
both. A client numbering anything otherwise logs an error and
triggers ContentMismatch: load the server’s content the way the server
did, or leave.
use bevy::prelude::*;
use bevy_mettle::prelude::*;
/// Leaves a server whose content isn't this client's.
fn leave(mismatch: On<ContentMismatch>, mut exit: MessageWriter<AppExit>) {
warn!("the server's content isn't ours: {:?}", mismatch.server);
exit.write(AppExit::error());
}
let mut app = App::new();
app.add_observer(leave);
Matching content is told nothing. A server that reloads its content alone tells every client it no longer matches; reloaded alike, they match again.
Pitfalls
- Plugin order. lightyear’s plugins, then
MettlePlugins. The other way round panics. Time. A server holds a client’s early cancels a while, by Bevy’sTime: a server app withoutMinimalPluginsorDefaultPlugins(a bare test app) panics as that system first runs. AddTimePlugin.- The same protocol. Both sides register the same attributes with
replicate_attribute, and the same components, in the same order. - The same content. Effects, abilities and tags go as their numbers,
never their definitions or names. Both sides need the same content,
loaded the same way, or a number names different things: a client that
doesn’t is told, by
ContentMismatch. Observe it. - A modifier source without its replica. An item replicated without a
ModifiersReplicashows no modifiers on a client. - Control. A mage without
ControlledByits player’s link can’t be cast for: every request is rejected.