Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Removing `Replicated` from an entity now stops replication without despawning the entity on clients. Client-side despawns of `Remote` entities clean up their `ServerEntityMap` mappings. Despawning an entity still replicates as despawn.

## [0.41.1] - 2026-06-24

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ impl Plugin for ClientPlugin {
(ClientSystems::Send, ClientSystems::SendPackets).chain(),
)
.add_observer(cleanup_storage)
.add_observer(cleanup_entity_map)
.add_systems(
PreUpdate,
receive_replication
Expand Down Expand Up @@ -189,6 +190,13 @@ fn cleanup_storage(remove: On<Remove, Remote>, mut storage: If<ResMut<Replicatio
storage.entities.remove(&remove.entity);
}

// The server can despawn an entity without sending a replication message,
// so we need to manually remove the entity from the `ServerEntityMap`
// when it is despawned on the client.
fn cleanup_entity_map(despawn: On<Despawn, Remote>, mut entity_map: If<ResMut<ServerEntityMap>>) {
entity_map.remove_by_client(despawn.entity);
}

fn reset(
mut messages: ResMut<ClientMessages>,
mut stats: ResMut<ClientStats>,
Expand Down
5 changes: 4 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ component on the server for entities you want to replicate.

On clients [`Remote`] will be automatically inserted for every entity spawned by replication.

If you remove the [`Replicated`] component from an entity on the server, it will be despawned on all clients.
When an entity with the [`Replicated`] component is despawned, clients also receive a despawn.

If you remove the [`Replicated`] component from an entity on the server, replication stops.
No replication messages will be sent, including when the entity is despawned.

Entity IDs differ between clients and server. As a result, clients maps server entities to local entities
on receive. These mappings are stored in the [`ServerEntityMap`](shared::server_entity_map::ServerEntityMap)
Expand Down
10 changes: 3 additions & 7 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,13 @@ fn buffer_removals(
}

fn buffer_despawn(
remove: On<Remove, Replicated>,
despawn: On<Despawn, Replicated>,
mut despawn_buffer: ResMut<DespawnBuffer>,
state: Res<State<ServerState>>,
) {
if *state == ServerState::Running {
trace!("buffering despawn of `{}`", remove.entity);
despawn_buffer.push(remove.entity);
trace!("buffering despawn of `{}`", despawn.entity);
despawn_buffer.push(despawn.entity);
}
}

Expand Down Expand Up @@ -940,10 +940,6 @@ pub enum ServerSystems {
struct ServerChangeTick(Tick);

/// Buffer with all despawned entities.
///
/// We treat removals of [`Replicated`] component as despawns
/// to avoid missing events in case the server's tick policy is
/// not [`TickPolicy::EveryFrame`].
#[derive(Resource, Deref, DerefMut, Default)]
struct DespawnBuffer(Vec<Entity>);

Expand Down
11 changes: 11 additions & 0 deletions src/shared/server_entity_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ impl ServerEntityMap {
)
}

/// Removes a mapping by its client entity.
pub(crate) fn remove_by_client(&mut self, client_entity: Entity) -> Option<Entity> {
let server_entity = self.client_to_server.remove(&client_entity)?;
self.server_to_client.remove(&server_entity);
Some(server_entity)
}

/// Clears the map.
pub(crate) fn clear(&mut self) {
self.client_to_server.clear();
Expand Down Expand Up @@ -192,5 +199,9 @@ mod tests {
CLIENT_ENTITY
);
assert_eq!(map.server_entry(SERVER_ENTITY).get(), Some(CLIENT_ENTITY));

assert_eq!(map.remove_by_client(CLIENT_ENTITY), Some(SERVER_ENTITY));
assert_eq!(map.server_entry(SERVER_ENTITY).get(), None);
assert!(!map.to_server().contains_key(&CLIENT_ENTITY));
}
}
57 changes: 55 additions & 2 deletions tests/despawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fn after_spawn() {

server_app.connect_client(&mut client_app);

// Insert and remove `Replicated` to trigger spawn and despawn for client at the same time.
// Insert and remove `Replicated` in the same tick.
server_app
.world_mut()
.spawn((Replicated, TestComponent))
Expand All @@ -148,10 +148,63 @@ fn after_spawn() {
assert_eq!(
messages.drain_sent().len(),
0,
"client shouldn't receive anything for a despawned entity"
"client shouldn't receive anything for an unreplicated entity"
);
}

#[test]
fn after_unreplicate() {
let mut server_app = App::new();
let mut client_app = App::new();
for app in [&mut server_app, &mut client_app] {
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins.set(ServerPlugin::new(PostUpdate)),
))
.finish();
}

server_app.connect_client(&mut client_app);

let server_entity = server_app.world_mut().spawn(Replicated).id();

server_app.update();
server_app.exchange_with_client(&mut client_app);
client_app.update();
server_app.exchange_with_client(&mut client_app);

let client_entity = *client_app
.world()
.resource::<ServerEntityMap>()
.to_client()
.get(&server_entity)
.unwrap();

let mut server_entity = server_app.world_mut().entity_mut(server_entity);
server_entity.remove::<Replicated>();
server_entity.despawn();

server_app.update();
server_app.exchange_with_client(&mut client_app);
client_app.update();

assert!(
client_app.world().get_entity(client_entity).is_ok(),
"client shouldn't receive a despawn for unreplicated entity"
);

let entity_map = client_app.world().resource::<ServerEntityMap>();
assert!(!entity_map.to_client().is_empty());
assert!(!entity_map.to_server().is_empty());

client_app.world_mut().despawn(client_entity);

let entity_map = client_app.world().resource::<ServerEntityMap>();
assert!(entity_map.to_client().is_empty());
assert!(entity_map.to_server().is_empty());
}

#[test]
fn signature() {
let mut server_app = App::new();
Expand Down
64 changes: 62 additions & 2 deletions tests/insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,63 @@ fn after_removal() {
);
}

#[test]
fn after_unpause() {
let mut server_app = App::new();
let mut client_app = App::new();
for app in [&mut server_app, &mut client_app] {
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins.set(ServerPlugin::new(PostUpdate)),
))
.replicate::<A>()
.replicate::<B>()
.finish();
}

server_app.connect_client(&mut client_app);

let server_entity = server_app.world_mut().spawn((Replicated, A)).id();

server_app.update();
server_app.exchange_with_client(&mut client_app);
client_app.update();
server_app.exchange_with_client(&mut client_app);

let mut remote = client_app
.world_mut()
.query_filtered::<Entity, (With<Remote>, With<A>, Without<B>)>();
let client_entity = remote.single(client_app.world()).unwrap();

server_app
.world_mut()
.entity_mut(server_entity)
.remove::<Replicated>()
.insert(B);

server_app.update();
server_app.exchange_with_client(&mut client_app);
client_app.update();

let client_entity_ref = client_app.world().entity(client_entity);
assert!(client_entity_ref.contains::<A>());
assert!(!client_entity_ref.contains::<B>());

server_app
.world_mut()
.entity_mut(server_entity)
.insert(Replicated);

server_app.update();
server_app.exchange_with_client(&mut client_app);
client_app.update();

let client_entity_ref = client_app.world().entity(client_entity);
assert!(client_entity_ref.contains::<A>());
assert!(client_entity_ref.contains::<B>());
}

#[test]
fn with_client_despawn() {
let mut server_app = App::new();
Expand Down Expand Up @@ -589,8 +646,11 @@ fn with_client_despawn() {
server_app.exchange_with_client(&mut client_app);
client_app.update();

let mut components = client_app.world_mut().query::<&A>();
assert_eq!(components.iter(client_app.world()).len(), 0);
let mut components = client_app
.world_mut()
.query_filtered::<Entity, (With<Remote>, With<A>)>();
let new_client_entity = components.single(client_app.world()).unwrap();
assert_ne!(new_client_entity, client_entity);
}

#[test]
Expand Down
12 changes: 8 additions & 4 deletions tests/removal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ fn after_spawn() {
}

#[test]
fn after_despawn() {
fn after_unreplicate() {
let mut server_app = App::new();
let mut client_app = App::new();
for app in [&mut server_app, &mut client_app] {
Expand All @@ -417,8 +417,10 @@ fn after_despawn() {
client_app.update();
server_app.exchange_with_client(&mut client_app);

let mut remote = client_app.world_mut().query::<&Remote>();
assert_eq!(remote.iter(client_app.world()).len(), 1);
let mut remote = client_app
.world_mut()
.query_filtered::<Entity, With<Remote>>();
let client_entity = remote.single(client_app.world()).unwrap();

// Un-replicate and remove at the same time.
server_app
Expand All @@ -431,7 +433,9 @@ fn after_despawn() {
server_app.exchange_with_client(&mut client_app);
client_app.update();

assert_eq!(remote.iter(client_app.world()).len(), 0);
let client_entity = client_app.world().entity(client_entity);
assert!(client_entity.contains::<Remote>());
assert!(client_entity.contains::<A>());
}

#[test]
Expand Down