Skip to content

Conversation

ItsDoot
Copy link
Contributor

@ItsDoot ItsDoot commented Aug 2, 2025

Objective

Solution

  • Renamed RelationshipSourceCollection to RelationshipCollection (alternatives welcome), since it's also used for target entities now.
  • Added Collection: RelationshipCollection associated type to Relationship, mirroring the RelationshipTarget trait.
  • Updated the hooks to handle multiple target entities (the most important and difficult part of the feature).
  • Further less important changes are listed in the migration guide.

The API and docs are probably due for some improvements; this is mostly intended as a "get it working" PR. Originally I had taken this a lot further API-wise with RelationshipCollection sub-traits so each collection could have its own default-specified hooks, but I think it's better to start with a minimal support PR first.

Testing

Added many (heh) new tests for many-to-many relationships.


Showcase

The Relationship side of a relationship now allows more than just Entity:

#[derive(Component)]
#[relationship(relationship_target = LikedBy)]
struct Likes(pub Vec<Entity>); // also accepts any type that `LikedBy` would accept

#[derive(Component)]
#[relationship_target(relationship = Likes)]
struct LikedBy(Vec<Entity>);

@ItsDoot ItsDoot added A-ECS Entities, components, systems, and events C-Usability A targeted quality-of-life change that makes Bevy easier to use D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes labels Aug 2, 2025
@ItsDoot ItsDoot added this to the 0.18 milestone Aug 2, 2025
@ItsDoot ItsDoot force-pushed the relations/manytomany branch from 8435040 to d486991 Compare August 2, 2025 04:12
@ItsDoot ItsDoot changed the title Minimal Many-to-many Relationships Minimal Many-to-Many Relationships Aug 2, 2025
@ItsDoot ItsDoot added the S-Needs-Review Needs reviewer attention (from anyone!) to move forward label Aug 2, 2025
@alice-i-cecile alice-i-cecile added X-Controversial There is active debate or serious implications around merging this PR M-Needs-Release-Note Work that should be called out in the blog due to impact labels Aug 2, 2025
Copy link
Contributor

github-actions bot commented Aug 2, 2025

It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note.

Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes.

@hukasu
Copy link
Contributor

hukasu commented Aug 2, 2025

the previous Relationship was immutable right? with the addition of get_mut_risky it shows that it is now mutable, right? how does the hooks function for mutations instead of replacing the component?

Copy link
Contributor

@cBournhonesque cBournhonesque left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Just one question on performance; but it doesn't affect existing one-to-many relationships so I don't think it's a blocker.

.unwrap()
.get();
for target_entity in target_entities.iter() {
if target_entity == entity {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe .filter(|target_entity| target_entity != entity)
(with the comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matter of taste; I prefer the if statement to give space inside the loop for the lengthy comment.


world
.commands()
if let Ok(target_entity_cell) = world_cell.get_entity(target_entity) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if the source_entity has a Relationship with 10 entities and we add an 11th; we will first apply the remove on all 10 entities in on_replace (potentially queuing up 10 remove commands), and then in on_insert we queue up 11 commands to add the entity?

It's probably fine since relationship modifications are usually rare, but maybe this should be documented? (updating the relationship 1 by 1 is a O(n2) op)

Maybe in the future we could have an improved ON_REPLACE observer that gives you both the existing component value and the new component value. Theoretically it should be doable since both are accessible here:

deferred_world.trigger_on_replace(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could have a flag (like LINKED_SPAWN); if present we would use a diffing approach to compare the old and the new values.
The diffing would require changes to the ON_REPLACE observer; or adding another component like CachedRelationship that holds the previous values of the Relationship component.

This could be nice in particular for RelationshipCollections like HashSet or IndexSet where you can efficiently get the diff.

Out of scope for this PR though

if let Some(relationship) = source_entity_mut.get::<Self::Relationship>() {
// Only despawn if this source entity's target collection only contains the entity being despawned
if relationship.len() == 1 && relationship.get().contains(entity) {
source_entity_mut.despawn();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is unrelated to your PR, but I don't understand this part of the code anymore.
The docs say that Relationship is the source of truth; so how come we despawn the source entity if it has no more targets? Shouldn't we at least check for LINKED_SPAWN?

let b = world.spawn(Likes(a)).id();
let c = world.spawn(Likes(a)).id();
let b = world.spawn(Likes(vec![a])).id();
let c = world.spawn(Likes(vec![a, b])).id();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add

assert_eq!(world.entity(b).get::<LikedBy>().unwrap().0, &[c]);
assert!(world.entity(c).get::<LikedBy>().is_none());


assert_eq!((spawner, spawn_tick), *TRACKED.get().unwrap());
assert_eq!(
despawner,
spawner,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have to slightly change despawn behavior to support many-to-many relationships, but I'm not exactly sure why this test needed to be changed. Guidance welcome!

@ItsDoot
Copy link
Contributor Author

ItsDoot commented Aug 2, 2025

the previous Relationship was immutable right?

The Relationship trait itself doesn't require Mutability = Immutable, but the derive automatically sets it to be immutable.

with the addition of get_mut_risky it shows that it is now mutable, right? how does the hooks function for mutations instead of replacing the component?

It doesn't function correctly by directly mutating, that's why the docs for the _risky functions have a warning.

@ItsDoot ItsDoot added D-Complex Quite challenging from either a design or technical perspective. Ask for help! and removed D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes labels Aug 2, 2025
@hukasu
Copy link
Contributor

hukasu commented Aug 3, 2025

@ItsDoot so the expected way to mutate the Relationship is still reinserting, right? how is the performance of reinserting a Vec with over 1000 items on it?

@cart cart moved this to Respond (With Priority) in @cart's attention Aug 3, 2025
@ItsDoot
Copy link
Contributor Author

ItsDoot commented Aug 14, 2025

Doesn't seem to be playing well with SpawnRelatedBundle BundleEffect spawning in my game, so will need some additional work.

@ItsDoot ItsDoot added S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Aug 14, 2025
@hukasu
Copy link
Contributor

hukasu commented Aug 14, 2025

The path for n:m relationships that I have in my head is having a component storage type of multi map, that way you would be able to have multiple Relationship components on the same entity, but that would require the multi map component storage, a way to query the components, and a way to remove a specific instance of the component from the storage

@Diddykonga
Copy link

The path for n:m relationships that I have in my head is having a component storage type of multi map, that way you would be able to have multiple Relationship components on the same entity, but that would require the multi map component storage, a way to query the components, and a way to remove a specific instance of the component from the storage

Sounds like Fragmenting Relations

@NthTensor
Copy link
Contributor

Maybe you've answered this elsewhere, but is there any mechanism that prevents you doing stuff like this?

#[derive(Component)]
#[relationship(relationship_target = Friends)]
#[relationship_target(relationship = Friends)]
struct Friends(pub Vec<Entity>);

@ItsDoot
Copy link
Contributor Author

ItsDoot commented Aug 20, 2025

Maybe you've answered this elsewhere, but is there any mechanism that prevents you doing stuff like this?

#[derive(Component)]
#[relationship(relationship_target = Friends)]
#[relationship_target(relationship = Friends)]
struct Friends(pub Vec<Entity>);

Nope. We could make the attributes mutually exclusive in the derive, but I don't think we could prevent someone manually implementing Relationship and RelationshipTarget for the same type.

@NthTensor
Copy link
Contributor

NthTensor commented Aug 20, 2025

Would this work? Is this a back-door into undirected many-to-many relationships?

I'd be pretty happy if this was something we could support. I don't think we should try to prevent this, it seems great.

@hukasu
Copy link
Contributor

hukasu commented Aug 20, 2025

static_assert_ne!(TypeId::of::<Self>(), TypeId::of::<Self::Relationship>) or something like that if TypeId::of is not static?

@NthTensor
Copy link
Contributor

Ok, now I have a related (pardon the pun) question:

It seems like under this, many-to-one relationships can be implemented in two ways. Either the relationship or the relationship_target can have the single item. Is that correct?

@ItsDoot
Copy link
Contributor Author

ItsDoot commented Aug 20, 2025

It seems like under this, many-to-one relationships can be implemented in two ways. Either the relationship or the relationship_target can have the single item. Is that correct?

This seems possible yes, but I have not personally tested it.

@NthTensor
Copy link
Contributor

I'll have a go at it. That seems like it might present a potential problem. You can't write generic stuff for directed many to one relations if you're not sure which side is the many and which side is the one.

At the very least, the relationship_sources and RelationshipTarget terminology starts to make a lot less sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Usability A targeted quality-of-life change that makes Bevy easier to use D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Needs-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged X-Controversial There is active debate or serious implications around merging this PR
Projects
Status: Respond (With Priority)
Development

Successfully merging this pull request may close these issues.

Support Many-to-Many relationships
6 participants