Lightyear 0.29.0
Deterministic peer-to-peer
Lightyear can now run deterministic, input-only P2P games without an authoritative server. For now this only works when the total number of peers is fixed.
A minimal application setup looks like this:
app.add_plugins(ClientPlugins { tick_duration });
app.add_plugins(P2PSessionPlugin);
// This is a capacity including the local peer, not a required player count.
app.insert_resource(P2PSession::new(max_peers));You need to add the P2P component on each Link that should be part of the P2P session.
commands.spawn((
P2P,
Link::default(),
));Trigger P2PStart once the initial cohort has been created; Lightyear waits until the frozen cohort and the input timeline are ready, negotiates one future start tick, and then emits the P2PStarted event on every peer.
Most examples can now be run in P2P mode.
One timeline and rollback pipeline
LocalTimeline and InterpolationTimeline were previously components, but it doesn't make sense to have more than one per App. (LocalTimeline is used to update the global Time<Virtual> resource, and Interpolated entities cannot be displayed for multiple interpolation timelines at once).
They have both been changed into Resources.
LocalTimeline is used to get the local fixed-update tick, and LocalTimelineSync is the resource used to sync the local Time<Virtual> with the remote peers.
You can use the SystemParams SyncedInterpolationTimeline and SyncedLocalTimeline to access the resources while skipping the system if the timelines are not synced.
Similarly, prediction needs storing a history according to the local LocalTimeline, so Prediction/Rollback state is now global per application. Rollback and PredictionManager are now resources.
This lets timeline syncing and prediction work similarly for a client-server or a P2P app (where there is no Link entity symbolizing the local peer).
Interpolation rules
Interpolation is now rule based, similar to Replicon's replication rules. You can define multiple rules for a given component, and they will be applied in order of priority.
The main benefit is that now you only need to define the interpolation logic once. The selected rule is then reused in all interpolation situations:
- delayed interpolation of remote
Interpolatedentities FrameInterpolatebetween fixed ticks- visual correction after a prediction rollback
For a type that implements Bevy's Ease trait, the common setup stays short:
app.component::<Position>()
.replicate()
.predict()
.add_linear_interpolation()
.add_correction();For a custom function, use InterpolationFns::interpolate:
fn lerp_position(start: Position, end: Position, t: f32) -> Position {
Position(start.0 + (end.0 - start.0) * t)
}
app.interpolate_with::<Position>(
InterpolationFns::interpolate(lerp_position),
);InterpolationFns also lets you specialize the interpolation:
interpolate_with_context(fn)also passes the interpolation duration to the interpolation function.history_only()keeps a history of confirmed values (ConfirmedHistory<C>) but doesn't perform any interpolation forInterpolatedentities. Use this if you want to use your own custom logic for interpolating between history values.history_only().interpolate(fn)does the same thing but also registers a function that will be used for correction or frame interpolation.no_history(fn)registers a function only for frame interpolation or correction.disabled()opts matching entities out of that rule kind.
Multiple rules and priority
You can register several rules for the same component. Filters choose which archetypes can use a rule, while priority decides which matching rule wins:
// Broad default rule. A single-component rule has default priority 1.
app.linear_interpolate::<Position>();
// Projectiles use a different curve.
app.interpolate_with_priority_filtered::<Position, With<Projectile>>(
10,
InterpolationFns::interpolate(interpolate_projectile),
);
// Teleporting entities should not interpolate at all.
app.interpolate_with_priority_filtered::<Position, With<Teleporting>>(
100,
InterpolationFns::disabled(),
);For each entity archetype, higher priority wins. If priorities are equal, the rule registered first wins. A filter does not receive an automatic priority bonus, so a filtered override should normally use an explicit higher priority.
Rules can also target a bundle:
app.interpolate_bundle_with::<(Position, Rotation)>(
InterpolationFns::interpolate(interpolate_pose),
);The default priority is the number of components in the target. A default (Position, Rotation) rule therefore has priority 2 and wins over default single-component rules for Position and Rotation on an archetype where all of them match. Bundle rules are useful when several components must be sampled together.
This means an application can define a broad component rule, add specialized filtered rules for particular gameplay archetypes, and add a higher-priority bundle rule where several values must stay coupled. The rule selected for an entity is shared by delayed interpolation, frame interpolation, and correction rather than configuring those three systems separately.
Avian integration
Replication modes
The Avian integration now uses Position mode by default:
app.add_plugins(LightyearAvianPlugin {
replication_mode: AvianReplicationMode::Position {
sync_to_transform: false,
},
..default()
});There is no longer a mode that replicates Position but interpolates Transform. We can always interpolate the Position/Rotation values, even in frame interpolation. Position mode is preferred because it sends the compact physics pose instead of the full Transform. It also keeps simulation state separate from the temporary visual state produced by frame interpolation and correction.
Child colliders
The old integration had a problem with compound rigid bodies; (RigidBodies that have child Collider entities) this is now fixed.
The default Avian protocol now replicates Position only for entities with their own RigidBody:
app.component::<Position>()
.replicate_filtered::<With<RigidBody>>()
.predict()
.add_linear_interpolation()
.add_correction();Child colliders keep their local offset (Transform) and follow the RigidBody root through normal transform propagation.
Misc
Position mode automatically registers a Hermite bundle rule for (Position, Rotation, LinearVelocity, AngularVelocity).
The sync_to_transform option is about the gameplay authoring API, not about what gets interpolated:
- Keep it
falsewhen gameplay updatesPosition,Rotation. - Set it to
trueonly when fixed-update gameplay directly reads or writesTransform. - Use
AvianReplicationMode::Transformonly when transform data such as scale or local hierarchy state is itself part of the networked gameplay state.
Replication improvements
You can add the Persistent marker on replicated entities on the client to not despawn them when the client disconnects. You can add the Persistent marker on the ReplicationReceiver entity to extend this behavior to all replicated entities received by this receiver.
This is the flip-side to the ControlledBy::Lifetime value, which specified what the server does when a client that controls an entity disconnects.
Other changes
-
Tickis now an ordinary non-wrappingu32newtype. Ordering is numeric and arithmetic that returns anotherTicksaturates instead of wrapping. -
Messages and events can also be assigned to a remote timeline with
ChannelSettings::on_timeline. The receiver holds them until that timeline reaches the sender tick. This is mostly useful for the server to trigger events on a client's interpolation timeline. -
the serializer changed to
postcardsincebincodewas unmaintained.
Migration guide
Client setup and connection state
Client is now a unit marker. Connection state can be accessed via the custom QueryData ClientState .
A client that receives replication should include ReplicationReceiver. Prediction is now enabled by inserting PredictionManager as a resource:
app.insert_resource(PredictionManager::default());
commands.spawn((
Client,
ReplicationReceiver,
Link::default().with_conditioner(conditioner),
));Unlink::reason, Unlinked::reason, and Disconnected::reason are no longer strings. Use UnlinkReason and DisconnectedReason, for example UnlinkReason::UserRequested(None) or DisconnectedReason::TransportError(error.to_string()).
Timelines, prediction, and prespawning
InputTimeline has been removed. Read Res<LocalTimeline> for the current simulation tick. Use SyncedLocalTimeline for systems that should only run once the local clock is synchronized; use Option<SyncedLocalTimeline> in systems shared with server or host modes. (or if local_timeline.is_synced())
InterpolationTimeline and InterpolationConfig are resources. Replace client-entity queries with Res<InterpolationTimeline>, or use SyncedInterpolationTimeline when the system should wait for sync.
PredictionManager, LastConfirmedInput, Rollback, InputTimelineConfig, and PreSpawnedReceiver are resources rather than components on the client entity.
SyncEvent<T> has been replaced by the application-wide LocalTimelineShift { delta } event. Code that relied on tick wraparound should use ordinary comparisons and the new saturating Tick arithmetic.
Interpolation, frame interpolation, and correction
The component-builder API still provides the shortest migration for the common case:
app.component::<Position>()
.replicate()
.predict()
.add_linear_interpolation()
.add_correction();For custom or multiple rules, register them on App with linear_interpolate, interpolate_with, the filtered and priority variants, or interpolate_bundle_with. The Interpolation rules section above explains selection and priority in more detail.
FrameInterpolationPlugin<C> is now FrameInterpolationPlugin, and FrameInterpolate<C> is now the type-erased FrameInterpolate marker. SkipFrameInterpolation is gone; frame interpolation is opt-in by adding FrameInterpolate to the entities that need it.
For prediction correction, add_linear_correction_fn::<D>() is now add_linear_correction::<D>(). The common same-type case can use add_correction().
enable_correction() is deprecated in favor of custom_correction() when the application owns the correction system.
For Avian, both old position-based modes now use AvianReplicationMode::Position:
AvianReplicationMode::Position {
sync_to_transform: false,
}The old PositionButInterpolateTransform mode usually maps to sync_to_transform: false. Set sync_to_transform: true only if your fixed-update gameplay authors Transform directly.
If your protocol registers custom physics rules, set LightyearAvianPlugin::register_physics_components to false. Otherwise the plugin now registers Position, Rotation, LinearVelocity, and AngularVelocity for rigid-body roots by default, together with the Hermite interpolation rule.
Delta replication
The legacy Lightyear delta manager and .add_delta_compression::<D>() API were removed. Implement bevy_replicon::prelude::Diffable, register the component with .replicate_diff(), and apply mutations through Replicon's EntityDiffExt::apply_diff so the diff is recorded. Use .predict_diff() and the diff interpolation methods when prediction or interpolation is needed.
Lightyear's own Diffable trait remains for prediction correction; it is separate from Replicon's network diff trait.
Low-level link, transport, and serde APIs
Link::new(conditioner) becomes Link::default().with_conditioner(conditioner).
LinkConditionerConfig::new(latency, jitter, loss) and the incoming_loss field were replaced by builder methods. Start with LinkConditionerConfig::default(), set latency and jitter with with_incoming_latency and with_incoming_jitter, then choose with_fixed_loss or one of the Gilbert loss builders.
Thanks to @nick-e, @Ploruto, @hlbarber, @BoysGameStudio, and @bugsweeper for contributions to this release.
Full changelog: 0.28.0...0.29.0