Repository: mlange-42/arche
Pull request #335 (2024-01-16 12:15:09)

Listener component subscriptions
* Events are filtered by component IDs in addition to event types
* `Mask` has new methods `And`, `Or` and `Xor`

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


* **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/65098312/badge)](https://coveralls.io/builds/65098312) |
| :-- | --: |
| Change from base [Build 7543381373](https://coveralls.io/builds/65098229): |  0.0% |
| Covered Lines: | 5181 |
| Relevant Lines: | 5181 |

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


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a22f7a3..44c817a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@
 * Arche supports full world serialization and deserialization, in conjunction with [arche-serde](https://github.com/mlange-42/arche-serde) (#319)
 * Supports 256 instead of 128 component types as well as resource types and engine locks (#313)
 * Generic API supports up to 12 instead of 8 component types (#324)
-* Reworked event system with independent subscription for different event types (#333, #334)
+* Reworked event system with granular subscription to different event types and components (#333, #334, #335)
 
 ### Breaking changes
 
@@ -26,10 +26,12 @@ This change was necessary to get the same performance as before, despite the mor
 * Entities support JSON marshalling and unmarshalling (#319)
 * The world's entity state can be extracted and re-established via `World.DumpEntities()` and `World.LoadEntities()` (#319, #326)
 * Adds functions `ComponentIDs(*World)` and `ResourceIDs(*World)` to get all registered IDs (#329)
+* Adds methods `Mask.And`, `Mask.Or` and `Mask.Xor` (#335)
 
 ### 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)
 
 ### Other
 
diff --git a/README.md b/README.md
index 91196dd..b29d113 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,9 @@
 * Simple [core API](https://pkg.go.dev/github.com/mlange-42/arche/ecs). See the [API docs](https://pkg.go.dev/github.com/mlange-42/arche).
 * Optional logic [filter](https://pkg.go.dev/github.com/mlange-42/arche/filter) and type-safe [generic](https://pkg.go.dev/github.com/mlange-42/arche/generic) API.
 * Entity relations as first-class feature. See [Architecture](https://github.com/mlange-42/arche/blob/main/ARCHITECTURE.md#entity-relations).
+* Supports serialization and deserialization of the entire world state via [arche-serde](https://github.com/mlange-42/arche-serde).
 * No systems. Just queries. Use your own structure (or the [Tools](#tools)).
 * No dependencies. Except for unit tests ([100% coverage](https://coveralls.io/github/mlange-42/arche)).
-* Supports serialization and deserialization of the entire world state via [arche-serde](https://github.com/mlange-42/arche-serde).
 * Probably the fastest Go ECS out there. See the [Benchmarks](#benchmarks).
 
 ## Installation
diff --git a/doc.go b/doc.go
index 3b38b96..1090679 100644
--- a/doc.go
+++ b/doc.go
@@ -1,6 +1,7 @@
 // Package arche is an archetype-based Entity Component System for Go.
 //
 // See the sub-packages:
+//
 //   - Core API -- [github.com/mlange-42/arche/ecs]
 //   - Generic queries -- [github.com/mlange-42/arche/generic]
 //   - Advanced filters -- [github.com/mlange-42/arche/filter]
diff --git a/ecs/bitmask.go b/ecs/bitmask.go
index bdd55ad..31cfe9b 100644
--- a/ecs/bitmask.go
+++ b/ecs/bitmask.go
@@ -1,6 +1,8 @@
 package ecs
 
-import "math/bits"
+import (
+	"math/bits"
+)
 
 // MaskTotalBits is the size of Mask in bits.
 //
@@ -110,6 +112,42 @@ func (b *Mask) ContainsAny(other *Mask) bool {
 		b.bits[3]&other.bits[3] != 0
 }
 
+// And returns the bitwise AND of two masks.
+func (b *Mask) And(other *Mask) Mask {
+	return Mask{
+		bits: [4]uint64{
+			b.bits[0] & other.bits[0],
+			b.bits[1] & other.bits[1],
+			b.bits[2] & other.bits[2],
+			b.bits[3] & other.bits[3],
+		},
+	}
+}
+
+// Or returns the bitwise OR of two masks.
+func (b *Mask) Or(other *Mask) Mask {
+	return Mask{
+		bits: [4]uint64{
+			b.bits[0] | other.bits[0],
+			b.bits[1] | other.bits[1],
+			b.bits[2] | other.bits[2],
+			b.bits[3] | other.bits[3],
+		},
+	}
+}
+
+// Xor returns the bitwise XOR of two masks.
+func (b *Mask) Xor(other *Mask) Mask {
+	return Mask{
+		bits: [4]uint64{
+			b.bits[0] ^ other.bits[0],
+			b.bits[1] ^ other.bits[1],
+			b.bits[2] ^ other.bits[2],
+			b.bits[3] ^ other.bits[3],
+		},
+	}
+}
+
 // TotalBitsSet returns how many bits are set in this mask.
 func (b *Mask) TotalBitsSet() int {
 	return bits.OnesCount64(b.bits[0]) + bits.OnesCount64(b.bits[1]) + bits.OnesCount64(b.bits[2]) + bits.OnesCount64(b.bits[3])
diff --git a/ecs/doc.go b/ecs/doc.go
index 9730e47..91e184b 100644
--- a/ecs/doc.go
+++ b/ecs/doc.go
@@ -16,5 +16,10 @@
 //     with [Cache.Register] and [Cache.Unregister].
 //   - [Resources] provide a storage for global resources, with functionality like
 //     [Resources.Get], [Resources.Add] and [Resources.Remove].
+//   - [Listener] provides [EntityEvent] notifications for ECS operations.
 //   - Useful functions: [All], [ComponentID], [ResourceID], [GetResource], [AddResource].
+//
+// # Sub-packages
+//   - [github.com/mlange-42/arche/ecs/event] provides event subscription masks.
+//   - [github.com/mlange-42/arche/ecs/stats] provides world statistics for monitoring purposes.
 package ecs
diff --git a/ecs/event.go b/ecs/event.go
index ebfa0a7..efae6fb 100644
--- a/ecs/event.go
+++ b/ecs/event.go
@@ -25,11 +25,11 @@ 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.
-	OldMask                  Mask               // The old component masks. Get the new mask with [World.Mask].
+	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].
 	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 [Subscription].
+	EventTypes               event.Subscription // Bit mask of event types. See [event.Subscription].
 }
 
 // Contains returns whether the event's types contain the given type/subscription bit.
@@ -37,35 +37,47 @@ func (e *EntityEvent) Contains(bit event.Subscription) bool {
 	return e.EventTypes.Contains(bit)
 }
 
-// Listener interface for listening to [EntityEvent]s.
+// Listener interface for listening to [EntityEvent] notifications.
+//
+// A listener can be added to a [World] with [World.SetListener].
+//
 // See [EntityEvent] for details.
+// See package [github.com/mlange-42/arche/listener] for Listener implementations.
 type Listener interface {
 	// Notify the listener about a subscribed event.
-	Notify(evt EntityEvent)
+	Notify(world *World, evt EntityEvent)
 	// Subscriptions to event types.
 	Subscriptions() event.Subscription
+	// Components the listener subscribes to.
+	Components() *Mask
 }
 
 // testListener for [EntityEvent]s.
 type testListener struct {
-	Callback  func(e EntityEvent)
+	Callback  func(world *World, e EntityEvent)
 	Subscribe event.Subscription
 }
 
 // newTestListener creates a new [CallbackListener] that subscribes to all event types.
-func newTestListener(callback func(e EntityEvent)) testListener {
+func newTestListener(callback func(world *World, e EntityEvent)) testListener {
 	return testListener{
 		Callback:  callback,
 		Subscribe: event.EntityCreated | event.EntityRemoved | event.ComponentAdded | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}
 }
 
-// Notify the listener
-func (l *testListener) Notify(e EntityEvent) {
-	l.Callback(e)
+// Notify the listener.
+func (l *testListener) Notify(world *World, e EntityEvent) {
+	l.Callback(world, e)
 }
 
-// Subscriptions of the listener
+// Subscriptions of the listener in terms of event types.
 func (l *testListener) Subscriptions() event.Subscription {
 	return l.Subscribe
 }
+
+// Components the listener subscribes to.
+// Will be notified about changes on any (not all!) of the components in the mask.
+func (l *testListener) Components() *Mask {
+	return nil
+}
diff --git a/ecs/event/event.go b/ecs/event/event.go
index 010e7d3..5ac49e1 100644
--- a/ecs/event/event.go
+++ b/ecs/event/event.go
@@ -6,17 +6,17 @@ package event
 // Subscription bits for individual events
 const (
 	// EntityCreated subscription bit
-	EntityCreated Subscription = 0b00000001
+	EntityCreated Subscription = 1
 	// EntityRemoved subscription bit
-	EntityRemoved Subscription = 0b00000010
+	EntityRemoved Subscription = 1 << 1
 	// ComponentAdded subscription bit
-	ComponentAdded Subscription = 0b00000100
+	ComponentAdded Subscription = 1 << 2
 	// ComponentRemoved subscription bit
-	ComponentRemoved Subscription = 0b000001000
+	ComponentRemoved Subscription = 1 << 3
 	// RelationChanged subscription bit
-	RelationChanged Subscription = 0b000010000
+	RelationChanged Subscription = 1 << 4
 	// TargetChanged subscription bit
-	TargetChanged Subscription = 0b000100000
+	TargetChanged Subscription = 1 << 5
 )
 
 // Subscription bits for groups of events
diff --git a/ecs/util.go b/ecs/util.go
index fa44944..2f74d51 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -65,6 +65,14 @@ 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))
+	}
+	return subs == nil || subs.ContainsAny(changed)
+}
+
 func maskToTypes(mask Mask, reg *componentRegistry) []componentType {
 	count := int(mask.TotalBitsSet())
 	types := make([]componentType, count)
diff --git a/ecs/world.go b/ecs/world.go
index a2768d1..b924ac0 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -214,8 +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) {
-			w.listener.Notify(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
+		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})
 		}
 	}
 	return entity
@@ -262,8 +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) {
-			w.listener.Notify(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
+		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})
 		}
 	}
 	return entity
@@ -292,8 +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) {
-			w.listener.Notify(EntityEvent{entity, Mask{}, comps, nil, nil, &targetID, Entity{}, bits})
+		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})
 		}
 	}
 	return entity
@@ -328,8 +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) {
-			w.listener.Notify(EntityEvent{entity, Mask{}, ids, nil, nil, &targetID, Entity{}, bits})
+		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})
 		}
 	}
 	return entity
@@ -346,13 +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) {
+		if w.listener.Subscriptions().ContainsAny(bits) &&
+			subscribes(bits, &arch.Mask, 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(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, arch.Mask, comps, nil, nil, newRel, Entity{}, bits})
 			}
 		}
 	}
@@ -390,13 +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) {
+		if w.listener.Subscriptions().ContainsAny(bits) &&
+			subscribes(bits, &arch.Mask, 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(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, arch.Mask, ids, nil, nil, newRel, Entity{}, bits})
 			}
 		}
 	}
@@ -447,9 +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) {
+		if w.listener.Subscriptions().ContainsAny(bits) &&
+			subscribes(bits, &oldArch.Mask, w.listener.Components(), oldRel, nil) {
 			lock := w.lock()
-			w.listener.Notify(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
+			w.listener.Notify(w, EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
 			w.unlock(lock)
 		}
 	}
@@ -510,14 +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)
+			listen = w.listener.Subscriptions().ContainsAny(bits) &&
+				subscribes(bits, &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(EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
 			}
 			index := &w.entities[entity.id]
 			index.arch = nil
@@ -771,7 +779,10 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 
 		bits := subscription(false, false, len(add) > 0, len(rem) > 0, relChanged, targChanged)
 		if w.listener.Subscriptions().ContainsAny(bits) {
-			w.listener.Notify(EntityEvent{entity, oldMask, add, rem, oldRel, newRel, oldTarget, bits})
+			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})
+			}
 		}
 	}
 }
@@ -969,8 +980,9 @@ 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) {
-		w.listener.Notify(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, event.TargetChanged})
+	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})
 	}
 }
 
@@ -1679,7 +1691,7 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 		}
 
 		event := EntityEvent{
-			Entity{}, Mask{}, batchArch.Added, batchArch.Removed,
+			Entity{}, arch.Mask, batchArch.Added, batchArch.Removed,
 			nil, newRel,
 			Entity{}, 0,
 		}
@@ -1698,7 +1710,7 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 				relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel
 			}
 			targChanged = oldArch.RelationTarget != arch.RelationTarget
-			event.OldMask = oldArch.node.Mask
+			event.Changed = event.Changed.Xor(&oldArch.node.Mask)
 			event.OldTarget = oldArch.RelationTarget
 			event.OldRelation = oldRel
 		}
@@ -1706,13 +1718,14 @@ 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) {
+		if w.listener.Subscriptions().ContainsAny(bits) &&
+			subscribes(bits, &event.Changed, w.listener.Components(), event.OldRelation, event.NewRelation) {
 			start, end := batchArch.StartIndex[i], batchArch.EndIndex[i]
 			var e uint32
 			for e = start; e < end; e++ {
 				entity := arch.GetEntity(e)
 				event.Entity = entity
-				w.listener.Notify(event)
+				w.listener.Notify(w, event)
 			}
 		}
 	}
diff --git a/examples/change_listener/main.go b/examples/change_listener/main.go
index e5db5f1..b1a6887 100644
--- a/examples/change_listener/main.go
+++ b/examples/change_listener/main.go
@@ -27,30 +27,15 @@ type Rotation struct {
 	A float64
 }
 
-// TestListener type
-type TestListener struct {
-	World *ecs.World
-}
-
-// Notify is called on entity changes.
-func (l *TestListener) Notify(evt ecs.EntityEvent) {
-	// Just prints out what the event is about.
-	// This could also be a method of a type that manages events.
-	// Could use e.g. filters to distribute events to interested/registered systems.
-	if evt.Contains(event.EntityCreated) {
-		fmt.Printf("Entity added, has components %v\n", l.World.Ids(evt.Entity))
-	} else if evt.Contains(event.EntityRemoved) {
-		fmt.Printf("Entity removed, had components %v\n", l.World.Ids(evt.Entity))
-	} else {
-		fmt.Printf("Entity changed, has components %v\n", l.World.Ids(evt.Entity))
-	}
-}
-
 func main() {
 	// Create a World.
 	world := ecs.NewWorld()
-	ls := TestListener{World: &world}
-	wrapper := listener.NewCallback(event.Entities|event.Components, ls.Notify)
+	listener := listener.NewCallback(
+		func(world *ecs.World, evt ecs.EntityEvent) {
+			fmt.Printf("Events: %08b Added: %v Removed: %v\n", evt.EventTypes, evt.Added, evt.Removed)
+		},
+		event.Entities|event.Components,
+	)
 
 	// Get component IDs
 	posID := ecs.ComponentID[Position](&world)
@@ -58,7 +43,7 @@ func main() {
 	rotID := ecs.ComponentID[Rotation](&world)
 
 	// Register a listener function.
-	world.SetListener(&wrapper)
+	world.SetListener(&listener)
 
 	// Create/manipulate/delete entities and observe the listener's output
 	e0 := world.NewEntity(posID)
diff --git a/listener/callback.go b/listener/callback.go
index ee78bec..a949d45 100644
--- a/listener/callback.go
+++ b/listener/callback.go
@@ -9,24 +9,39 @@ import (
 //
 // Calls a function on events that are contained in the subscription mask.
 type Callback struct {
-	callback func(e ecs.EntityEvent)
-	events   event.Subscription
+	callback      func(w *ecs.World, e ecs.EntityEvent)
+	events        event.Subscription
+	components    ecs.Mask
+	hasComponents bool
 }
 
 // NewCallback creates a new Callback listener for the given events.
-func NewCallback(events event.Subscription, callback func(ecs.EntityEvent)) Callback {
+//
+// Subscribes to the specified events with changes on the specified components.
+// If no component IDs are given, is subscribes to all components.
+func NewCallback(callback func(*ecs.World, ecs.EntityEvent), events event.Subscription, components ...ecs.ID) Callback {
 	return Callback{
-		callback: callback,
-		events:   events,
+		callback:      callback,
+		events:        events,
+		components:    ecs.All(components...),
+		hasComponents: len(components) > 0,
 	}
 }
 
 // Notify the listener.
-func (l *Callback) Notify(e ecs.EntityEvent) {
-	l.callback(e)
+func (l *Callback) Notify(w *ecs.World, e ecs.EntityEvent) {
+	l.callback(w, e)
 }
 
 // Subscriptions of the listener.
 func (l *Callback) Subscriptions() event.Subscription {
 	return l.events
 }
+
+// Components the listener subscribes to.
+func (l *Callback) Components() *ecs.Mask {
+	if l.hasComponents {
+		return &l.components
+	}
+	return nil
+}
diff --git a/listener/dispatch.go b/listener/dispatch.go
new file mode 100644
index 0000000..93da6cd
--- /dev/null
+++ b/listener/dispatch.go
@@ -0,0 +1,84 @@
+package listener
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+)
+
+// Dispatch event listener.
+//
+// Dispatches events to sub-listeners and manages subscription automatically.
+// Sub-listeners should not alter their subscriptions or components after adding them.
+//
+// To make it possible for systems to add listeners, Dispatch should be added to the [ecs.World] as a resource.
+type Dispatch struct {
+	listeners     []ecs.Listener
+	events        event.Subscription
+	components    ecs.Mask
+	hasComponents bool
+}
+
+// NewDispatch returns a new [Dispatched] listener with the given sub-listeners.
+func NewDispatch(listeners ...ecs.Listener) Dispatch {
+	var events event.Subscription = 0
+	var components ecs.Mask
+	hasComponents := true
+	for _, l := range listeners {
+		events |= l.Subscriptions()
+		cmp := l.Components()
+		if cmp == nil {
+			hasComponents = false
+		} else {
+			components = components.Or(cmp)
+		}
+	}
+	return Dispatch{
+		listeners:     listeners,
+		events:        events,
+		components:    components,
+		hasComponents: hasComponents,
+	}
+}
+
+// AddListener adds a sub-listener to this listener.
+func (l *Dispatch) AddListener(ls ecs.Listener) {
+	l.listeners = append(l.listeners, ls)
+	l.events |= ls.Subscriptions()
+
+	cmp := l.Components()
+	if cmp == nil {
+		l.hasComponents = false
+	} else {
+		l.components = l.components.Or(cmp)
+	}
+}
+
+// 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) {
+			ls.Notify(world, evt)
+		}
+	}
+}
+
+// Subscriptions of the listener.
+func (l *Dispatch) Subscriptions() event.Subscription {
+	return l.events
+}
+
+// Components the listener subscribes to.
+func (l *Dispatch) Components() *ecs.Mask {
+	if l.hasComponents {
+		return &l.components
+	}
+	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/dispatched.go b/listener/dispatched.go
deleted file mode 100644
index 97dc3a9..0000000
--- a/listener/dispatched.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package listener
-
-import (
-	"github.com/mlange-42/arche/ecs"
-	"github.com/mlange-42/arche/ecs/event"
-)
-
-// Dispatched event listener.
-type Dispatched struct {
-	listeners []ecs.Listener
-	events    event.Subscription
-}
-
-// NewDispatched returns a new [Dispatched] listener with sub-listeners.
-func NewDispatched(listeners ...ecs.Listener) Dispatched {
-	var events event.Subscription = 0
-	for _, l := range listeners {
-		events |= l.Subscriptions()
-	}
-	return Dispatched{
-		listeners: listeners,
-		events:    events,
-	}
-}
-
-// AddListener adds a listener to this listener.
-func (l *Dispatched) AddListener(ls ecs.Listener) {
-	l.listeners = append(l.listeners, ls)
-	l.events |= ls.Subscriptions()
-}
-
-// Notify the listener.
-func (l *Dispatched) Notify(e ecs.EntityEvent) {
-	for _, ls := range l.listeners {
-		if ls.Subscriptions().ContainsAny(e.EventTypes) {
-			ls.Notify(e)
-		}
-	}
-}
-
-// Subscriptions of the listener.
-func (l *Dispatched) Subscriptions() event.Subscription {
-	return l.events
-}


Test patch:
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index 8a608fd..c8ab4e5 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -51,6 +51,12 @@ func TestBitMask(t *testing.T) {
 	assert.False(t, mask.ContainsAny(&other2))
 }
 
+func TestBitMaskLogic(t *testing.T) {
+	assert.Equal(t, All(id(5)), all(id(0), id(5)).And(all(id(5), id(200))))
+	assert.Equal(t, All(id(0), id(5), id(200)), all(id(0), id(5)).Or(all(id(5), id(200))))
+	assert.Equal(t, All(id(0), id(200)), all(id(0), id(5)).Xor(all(id(5), id(200))))
+}
+
 func TestBitMaskCopy(t *testing.T) {
 	mask := All(id(1), id(2), id(13), id(27), id(200))
 	mask2 := mask
diff --git a/ecs/event/event_test.go b/ecs/event/event_test.go
index 09731f6..21f8050 100644
--- a/ecs/event/event_test.go
+++ b/ecs/event/event_test.go
@@ -1,6 +1,7 @@
 package event_test
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/mlange-42/arche/ecs/event"
@@ -16,3 +17,24 @@ func TestSubscriptions(t *testing.T) {
 	assert.True(t, m1.ContainsAny(event.ComponentAdded|event.TargetChanged))
 	assert.False(t, m1.Contains(event.ComponentAdded|event.RelationChanged))
 }
+
+func ExampleSubscription() {
+	mask := event.EntityCreated | event.EntityRemoved
+
+	fmt.Printf("%08b contains\n%08b -> %t\n\n", mask, event.EntityRemoved, mask.Contains(event.EntityRemoved))
+	fmt.Printf("%08b contains\n%08b -> %t\n\n", mask, event.ComponentAdded, mask.Contains(event.ComponentAdded))
+
+	fmt.Printf("%08b contains any\n%08b -> %t\n\n", mask, event.EntityRemoved|event.ComponentAdded, mask.ContainsAny(event.EntityRemoved|event.ComponentAdded))
+	fmt.Printf("%08b contains any\n%08b -> %t\n\n", mask, event.ComponentAdded|event.ComponentRemoved, mask.ContainsAny(event.ComponentAdded|event.ComponentRemoved))
+	// Output: 00000011 contains
+	// 00000010 -> true
+	//
+	// 00000011 contains
+	// 00000100 -> false
+	//
+	// 00000011 contains any
+	// 00000110 -> true
+	//
+	// 00000011 contains any
+	// 00001100 -> false
+}
diff --git a/ecs/event_test.go b/ecs/event_test.go
index 54d0211..3acd6e5 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, OldMask: mask, Added: added, Removed: nil}
+		event = ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: 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, OldMask: mask, Added: added, Removed: nil}
+		event = &ecs.EntityEvent{Entity: e, Changed: mask, Added: added, Removed: 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{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil})
+		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
 	}
 }
 
 func BenchmarkEntityEventCopyReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: 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{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil})
+		handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil})
 	}
 }
 
 func BenchmarkEntityEventPointerReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, Changed: ecs.Mask{}, Added: nil, Removed: nil}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenPointer(&event)
@@ -111,9 +111,9 @@ func BenchmarkEntityEventPointerReuse(b *testing.B) {
 func ExampleEntityEvent() {
 	world := ecs.NewWorld()
 
-	listener := NewTestListener(
-		func(evt ecs.EntityEvent) { fmt.Println(evt) },
-	)
+	listener := TestListener{
+		Callback: func(world *ecs.World, evt ecs.EntityEvent) { fmt.Println(evt) },
+	}
 	world.SetListener(&listener)
 
 	world.NewEntity()
@@ -121,10 +121,10 @@ func ExampleEntityEvent() {
 }
 
 func ExampleEntityEvent_Contains() {
-	mask := event.EntityCreated | event.EntityRemoved
+	evt := ecs.EntityEvent{EventTypes: event.EntityCreated | event.EntityRemoved}
 
-	fmt.Println(mask.Contains(event.EntityRemoved))
-	fmt.Println(mask.Contains(event.RelationChanged))
+	fmt.Println(evt.Contains(event.EntityRemoved))
+	fmt.Println(evt.Contains(event.RelationChanged))
 	// Output: true
 	// false
 }
diff --git a/ecs/util_test.go b/ecs/util_test.go
index 67baffb..29dec9c 100644
--- a/ecs/util_test.go
+++ b/ecs/util_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 	"unsafe"
 
+	"github.com/mlange-42/arche/ecs/event"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -64,6 +65,39 @@ func TestPagedSlice(t *testing.T) {
 	assert.Equal(t, int32(100), *a.Get(3))
 }
 
+func TestSubscribes(t *testing.T) {
+	id1 := id(1)
+	id2 := id(2)
+	id3 := id(3)
+
+	assert.True(t,
+		subscribes(event.ComponentAdded, all(id1), all(id1, id2), nil, nil),
+	)
+	assert.True(t,
+		subscribes(event.ComponentAdded, all(id1, id2), all(id2), nil, nil),
+	)
+	assert.False(t,
+		subscribes(event.ComponentAdded, all(id1, id2), all(id3), nil, nil),
+	)
+
+	assert.True(t,
+		subscribes(event.RelationChanged, &Mask{}, all(id1, id2), nil, &id1),
+	)
+	assert.True(t,
+		subscribes(event.RelationChanged, &Mask{}, all(id1, id2), &id1, &id3),
+	)
+	assert.False(t,
+		subscribes(event.RelationChanged, &Mask{}, all(id1), &id2, &id3),
+	)
+
+	assert.True(t,
+		subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id1, &id1),
+	)
+	assert.False(t,
+		subscribes(event.TargetChanged, &Mask{}, all(id1, id2), &id3, &id3),
+	)
+}
+
 func TestPagedSlicePointerPersistence(t *testing.T) {
 	a := pagedSlice[int32]{}
 
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index 308f948..d4f5106 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -321,7 +321,7 @@ func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) {
 	world.Batch().RemoveEntities(filterPos)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	listener.Subscribe = 0
 
 	world.SetListener(&listener)
@@ -349,7 +349,7 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) {
 	world.Batch().RemoveEntities(filterPos)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
 
 	for i := 0; i < b.N; i++ {
@@ -428,7 +428,7 @@ func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterVel, pos, vel)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	listener.Subscribe = 0
 	world.SetListener(&listener)
 
@@ -474,7 +474,7 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterVel, pos, vel)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
 
 	b.StartTimer()
@@ -547,7 +547,7 @@ func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterVel, pos, vel)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	listener.Subscribe = 0
 	world.SetListener(&listener)
 
@@ -585,7 +585,7 @@ func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterVel, pos, vel)
 
 	var temp event.Subscription
-	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
 
 	b.StartTimer()
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index 2a7e436..93464d2 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -276,11 +276,11 @@ func ExampleWorld_Batch() {
 func ExampleWorld_SetListener() {
 	world := ecs.NewWorld()
 
-	listener := NewTestListener(
-		func(evt ecs.EntityEvent) {
+	listener := TestListener{
+		Callback: func(world *ecs.World, evt ecs.EntityEvent) {
 			fmt.Println(evt)
 		},
-	)
+	}
 	world.SetListener(&listener)
 
 	world.NewEntity()
@@ -296,22 +296,23 @@ func ExampleWorld_Stats() {
 
 // TestListener for all [EntityEvent]s.
 type TestListener struct {
-	Callback func(e ecs.EntityEvent)
+	Callback func(world *ecs.World, evt ecs.EntityEvent)
 }
 
-// NewTestListener creates a new [TestListener] that subscribes to all event types.
-func NewTestListener(callback func(e ecs.EntityEvent)) TestListener {
-	return TestListener{
-		Callback: callback,
-	}
+// Notify the listener.
+// Calls the Callback.
+func (l *TestListener) Notify(world *ecs.World, evt ecs.EntityEvent) {
+	l.Callback(world, evt)
 }
 
-// Notify the listener
-func (l *TestListener) Notify(e ecs.EntityEvent) {
-	l.Callback(e)
-}
-
-// Subscriptions of the listener
+// Subscriptions of the listener.
+// Subscribes to all events.
 func (l *TestListener) Subscriptions() event.Subscription {
 	return event.All
 }
+
+// Components the listener subscribes to.
+// Subscribes to all components.
+func (l *TestListener) Components() *ecs.Mask {
+	return nil
+}
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 46f2042..d7ffb4b 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -331,7 +331,7 @@ func TestWorldExchangeBatch(t *testing.T) {
 	w := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	w.SetListener(&listener)
@@ -350,6 +350,7 @@ 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},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
@@ -381,7 +382,7 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 502, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{102, 0},
-		OldMask:     All(velID, relID),
+		Changed:     All(posID, velID),
 		Added:       []ID{posID},
 		Removed:     []ID{velID},
 		OldRelation: &relID,
@@ -403,7 +404,7 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 702, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{102, 0},
-		OldMask:     All(posID, relID),
+		Changed:     All(relID),
 		Removed:     []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
@@ -417,7 +418,7 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	assert.Equal(t, EntityEvent{
 		Entity:     Entity{102, 0},
-		OldMask:    All(posID),
+		Changed:    All(posID),
 		Removed:    []ID{posID},
 		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[801])
@@ -601,7 +602,7 @@ func TestWorldNewEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(w *World, e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
@@ -677,7 +678,7 @@ func TestWorldNewEntitiesWith(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
@@ -752,7 +753,7 @@ func TestWorldRemoveEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
@@ -793,7 +794,7 @@ func TestWorldRelationSet(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	world.SetListener(&listener)
@@ -817,7 +818,7 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e1,
-		OldMask:     All(relID, rotID),
+		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		OldTarget:   Entity{},
@@ -835,7 +836,7 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e1,
-		OldMask:     All(relID, rotID),
+		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		OldTarget:   targ,
@@ -860,7 +861,7 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e2,
-		OldMask:     All(relID, rotID),
+		Changed:     All(relID),
 		Removed:     []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
@@ -892,7 +893,7 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	world.SetListener(&listener)
@@ -981,7 +982,7 @@ func TestWorldRelationRemove(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	world.SetListener(&listener)
@@ -1201,7 +1202,7 @@ func TestWorldRelation(t *testing.T) {
 func TestWorldRelationCreate(t *testing.T) {
 	world := NewWorld()
 
-	listener := newTestListener(func(e EntityEvent) {})
+	listener := newTestListener(func(world *World, e EntityEvent) {})
 	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
@@ -1242,7 +1243,7 @@ func TestWorldRelationCreate(t *testing.T) {
 
 func TestWorldRelationMove(t *testing.T) {
 	world := NewWorld()
-	listener := newTestListener(func(e EntityEvent) {})
+	listener := newTestListener(func(world *World, e EntityEvent) {})
 	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
@@ -1440,7 +1441,7 @@ func TestWorldBatchRemove(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	world.SetListener(&listener)
@@ -1467,6 +1468,7 @@ 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},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
@@ -1485,7 +1487,7 @@ func TestWorldBatchRemove(t *testing.T) {
 	assert.Equal(t, 43, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{13, 0},
-		OldMask:     All(rotID, relID),
+		Changed:     All(rotID, relID),
 		Removed:     []ID{rotID, relID},
 		OldRelation: &relID,
 		EventTypes:  event.EntityRemoved | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
@@ -1517,7 +1519,7 @@ func TestWorldBatchRemove(t *testing.T) {
 
 func TestWorldReset(t *testing.T) {
 	world := NewWorld()
-	listener := newTestListener(func(e EntityEvent) {})
+	listener := newTestListener(func(world *World, e EntityEvent) {})
 	world.SetListener(&listener)
 
 	AddResource(&world, &rotation{100})
@@ -1597,7 +1599,7 @@ func TestWorldListener(t *testing.T) {
 	w := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	w.SetListener(&listener)
@@ -1625,6 +1627,7 @@ 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},
 		EventTypes: event.EntityCreated | event.ComponentAdded,
 	}, events[len(events)-1])
@@ -1633,7 +1636,7 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:     e0,
-		OldMask:    All(posID, velID),
+		Changed:    All(posID, velID),
 		Removed:    []ID{posID, velID},
 		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[len(events)-1])
@@ -1642,6 +1645,7 @@ 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},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
@@ -1651,7 +1655,7 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		OldMask:     All(posID, velID, relID),
+		Changed:     All(rotID),
 		Added:       []ID{rotID},
 		OldRelation: &relID,
 		NewRelation: &relID,
@@ -1662,7 +1666,7 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 7, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		OldMask:     All(posID, velID, rotID, relID),
+		Changed:     All(posID),
 		Removed:     []ID{posID},
 		OldRelation: &relID,
 		NewRelation: &relID,
@@ -1674,7 +1678,7 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 9, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		OldMask:     All(velID, rotID, relID),
+		Changed:     All(),
 		OldRelation: &relID,
 		NewRelation: &relID,
 		EventTypes:  event.TargetChanged,
@@ -1684,7 +1688,7 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 10, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      e0,
-		OldMask:     All(velID, rotID, relID),
+		Changed:     All(relID),
 		Removed:     []ID{relID},
 		OldRelation: &relID,
 		NewRelation: nil,
@@ -1698,7 +1702,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	w := NewWorld()
 
 	events := []EntityEvent{}
-	listener := newTestListener(func(e EntityEvent) {
+	listener := newTestListener(func(world *World, e EntityEvent) {
 		events = append(events, e)
 	})
 	w.SetListener(&listener)
@@ -1714,7 +1718,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 11, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{11, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
@@ -1726,7 +1730,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 21, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{21, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
@@ -1737,7 +1741,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 31, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{31, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
@@ -1749,7 +1753,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 41, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{41, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
@@ -1765,7 +1769,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 51, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{51, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
@@ -1777,7 +1781,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 61, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{61, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
@@ -1788,7 +1792,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 71, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{71, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
@@ -1800,7 +1804,7 @@ func TestWorldListenerBuilder(t *testing.T) {
 	assert.Equal(t, 81, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:      Entity{81, 0},
-		OldMask:     All(),
+		Changed:     All(posID, relID),
 		Added:       []ID{posID, relID},
 		NewRelation: &relID,
 		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
diff --git a/listener/callback_test.go b/listener/callback_test.go
index af10a16..88896d6 100644
--- a/listener/callback_test.go
+++ b/listener/callback_test.go
@@ -11,28 +11,72 @@ import (
 )
 
 func TestCallback(t *testing.T) {
+	w := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&w)
+	velID := ecs.ComponentID[Velocity](&w)
+
 	evt := []ecs.EntityEvent{}
 	ls := listener.NewCallback(
-		event.All,
-		func(e ecs.EntityEvent) {
+		func(w *ecs.World, e ecs.EntityEvent) {
 			evt = append(evt, e)
 		},
+		event.All,
+		posID,
 	)
+	w.SetListener(&ls)
+
 	assert.Equal(t, event.All, ls.Subscriptions())
-	ls.Notify(ecs.EntityEvent{})
+	assert.Equal(t, ecs.All(posID), *ls.Components())
+
+	w.NewEntity(posID)
+	assert.Equal(t, 1, len(evt))
+
+	w.NewEntity(velID)
 	assert.Equal(t, 1, len(evt))
 }
 
 func ExampleCallback() {
 	world := ecs.NewWorld()
 
+	posID := ecs.ComponentID[Position](&world)
+
 	ls := listener.NewCallback(
-		event.Entities|event.Components,
-		func(e ecs.EntityEvent) {
-			fmt.Println(e)
+		func(w *ecs.World, e ecs.EntityEvent) {
+			// Print the EventType bits of the event.
+			fmt.Printf("   EventType: %08b\n", e.EventTypes)
 		},
+		// Subscribe to all events.
+		event.All,
 	)
 	world.SetListener(&ls)
 
-	world.NewEntity()
+	fmt.Println("Create entity")
+	e := world.NewEntity()
+
+	fmt.Println("Add component")
+	world.Add(e, posID)
+
+	fmt.Println("Remove component")
+	world.Remove(e, posID)
+
+	fmt.Println("Remove entity")
+	world.RemoveEntity(e)
+
+	fmt.Println("Create entity with component(s)")
+	e = world.NewEntity(posID)
+
+	fmt.Println("Remove entity with component(s)")
+	world.RemoveEntity(e)
+	// Output: Create entity
+	//    EventType: 00000001
+	// Add component
+	//    EventType: 00000100
+	// Remove component
+	//    EventType: 00001000
+	// Remove entity
+	//    EventType: 00000010
+	// Create entity with component(s)
+	//    EventType: 00000101
+	// Remove entity with component(s)
+	//    EventType: 00001010
 }
diff --git a/listener/dispatch_test.go b/listener/dispatch_test.go
new file mode 100644
index 0000000..40fa2c3
--- /dev/null
+++ b/listener/dispatch_test.go
@@ -0,0 +1,203 @@
+package listener_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+	"github.com/mlange-42/arche/listener"
+	"github.com/stretchr/testify/assert"
+)
+
+type EventHandler struct {
+	events []ecs.EntityEvent
+}
+
+func (h *EventHandler) Notify(w *ecs.World, e ecs.EntityEvent) {
+	h.events = append(h.events, e)
+}
+
+func TestDispatch(t *testing.T) {
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	velID := ecs.ComponentID[Velocity](&world)
+
+	h1 := EventHandler{}
+	l1 := listener.NewCallback(
+		h1.Notify,
+		event.Entities,
+		posID,
+	)
+	h2 := EventHandler{}
+	l2 := listener.NewCallback(
+		h2.Notify,
+		event.Components,
+		posID,
+	)
+
+	ls := listener.NewDispatch(&l1)
+	ls.AddListener(&l2)
+	world.SetListener(&ls)
+
+	assert.Equal(t, event.Entities|event.Components, ls.Subscriptions())
+
+	world.NewEntity(posID, velID)
+	e := world.NewEntity()
+	world.Add(e, posID, velID)
+
+	assert.Equal(t, 1, len(h1.events))
+	assert.Equal(t, 2, len(h2.events))
+
+	world.NewEntity(velID)
+	e = world.NewEntity()
+	world.Add(e, velID)
+
+	assert.Equal(t, 1, len(h1.events))
+	assert.Equal(t, 2, len(h2.events))
+}
+
+func TestDispatchRelations(t *testing.T) {
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	rel1ID := ecs.ComponentID[Relation1](&world)
+	rel2ID := ecs.ComponentID[Relation2](&world)
+
+	h1 := EventHandler{}
+	l1 := listener.NewCallback(
+		h1.Notify,
+		event.RelationChanged,
+		rel1ID,
+	)
+	h2 := EventHandler{}
+	l2 := listener.NewCallback(
+		h2.Notify,
+		event.TargetChanged,
+		rel1ID,
+	)
+
+	ls := listener.NewDispatch(&l1)
+	ls.AddListener(&l2)
+	world.SetListener(&ls)
+
+	parent1 := world.NewEntity(posID)
+	parent2 := world.NewEntity(posID)
+
+	builder1 := ecs.NewBuilder(&world, posID, rel1ID).WithRelation(rel1ID)
+	builder2 := ecs.NewBuilder(&world, posID, rel2ID).WithRelation(rel2ID)
+
+	assert.Equal(t, 0, len(h1.events))
+	assert.Equal(t, 0, len(h2.events))
+
+	builder1.NewBatch(10)
+	builder1.NewBatch(10, parent1)
+
+	assert.Equal(t, 20, len(h1.events))
+	assert.Equal(t, 10, len(h2.events))
+
+	builder2.NewBatch(10)
+	builder2.NewBatch(10, parent2)
+
+	assert.Equal(t, 20, len(h1.events))
+	assert.Equal(t, 10, len(h2.events))
+
+	world.Batch().SetRelation(ecs.All(rel1ID), rel1ID, parent2)
+
+	assert.Equal(t, 20, len(h1.events))
+	assert.Equal(t, 30, len(h2.events))
+
+	world.Batch().RemoveEntities(ecs.All(rel1ID))
+
+	assert.Equal(t, 40, len(h1.events))
+	assert.Equal(t, 50, len(h2.events))
+}
+
+func TestDispatchAllComps(t *testing.T) {
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	velID := ecs.ComponentID[Position](&world)
+	relID := ecs.ComponentID[Relation1](&world)
+
+	h1 := EventHandler{}
+	l1 := listener.NewCallback(
+		h1.Notify,
+		event.Components,
+	)
+	h2 := EventHandler{}
+	l2 := listener.NewCallback(
+		h2.Notify,
+		event.Components,
+		posID, velID,
+	)
+
+	ls := listener.NewDispatch(&l1)
+	ls.AddListener(&l2)
+	world.SetListener(&ls)
+
+	world.NewEntity(relID)
+
+	assert.Equal(t, 1, len(h1.events))
+	assert.Equal(t, 0, len(h2.events))
+
+	world.NewEntity(posID)
+
+	assert.Equal(t, 2, len(h1.events))
+	assert.Equal(t, 1, len(h2.events))
+}
+
+type Position struct {
+	X float64
+	Y float64
+}
+
+type Velocity struct {
+	X float64
+	Y float64
+}
+
+type Relation1 struct {
+	ecs.Relation
+}
+
+type Relation2 struct {
+	ecs.Relation
+}
+
+func ExampleDispatch() {
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	velID := ecs.ComponentID[Velocity](&world)
+
+	entityListener := listener.NewCallback(
+		func(w *ecs.World, ee ecs.EntityEvent) { fmt.Println("Entity event") },
+		event.EntityCreated|event.EntityRemoved,
+	)
+	componentListener := listener.NewCallback(
+		func(w *ecs.World, ee ecs.EntityEvent) { fmt.Println("Component event on Position") },
+		event.ComponentAdded|event.ComponentRemoved,
+		posID, // optional restriction of subscribed components
+	)
+
+	mainListener := listener.NewDispatch(
+		&entityListener,
+		&componentListener,
+	)
+
+	world.SetListener(&mainListener)
+
+	// Triggers entityListener
+	e := world.NewEntity()
+	// Triggers componentListener
+	world.Add(e, posID)
+
+	// Triggers entityListener and componentListener
+	_ = world.NewEntity(posID)
+
+	// Triggers entityListener but not componentListener, as it is restricted to posID
+	_ = world.NewEntity(velID)
+	// Output: Entity event
+	// Component event on Position
+	// Entity event
+	// Component event on Position
+	// Entity event
+}
diff --git a/listener/dispatched_test.go b/listener/dispatched_test.go
deleted file mode 100644
index e82391e..0000000
--- a/listener/dispatched_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package listener_test
-
-import (
-	"fmt"
-	"testing"
-
-	"github.com/mlange-42/arche/ecs"
-	"github.com/mlange-42/arche/ecs/event"
-	"github.com/mlange-42/arche/listener"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestDispatched(t *testing.T) {
-	entityEvents := []ecs.EntityEvent{}
-	componentEvents := []ecs.EntityEvent{}
-
-	l1 := listener.NewCallback(event.Entities, func(evt ecs.EntityEvent) { entityEvents = append(entityEvents, evt) })
-	l2 := listener.NewCallback(event.Components, func(evt ecs.EntityEvent) { componentEvents = append(componentEvents, evt) })
-
-	ls := listener.NewDispatched(&l1)
-	ls.AddListener(&l2)
-
-	assert.Equal(t, event.Entities|event.Components, ls.Subscriptions())
-
-	ls.Notify(ecs.EntityEvent{EventTypes: event.EntityCreated | event.ComponentAdded})
-	ls.Notify(ecs.EntityEvent{EventTypes: event.EntityCreated | event.RelationChanged})
-	ls.Notify(ecs.EntityEvent{EventTypes: event.Relations})
-
-	assert.Equal(t, 2, len(entityEvents))
-	assert.Equal(t, 1, len(componentEvents))
-}
-
-type Position struct {
-	X float64
-	Y float64
-}
-
-func ExampleDispatched() {
-	world := ecs.NewWorld()
-
-	posID := ecs.ComponentID[Position](&world)
-
-	entityListener := listener.NewCallback(
-		event.EntityCreated|event.EntityRemoved,
-		func(evt ecs.EntityEvent) { fmt.Println("Entity event") },
-	)
-	componentListener := listener.NewCallback(
-		event.ComponentAdded|event.ComponentRemoved,
-		func(evt ecs.EntityEvent) { fmt.Println("Component event") },
-	)
-
-	mainListener := listener.NewDispatched(
-		&entityListener,
-		&componentListener,
-	)
-
-	world.SetListener(&mainListener)
-
-	// Triggers event.EntityCreated
-	e := world.NewEntity()
-	// Triggers event.ComponentAdded
-	world.Add(e, posID)
-
-	// Triggers event.EntityCreated and event.ComponentAdded
-	_ = world.NewEntity(posID)
-	// Output: Entity event
-	// Component event
-	// Entity event
-	// Component event
-}