Repository: mlange-42/arche
Pull request #337 (2024-01-17 10:31:31)

Fix component subscription logic, tweak docs
* use saparate masks for component addition and removal -- was not taken into account correctly
* fix component subscription logic -- some relation subscriptions could get lost
* tweak docs of listener implementations

coveralls: ## Pull Request Test Coverage Report for [Build 7554392494](https://coveralls.io/builds/65119688)


* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/65119688/badge)](https://coveralls.io/builds/65119688) |
| :-- | --: |
| Change from base [Build 7548759966](https://coveralls.io/builds/65110514): |  0.0% |
| Covered Lines: | 5181 |
| Relevant Lines: | 5181 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44c817a..f7e0011 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,7 +31,7 @@ This change was necessary to get the same performance as before, despite the mor
 ### Performance
 
 * Reduces archetype memory footprint by using a dynamically sized slice for storage lookup (#327)
-* Reduces event listener overhead through granular subscriptions and elimination of a heap allocation (#333, #334, #335)
+* Reduces event listener overhead through granular subscriptions and elimination of a heap allocation (#333, #334, #335, #337)
 
 ### Other
 
diff --git a/ecs/event.go b/ecs/event.go
index efae6fb..8042116 100644
--- a/ecs/event.go
+++ b/ecs/event.go
@@ -25,8 +25,8 @@ import "github.com/mlange-42/arche/ecs/event"
 // This allows the [World] to be in an unlocked state, and notifies after potential entity initialization.
 type EntityEvent struct {
 	Entity                   Entity             // The entity that was changed.
-	Changed                  Mask               // Mask indicating changed components (additions and removals).
-	Added, Removed           []ID               // Components added and removed. DO NOT MODIFY! Get the current components with [World.Ids].
+	Added, Removed           Mask               // Masks indicating changed components (additions and removals).
+	AddedIDs, RemovedIDs     []ID               // Components added and removed. DO NOT MODIFY! Get the current components with [World.Ids].
 	OldRelation, NewRelation *ID                // Old and new relation component ID. No relation is indicated by nil.
 	OldTarget                Entity             // Old relation target entity. Get the new target with [World.Relations] and [Relations.Get].
 	EventTypes               event.Subscription // Bit mask of event types. See [event.Subscription].
diff --git a/ecs/util.go b/ecs/util.go
index 2f74d51..74652a0 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -65,12 +65,37 @@ func subscription(entityCreated, entityRemoved, componentAdded, componentRemoved
 	return bits
 }
 
-// Returns whether a listener that subscribes to an event is also interested in terms of component subscription.
-func subscribes(evtType event.Subscription, changed *Mask, subs *Mask, oldRel *ID, newRel *ID) bool {
-	if event.Relations.Contains(evtType) {
-		return subs == nil || (oldRel != nil && subs.Get(*oldRel)) || (newRel != nil && subs.Get(*newRel))
+// Returns whether a listener is interested in an event based on event type and component subscriptions.
+//
+// Argument trigger should only contain the subscription bits that triggered the event.
+// I.e. subscriptions & evenTypes.
+func subscribes(trigger event.Subscription, added *Mask, removed *Mask, subs *Mask, oldRel *ID, newRel *ID) bool {
+	if trigger == 0 {
+		return false
+	}
+	if subs == nil {
+		// No component subscriptions
+		return true
+	}
+	if trigger.ContainsAny(event.Relations) {
+		// Contains event.RelationChanged and/or event.TargetChanged
+		if (oldRel != nil && subs.Get(*oldRel)) || (newRel != nil && subs.Get(*newRel)) {
+			return true
+		}
+	}
+	if trigger.ContainsAny(event.EntityCreated | event.ComponentAdded) {
+		// Contains additions-like types
+		if added != nil && subs.ContainsAny(added) {
+			return true
+		}
+	}
+	if trigger.ContainsAny(event.EntityRemoved | event.ComponentRemoved) {
+		// Contains additions-like types
+		if removed != nil && subs.ContainsAny(removed) {
+			return true
+		}
 	}
-	return subs == nil || subs.ContainsAny(changed)
+	return false
 }
 
 func maskToTypes(mask Mask, reg *componentRegistry) []componentType {
diff --git a/ecs/world.go b/ecs/world.go
index b924ac0..e14b5ae 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -214,9 +214,9 @@ func (w *World) NewEntity(comps ...ID) Entity {
 			newRel = &arch.RelationComponent
 		}
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false)
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, newRel) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, comps, nil, nil, newRel, Entity{}, bits})
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
+			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
 		}
 	}
 	return entity
@@ -263,9 +263,9 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 			newRel = &arch.RelationComponent
 		}
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false)
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, newRel) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, ids, nil, nil, newRel, Entity{}, bits})
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
+			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
 		}
 	}
 	return entity
@@ -294,9 +294,9 @@ func (w *World) newEntityTarget(targetID ID, target Entity, comps ...ID) Entity
 
 	if w.listener != nil {
 		bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero())
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, &targetID) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, comps, nil, nil, &targetID, Entity{}, bits})
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, &targetID) {
+			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, &targetID, Entity{}, bits})
 		}
 	}
 	return entity
@@ -331,9 +331,9 @@ func (w *World) newEntityTargetWith(targetID ID, target Entity, comps ...Compone
 
 	if w.listener != nil {
 		bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero())
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, &targetID) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, ids, nil, nil, &targetID, Entity{}, bits})
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, &targetID) {
+			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, &targetID, Entity{}, bits})
 		}
 	}
 	return entity
@@ -350,14 +350,14 @@ func (w *World) newEntities(count int, targetID ID, hasTarget bool, target Entit
 			newRel = &arch.RelationComponent
 		}
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero())
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, newRel) {
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
 			cnt := uint32(count)
 			var i uint32
 			for i = 0; i < cnt; i++ {
 				idx := startIdx + i
 				entity := arch.GetEntity(idx)
-				w.listener.Notify(w, EntityEvent{entity, arch.Mask, comps, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
 			}
 		}
 	}
@@ -395,14 +395,14 @@ func (w *World) newEntitiesWith(count int, targetID ID, hasTarget bool, target E
 			newRel = &arch.RelationComponent
 		}
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero())
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &arch.Mask, w.listener.Components(), nil, newRel) {
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
 			var i uint32
 			cnt := uint32(count)
 			for i = 0; i < cnt; i++ {
 				idx := startIdx + i
 				entity := arch.GetEntity(idx)
-				w.listener.Notify(w, EntityEvent{entity, arch.Mask, ids, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
 			}
 		}
 	}
@@ -453,10 +453,10 @@ func (w *World) RemoveEntity(entity Entity) {
 		}
 
 		bits := subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !oldArch.RelationTarget.IsZero())
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &oldArch.Mask, w.listener.Components(), oldRel, nil) {
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, nil, &oldArch.Mask, w.listener.Components(), oldRel, nil) {
 			lock := w.lock()
-			w.listener.Notify(w, EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
+			w.listener.Notify(w, EntityEvent{entity, Mask{}, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
 			w.unlock(lock)
 		}
 	}
@@ -517,15 +517,15 @@ func (w *World) removeEntities(filter Filter) int {
 				oldIds = arch.node.Ids
 			}
 			bits = subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !arch.RelationTarget.IsZero())
-			listen = w.listener.Subscriptions().ContainsAny(bits) &&
-				subscribes(bits, &arch.Mask, w.listener.Components(), oldRel, nil)
+			trigger := w.listener.Subscriptions() & bits
+			listen = trigger != 0 && subscribes(trigger, nil, &arch.Mask, w.listener.Components(), oldRel, nil)
 		}
 
 		var j uint32
 		for j = 0; j < ln; j++ {
 			entity := arch.GetEntity(j)
 			if listen {
-				w.listener.Notify(w, EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, Mask{}, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
 			}
 			index := &w.entities[entity.id]
 			index.arch = nil
@@ -778,10 +778,13 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 		targChanged := oldTarget != arch.RelationTarget
 
 		bits := subscription(false, false, len(add) > 0, len(rem) > 0, relChanged, targChanged)
-		if w.listener.Subscriptions().ContainsAny(bits) {
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 {
 			changed := oldMask.Xor(&arch.Mask)
-			if subscribes(bits, &changed, w.listener.Components(), oldRel, newRel) {
-				w.listener.Notify(w, EntityEvent{entity, changed, add, rem, oldRel, newRel, oldTarget, bits})
+			added := arch.Mask.And(&changed)
+			removed := oldMask.And(&changed)
+			if subscribes(trigger, &added, &removed, w.listener.Components(), oldRel, newRel) {
+				w.listener.Notify(w, EntityEvent{entity, added, removed, add, rem, oldRel, newRel, oldTarget, bits})
 			}
 		}
 	}
@@ -980,9 +983,11 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 	oldTarget := oldArch.RelationTarget
 	w.cleanupArchetype(oldArch)
 
-	if w.listener != nil && w.listener.Subscriptions().Contains(event.TargetChanged) &&
-		subscribes(event.TargetChanged, nil, w.listener.Components(), &comp, &comp) {
-		w.listener.Notify(w, EntityEvent{entity, Mask{}, nil, nil, &comp, &comp, oldTarget, event.TargetChanged})
+	if w.listener != nil {
+		trigger := w.listener.Subscriptions() & event.TargetChanged
+		if trigger != 0 && subscribes(trigger, nil, nil, w.listener.Components(), &comp, &comp) {
+			w.listener.Notify(w, EntityEvent{entity, Mask{}, Mask{}, nil, nil, &comp, &comp, oldTarget, event.TargetChanged})
+		}
 	}
 }
 
@@ -1691,7 +1696,7 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 		}
 
 		event := EntityEvent{
-			Entity{}, arch.Mask, batchArch.Added, batchArch.Removed,
+			Entity{}, arch.Mask, Mask{}, batchArch.Added, batchArch.Removed,
 			nil, newRel,
 			Entity{}, 0,
 		}
@@ -1710,7 +1715,9 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 				relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel
 			}
 			targChanged = oldArch.RelationTarget != arch.RelationTarget
-			event.Changed = event.Changed.Xor(&oldArch.node.Mask)
+			changed := event.Added.Xor(&oldArch.node.Mask)
+			event.Added = changed.And(&event.Added)
+			event.Removed = changed.And(&oldArch.node.Mask)
 			event.OldTarget = oldArch.RelationTarget
 			event.OldRelation = oldRel
 		}
@@ -1718,8 +1725,8 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 		bits := subscription(oldArch == nil, false, len(batchArch.Added) > 0, len(batchArch.Removed) > 0, relChanged, targChanged)
 		event.EventTypes = bits
 
-		if w.listener.Subscriptions().ContainsAny(bits) &&
-			subscribes(bits, &event.Changed, w.listener.Components(), event.OldRelation, event.NewRelation) {
+		trigger := w.listener.Subscriptions() & bits
+		if trigger != 0 && subscribes(trigger, &event.Added, &event.Removed, w.listener.Components(), event.OldRelation, event.NewRelation) {
 			start, end := batchArch.StartIndex[i], batchArch.EndIndex[i]
 			var e uint32
 			for e = start; e < end; e++ {
diff --git a/examples/events/main.go b/examples/events/main.go
index d4a53c2..ba66a5a 100644
--- a/examples/events/main.go
+++ b/examples/events/main.go
@@ -33,7 +33,7 @@ type EventHandler struct{}
 func (h *EventHandler) Notify(world *ecs.World, evt ecs.EntityEvent) {
 	fmt.Printf("    Type mask:     %06b\n", evt.EventTypes)
 	fmt.Printf("    Entity:        %+v\n", evt.Entity)
-	fmt.Printf("    Added/Removed: %+v / %+v\n", evt.Added, evt.Removed)
+	fmt.Printf("    Added/Removed: %+v / %+v\n", evt.AddedIDs, evt.RemovedIDs)
 	fmt.Printf("    Relation:      %+v -> %+v\n", evt.OldRelation, evt.NewRelation)
 
 	var target ecs.Entity
diff --git a/listener/callback.go b/listener/callback.go
index a949d45..efcd518 100644
--- a/listener/callback.go
+++ b/listener/callback.go
@@ -18,7 +18,7 @@ type Callback struct {
 // NewCallback creates a new Callback listener for the given events.
 //
 // Subscribes to the specified events with changes on the specified components.
-// If no component IDs are given, is subscribes to all components.
+// If no component IDs are given, it subscribes to all components.
 func NewCallback(callback func(*ecs.World, ecs.EntityEvent), events event.Subscription, components ...ecs.ID) Callback {
 	return Callback{
 		callback:      callback,
diff --git a/listener/dispatch.go b/listener/dispatch.go
index 93da6cd..c4a2f73 100644
--- a/listener/dispatch.go
+++ b/listener/dispatch.go
@@ -7,10 +7,10 @@ import (
 
 // Dispatch event listener.
 //
-// Dispatches events to sub-listeners and manages subscription automatically.
-// Sub-listeners should not alter their subscriptions or components after adding them.
+// Dispatches events to sub-listeners and manages subscription automatically, based on their settings.
+// Sub-listeners should not alter their subscriptions or components after being added.
 //
-// To make it possible for systems to add listeners, Dispatch should be added to the [ecs.World] as a resource.
+// To make it possible for systems to add listeners, Dispatch can be added to the [ecs.World] as a resource.
 type Dispatch struct {
 	listeners     []ecs.Listener
 	events        event.Subscription
@@ -56,8 +56,8 @@ func (l *Dispatch) AddListener(ls ecs.Listener) {
 // Notify the listener.
 func (l *Dispatch) Notify(world *ecs.World, evt ecs.EntityEvent) {
 	for _, ls := range l.listeners {
-		if ls.Subscriptions().ContainsAny(evt.EventTypes) &&
-			subscribes(evt.EventTypes, &evt.Changed, ls.Components(), evt.OldRelation, evt.NewRelation) {
+		trigger := ls.Subscriptions() & evt.EventTypes
+		if trigger != 0 && subscribes(trigger, &evt.Added, &evt.Removed, ls.Components(), evt.OldRelation, evt.NewRelation) {
 			ls.Notify(world, evt)
 		}
 	}
@@ -75,10 +75,3 @@ func (l *Dispatch) Components() *ecs.Mask {
 	}
 	return nil
 }
-
-func subscribes(evtType event.Subscription, changed *ecs.Mask, subs *ecs.Mask, oldRel *ecs.ID, newRel *ecs.ID) bool {
-	if event.Relations.Contains(evtType) {
-		return subs == nil || (oldRel != nil && subs.Get(*oldRel)) || (newRel != nil && subs.Get(*newRel))
-	}
-	return subs == nil || subs.ContainsAny(changed)
-}
diff --git a/listener/util.go b/listener/util.go
new file mode 100644
index 0000000..83c75d6
--- /dev/null
+++ b/listener/util.go
@@ -0,0 +1,39 @@
+package listener
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+)
+
+// Returns whether a listener is interested in an event based on event type and component subscriptions.
+//
+// Argument trigger should only contain the subscription bits that triggered the event.
+// I.e. subscriptions & evenTypes.
+func subscribes(trigger event.Subscription, added *ecs.Mask, removed *ecs.Mask, subs *ecs.Mask, oldRel *ecs.ID, newRel *ecs.ID) bool {
+	if trigger == 0 {
+		return false
+	}
+	if subs == nil {
+		// No component subscriptions
+		return true
+	}
+	if trigger.ContainsAny(event.Relations) {
+		// Contains event.RelationChanged and/or event.TargetChanged
+		if (oldRel != nil && subs.Get(*oldRel)) || (newRel != nil && subs.Get(*newRel)) {
+			return true
+		}
+	}
+	if trigger.ContainsAny(event.EntityCreated | event.ComponentAdded) {
+		// Contains additions-like types
+		if added != nil && subs.ContainsAny(added) {
+			return true
+		}
+	}
+	if trigger.ContainsAny(event.EntityRemoved | event.ComponentRemoved) {
+		// Contains additions-like types
+		if removed != nil && subs.ContainsAny(removed) {
+			return true
+		}
+	}
+	return false
+}


Test patch:
diff --git a/ecs/event_test.go b/ecs/event_test.go
index 3acd6e5..dac4137 100644
--- a/ecs/event_test.go
+++ b/ecs/event_test.go
@@ -50,7 +50,7 @@ func BenchmarkEntityEventCreate(b *testing.B) {
 
 	b.StartTimer()
 	for i := 0; i < b.N; i++ {
-		event = ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: nil}
+		event = ecs.EntityEvent{Entity: e, Added: mask, Removed: mask, AddedIDs: added, RemovedIDs: nil}
 	}
 	b.StopTimer()
 	_ = event
@@ -68,7 +68,7 @@ func BenchmarkEntityEventHeapPointer(b *testing.B) {
 
 	b.StartTimer()
 	for i := 0; i < b.N; i++ {
-		event = &ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: nil}
+		event = &ecs.EntityEvent{Entity: e, Added: mask, Removed: mask, AddedIDs: added, RemovedIDs: nil}
 	}
 	b.StopTimer()
 	_ = event
@@ -78,13 +78,13 @@ func BenchmarkEntityEventCopy(b *testing.B) {
 	handler := eventHandler{}
 
 	for i := 0; i < b.N; i++ {
-		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
+		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, Added: ecs.Mask{}, Removed: ecs.Mask{}, AddedIDs: nil, RemovedIDs: nil})
 	}
 }
 
 func BenchmarkEntityEventCopyReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, Added: ecs.Mask{}, Removed: ecs.Mask{}, AddedIDs: nil, RemovedIDs: nil}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenCopy(event)
@@ -95,13 +95,13 @@ func BenchmarkEntityEventPointer(b *testing.B) {
 	handler := eventHandler{}
 
 	for i := 0; i < b.N; i++ {
-		handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
+		handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, Added: ecs.Mask{}, Removed: ecs.Mask{}, AddedIDs: nil, RemovedIDs: nil})
 	}
 }
 
 func BenchmarkEntityEventPointerReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, Added: ecs.Mask{}, Removed: ecs.Mask{}, AddedIDs: nil, RemovedIDs: nil}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenPointer(&event)
@@ -117,7 +117,7 @@ func ExampleEntityEvent() {
 	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
+	// Output: {{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
 }
 
 func ExampleEntityEvent_Contains() {
diff --git a/ecs/util_test.go b/ecs/util_test.go
index 29dec9c..8e5e802 100644
--- a/ecs/util_test.go
+++ b/ecs/util_test.go
@@ -70,31 +70,58 @@ func TestSubscribes(t *testing.T) {
 	id2 := id(2)
 	id3 := id(3)
 
+	assert.False(t,
+		subscribes(0, all(id1), all(id2), all(id1, id2), nil, nil),
+	)
+
 	assert.True(t,
-		subscribes(event.ComponentAdded, all(id1), all(id1, id2), nil, nil),
+		subscribes(event.ComponentAdded, all(id1), nil, all(id1, id2), nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentAdded, nil, all(id1), all(id1, id2), nil, nil),
 	)
 	assert.True(t,
-		subscribes(event.ComponentAdded, all(id1, id2), all(id2), nil, nil),
+		subscribes(event.ComponentAdded, all(id1, id2), nil, all(id2), nil, nil),
 	)
 	assert.False(t,
-		subscribes(event.ComponentAdded, all(id1, id2), all(id3), nil, nil),
+		subscribes(event.ComponentAdded, all(id1, id2), nil, all(id3), nil, nil),
 	)
 
 	assert.True(t,
-		subscribes(event.RelationChanged, &Mask{}, all(id1, id2), nil, &id1),
+		subscribes(event.ComponentRemoved, nil, all(id1), all(id1, id2), nil, nil),
 	)
+	assert.False(t,
+		subscribes(event.ComponentRemoved, all(id1), nil, all(id1, id2), nil, nil),
+	)
+	assert.True(t,
+		subscribes(event.ComponentRemoved, nil, all(id1, id2), all(id2), nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentRemoved, nil, all(id1, id2), all(id3), nil, nil),
+	)
+
+	assert.True(t,
+		subscribes(event.RelationChanged, &Mask{}, &Mask{}, all(id1, id2), nil, &id1),
+	)
+	assert.True(t,
+		subscribes(event.RelationChanged, &Mask{}, &Mask{}, all(id1, id2), &id1, &id3),
+	)
+	assert.False(t,
+		subscribes(event.RelationChanged, &Mask{}, &Mask{}, all(id1), &id2, &id3),
+	)
+
 	assert.True(t,
-		subscribes(event.RelationChanged, &Mask{}, all(id1, id2), &id1, &id3),
+		subscribes(event.TargetChanged, &Mask{}, &Mask{}, all(id1, id2), &id1, &id1),
 	)
 	assert.False(t,
-		subscribes(event.RelationChanged, &Mask{}, all(id1), &id2, &id3),
+		subscribes(event.TargetChanged, &Mask{}, &Mask{}, all(id1, id2), &id3, &id3),
 	)
 
 	assert.True(t,
-		subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id1, &id1),
+		subscribes(event.ComponentAdded|event.ComponentRemoved|event.TargetChanged, all(id1, id2), all(id1, id2), all(id3), &id3, &id3),
 	)
 	assert.False(t,
-		subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id3, &id3),
+		subscribes(event.ComponentAdded|event.ComponentRemoved|event.TargetChanged, all(id1), all(id1), all(id3), &id2, &id2),
 	)
 }
 
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index 93464d2..f89e2e4 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -284,7 +284,7 @@ func ExampleWorld_SetListener() {
 	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
+	// Output: {{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
 }
 
 func ExampleWorld_Stats() {
diff --git a/ecs/world_test.go b/ecs/world_test.go
index d7ffb4b..f9a0da9 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -350,8 +350,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 202, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{202, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[201])
@@ -382,9 +382,10 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 502, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{102, 0},
-		Changed:     All(posID, velID),
-		Added:       []ID{posID},
-		Removed:     []ID{velID},
+		Added:       All(posID),
+		Removed:     All(velID),
+		AddedIDs:    []ID{posID},
+		RemovedIDs:  []ID{velID},
 		OldRelation: &relID,
 		NewRelation: &relID,
 		OldTarget:   target1,
@@ -404,8 +405,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 702, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{102, 0},
-		Changed:     All(relID),
-		Removed:     []ID{relID},
+		Removed:     All(relID),
+		RemovedIDs:  []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
 		OldTarget:   target1,
@@ -418,13 +419,13 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	assert.Equal(t, EntityEvent{
 		Entity:     Entity{102, 0},
-		Changed:    All(posID),
-		Removed:    []ID{posID},
+		Removed:    All(posID),
+		RemovedIDs: []ID{posID},
 		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[801])
 
-	assert.Equal(t, []ID{velID}, events[202].Added)
-	assert.Equal(t, []ID{posID}, events[202].Removed)
+	assert.Equal(t, []ID{velID}, events[202].AddedIDs)
+	assert.Equal(t, []ID{posID}, events[202].RemovedIDs)
 
 	filter = All(velID)
 	w.Batch().Remove(filter, velID)
@@ -818,7 +819,6 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e1,
-		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		OldTarget:   Entity{},
@@ -836,7 +836,6 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e1,
-		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		OldTarget:   targ,
@@ -861,8 +860,8 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e2,
-		Changed:     All(relID),
-		Removed:     []ID{relID},
+		Removed:     All(relID),
+		RemovedIDs:  []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
 		EventTypes:  event.ComponentRemoved | event.RelationChanged,
@@ -1468,8 +1467,8 @@ func TestWorldBatchRemove(t *testing.T) {
 	assert.Equal(t, 33, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{33, 0},
-		Changed:     All(rotID, relID),
-		Added:       []ID{rotID, relID},
+		Added:       All(rotID, relID),
+		AddedIDs:    []ID{rotID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
@@ -1487,8 +1486,8 @@ func TestWorldBatchRemove(t *testing.T) {
 	assert.Equal(t, 43, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{13, 0},
-		Changed:     All(rotID, relID),
-		Removed:     []ID{rotID, relID},
+		Removed:     All(rotID, relID),
+		RemovedIDs:  []ID{rotID, relID},
 		OldRelation: &relID,
 		EventTypes:  event.EntityRemoved | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
@@ -1627,8 +1626,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 3, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:     e0,
-		Changed:    All(posID, velID),
-		Added:      []ID{posID, velID},
+		Added:      All(posID, velID),
+		AddedIDs:   []ID{posID, velID},
 		EventTypes: event.EntityCreated | event.ComponentAdded,
 	}, events[len(events)-1])
 
@@ -1636,8 +1635,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:     e0,
-		Changed:    All(posID, velID),
-		Removed:    []ID{posID, velID},
+		Removed:    All(posID, velID),
+		RemovedIDs: []ID{posID, velID},
 		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[len(events)-1])
 
@@ -1645,8 +1644,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		Changed:     All(posID, velID, relID),
-		Added:       []ID{posID, velID, relID},
+		Added:       All(posID, velID, relID),
+		AddedIDs:    []ID{posID, velID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
@@ -1655,8 +1654,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		Changed:     All(rotID),
-		Added:       []ID{rotID},
+		Added:       All(rotID),
+		AddedIDs:    []ID{rotID},
 		OldRelation: &relID,
 		NewRelation: &relID,
 		EventTypes:  event.ComponentAdded,
@@ -1666,8 +1665,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 7, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		Changed:     All(posID),
-		Removed:     []ID{posID},
+		Removed:     All(posID),
+		RemovedIDs:  []ID{posID},
 		OldRelation: &relID,
 		NewRelation: &relID,
 		EventTypes:  event.ComponentRemoved,
@@ -1678,7 +1677,6 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 9, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		EventTypes:  event.TargetChanged,
@@ -1688,8 +1686,8 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 10, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		Changed:     All(relID),
-		Removed:     []ID{relID},
+		Removed:     All(relID),
+		RemovedIDs:  []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
 		OldTarget:   e1,
@@ -1718,8 +1716,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 11, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{11, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
@@ -1730,8 +1728,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 21, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{21, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
@@ -1741,8 +1739,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 31, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{31, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
@@ -1753,8 +1751,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 41, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{41, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
@@ -1769,8 +1767,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 51, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{51, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
@@ -1781,8 +1779,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 61, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{61, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
@@ -1792,8 +1790,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 71, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{71, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
@@ -1804,8 +1802,8 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 81, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{81, 0},
-		Changed:     All(posID, relID),
-		Added:       []ID{posID, relID},
+		Added:       All(posID, relID),
+		AddedIDs:    []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
diff --git a/listener/util_test.go b/listener/util_test.go
new file mode 100644
index 0000000..0ecb9dd
--- /dev/null
+++ b/listener/util_test.go
@@ -0,0 +1,79 @@
+package listener
+
+import (
+	"testing"
+
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+	"github.com/stretchr/testify/assert"
+)
+
+type comp1 struct{}
+type comp2 struct{}
+type comp3 struct{}
+
+func TestSubscribes(t *testing.T) {
+	world := ecs.NewWorld()
+	id1 := ecs.ComponentID[comp1](&world)
+	id2 := ecs.ComponentID[comp2](&world)
+	id3 := ecs.ComponentID[comp3](&world)
+
+	m1 := ecs.All(id1)
+	m12 := ecs.All(id1, id2)
+	m2 := ecs.All(id2)
+	m3 := ecs.All(id3)
+
+	assert.False(t,
+		subscribes(0, &m1, nil, &m12, nil, nil),
+	)
+
+	assert.True(t,
+		subscribes(event.ComponentAdded, &m1, nil, &m12, nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentAdded, nil, &m1, &m12, nil, nil),
+	)
+	assert.True(t,
+		subscribes(event.ComponentAdded, &m12, nil, &m2, nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentAdded, &m12, nil, &m3, nil, nil),
+	)
+
+	assert.True(t,
+		subscribes(event.ComponentRemoved, nil, &m1, &m12, nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentRemoved, &m1, nil, &m12, nil, nil),
+	)
+	assert.True(t,
+		subscribes(event.ComponentRemoved, nil, &m12, &m2, nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentRemoved, nil, &m12, &m3, nil, nil),
+	)
+
+	assert.True(t,
+		subscribes(event.RelationChanged, nil, nil, &m12, nil, &id1),
+	)
+	assert.True(t,
+		subscribes(event.RelationChanged, nil, nil, &m12, &id1, &id3),
+	)
+	assert.False(t,
+		subscribes(event.RelationChanged, nil, nil, &m1, &id2, &id3),
+	)
+
+	assert.True(t,
+		subscribes(event.TargetChanged, nil, nil, &m12, &id1, &id1),
+	)
+	assert.False(t,
+		subscribes(event.TargetChanged, nil, nil, &m12, &id3, &id3),
+	)
+
+	assert.True(t,
+		subscribes(event.ComponentAdded|event.ComponentRemoved|event.TargetChanged, &m12, &m12, &m3, &id3, &id3),
+	)
+	assert.False(t,
+		subscribes(event.ComponentAdded|event.ComponentRemoved|event.TargetChanged, &m1, &m1, &m3, &id2, &id2),
+	)
+}