Repository: mlange-42/arche
Pull request #334 (2024-01-16 00:01:52)

Listener subscriptions
* Replaces some fields of `EntityEvent` by a bitmask of triggered event types
* Allows filtering events by a subscriptions mask
* Introduces a `Listener` interface
* Splits out subscriptions and default `Listener` implementation into packages

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


* **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/65090032/badge)](https://coveralls.io/builds/65090032) |
| :-- | --: |
| Change from base [Build 7538964871](https://coveralls.io/builds/65089998): |  0.0% |
| Covered Lines: | 5080 |
| Relevant Lines: | 5080 |

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


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60091c3..a22f7a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +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)
 
 ### Breaking changes
 
@@ -16,7 +17,7 @@
 This change was necessary to get the same performance as before, despite the more heavyweight implementation of the now 256 bits `Mask`.
 * Component and resource IDs are now opaque types instead of type aliases for `uint8` (#329)
 * Restructures `EntityEvent` to remove redundant information and better handle relation changes (#333)
-* World event listener function signature changed from `func(*EntityEvent)` to `func(EntityEvent)` (#333)
+* World event listener changed from a simple function to a `Listener` interface (#334)
 
 ### Features
 
diff --git a/doc.go b/doc.go
index c590b60..3b38b96 100644
--- a/doc.go
+++ b/doc.go
@@ -4,5 +4,6 @@
 //   - Core API -- [github.com/mlange-42/arche/ecs]
 //   - Generic queries -- [github.com/mlange-42/arche/generic]
 //   - Advanced filters -- [github.com/mlange-42/arche/filter]
+//   - Event listeners -- [github.com/mlange-42/arche/listener]
 //   - Usage examples -- [github.com/mlange-42/arche/examples]
 package arche
diff --git a/ecs/batch.go b/ecs/batch.go
index 0d4e94a..e708a74 100644
--- a/ecs/batch.go
+++ b/ecs/batch.go
@@ -55,6 +55,9 @@ func (b *Batch) RemoveQ(filter Filter, comps ...ID) Query {
 
 // SetRelation sets the [Relation] target for many entities, matching a filter.
 //
+// Entities that match the filter but already have the desired target entity are not processed,
+// and no events are emitted for them.
+//
 // Panics:
 //   - when called for a missing component.
 //   - when called for a component that is not a relation.
@@ -68,6 +71,9 @@ func (b *Batch) SetRelation(filter Filter, comp ID, target Entity) {
 // SetRelationQ sets the [Relation] target for many entities, matching a filter.
 // It returns a query over the affected entities.
 //
+// Entities that match the filter but already have the desired target entity are not processed,
+// not included in the query, and no events are emitted for them.
+//
 // Panics:
 //   - when called for a missing component.
 //   - when called for a component that is not a relation.
diff --git a/ecs/event.go b/ecs/event.go
index e974a8e..ebfa0a7 100644
--- a/ecs/event.go
+++ b/ecs/event.go
@@ -1,10 +1,18 @@
 package ecs
 
+import "github.com/mlange-42/arche/ecs/event"
+
 // EntityEvent contains information about component and relation changes to an [Entity].
 //
-// To receive change events, register a function func(e *EntityEvent) with [World.SetListener].
+// To receive change events, register a [Listener] with [World.SetListener].
+//
+// Events notified are entity creation and removal, component addition and removal,
+// and change of relations and their targets.
+//
+// Event types that are subscribed are determined by [Listener.Subscriptions].
+// Events that cover multiple types (e.g. entity creation and component addition) are only notified once.
+// Field EventTypes contains the [event.Subscription] bits of covered event types.
 //
-// Events notified are entity creation, removal, changes to the component composition and change of relation targets.
 // Events are emitted immediately after the change is applied.
 //
 // Except for removed entities, events are always fired when the [World] is in an unlocked state.
@@ -16,22 +24,48 @@ package ecs
 // For batch methods that return a [Query], events are fired after the [Query] is closed (or fully iterated).
 // 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].
-	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].
-	AddedRemoved             int8   // Whether the entity itself was added (> 0), removed (< 0), or only changed (= 0).
-	RelationChanged          bool   // Whether the relation component has changed.
-	TargetChanged            bool   // Whether the relation target has changed. Will be false if the relation component changes, but the target does not.
+	Entity                   Entity             // The entity that was changed.
+	OldMask                  Mask               // The old component masks. Get the new mask with [World.Mask].
+	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].
+}
+
+// Contains returns whether the event's types contain the given type/subscription bit.
+func (e *EntityEvent) Contains(bit event.Subscription) bool {
+	return e.EventTypes.Contains(bit)
+}
+
+// Listener interface for listening to [EntityEvent]s.
+// See [EntityEvent] for details.
+type Listener interface {
+	// Notify the listener about a subscribed event.
+	Notify(evt EntityEvent)
+	// Subscriptions to event types.
+	Subscriptions() event.Subscription
+}
+
+// testListener for [EntityEvent]s.
+type testListener struct {
+	Callback  func(e EntityEvent)
+	Subscribe event.Subscription
+}
+
+// newTestListener creates a new [CallbackListener] that subscribes to all event types.
+func newTestListener(callback func(e EntityEvent)) testListener {
+	return testListener{
+		Callback:  callback,
+		Subscribe: event.EntityCreated | event.EntityRemoved | event.ComponentAdded | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
+	}
 }
 
-// EntityAdded reports whether the entity was newly added.
-func (e *EntityEvent) EntityAdded() bool {
-	return e.AddedRemoved > 0
+// Notify the listener
+func (l *testListener) Notify(e EntityEvent) {
+	l.Callback(e)
 }
 
-// EntityRemoved reports whether the entity was removed.
-func (e *EntityEvent) EntityRemoved() bool {
-	return e.AddedRemoved < 0
+// Subscriptions of the listener
+func (l *testListener) Subscriptions() event.Subscription {
+	return l.Subscribe
 }
diff --git a/ecs/event/event.go b/ecs/event/event.go
new file mode 100644
index 0000000..010e7d3
--- /dev/null
+++ b/ecs/event/event.go
@@ -0,0 +1,45 @@
+// Package event contains a mask type and bit switches for listener subscriptions.
+//
+// See also ecs.Listener and ecs.EntityEvent.
+package event
+
+// Subscription bits for individual events
+const (
+	// EntityCreated subscription bit
+	EntityCreated Subscription = 0b00000001
+	// EntityRemoved subscription bit
+	EntityRemoved Subscription = 0b00000010
+	// ComponentAdded subscription bit
+	ComponentAdded Subscription = 0b00000100
+	// ComponentRemoved subscription bit
+	ComponentRemoved Subscription = 0b000001000
+	// RelationChanged subscription bit
+	RelationChanged Subscription = 0b000010000
+	// TargetChanged subscription bit
+	TargetChanged Subscription = 0b000100000
+)
+
+// Subscription bits for groups of events
+const (
+	// Entities subscription for entity creation or removal
+	Entities Subscription = EntityCreated | EntityRemoved
+	// Components subscription for component addition or removal
+	Components Subscription = ComponentAdded | ComponentRemoved
+	// Relations subscription for relation and target changes
+	Relations Subscription = RelationChanged | TargetChanged
+	// All subscriptions
+	All Subscription = Entities | Components | Relations
+)
+
+// Subscription bits for an ecs.Listener
+type Subscription uint8
+
+// Contains checks whether all the argument's bits are contained in this Subscription.
+func (s Subscription) Contains(bits Subscription) bool {
+	return (bits & s) == bits
+}
+
+// ContainsAny checks whether any of the argument's bits are contained in this Subscription.
+func (s Subscription) ContainsAny(bits Subscription) bool {
+	return (bits & s) != 0
+}
diff --git a/ecs/util.go b/ecs/util.go
index e8f6b99..fa44944 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -3,6 +3,8 @@ package ecs
 import (
 	"fmt"
 	"strings"
+
+	"github.com/mlange-42/arche/ecs/event"
 )
 
 // Page size of pagedSlice type
@@ -39,6 +41,30 @@ func capacityU32(size, increment uint32) uint32 {
 	return cap
 }
 
+// Creates an [event.Subscription] mask from the given booleans.
+func subscription(entityCreated, entityRemoved, componentAdded, componentRemoved, relationChanged, targetChanged bool) event.Subscription {
+	var bits event.Subscription = 0
+	if entityCreated {
+		bits |= event.EntityCreated
+	}
+	if entityRemoved {
+		bits |= event.EntityRemoved
+	}
+	if componentAdded {
+		bits |= event.ComponentAdded
+	}
+	if componentRemoved {
+		bits |= event.ComponentRemoved
+	}
+	if relationChanged {
+		bits |= event.RelationChanged
+	}
+	if targetChanged {
+		bits |= event.TargetChanged
+	}
+	return bits
+}
+
 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 d184b80..a2768d1 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -5,6 +5,7 @@ import (
 	"reflect"
 	"unsafe"
 
+	"github.com/mlange-42/arche/ecs/event"
 	"github.com/mlange-42/arche/ecs/stats"
 )
 
@@ -114,7 +115,7 @@ func AddResource[T any](w *World, res *T) ResID {
 // [World.Batch], [World.Cache] and [Builder].
 type World struct {
 	config         Config                // World configuration.
-	listener       func(e EntityEvent)   // Component change listener.
+	listener       Listener              // EntityEvent listener.
 	resources      Resources             // World resources.
 	entities       []entityIndex         // Mapping from entities to archetype and index.
 	targetEntities bitSet                // Whether entities are potential relation targets.
@@ -212,7 +213,10 @@ func (w *World) NewEntity(comps ...ID) Entity {
 		if arch.HasRelationComponent {
 			newRel = &arch.RelationComponent
 		}
-		w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, false})
+		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})
+		}
 	}
 	return entity
 }
@@ -257,7 +261,10 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 		if arch.HasRelationComponent {
 			newRel = &arch.RelationComponent
 		}
-		w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, false})
+		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})
+		}
 	}
 	return entity
 }
@@ -284,7 +291,10 @@ func (w *World) newEntityTarget(targetID ID, target Entity, comps ...ID) Entity
 	}
 
 	if w.listener != nil {
-		w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()})
+		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})
+		}
 	}
 	return entity
 }
@@ -317,7 +327,10 @@ func (w *World) newEntityTargetWith(targetID ID, target Entity, comps ...Compone
 	}
 
 	if w.listener != nil {
-		w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()})
+		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})
+		}
 	}
 	return entity
 }
@@ -332,13 +345,15 @@ func (w *World) newEntities(count int, targetID ID, hasTarget bool, target Entit
 		if arch.HasRelationComponent {
 			newRel = &arch.RelationComponent
 		}
-
-		cnt := uint32(count)
-		var i uint32
-		for i = 0; i < cnt; i++ {
-			idx := startIdx + i
-			entity := arch.GetEntity(idx)
-			w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()})
+		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero())
+		if w.listener.Subscriptions().ContainsAny(bits) {
+			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})
+			}
 		}
 	}
 
@@ -374,13 +389,15 @@ func (w *World) newEntitiesWith(count int, targetID ID, hasTarget bool, target E
 		if arch.HasRelationComponent {
 			newRel = &arch.RelationComponent
 		}
-
-		var i uint32
-		cnt := uint32(count)
-		for i = 0; i < cnt; i++ {
-			idx := startIdx + i
-			entity := arch.GetEntity(idx)
-			w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()})
+		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, !target.IsZero())
+		if w.listener.Subscriptions().ContainsAny(bits) {
+			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})
+			}
 		}
 	}
 
@@ -429,9 +446,12 @@ func (w *World) RemoveEntity(entity Entity) {
 			oldIds = oldArch.node.Ids
 		}
 
-		lock := w.lock()
-		w.listener(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, -1, oldRel != nil, !oldArch.RelationTarget.IsZero()})
-		w.unlock(lock)
+		bits := subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !oldArch.RelationTarget.IsZero())
+		if w.listener.Subscriptions().ContainsAny(bits) {
+			lock := w.lock()
+			w.listener.Notify(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
+			w.unlock(lock)
+		}
 	}
 
 	swapped := oldArch.Remove(index.index)
@@ -463,6 +483,9 @@ func (w *World) removeEntities(filter Filter) int {
 
 	lock := w.lock()
 
+	var bits event.Subscription
+	var listen bool
+
 	var count uint32
 
 	arches := w.getArchetypes(filter)
@@ -477,19 +500,24 @@ func (w *World) removeEntities(filter Filter) int {
 
 		count += ln
 
+		var oldRel *ID
+		var oldIds []ID
+		if w.listener != nil {
+			if arch.HasRelationComponent {
+				oldRel = &arch.RelationComponent
+			}
+			if len(arch.node.Ids) > 0 {
+				oldIds = arch.node.Ids
+			}
+			bits = subscription(false, true, false, len(oldIds) > 0, oldRel != nil, !arch.RelationTarget.IsZero())
+			listen = w.listener.Subscriptions().ContainsAny(bits)
+		}
+
 		var j uint32
 		for j = 0; j < ln; j++ {
 			entity := arch.GetEntity(j)
-			if w.listener != nil {
-				var oldRel *ID
-				if arch.HasRelationComponent {
-					oldRel = &arch.RelationComponent
-				}
-				var oldIds []ID
-				if len(arch.node.Ids) > 0 {
-					oldIds = arch.node.Ids
-				}
-				w.listener(EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, -1, oldRel != nil, !arch.RelationTarget.IsZero()})
+			if listen {
+				w.listener.Notify(EntityEvent{entity, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
 			}
 			index := &w.entities[entity.id]
 			index.arch = nil
@@ -739,7 +767,12 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 		if oldRel != nil || newRel != nil {
 			relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel
 		}
-		w.listener(EntityEvent{entity, oldMask, add, rem, oldRel, newRel, oldTarget, 0, relChanged, oldTarget != arch.RelationTarget})
+		targChanged := oldTarget != arch.RelationTarget
+
+		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})
+		}
 	}
 }
 
@@ -936,8 +969,8 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 	oldTarget := oldArch.RelationTarget
 	w.cleanupArchetype(oldArch)
 
-	if w.listener != nil {
-		w.listener(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, 0, false, true})
+	if w.listener != nil && w.listener.Subscriptions().Contains(event.TargetChanged) {
+		w.listener.Notify(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, event.TargetChanged})
 	}
 }
 
@@ -945,7 +978,7 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 func (w *World) setRelationBatch(filter Filter, comp ID, target Entity) {
 	batches := batchArchetypes{}
 	w.setRelationBatchNoNotify(filter, comp, target, &batches)
-	if w.listener != nil {
+	if w.listener != nil && w.listener.Subscriptions().Contains(event.TargetChanged) {
 		w.notifyQuery(&batches)
 	}
 }
@@ -977,6 +1010,10 @@ func (w *World) setRelationBatchNoNotify(filter Filter, comp ID, target Entity,
 			continue
 		}
 
+		if arch.RelationTarget == target {
+			continue
+		}
+
 		newArch, start, end := w.setRelationArch(arch, archLen, comp, target)
 		batches.Add(newArch, arch, start, end)
 	}
@@ -985,9 +1022,13 @@ func (w *World) setRelationBatchNoNotify(filter Filter, comp ID, target Entity,
 func (w *World) setRelationArch(oldArch *archetype, oldArchLen uint32, comp ID, target Entity) (*archetype, uint32, uint32) {
 	w.checkRelation(oldArch, comp)
 
-	if oldArch.RelationTarget == target {
-		return oldArch, 0, oldArchLen
-	}
+	// Before, entities with unchanged target were included in the query,
+	// end events were emitted for them. Seems better to skip them completely,
+	// which is done in World.setRelationBatchNoNotify.
+	//if oldArch.RelationTarget == target {
+	//	return oldArch, 0, oldArchLen
+	//}
+
 	oldIDs := oldArch.Components()
 
 	arch := oldArch.node.GetArchetype(target)
@@ -1145,7 +1186,7 @@ func (w *World) ComponentType(id ID) (reflect.Type, bool) {
 // Replaces the current listener. Call with nil to remove a listener.
 //
 // For details, see [EntityEvent].
-func (w *World) SetListener(listener func(e EntityEvent)) {
+func (w *World) SetListener(listener Listener) {
 	w.listener = listener
 }
 
@@ -1640,33 +1681,39 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 		event := EntityEvent{
 			Entity{}, Mask{}, batchArch.Added, batchArch.Removed,
 			nil, newRel,
-			Entity{}, 1, newRel != nil, !arch.RelationTarget.IsZero(),
+			Entity{}, 0,
 		}
 
 		oldArch := batchArch.OldArchetype[i]
+		relChanged := newRel != nil
+		targChanged := !arch.RelationTarget.IsZero()
+
 		if oldArch != nil {
 			var oldRel *ID
 			if oldArch.HasRelationComponent {
 				oldRel = &oldArch.RelationComponent
 			}
-			relChanged := false
+			relChanged = false
 			if oldRel != nil || newRel != nil {
 				relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel
 			}
+			targChanged = oldArch.RelationTarget != arch.RelationTarget
 			event.OldMask = oldArch.node.Mask
 			event.OldTarget = oldArch.RelationTarget
 			event.OldRelation = oldRel
-			event.AddedRemoved = 0
-			event.RelationChanged = relChanged
-			event.TargetChanged = oldArch.RelationTarget != arch.RelationTarget
 		}
 
-		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(event)
+		bits := subscription(oldArch == nil, false, len(batchArch.Added) > 0, len(batchArch.Removed) > 0, relChanged, targChanged)
+		event.EventTypes = bits
+
+		if w.listener.Subscriptions().ContainsAny(bits) {
+			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)
+			}
 		}
 	}
 }
diff --git a/examples/change_listener/main.go b/examples/change_listener/main.go
index 6f643d1..e5db5f1 100644
--- a/examples/change_listener/main.go
+++ b/examples/change_listener/main.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 
 	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+	"github.com/mlange-42/arche/listener"
 )
 
 // Position component
@@ -25,19 +27,19 @@ type Rotation struct {
 	A float64
 }
 
-// Listener type
-type Listener struct {
+// TestListener type
+type TestListener struct {
 	World *ecs.World
 }
 
-// Listen is called on entity changes.
-func (l *Listener) Listen(evt ecs.EntityEvent) {
+// 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.EntityAdded() {
+	if evt.Contains(event.EntityCreated) {
 		fmt.Printf("Entity added, has components %v\n", l.World.Ids(evt.Entity))
-	} else if evt.EntityRemoved() {
+	} 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))
@@ -47,7 +49,8 @@ func (l *Listener) Listen(evt ecs.EntityEvent) {
 func main() {
 	// Create a World.
 	world := ecs.NewWorld()
-	listener := Listener{World: &world}
+	ls := TestListener{World: &world}
+	wrapper := listener.NewCallback(event.Entities|event.Components, ls.Notify)
 
 	// Get component IDs
 	posID := ecs.ComponentID[Position](&world)
@@ -55,7 +58,7 @@ func main() {
 	rotID := ecs.ComponentID[Rotation](&world)
 
 	// Register a listener function.
-	world.SetListener(listener.Listen)
+	world.SetListener(&wrapper)
 
 	// Create/manipulate/delete entities and observe the listener's output
 	e0 := world.NewEntity(posID)
diff --git a/listener/callback.go b/listener/callback.go
new file mode 100644
index 0000000..ee78bec
--- /dev/null
+++ b/listener/callback.go
@@ -0,0 +1,32 @@
+package listener
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+)
+
+// Callback listener for ecs.EntityEvent.
+//
+// Calls a function on events that are contained in the subscription mask.
+type Callback struct {
+	callback func(e ecs.EntityEvent)
+	events   event.Subscription
+}
+
+// NewCallback creates a new Callback listener for the given events.
+func NewCallback(events event.Subscription, callback func(ecs.EntityEvent)) Callback {
+	return Callback{
+		callback: callback,
+		events:   events,
+	}
+}
+
+// Notify the listener.
+func (l *Callback) Notify(e ecs.EntityEvent) {
+	l.callback(e)
+}
+
+// Subscriptions of the listener.
+func (l *Callback) Subscriptions() event.Subscription {
+	return l.events
+}
diff --git a/listener/dispatched.go b/listener/dispatched.go
new file mode 100644
index 0000000..97dc3a9
--- /dev/null
+++ b/listener/dispatched.go
@@ -0,0 +1,44 @@
+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
+}
diff --git a/listener/doc.go b/listener/doc.go
new file mode 100644
index 0000000..1965561
--- /dev/null
+++ b/listener/doc.go
@@ -0,0 +1,4 @@
+// Package listener provides event listener implementations for Arche.
+//
+// See the top level module [github.com/mlange-42/arche] for an overview.
+package listener


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4fd42d2..ec6a5fe 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -5,8 +5,6 @@ on:
     branches:
     - main
   pull_request:
-    branches:
-    - main
 
 jobs:
 
diff --git a/ecs/event/event_test.go b/ecs/event/event_test.go
new file mode 100644
index 0000000..09731f6
--- /dev/null
+++ b/ecs/event/event_test.go
@@ -0,0 +1,18 @@
+package event_test
+
+import (
+	"testing"
+
+	"github.com/mlange-42/arche/ecs/event"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSubscriptions(t *testing.T) {
+	m1 := event.EntityCreated | event.TargetChanged
+
+	assert.True(t, m1.Contains(event.EntityCreated))
+	assert.False(t, m1.Contains(event.EntityRemoved))
+
+	assert.True(t, m1.ContainsAny(event.ComponentAdded|event.TargetChanged))
+	assert.False(t, m1.Contains(event.ComponentAdded|event.RelationChanged))
+}
diff --git a/ecs/event_test.go b/ecs/event_test.go
index 3483327..54d0211 100644
--- a/ecs/event_test.go
+++ b/ecs/event_test.go
@@ -5,24 +5,25 @@ import (
 	"testing"
 
 	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestEntityEvent(t *testing.T) {
-	e := ecs.EntityEvent{AddedRemoved: 0}
+	e := ecs.EntityEvent{EventTypes: event.ComponentAdded}
 
-	assert.False(t, e.EntityAdded())
-	assert.False(t, e.EntityRemoved())
+	assert.False(t, e.Contains(event.EntityCreated))
+	assert.False(t, e.Contains(event.EntityRemoved))
 
-	e = ecs.EntityEvent{AddedRemoved: 1}
+	e = ecs.EntityEvent{EventTypes: event.EntityCreated | event.ComponentAdded}
 
-	assert.True(t, e.EntityAdded())
-	assert.False(t, e.EntityRemoved())
+	assert.True(t, e.Contains(event.EntityCreated))
+	assert.False(t, e.Contains(event.EntityRemoved))
 
-	e = ecs.EntityEvent{AddedRemoved: -1}
+	e = ecs.EntityEvent{EventTypes: event.EntityRemoved | event.ComponentRemoved}
 
-	assert.False(t, e.EntityAdded())
-	assert.True(t, e.EntityRemoved())
+	assert.False(t, e.Contains(event.EntityCreated))
+	assert.True(t, e.Contains(event.EntityRemoved))
 }
 
 type eventHandler struct {
@@ -49,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, AddedRemoved: 0}
+		event = ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil}
 	}
 	b.StopTimer()
 	_ = event
@@ -67,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, AddedRemoved: 0}
+		event = &ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil}
 	}
 	b.StopTimer()
 	_ = event
@@ -77,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, AddedRemoved: 0})
+		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: 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, AddedRemoved: 0}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenCopy(event)
@@ -94,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, AddedRemoved: 0})
+		handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: 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, AddedRemoved: 0}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenPointer(&event)
@@ -110,11 +111,20 @@ func BenchmarkEntityEventPointerReuse(b *testing.B) {
 func ExampleEntityEvent() {
 	world := ecs.NewWorld()
 
-	listener := func(evt ecs.EntityEvent) {
-		fmt.Println(evt)
-	}
-	world.SetListener(listener)
+	listener := NewTestListener(
+		func(evt ecs.EntityEvent) { fmt.Println(evt) },
+	)
+	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1 false false}
+	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
+}
+
+func ExampleEntityEvent_Contains() {
+	mask := event.EntityCreated | event.EntityRemoved
+
+	fmt.Println(mask.Contains(event.EntityRemoved))
+	fmt.Println(mask.Contains(event.RelationChanged))
+	// Output: true
+	// false
 }
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index e249b6d..308f948 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -3,6 +3,7 @@ package ecs
 import (
 	"testing"
 
+	"github.com/mlange-42/arche/ecs/event"
 	"github.com/mlange-42/arche/ecs/stats"
 )
 
@@ -285,6 +286,28 @@ func BenchmarkWorldStats_10Arch(b *testing.B) {
 	_ = st
 }
 
+func BenchmarkWorldNewEntityNoListener_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	filterPos := All(posID)
+
+	builder := NewBuilder(&world, posID)
+	builder.NewBatch(1000)
+	world.Batch().RemoveEntities(filterPos)
+
+	for i := 0; i < b.N; i++ {
+		b.StartTimer()
+		for j := 0; j < 1000; j++ {
+			world.NewEntity(posID)
+		}
+		b.StopTimer()
+		world.Batch().RemoveEntities(filterPos)
+	}
+}
+
 func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) {
 	b.StopTimer()
 
@@ -297,6 +320,11 @@ func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) {
 	builder.NewBatch(1000)
 	world.Batch().RemoveEntities(filterPos)
 
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener.Subscribe = 0
+
+	world.SetListener(&listener)
 	for i := 0; i < b.N; i++ {
 		b.StartTimer()
 		for j := 0; j < 1000; j++ {
@@ -305,6 +333,7 @@ func BenchmarkWorldNewEntityNoEvent_1000(b *testing.B) {
 		b.StopTimer()
 		world.Batch().RemoveEntities(filterPos)
 	}
+	_ = temp
 }
 
 func BenchmarkWorldNewEntityEvent_1000(b *testing.B) {
@@ -319,8 +348,9 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) {
 	builder.NewBatch(1000)
 	world.Batch().RemoveEntities(filterPos)
 
-	var temp int8
-	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	world.SetListener(&listener)
 
 	for i := 0; i < b.N; i++ {
 		b.StartTimer()
@@ -333,6 +363,46 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) {
 	_ = temp
 }
 
+func BenchmarkWorldExchangeNoListener_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	builder := NewBuilder(&world, posID)
+	entities := make([]Entity, 0, 1000)
+	query := builder.NewBatchQ(1000)
+	for query.Next() {
+		entities = append(entities, query.Entity())
+	}
+
+	filterPos := All(posID)
+	filterVel := All(velID)
+
+	pos := []ID{posID}
+	vel := []ID{velID}
+
+	world.Batch().Exchange(filterPos, vel, pos)
+	world.Batch().Exchange(filterVel, pos, vel)
+
+	b.StartTimer()
+	hasPos := true
+	for i := 0; i < b.N; i++ {
+		if hasPos {
+			for _, e := range entities {
+				world.Exchange(e, vel, pos)
+			}
+		} else {
+			for _, e := range entities {
+				world.Exchange(e, pos, vel)
+			}
+		}
+		hasPos = !hasPos
+	}
+}
+
 func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) {
 	b.StopTimer()
 
@@ -357,6 +427,11 @@ func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener.Subscribe = 0
+	world.SetListener(&listener)
+
 	b.StartTimer()
 	hasPos := true
 	for i := 0; i < b.N; i++ {
@@ -371,6 +446,7 @@ func BenchmarkWorldExchangeNoEvent_1000(b *testing.B) {
 		}
 		hasPos = !hasPos
 	}
+	_ = temp
 }
 
 func BenchmarkWorldExchangeEvent_1000(b *testing.B) {
@@ -397,8 +473,9 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
-	var temp int8
-	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	world.SetListener(&listener)
 
 	b.StartTimer()
 	hasPos := true
@@ -417,6 +494,38 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) {
 	_ = temp
 }
 
+func BenchmarkWorldExchangeBatchNoListener_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	builder := NewBuilder(&world, posID)
+	builder.NewBatch(1000)
+
+	filterPos := All(posID)
+	filterVel := All(velID)
+
+	pos := []ID{posID}
+	vel := []ID{velID}
+
+	world.Batch().Exchange(filterPos, vel, pos)
+	world.Batch().Exchange(filterVel, pos, vel)
+
+	b.StartTimer()
+	hasPos := true
+	for i := 0; i < b.N; i++ {
+		if hasPos {
+			world.Batch().Exchange(filterPos, vel, pos)
+		} else {
+			world.Batch().Exchange(filterVel, pos, vel)
+		}
+		hasPos = !hasPos
+	}
+}
+
 func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) {
 	b.StopTimer()
 
@@ -437,6 +546,11 @@ func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	listener.Subscribe = 0
+	world.SetListener(&listener)
+
 	b.StartTimer()
 	hasPos := true
 	for i := 0; i < b.N; i++ {
@@ -447,6 +561,7 @@ func BenchmarkWorldExchangeBatchNoEvent_1000(b *testing.B) {
 		}
 		hasPos = !hasPos
 	}
+	_ = temp
 }
 
 func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) {
@@ -469,8 +584,9 @@ func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
-	var temp int8
-	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+	var temp event.Subscription
+	listener := newTestListener(func(e EntityEvent) { temp = e.EventTypes })
+	world.SetListener(&listener)
 
 	b.StartTimer()
 	hasPos := true
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index f83a4f6..2a7e436 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -5,6 +5,7 @@ import (
 	"reflect"
 
 	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
 )
 
 func ExampleComponentID() {
@@ -275,13 +276,15 @@ func ExampleWorld_Batch() {
 func ExampleWorld_SetListener() {
 	world := ecs.NewWorld()
 
-	listener := func(evt ecs.EntityEvent) {
-		fmt.Println(evt)
-	}
-	world.SetListener(listener)
+	listener := NewTestListener(
+		func(evt ecs.EntityEvent) {
+			fmt.Println(evt)
+		},
+	)
+	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1 false false}
+	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
 }
 
 func ExampleWorld_Stats() {
@@ -290,3 +293,25 @@ func ExampleWorld_Stats() {
 	fmt.Println(stats.Entities.String())
 	// Output: Entities -- Used: 0, Recycled: 0, Total: 0, Capacity: 128
 }
+
+// TestListener for all [EntityEvent]s.
+type TestListener struct {
+	Callback func(e 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
+func (l *TestListener) Notify(e ecs.EntityEvent) {
+	l.Callback(e)
+}
+
+// Subscriptions of the listener
+func (l *TestListener) Subscriptions() event.Subscription {
+	return event.All
+}
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 270deda..46f2042 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -7,6 +7,7 @@ import (
 	"runtime"
 	"testing"
 
+	"github.com/mlange-42/arche/ecs/event"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -330,9 +331,10 @@ func TestWorldExchangeBatch(t *testing.T) {
 	w := NewWorld()
 
 	events := []EntityEvent{}
-	w.SetListener(func(e EntityEvent) {
+	listener := newTestListener(func(e EntityEvent) {
 		events = append(events, e)
 	})
+	w.SetListener(&listener)
 
 	posID := ComponentID[Position](&w)
 	velID := ComponentID[Velocity](&w)
@@ -347,12 +349,10 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	assert.Equal(t, 202, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{202, 0},
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		AddedRemoved:    1,
-		RelationChanged: true,
-		TargetChanged:   true,
+		Entity:      Entity{202, 0},
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[201])
 
 	filter := All(posID, relID)
@@ -380,16 +380,14 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	assert.Equal(t, 502, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{102, 0},
-		OldMask:         All(velID, relID),
-		Added:           []ID{posID},
-		Removed:         []ID{velID},
-		OldRelation:     &relID,
-		NewRelation:     &relID,
-		OldTarget:       target1,
-		AddedRemoved:    0,
-		RelationChanged: false,
-		TargetChanged:   false,
+		Entity:      Entity{102, 0},
+		OldMask:     All(velID, relID),
+		Added:       []ID{posID},
+		Removed:     []ID{velID},
+		OldRelation: &relID,
+		NewRelation: &relID,
+		OldTarget:   target1,
+		EventTypes:  event.ComponentAdded | event.ComponentRemoved,
 	}, events[501])
 
 	query = w.Query(All(posID))
@@ -404,15 +402,13 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	assert.Equal(t, 702, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{102, 0},
-		OldMask:         All(posID, relID),
-		Removed:         []ID{relID},
-		OldRelation:     &relID,
-		NewRelation:     nil,
-		OldTarget:       target1,
-		AddedRemoved:    0,
-		RelationChanged: true,
-		TargetChanged:   true,
+		Entity:      Entity{102, 0},
+		OldMask:     All(posID, relID),
+		Removed:     []ID{relID},
+		OldRelation: &relID,
+		NewRelation: nil,
+		OldTarget:   target1,
+		EventTypes:  event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[701])
 
 	w.Batch().RemoveEntities(All(posID))
@@ -420,20 +416,12 @@ func TestWorldExchangeBatch(t *testing.T) {
 	assert.Equal(t, 802, len(events))
 
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{102, 0},
-		OldMask:         All(posID),
-		Removed:         []ID{posID},
-		AddedRemoved:    -1,
-		RelationChanged: false,
-		TargetChanged:   false,
+		Entity:     Entity{102, 0},
+		OldMask:    All(posID),
+		Removed:    []ID{posID},
+		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[801])
 
-	assert.Equal(t, int8(1), events[0].AddedRemoved)
-	assert.Equal(t, int8(1), events[1].AddedRemoved)
-	assert.Equal(t, int8(1), events[2].AddedRemoved)
-	assert.Equal(t, int8(1), events[201].AddedRemoved)
-
-	assert.Equal(t, int8(0), events[202].AddedRemoved)
 	assert.Equal(t, []ID{velID}, events[202].Added)
 	assert.Equal(t, []ID{posID}, events[202].Removed)
 
@@ -613,10 +601,11 @@ func TestWorldNewEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) {
-		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
+	listener := newTestListener(func(e EntityEvent) {
+		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	rotID := ComponentID[rotation](&world)
@@ -688,10 +677,11 @@ func TestWorldNewEntitiesWith(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) {
-		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
+	listener := newTestListener(func(e EntityEvent) {
+		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	rotID := ComponentID[rotation](&world)
@@ -762,10 +752,11 @@ func TestWorldRemoveEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) {
-		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
+	listener := newTestListener(func(e EntityEvent) {
+		assert.Equal(t, world.IsLocked(), e.Contains(event.EntityRemoved))
 		events = append(events, e)
 	})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	rotID := ComponentID[rotation](&world)
@@ -802,9 +793,10 @@ func TestWorldRelationSet(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) {
+	listener := newTestListener(func(e EntityEvent) {
 		events = append(events, e)
 	})
+	world.SetListener(&listener)
 
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -824,12 +816,12 @@ func TestWorldRelationSet(t *testing.T) {
 
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:        e1,
-		OldMask:       All(relID, rotID),
-		OldRelation:   &relID,
-		NewRelation:   &relID,
-		OldTarget:     Entity{},
-		TargetChanged: true,
+		Entity:      e1,
+		OldMask:     All(relID, rotID),
+		OldRelation: &relID,
+		NewRelation: &relID,
+		OldTarget:   Entity{},
+		EventTypes:  event.TargetChanged,
 	}, events[len(events)-1])
 
 	assert.Equal(t, targ, world.Relations().Get(e1, relID))
@@ -842,12 +834,12 @@ func TestWorldRelationSet(t *testing.T) {
 
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:        e1,
-		OldMask:       All(relID, rotID),
-		OldRelation:   &relID,
-		NewRelation:   &relID,
-		OldTarget:     targ,
-		TargetChanged: true,
+		Entity:      e1,
+		OldMask:     All(relID, rotID),
+		OldRelation: &relID,
+		NewRelation: &relID,
+		OldTarget:   targ,
+		EventTypes:  event.TargetChanged,
 	}, events[len(events)-1])
 
 	assert.Panics(t, func() { world.Relations().Get(e1, rotID) })
@@ -867,13 +859,12 @@ func TestWorldRelationSet(t *testing.T) {
 
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          e2,
-		OldMask:         All(relID, rotID),
-		Removed:         []ID{relID},
-		OldRelation:     &relID,
-		NewRelation:     nil,
-		RelationChanged: true,
-		TargetChanged:   false,
+		Entity:      e2,
+		OldMask:     All(relID, rotID),
+		Removed:     []ID{relID},
+		OldRelation: &relID,
+		NewRelation: nil,
+		EventTypes:  event.ComponentRemoved | event.RelationChanged,
 	}, events[len(events)-1])
 
 	assert.Panics(t, func() { world.Relations().Get(e2, relID) })
@@ -901,9 +892,10 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) {
+	listener := newTestListener(func(e EntityEvent) {
 		events = append(events, e)
 	})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	rotID := ComponentID[rotation](&world)
@@ -913,11 +905,15 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	targ2 := world.NewEntity(posID)
 	targ3 := world.NewEntity(posID)
 
+	assert.Equal(t, 3, len(events))
+
 	builder := NewBuilder(&world, rotID, relID).WithRelation(relID)
 	builder.NewBatch(100, targ1)
 	builder.NewBatch(100, targ2)
 	builder.NewBatch(100, targ3)
 
+	assert.Equal(t, 303, len(events))
+
 	relFilter := NewRelationFilter(All(relID), targ2)
 	q := world.Batch().SetRelationQ(&relFilter, relID, targ1)
 	assert.Equal(t, 100, q.Count())
@@ -928,14 +924,18 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	}
 	assert.Equal(t, 100, cnt)
 
+	assert.Equal(t, 403, len(events))
+
 	q = world.Batch().SetRelationQ(All(relID), relID, targ3)
-	assert.Equal(t, 300, q.Count())
+	assert.Equal(t, 200, q.Count())
 	cnt = 0
 	for q.Next() {
 		assert.Equal(t, targ3, q.Relation(relID))
 		cnt++
 	}
-	assert.Equal(t, 300, cnt)
+	assert.Equal(t, 200, cnt)
+
+	assert.Equal(t, 603, len(events))
 
 	relFilter = NewRelationFilter(All(relID), targ3)
 	q = world.Batch().SetRelationQ(&relFilter, relID, Entity{})
@@ -947,22 +947,31 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	}
 	assert.Equal(t, 300, cnt)
 
+	assert.Equal(t, 903, len(events))
+
 	relFilter = NewRelationFilter(All(relID), Entity{})
 	world.Batch().SetRelation(&relFilter, relID, targ1)
 
+	assert.Equal(t, 1203, len(events))
+
 	world.RemoveEntity(targ3)
+	assert.Equal(t, 1204, len(events))
+
 	assert.Panics(t, func() {
 		world.Batch().SetRelation(All(relID), relID, targ3)
 	})
 
-	assert.Equal(t, 1304, len(events))
+	assert.Equal(t, 1204, len(events))
 
 	world.Relations().SetBatch(All(relID), relID, targ1)
+	assert.Equal(t, 1204, len(events))
 
 	q = world.Relations().SetBatchQ(All(relID), relID, targ2)
 	assert.Equal(t, 300, q.Count())
 	q.Close()
 
+	assert.Equal(t, 1504, len(events))
+
 	fmt.Println(debugPrintWorld(&world))
 
 	world.Reset()
@@ -972,7 +981,10 @@ func TestWorldRelationRemove(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e EntityEvent) { events = append(events, e) })
+	listener := newTestListener(func(e EntityEvent) {
+		events = append(events, e)
+	})
+	world.SetListener(&listener)
 
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1188,7 +1200,9 @@ func TestWorldRelation(t *testing.T) {
 
 func TestWorldRelationCreate(t *testing.T) {
 	world := NewWorld()
-	world.SetListener(func(e EntityEvent) {})
+
+	listener := newTestListener(func(e EntityEvent) {})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1228,7 +1242,8 @@ func TestWorldRelationCreate(t *testing.T) {
 
 func TestWorldRelationMove(t *testing.T) {
 	world := NewWorld()
-	world.SetListener(func(e EntityEvent) {})
+	listener := newTestListener(func(e EntityEvent) {})
+	world.SetListener(&listener)
 
 	posID := ComponentID[Position](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1422,13 +1437,13 @@ func TestRegisterComponents(t *testing.T) {
 }
 
 func TestWorldBatchRemove(t *testing.T) {
+	world := NewWorld()
+
 	events := []EntityEvent{}
-	listen := func(e EntityEvent) {
+	listener := newTestListener(func(e EntityEvent) {
 		events = append(events, e)
-	}
-
-	world := NewWorld()
-	world.SetListener(listen)
+	})
+	world.SetListener(&listener)
 
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1439,8 +1454,8 @@ func TestWorldBatchRemove(t *testing.T) {
 
 	assert.Equal(t, 3, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       target3,
-		AddedRemoved: 1,
+		Entity:     target3,
+		EventTypes: event.EntityCreated,
 	}, events[len(events)-1])
 
 	builder := NewBuilder(&world, rotID, relID).WithRelation(relID)
@@ -1451,12 +1466,10 @@ func TestWorldBatchRemove(t *testing.T) {
 
 	assert.Equal(t, 33, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{33, 0},
-		Added:           []ID{rotID, relID},
-		NewRelation:     &relID,
-		AddedRemoved:    1,
-		RelationChanged: true,
-		TargetChanged:   true,
+		Entity:      Entity{33, 0},
+		Added:       []ID{rotID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 	filter := All(rotID).Exclusive()
@@ -1471,13 +1484,11 @@ func TestWorldBatchRemove(t *testing.T) {
 
 	assert.Equal(t, 43, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{13, 0},
-		OldMask:         All(rotID, relID),
-		Removed:         []ID{rotID, relID},
-		OldRelation:     &relID,
-		AddedRemoved:    -1,
-		RelationChanged: true,
-		TargetChanged:   true,
+		Entity:      Entity{13, 0},
+		OldMask:     All(rotID, relID),
+		Removed:     []ID{rotID, relID},
+		OldRelation: &relID,
+		EventTypes:  event.EntityRemoved | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 	relFilter = NewRelationFilter(All(rotID, relID), target2)
@@ -1490,8 +1501,8 @@ func TestWorldBatchRemove(t *testing.T) {
 
 	assert.Equal(t, 56, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       Entity{3, 0},
-		AddedRemoved: -1,
+		Entity:     Entity{3, 0},
+		EventTypes: event.EntityRemoved,
 	}, events[len(events)-1])
 
 	relFilter = NewRelationFilter(All(rotID, relID), target3)
@@ -1506,8 +1517,9 @@ func TestWorldBatchRemove(t *testing.T) {
 
 func TestWorldReset(t *testing.T) {
 	world := NewWorld()
+	listener := newTestListener(func(e EntityEvent) {})
+	world.SetListener(&listener)
 
-	world.SetListener(func(e EntityEvent) {})
 	AddResource(&world, &rotation{100})
 
 	posID := ComponentID[Position](&world)
@@ -1582,14 +1594,13 @@ func TestArchetypeGraph(t *testing.T) {
 }
 
 func TestWorldListener(t *testing.T) {
-	events := []EntityEvent{}
-	listen := func(e EntityEvent) {
-		events = append(events, e)
-	}
-
 	w := NewWorld()
 
-	w.SetListener(listen)
+	events := []EntityEvent{}
+	listener := newTestListener(func(e EntityEvent) {
+		events = append(events, e)
+	})
+	w.SetListener(&listener)
 
 	posID := ComponentID[Position](&w)
 	velID := ComponentID[Velocity](&w)
@@ -1599,101 +1610,98 @@ func TestWorldListener(t *testing.T) {
 	e0 := w.NewEntity()
 	assert.Equal(t, 1, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity: e0, AddedRemoved: 1,
+		Entity:     e0,
+		EventTypes: event.EntityCreated,
 	}, events[len(events)-1])
 
 	w.RemoveEntity(e0)
 	assert.Equal(t, 2, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity: e0, AddedRemoved: -1,
+		Entity:     e0,
+		EventTypes: event.EntityRemoved,
 	}, events[len(events)-1])
 
 	e0 = w.NewEntity(posID, velID)
 	assert.Equal(t, 3, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       e0,
-		Added:        []ID{posID, velID},
-		AddedRemoved: 1,
+		Entity:     e0,
+		Added:      []ID{posID, velID},
+		EventTypes: event.EntityCreated | event.ComponentAdded,
 	}, events[len(events)-1])
 
 	w.RemoveEntity(e0)
 	assert.Equal(t, 4, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       e0,
-		OldMask:      All(posID, velID),
-		Removed:      []ID{posID, velID},
-		AddedRemoved: -1,
+		Entity:     e0,
+		OldMask:    All(posID, velID),
+		Removed:    []ID{posID, velID},
+		EventTypes: event.EntityRemoved | event.ComponentRemoved,
 	}, events[len(events)-1])
 
 	e0 = w.NewEntityWith(Component{posID, &Position{}}, Component{velID, &Velocity{}}, Component{relID, &relationComp{}})
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          e0,
-		Added:           []ID{posID, velID, relID},
-		NewRelation:     &relID,
-		AddedRemoved:    1,
-		RelationChanged: true,
+		Entity:      e0,
+		Added:       []ID{posID, velID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
 
 	w.Add(e0, rotID)
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       e0,
-		OldMask:      All(posID, velID, relID),
-		Added:        []ID{rotID},
-		OldRelation:  &relID,
-		NewRelation:  &relID,
-		AddedRemoved: 0,
+		Entity:      e0,
+		OldMask:     All(posID, velID, relID),
+		Added:       []ID{rotID},
+		OldRelation: &relID,
+		NewRelation: &relID,
+		EventTypes:  event.ComponentAdded,
 	}, events[len(events)-1])
 
 	w.Remove(e0, posID)
 	assert.Equal(t, 7, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       e0,
-		OldMask:      All(posID, velID, rotID, relID),
-		Removed:      []ID{posID},
-		OldRelation:  &relID,
-		NewRelation:  &relID,
-		AddedRemoved: 0,
+		Entity:      e0,
+		OldMask:     All(posID, velID, rotID, relID),
+		Removed:     []ID{posID},
+		OldRelation: &relID,
+		NewRelation: &relID,
+		EventTypes:  event.ComponentRemoved,
 	}, events[len(events)-1])
 
 	e1 := w.NewEntity(posID)
 	w.Relations().Set(e0, relID, e1)
 	assert.Equal(t, 9, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:        e0,
-		OldMask:       All(velID, rotID, relID),
-		OldRelation:   &relID,
-		NewRelation:   &relID,
-		TargetChanged: true,
-		AddedRemoved:  0,
+		Entity:      e0,
+		OldMask:     All(velID, rotID, relID),
+		OldRelation: &relID,
+		NewRelation: &relID,
+		EventTypes:  event.TargetChanged,
 	}, events[len(events)-1])
 
 	w.Remove(e0, relID)
 	assert.Equal(t, 10, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          e0,
-		OldMask:         All(velID, rotID, relID),
-		Removed:         []ID{relID},
-		OldRelation:     &relID,
-		NewRelation:     nil,
-		OldTarget:       e1,
-		RelationChanged: true,
-		TargetChanged:   true,
-		AddedRemoved:    0,
+		Entity:      e0,
+		OldMask:     All(velID, rotID, relID),
+		Removed:     []ID{relID},
+		OldRelation: &relID,
+		NewRelation: nil,
+		OldTarget:   e1,
+		EventTypes:  event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 }
 
 func TestWorldListenerBuilder(t *testing.T) {
-	events := []EntityEvent{}
-	listen := func(e EntityEvent) {
-		events = append(events, e)
-	}
-
 	w := NewWorld()
 
-	w.SetListener(listen)
+	events := []EntityEvent{}
+	listener := newTestListener(func(e EntityEvent) {
+		events = append(events, e)
+	})
+	w.SetListener(&listener)
 
 	posID := ComponentID[Position](&w)
 	relID := ComponentID[relationComp](&w)
@@ -1705,13 +1713,11 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 11, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{11, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   false,
-		AddedRemoved:    1,
+		Entity:      Entity{11, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
 
 	query := builder.NewBatchQ(10)
@@ -1719,26 +1725,22 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 21, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{21, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   false,
-		AddedRemoved:    1,
+		Entity:      Entity{21, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
 
 	builder.NewBatch(10, parent)
 
 	assert.Equal(t, 31, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{31, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   true,
-		AddedRemoved:    1,
+		Entity:      Entity{31, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 	query = builder.NewBatchQ(10, parent)
@@ -1746,13 +1748,11 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 41, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{41, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   true,
-		AddedRemoved:    1,
+		Entity:      Entity{41, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 	builder = NewBuilderWith(&w,
@@ -1764,13 +1764,11 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 51, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{51, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   false,
-		AddedRemoved:    1,
+		Entity:      Entity{51, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
 
 	query = builder.NewBatchQ(10)
@@ -1778,26 +1776,22 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 61, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{61, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   false,
-		AddedRemoved:    1,
+		Entity:      Entity{61, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged,
 	}, events[len(events)-1])
 
 	builder.NewBatch(10, parent)
 
 	assert.Equal(t, 71, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{71, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   true,
-		AddedRemoved:    1,
+		Entity:      Entity{71, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
 	query = builder.NewBatchQ(10, parent)
@@ -1805,13 +1799,11 @@ func TestWorldListenerBuilder(t *testing.T) {
 
 	assert.Equal(t, 81, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:          Entity{81, 0},
-		OldMask:         All(),
-		Added:           []ID{posID, relID},
-		NewRelation:     &relID,
-		RelationChanged: true,
-		TargetChanged:   true,
-		AddedRemoved:    1,
+		Entity:      Entity{81, 0},
+		OldMask:     All(),
+		Added:       []ID{posID, relID},
+		NewRelation: &relID,
+		EventTypes:  event.EntityCreated | event.ComponentAdded | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 }
 
diff --git a/listener/callback_test.go b/listener/callback_test.go
new file mode 100644
index 0000000..af10a16
--- /dev/null
+++ b/listener/callback_test.go
@@ -0,0 +1,38 @@
+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 TestCallback(t *testing.T) {
+	evt := []ecs.EntityEvent{}
+	ls := listener.NewCallback(
+		event.All,
+		func(e ecs.EntityEvent) {
+			evt = append(evt, e)
+		},
+	)
+	assert.Equal(t, event.All, ls.Subscriptions())
+	ls.Notify(ecs.EntityEvent{})
+	assert.Equal(t, 1, len(evt))
+}
+
+func ExampleCallback() {
+	world := ecs.NewWorld()
+
+	ls := listener.NewCallback(
+		event.Entities|event.Components,
+		func(e ecs.EntityEvent) {
+			fmt.Println(e)
+		},
+	)
+	world.SetListener(&ls)
+
+	world.NewEntity()
+}
diff --git a/listener/dispatched_test.go b/listener/dispatched_test.go
new file mode 100644
index 0000000..e82391e
--- /dev/null
+++ b/listener/dispatched_test.go
@@ -0,0 +1,70 @@
+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
+}