From 6089cc1ee34eb10721081f311ef97d9c7e0b8bb0 Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Thu, 25 Jun 2026 20:26:15 -0400 Subject: [PATCH 1/8] Removing Replicated stops replication instead of sending a despawn message --- CHANGELOG.md | 4 ++ src/client.rs | 7 +++ src/lib.rs | 3 +- src/server.rs | 10 ++--- src/shared/server_entity_map.rs | 11 +++++ tests/despawn.rs | 80 ++++++++++++++++++++++++++++++++- tests/removal.rs | 13 +++--- 7 files changed, 113 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84df05cf..10a4bf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ## [0.41.1] - 2026-06-24 ### Fixed diff --git a/src/client.rs b/src/client.rs index d462ccd4..74dbda08 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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 @@ -189,6 +190,12 @@ fn cleanup_storage(remove: On, mut storage: If, mut entity_map: If>) { + entity_map.remove_by_client(despawn.entity); +} + fn reset( mut messages: ResMut, mut stats: ResMut, diff --git a/src/lib.rs b/src/lib.rs index 69c026f5..8b883159 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -130,7 +130,8 @@ 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. +If you remove the [`Replicated`] component from an entity on the server, replication stops. No replication messages +will be sent, including if the entity gets 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) diff --git a/src/server.rs b/src/server.rs index 666270d6..e33d0a49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -320,13 +320,13 @@ fn buffer_removals( } fn buffer_despawn( - remove: On, + despawn: On, mut despawn_buffer: ResMut, state: Res>, ) { 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); } } @@ -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); diff --git a/src/shared/server_entity_map.rs b/src/shared/server_entity_map.rs index 0a48e167..2f937b8f 100644 --- a/src/shared/server_entity_map.rs +++ b/src/shared/server_entity_map.rs @@ -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 { + 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(); @@ -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)); } } diff --git a/tests/despawn.rs b/tests/despawn.rs index 22a836c5..8e79d6b8 100644 --- a/tests/despawn.rs +++ b/tests/despawn.rs @@ -49,6 +49,82 @@ fn single() { assert!(entity_map.to_server().is_empty()); } +#[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::() + .to_client() + .get(&server_entity) + .unwrap(); + + let mut server_entity = server_app.world_mut().entity_mut(server_entity); + server_entity.remove::(); + 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()); +} + +#[test] +fn client_despawn_cleans_entity_map() { + 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::() + .to_client() + .get(&server_entity) + .unwrap(); + + client_app.world_mut().entity_mut(client_entity).despawn(); + + let entity_map = client_app.world().resource::(); + assert!(entity_map.to_client().is_empty()); + assert!(entity_map.to_server().is_empty()); +} + #[test] fn resource() { let mut server_app = App::new(); @@ -136,7 +212,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)) @@ -148,7 +224,7 @@ 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" ); } diff --git a/tests/removal.rs b/tests/removal.rs index f71f67f5..75aa7837 100644 --- a/tests/removal.rs +++ b/tests/removal.rs @@ -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] { @@ -417,10 +417,11 @@ 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::>(); + let client_entity = remote.single(client_app.world()).unwrap(); - // Un-replicate and remove at the same time. server_app .world_mut() .entity_mut(server_entity) @@ -431,7 +432,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::()); + assert!(client_entity.contains::()); } #[test] From f524dff63ac4c466a790ce65353ecfacc71aed23 Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Thu, 25 Jun 2026 20:41:56 -0400 Subject: [PATCH 2/8] add test for unpausing replication --- tests/insertion.rs | 7 ++++-- tests/removal.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/insertion.rs b/tests/insertion.rs index 4e2bb331..da1b2ace 100644 --- a/tests/insertion.rs +++ b/tests/insertion.rs @@ -589,8 +589,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::, With)>(); + let new_client_entity = components.single(client_app.world()).unwrap(); + assert_ne!(new_client_entity, client_entity); } #[test] diff --git a/tests/removal.rs b/tests/removal.rs index 75aa7837..b225a87a 100644 --- a/tests/removal.rs +++ b/tests/removal.rs @@ -437,6 +437,63 @@ fn after_unreplicate() { assert!(client_entity.contains::()); } +#[test] +fn unpause_replication() { + 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::() + .replicate::() + .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::, With, Without)>(); + let client_entity = remote.single(client_app.world()).unwrap(); + + server_app + .world_mut() + .entity_mut(server_entity) + .remove::() + .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::()); + assert!(!client_entity_ref.contains::()); + + 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::()); + assert!(client_entity_ref.contains::()); +} + #[test] fn confirm_history() { let mut server_app = App::new(); From 1230f8689caccf162ac85f9399b61db772f54da7 Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:30:37 +0300 Subject: [PATCH 3/8] Improve comment --- src/client.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client.rs b/src/client.rs index 74dbda08..b4c78906 100644 --- a/src/client.rs +++ b/src/client.rs @@ -190,8 +190,9 @@ fn cleanup_storage(remove: On, mut storage: If, mut entity_map: If>) { entity_map.remove_by_client(despawn.entity); } From 4752ab1897bd5f3c03cb977e7767fe91c280124b Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:33:29 +0300 Subject: [PATCH 4/8] Clarify despawn replication --- src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8b883159..ebb586ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -130,8 +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, replication stops. No replication messages -will be sent, including if the entity gets despawned. +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) From fe24659cbb0c4c5344793f3859579f62106914f5 Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:43:52 +0300 Subject: [PATCH 5/8] Minor despawn test refactor - Merge the map cleanup test into it (I think it's related). - Reorder it. --- tests/despawn.rs | 129 +++++++++++++++++++---------------------------- 1 file changed, 53 insertions(+), 76 deletions(-) diff --git a/tests/despawn.rs b/tests/despawn.rs index 8e79d6b8..adbafb41 100644 --- a/tests/despawn.rs +++ b/tests/despawn.rs @@ -49,82 +49,6 @@ fn single() { assert!(entity_map.to_server().is_empty()); } -#[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::() - .to_client() - .get(&server_entity) - .unwrap(); - - let mut server_entity = server_app.world_mut().entity_mut(server_entity); - server_entity.remove::(); - 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()); -} - -#[test] -fn client_despawn_cleans_entity_map() { - 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::() - .to_client() - .get(&server_entity) - .unwrap(); - - client_app.world_mut().entity_mut(client_entity).despawn(); - - let entity_map = client_app.world().resource::(); - assert!(entity_map.to_client().is_empty()); - assert!(entity_map.to_server().is_empty()); -} - #[test] fn resource() { let mut server_app = App::new(); @@ -228,6 +152,59 @@ fn after_spawn() { ); } +#[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::() + .to_client() + .get(&server_entity) + .unwrap(); + + let mut server_entity = server_app.world_mut().entity_mut(server_entity); + server_entity.remove::(); + 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::(); + 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::(); + assert!(entity_map.to_client().is_empty()); + assert!(entity_map.to_server().is_empty()); +} + #[test] fn signature() { let mut server_app = App::new(); From 9bfcb9b606c7853b8a3877812ea67330eaaf45c6 Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:50:37 +0300 Subject: [PATCH 6/8] Restore comment --- tests/removal.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/removal.rs b/tests/removal.rs index b225a87a..e1213900 100644 --- a/tests/removal.rs +++ b/tests/removal.rs @@ -422,6 +422,7 @@ fn after_unreplicate() { .query_filtered::>(); let client_entity = remote.single(client_app.world()).unwrap(); + // Un-replicate and remove at the same time. server_app .world_mut() .entity_mut(server_entity) From 857e11b9827a23347d8be299f720f00015eb099b Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:52:57 +0300 Subject: [PATCH 7/8] Move unpause test into insertion I think it fits here more. --- tests/insertion.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++ tests/removal.rs | 57 ---------------------------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/insertion.rs b/tests/insertion.rs index da1b2ace..2e5418ec 100644 --- a/tests/insertion.rs +++ b/tests/insertion.rs @@ -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::() + .replicate::() + .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::, With, Without)>(); + let client_entity = remote.single(client_app.world()).unwrap(); + + server_app + .world_mut() + .entity_mut(server_entity) + .remove::() + .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::()); + assert!(!client_entity_ref.contains::()); + + 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::()); + assert!(client_entity_ref.contains::()); +} + #[test] fn with_client_despawn() { let mut server_app = App::new(); diff --git a/tests/removal.rs b/tests/removal.rs index e1213900..ce176e76 100644 --- a/tests/removal.rs +++ b/tests/removal.rs @@ -438,63 +438,6 @@ fn after_unreplicate() { assert!(client_entity.contains::()); } -#[test] -fn unpause_replication() { - 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::() - .replicate::() - .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::, With, Without)>(); - let client_entity = remote.single(client_app.world()).unwrap(); - - server_app - .world_mut() - .entity_mut(server_entity) - .remove::() - .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::()); - assert!(!client_entity_ref.contains::()); - - 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::()); - assert!(client_entity_ref.contains::()); -} - #[test] fn confirm_history() { let mut server_app = App::new(); From 652c75d053cb296af921c721bb33b062d3da2548 Mon Sep 17 00:00:00 2001 From: Hennadii Chernyshchyk Date: Fri, 26 Jun 2026 18:54:04 +0300 Subject: [PATCH 8/8] Clarify despawn in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a4bf56..37104b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- 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