Repository: mlange-42/arche
Pull request #333 (2024-01-15 11:46:32)

Improve `EntityEvent` fields
* Pass events by value/copy instead of by pointer. It torned out heap escape is much more expensive than copying.
* Removes event fields that are accessible from the world.
* Adds event fields to better identify relation changes.
* Reduces the size of `EntityEvent` by 30%.

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


* **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/65072733/badge)](https://coveralls.io/builds/65072733) |
| :-- | --: |
| Change from base [Build 7528343499](https://coveralls.io/builds/65072660): |  0.0% |
| Covered Lines: | 4979 |
| Relevant Lines: | 4979 |

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


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 263fc8a..60091c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@
 * `Filters.Matches(Mask)` became `Filters.Matches(*Mask)`; same for all `Filter` implementations (#313)  
 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)
 
 ### Features
 
diff --git a/ecs/event.go b/ecs/event.go
index 8098bdc..e974a8e 100644
--- a/ecs/event.go
+++ b/ecs/event.go
@@ -1,10 +1,10 @@
 package ecs
 
-// EntityEvent contains information about component changes to an [Entity].
+// EntityEvent contains information about component and relation changes to an [Entity].
 //
 // To receive change events, register a function func(e *EntityEvent) with [World.SetListener].
 //
-// Events notified are entity creation, removal and changes to the component composition.
+// 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.
@@ -15,16 +15,15 @@ package ecs
 // Events for batch-creation of entities using a [Builder] are fired after all entities are created.
 // 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.
-//
-// Note that the event pointer received by the listener function should not be stored,
-// as the instance behind the pointer might be reused for further notifications.
 type EntityEvent struct {
-	Entity                  Entity // The entity that was changed.
-	OldMask, NewMask        Mask   // The old and new component masks.
-	Added, Removed, Current []ID   // Components added, removed, and after the change. DO NOT MODIFY!
-	AddedRemoved            int    // Whether the entity itself was added (> 0), removed (< 0), or only changed (= 0).
-	OldTarget, NewTarget    Entity // Old and new target entity
-	TargetChanged           bool   // Whether this is (only) a change of the relation target.
+	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.
 }
 
 // EntityAdded reports whether the entity was newly added.
diff --git a/ecs/world.go b/ecs/world.go
index b0083a5..d184b80 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -114,7 +114,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       func(e EntityEvent)   // Component change listener.
 	resources      Resources             // World resources.
 	entities       []entityIndex         // Mapping from entities to archetype and index.
 	targetEntities bitSet                // Whether entities are potential relation targets.
@@ -208,7 +208,11 @@ func (w *World) NewEntity(comps ...ID) Entity {
 	entity := w.createEntity(arch)
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+		var newRel *ID
+		if arch.HasRelationComponent {
+			newRel = &arch.RelationComponent
+		}
+		w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, false})
 	}
 	return entity
 }
@@ -249,7 +253,11 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 	}
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+		var newRel *ID
+		if arch.HasRelationComponent {
+			newRel = &arch.RelationComponent
+		}
+		w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, false})
 	}
 	return entity
 }
@@ -276,7 +284,7 @@ func (w *World) newEntityTarget(targetID ID, target Entity, comps ...ID) Entity
 	}
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+		w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()})
 	}
 	return entity
 }
@@ -309,7 +317,7 @@ func (w *World) newEntityTargetWith(targetID ID, target Entity, comps ...Compone
 	}
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+		w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, &targetID, Entity{}, 1, true, !target.IsZero()})
 	}
 	return entity
 }
@@ -320,12 +328,17 @@ func (w *World) newEntities(count int, targetID ID, hasTarget bool, target Entit
 	arch, startIdx := w.newEntitiesNoNotify(count, targetID, hasTarget, target, comps...)
 
 	if w.listener != nil {
+		var newRel *ID
+		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{}, arch.Mask, comps, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+			w.listener(EntityEvent{entity, Mask{}, comps, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()})
 		}
 	}
 
@@ -357,12 +370,17 @@ func (w *World) newEntitiesWith(count int, targetID ID, hasTarget bool, target E
 	arch, startIdx := w.newEntitiesWithNoNotify(count, targetID, hasTarget, target, ids, comps...)
 
 	if w.listener != nil {
+		var newRel *ID
+		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{}, arch.Mask, ids, nil, arch.node.Ids, 1, Entity{}, arch.RelationTarget, false})
+			w.listener(EntityEvent{entity, Mask{}, ids, nil, nil, newRel, Entity{}, 1, newRel != nil, !target.IsZero()})
 		}
 	}
 
@@ -402,8 +420,17 @@ func (w *World) RemoveEntity(entity Entity) {
 	oldArch := index.arch
 
 	if w.listener != nil {
+		var oldRel *ID
+		if oldArch.HasRelationComponent {
+			oldRel = &oldArch.RelationComponent
+		}
+		var oldIds []ID
+		if len(oldArch.node.Ids) > 0 {
+			oldIds = oldArch.node.Ids
+		}
+
 		lock := w.lock()
-		w.listener(&EntityEvent{entity, oldArch.Mask, Mask{}, nil, oldArch.node.Ids, nil, -1, oldArch.RelationTarget, Entity{}, false})
+		w.listener(EntityEvent{entity, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, -1, oldRel != nil, !oldArch.RelationTarget.IsZero()})
 		w.unlock(lock)
 	}
 
@@ -443,18 +470,26 @@ func (w *World) removeEntities(filter Filter) int {
 	var i int32
 	for i = 0; i < numArches; i++ {
 		arch := arches[i]
-		len := arch.Len()
-		if len == 0 {
+		ln := arch.Len()
+		if ln == 0 {
 			continue
 		}
 
-		count += len
+		count += ln
 
 		var j uint32
-		for j = 0; j < len; j++ {
+		for j = 0; j < ln; j++ {
 			entity := arch.GetEntity(j)
 			if w.listener != nil {
-				w.listener(&EntityEvent{entity, arch.Mask, Mask{}, nil, arch.node.Ids, nil, -1, arch.RelationTarget, Entity{}, false})
+				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()})
 			}
 			index := &w.entities[entity.id]
 			index.arch = nil
@@ -624,6 +659,9 @@ func (w *World) Remove(entity Entity, comps ...ID) {
 // Exchange adds and removes components in one pass.
 // This is more efficient than subsequent use of [World.Add] and [World.Remove].
 //
+// When a [Relation] component is removed and another one is added,
+// the target entity of the relation remains unchanged.
+//
 // Panics:
 //   - when called for a removed (and potentially recycled) entity.
 //   - when called with components that can't be added or removed because they are already present/not present, respectively.
@@ -653,10 +691,12 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 
 	if hasRelation {
 		if !mask.Get(relation) {
-			panic("can't add relation: resulting entity has no relation")
+			tp, _ := w.registry.ComponentType(relation.id)
+			panic(fmt.Sprintf("can't add relation: resulting entity has no component %s", tp.Name()))
 		}
 		if !w.registry.IsRelation.Get(relation) {
-			panic("can't add relation: this is not a relation component")
+			tp, _ := w.registry.ComponentType(relation.id)
+			panic(fmt.Sprintf("can't add relation: %s is not a relation component", tp.Name()))
 		}
 	} else {
 		target = oldArch.RelationTarget
@@ -682,10 +722,24 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 	}
 	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex}
 
+	var oldRel *ID
+	if oldArch.HasRelationComponent {
+		oldRel = &oldArch.RelationComponent
+	}
+	oldTarget := oldArch.RelationTarget
+
 	w.cleanupArchetype(oldArch)
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, oldMask, arch.Mask, add, rem, arch.node.Ids, 0, oldArch.RelationTarget, arch.RelationTarget, false})
+		var newRel *ID
+		if arch.HasRelationComponent {
+			newRel = &arch.RelationComponent
+		}
+		relChanged := false
+		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})
 	}
 }
 
@@ -855,7 +909,7 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 
 	oldArch := index.arch
 
-	if index.arch.RelationTarget == target {
+	if oldArch.RelationTarget == target {
 		return
 	}
 
@@ -879,10 +933,11 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex}
 	w.targetEntities.Set(target.id, true)
 
+	oldTarget := oldArch.RelationTarget
 	w.cleanupArchetype(oldArch)
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, arch.Mask, arch.Mask, nil, nil, arch.node.Ids, 0, oldArch.RelationTarget, arch.RelationTarget, true})
+		w.listener(EntityEvent{entity, arch.Mask, nil, nil, &comp, &comp, oldTarget, 0, false, true})
 	}
 }
 
@@ -1085,12 +1140,12 @@ func (w *World) ComponentType(id ID) (reflect.Type, bool) {
 	return w.registry.ComponentType(id.id)
 }
 
-// SetListener sets a listener callback func(e *EntityEvent) for the world.
+// SetListener sets a listener callback func(e EntityEvent) for the world.
 // The listener is immediately called on every [ecs.Entity] change.
 // 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 func(e EntityEvent)) {
 	w.listener = listener
 }
 
@@ -1456,7 +1511,7 @@ func (w *World) createArchetype(node *archNode, target Entity, forStorage bool)
 		w.archetypeData.Add(archetypeData{})
 		archIndex := w.archetypes.Len() - 1
 		arch = w.archetypes.Get(archIndex)
-		arch.Init(node, w.archetypeData.Get(archIndex), archIndex, forStorage, uint8(layouts), target)
+		arch.Init(node, w.archetypeData.Get(archIndex), archIndex, forStorage, uint8(layouts), Entity{})
 		node.SetArchetype(arch)
 	}
 	w.filterCache.addArchetype(arch)
@@ -1576,17 +1631,34 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 	var i int32
 	for i = 0; i < count; i++ {
 		arch := batchArch.Get(i)
+
+		var newRel *ID
+		if arch.HasRelationComponent {
+			newRel = &arch.RelationComponent
+		}
+
 		event := EntityEvent{
-			Entity{}, Mask{}, arch.Mask, batchArch.Added, batchArch.Removed, arch.node.Ids, 1,
-			Entity{}, arch.RelationTarget, false,
+			Entity{}, Mask{}, batchArch.Added, batchArch.Removed,
+			nil, newRel,
+			Entity{}, 1, newRel != nil, !arch.RelationTarget.IsZero(),
 		}
 
 		oldArch := batchArch.OldArchetype[i]
 		if oldArch != nil {
+			var oldRel *ID
+			if oldArch.HasRelationComponent {
+				oldRel = &oldArch.RelationComponent
+			}
+			relChanged := false
+			if oldRel != nil || newRel != nil {
+				relChanged = (oldRel == nil) != (newRel == nil) || *oldRel != *newRel
+			}
 			event.OldMask = oldArch.node.Mask
-			event.AddedRemoved = 0
 			event.OldTarget = oldArch.RelationTarget
-			event.TargetChanged = event.OldMask == event.NewMask
+			event.OldRelation = oldRel
+			event.AddedRemoved = 0
+			event.RelationChanged = relChanged
+			event.TargetChanged = oldArch.RelationTarget != arch.RelationTarget
 		}
 
 		start, end := batchArch.StartIndex[i], batchArch.EndIndex[i]
@@ -1594,7 +1666,7 @@ func (w *World) notifyQuery(batchArch *batchArchetypes) {
 		for e = start; e < end; e++ {
 			entity := arch.GetEntity(e)
 			event.Entity = entity
-			w.listener(&event)
+			w.listener(event)
 		}
 	}
 }
diff --git a/examples/change_listener/main.go b/examples/change_listener/main.go
index 4776a68..6f643d1 100644
--- a/examples/change_listener/main.go
+++ b/examples/change_listener/main.go
@@ -25,23 +25,29 @@ type Rotation struct {
 	A float64
 }
 
+// Listener type
+type Listener struct {
+	World *ecs.World
+}
+
 // Listen is called on entity changes.
-func Listen(evt *ecs.EntityEvent) {
+func (l *Listener) Listen(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() {
-		fmt.Printf("Entity added, has components %v\n", evt.Current)
+		fmt.Printf("Entity added, has components %v\n", l.World.Ids(evt.Entity))
 	} else if evt.EntityRemoved() {
-		fmt.Printf("Entity removed, had components %v\n", evt.Current)
+		fmt.Printf("Entity removed, had components %v\n", l.World.Ids(evt.Entity))
 	} else {
-		fmt.Printf("Entity changed, has components %v\n", evt.Current)
+		fmt.Printf("Entity changed, has components %v\n", l.World.Ids(evt.Entity))
 	}
 }
 
 func main() {
 	// Create a World.
 	world := ecs.NewWorld()
+	listener := Listener{World: &world}
 
 	// Get component IDs
 	posID := ecs.ComponentID[Position](&world)
@@ -49,7 +55,7 @@ func main() {
 	rotID := ecs.ComponentID[Rotation](&world)
 
 	// Register a listener function.
-	world.SetListener(Listen)
+	world.SetListener(listener.Listen)
 
 	// Create/manipulate/delete entities and observe the listener's output
 	e0 := world.NewEntity(posID)
diff --git a/generic/exchange.go b/generic/exchange.go
index 7b8889a..e65bae9 100644
--- a/generic/exchange.go
+++ b/generic/exchange.go
@@ -67,6 +67,9 @@ func (m *Exchange) Remove(entity ecs.Entity) {
 // Removes the components set via [Exchange.Removes].
 // Adds the components set via [Exchange.Adds].
 //
+// When a [Relation] component is removed and another one is added,
+// the target entity of the relation remains unchanged.
+//
 // See also [ecs.World.Exchange].
 func (m *Exchange) Exchange(entity ecs.Entity) {
 	m.world.Exchange(entity, m.add, m.remove)


Test patch:
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index 6be7a89..8a608fd 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -51,6 +51,22 @@ func TestBitMask(t *testing.T) {
 	assert.False(t, mask.ContainsAny(&other2))
 }
 
+func TestBitMaskCopy(t *testing.T) {
+	mask := All(id(1), id(2), id(13), id(27), id(200))
+	mask2 := mask
+	mask3 := &mask
+
+	mask2.Set(id(1), false)
+	mask3.Set(id(2), false)
+
+	assert.True(t, mask.Get(id(1)))
+	assert.False(t, mask2.Get(id(1)))
+
+	assert.True(t, mask2.Get(id(2)))
+	assert.False(t, mask.Get(id(2)))
+	assert.False(t, mask3.Get(id(2)))
+}
+
 func TestBitMaskWithoutExclusive(t *testing.T) {
 	mask := All(id(1), id(2), id(13))
 
@@ -234,7 +250,7 @@ func BenchmarkMaskPointer(b *testing.B) {
 	_ = v
 }
 
-func BenchmarkMask(b *testing.B) {
+func BenchmarkMaskMatch(b *testing.B) {
 	b.StopTimer()
 	mask := All(id(0), id(1), id(2))
 	bits := All(id(0), id(1), id(2))
@@ -248,6 +264,18 @@ func BenchmarkMask(b *testing.B) {
 	_ = v
 }
 
+func BenchmarkMaskCopy(b *testing.B) {
+	b.StopTimer()
+	mask := All(id(0), id(1), id(2))
+	var tempMask Mask
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		tempMask = mask
+	}
+	b.StopTimer()
+	_ = tempMask
+}
+
 // bitMask64 is there just for performance comparison with the new 256 bit Mask.
 type bitMask64 uint64
 
diff --git a/ecs/event_test.go b/ecs/event_test.go
index b65eabc..3483327 100644
--- a/ecs/event_test.go
+++ b/ecs/event_test.go
@@ -37,17 +37,53 @@ func (h *eventHandler) ListenPointer(e *ecs.EntityEvent) {
 	h.LastEntity = e.Entity
 }
 
+func BenchmarkEntityEventCreate(b *testing.B) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	e := world.NewEntity()
+	mask := ecs.All(posID)
+	added := []ecs.ID{posID}
+
+	var event ecs.EntityEvent
+
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		event = ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil, AddedRemoved: 0}
+	}
+	b.StopTimer()
+	_ = event
+}
+
+func BenchmarkEntityEventHeapPointer(b *testing.B) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+	posID := ecs.ComponentID[Position](&world)
+	e := world.NewEntity()
+	mask := ecs.All(posID)
+	added := []ecs.ID{posID}
+
+	var event *ecs.EntityEvent
+
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		event = &ecs.EntityEvent{Entity: e, OldMask: mask, Added: added, Removed: nil, AddedRemoved: 0}
+	}
+	b.StopTimer()
+	_ = event
+}
+
 func BenchmarkEntityEventCopy(b *testing.B) {
 	handler := eventHandler{}
 
 	for i := 0; i < b.N; i++ {
-		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, NewMask: ecs.Mask{}, Added: nil, Removed: nil, Current: nil, AddedRemoved: 0})
+		handler.ListenCopy(ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0})
 	}
 }
 
 func BenchmarkEntityEventCopyReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, NewMask: ecs.Mask{}, Added: nil, Removed: nil, Current: nil, AddedRemoved: 0}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenCopy(event)
@@ -58,13 +94,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{}, NewMask: ecs.Mask{}, Added: nil, Removed: nil, Current: nil, AddedRemoved: 0})
+		handler.ListenPointer(&ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0})
 	}
 }
 
 func BenchmarkEntityEventPointerReuse(b *testing.B) {
 	handler := eventHandler{}
-	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, NewMask: ecs.Mask{}, Added: nil, Removed: nil, Current: nil, AddedRemoved: 0}
+	event := ecs.EntityEvent{Entity: ecs.Entity{}, OldMask: ecs.Mask{}, Added: nil, Removed: nil, AddedRemoved: 0}
 
 	for i := 0; i < b.N; i++ {
 		handler.ListenPointer(&event)
@@ -74,11 +110,11 @@ func BenchmarkEntityEventPointerReuse(b *testing.B) {
 func ExampleEntityEvent() {
 	world := ecs.NewWorld()
 
-	listener := func(evt *ecs.EntityEvent) {
+	listener := func(evt ecs.EntityEvent) {
 		fmt.Println(evt)
 	}
 	world.SetListener(listener)
 
 	world.NewEntity()
-	// Output: &{{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] [] 1 {0 0} {0 0} false}
+	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1 false false}
 }
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index 918ace0..e249b6d 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -284,3 +284,203 @@ func BenchmarkWorldStats_10Arch(b *testing.B) {
 	}
 	_ = st
 }
+
+func BenchmarkWorldNewEntityNoEvent_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 BenchmarkWorldNewEntityEvent_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)
+
+	var temp int8
+	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+
+	for i := 0; i < b.N; i++ {
+		b.StartTimer()
+		for j := 0; j < 1000; j++ {
+			world.NewEntity(posID)
+		}
+		b.StopTimer()
+		world.Batch().RemoveEntities(filterPos)
+	}
+	_ = temp
+}
+
+func BenchmarkWorldExchangeNoEvent_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 BenchmarkWorldExchangeEvent_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)
+
+	var temp int8
+	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+
+	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
+	}
+	_ = temp
+}
+
+func BenchmarkWorldExchangeBatchNoEvent_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 BenchmarkWorldExchangeBatchEvent_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)
+
+	var temp int8
+	world.SetListener(func(e EntityEvent) { temp = e.AddedRemoved })
+
+	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
+	}
+	_ = temp
+}
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index 18d9172..f83a4f6 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -275,13 +275,13 @@ func ExampleWorld_Batch() {
 func ExampleWorld_SetListener() {
 	world := ecs.NewWorld()
 
-	listener := func(evt *ecs.EntityEvent) {
+	listener := func(evt ecs.EntityEvent) {
 		fmt.Println(evt)
 	}
 	world.SetListener(listener)
 
 	world.NewEntity()
-	// Output: &{{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] [] 1 {0 0} {0 0} false}
+	// Output: {{1 0} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1 false false}
 }
 
 func ExampleWorld_Stats() {
diff --git a/ecs/world_test.go b/ecs/world_test.go
index e43bf4b..270deda 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -330,8 +330,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 	w := NewWorld()
 
 	events := []EntityEvent{}
-	w.SetListener(func(e *EntityEvent) {
-		events = append(events, *e)
+	w.SetListener(func(e EntityEvent) {
+		events = append(events, e)
 	})
 
 	posID := ComponentID[Position](&w)
@@ -345,6 +345,16 @@ func TestWorldExchangeBatch(t *testing.T) {
 	builder.NewBatch(100, target1)
 	builder.NewBatch(100, target2)
 
+	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,
+	}, events[201])
+
 	filter := All(posID, relID)
 	query := w.Batch().ExchangeQ(filter, []ID{velID}, []ID{posID})
 	assert.Equal(t, 200, query.Count())
@@ -368,6 +378,20 @@ func TestWorldExchangeBatch(t *testing.T) {
 		assert.Equal(t, target1, query.Relation(relID))
 	}
 
+	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,
+	}, events[501])
+
 	query = w.Query(All(posID))
 	assert.Equal(t, 100, query.Count())
 	query.Close()
@@ -378,15 +402,38 @@ func TestWorldExchangeBatch(t *testing.T) {
 	w.Batch().Exchange(&relFilter, nil, []ID{relID})
 	w.Batch().Exchange(All(relID), nil, []ID{relID})
 
+	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,
+	}, events[701])
+
 	w.Batch().RemoveEntities(All(posID))
 
 	assert.Equal(t, 802, len(events))
-	assert.Equal(t, 1, events[0].AddedRemoved)
-	assert.Equal(t, 1, events[1].AddedRemoved)
-	assert.Equal(t, 1, events[2].AddedRemoved)
-	assert.Equal(t, 1, events[201].AddedRemoved)
 
-	assert.Equal(t, 0, events[202].AddedRemoved)
+	assert.Equal(t, EntityEvent{
+		Entity:          Entity{102, 0},
+		OldMask:         All(posID),
+		Removed:         []ID{posID},
+		AddedRemoved:    -1,
+		RelationChanged: false,
+		TargetChanged:   false,
+	}, 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)
 
@@ -566,9 +613,9 @@ func TestWorldNewEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) {
+	world.SetListener(func(e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
-		events = append(events, *e)
+		events = append(events, e)
 	})
 
 	posID := ComponentID[Position](&world)
@@ -641,9 +688,9 @@ func TestWorldNewEntitiesWith(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) {
+	world.SetListener(func(e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
-		events = append(events, *e)
+		events = append(events, e)
 	})
 
 	posID := ComponentID[Position](&world)
@@ -715,9 +762,9 @@ func TestWorldRemoveEntities(t *testing.T) {
 	world := NewWorld(NewConfig().WithCapacityIncrement(16))
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) {
+	world.SetListener(func(e EntityEvent) {
 		assert.Equal(t, world.IsLocked(), e.EntityRemoved())
-		events = append(events, *e)
+		events = append(events, e)
 	})
 
 	posID := ComponentID[Position](&world)
@@ -755,8 +802,8 @@ func TestWorldRelationSet(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) {
-		events = append(events, *e)
+	world.SetListener(func(e EntityEvent) {
+		events = append(events, e)
 	})
 
 	rotID := ComponentID[rotation](&world)
@@ -775,6 +822,16 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, Entity{}, world.Relations().GetUnchecked(e1, relID))
 	world.Relations().Set(e1, relID, targ)
 
+	assert.Equal(t, 4, len(events))
+	assert.Equal(t, EntityEvent{
+		Entity:        e1,
+		OldMask:       All(relID, rotID),
+		OldRelation:   &relID,
+		NewRelation:   &relID,
+		OldTarget:     Entity{},
+		TargetChanged: true,
+	}, events[len(events)-1])
+
 	assert.Equal(t, targ, world.Relations().Get(e1, relID))
 	assert.Equal(t, targ, world.Relations().GetUnchecked(e1, relID))
 	assert.Equal(t, int32(3), world.nodes.Len())
@@ -783,6 +840,16 @@ func TestWorldRelationSet(t *testing.T) {
 
 	world.Relations().Set(e1, relID, Entity{})
 
+	assert.Equal(t, 5, len(events))
+	assert.Equal(t, EntityEvent{
+		Entity:        e1,
+		OldMask:       All(relID, rotID),
+		OldRelation:   &relID,
+		NewRelation:   &relID,
+		OldTarget:     targ,
+		TargetChanged: true,
+	}, events[len(events)-1])
+
 	assert.Panics(t, func() { world.Relations().Get(e1, rotID) })
 	assert.Panics(t, func() { world.Relations().Get(e1, rel2ID) })
 	assert.Panics(t, func() { world.Relations().Set(e1, rotID, Entity{}) })
@@ -790,6 +857,7 @@ func TestWorldRelationSet(t *testing.T) {
 
 	// Should do nothing
 	world.Relations().Set(e1, relID, Entity{})
+	assert.Equal(t, 5, len(events))
 
 	assert.Equal(t, Entity{}, world.Relations().Get(e1, relID))
 	assert.Equal(t, int32(3), world.nodes.Len())
@@ -797,6 +865,17 @@ func TestWorldRelationSet(t *testing.T) {
 
 	world.Remove(e2, relID)
 
+	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,
+	}, events[len(events)-1])
+
 	assert.Panics(t, func() { world.Relations().Get(e2, relID) })
 	assert.Panics(t, func() { world.Relations().Set(e2, relID, Entity{}) })
 
@@ -822,8 +901,8 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) {
-		events = append(events, *e)
+	world.SetListener(func(e EntityEvent) {
+		events = append(events, e)
 	})
 
 	posID := ComponentID[Position](&world)
@@ -893,7 +972,7 @@ func TestWorldRelationRemove(t *testing.T) {
 	world := NewWorld()
 
 	events := []EntityEvent{}
-	world.SetListener(func(e *EntityEvent) { events = append(events, *e) })
+	world.SetListener(func(e EntityEvent) { events = append(events, e) })
 
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1109,7 +1188,7 @@ func TestWorldRelation(t *testing.T) {
 
 func TestWorldRelationCreate(t *testing.T) {
 	world := NewWorld()
-	world.SetListener(func(e *EntityEvent) {})
+	world.SetListener(func(e EntityEvent) {})
 
 	posID := ComponentID[Position](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1149,7 +1228,7 @@ func TestWorldRelationCreate(t *testing.T) {
 
 func TestWorldRelationMove(t *testing.T) {
 	world := NewWorld()
-	world.SetListener(func(e *EntityEvent) {})
+	world.SetListener(func(e EntityEvent) {})
 
 	posID := ComponentID[Position](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1343,7 +1422,13 @@ func TestRegisterComponents(t *testing.T) {
 }
 
 func TestWorldBatchRemove(t *testing.T) {
+	events := []EntityEvent{}
+	listen := func(e EntityEvent) {
+		events = append(events, e)
+	}
+
 	world := NewWorld()
+	world.SetListener(listen)
 
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
@@ -1352,29 +1437,68 @@ func TestWorldBatchRemove(t *testing.T) {
 	target2 := world.NewEntity()
 	target3 := world.NewEntity()
 
+	assert.Equal(t, 3, len(events))
+	assert.Equal(t, EntityEvent{
+		Entity:       target3,
+		AddedRemoved: 1,
+	}, events[len(events)-1])
+
 	builder := NewBuilder(&world, rotID, relID).WithRelation(relID)
 
 	builder.NewBatch(10, target1)
 	builder.NewBatch(10, target2)
 	builder.NewBatch(10, target3)
 
+	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,
+	}, events[len(events)-1])
+
 	filter := All(rotID).Exclusive()
 	filter2 := world.Cache().Register(&filter)
 	world.Batch().RemoveEntities(&filter)
 	world.Cache().Unregister(&filter2)
 
+	assert.Equal(t, 33, len(events))
+
 	relFilter := NewRelationFilter(All(rotID, relID), target1)
 	world.Batch().RemoveEntities(&relFilter)
 
+	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,
+	}, events[len(events)-1])
+
 	relFilter = NewRelationFilter(All(rotID, relID), target2)
 	world.Batch().RemoveEntities(&relFilter)
 
+	assert.Equal(t, 53, len(events))
+
 	filter = All().Exclusive()
 	world.Batch().RemoveEntities(&filter)
 
+	assert.Equal(t, 56, len(events))
+	assert.Equal(t, EntityEvent{
+		Entity:       Entity{3, 0},
+		AddedRemoved: -1,
+	}, events[len(events)-1])
+
 	relFilter = NewRelationFilter(All(rotID, relID), target3)
 	world.Batch().RemoveEntities(&relFilter)
 
+	assert.Equal(t, 66, len(events))
+
 	query := world.Query(All())
 	assert.Equal(t, 0, query.Count())
 	query.Close()
@@ -1383,7 +1507,7 @@ func TestWorldBatchRemove(t *testing.T) {
 func TestWorldReset(t *testing.T) {
 	world := NewWorld()
 
-	world.SetListener(func(e *EntityEvent) {})
+	world.SetListener(func(e EntityEvent) {})
 	AddResource(&world, &rotation{100})
 
 	posID := ComponentID[Position](&world)
@@ -1459,8 +1583,8 @@ func TestArchetypeGraph(t *testing.T) {
 
 func TestWorldListener(t *testing.T) {
 	events := []EntityEvent{}
-	listen := func(e *EntityEvent) {
-		events = append(events, *e)
+	listen := func(e EntityEvent) {
+		events = append(events, e)
 	}
 
 	w := NewWorld()
@@ -1470,28 +1594,25 @@ func TestWorldListener(t *testing.T) {
 	posID := ComponentID[Position](&w)
 	velID := ComponentID[Velocity](&w)
 	rotID := ComponentID[rotation](&w)
+	relID := ComponentID[relationComp](&w)
 
 	e0 := w.NewEntity()
 	assert.Equal(t, 1, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity: e0, AddedRemoved: 1,
-		Current: []ID{},
 	}, events[len(events)-1])
 
 	w.RemoveEntity(e0)
 	assert.Equal(t, 2, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity: e0, AddedRemoved: -1,
-		Removed: []ID{},
 	}, events[len(events)-1])
 
 	e0 = w.NewEntity(posID, velID)
 	assert.Equal(t, 3, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:       e0,
-		NewMask:      All(posID, velID),
 		Added:        []ID{posID, velID},
-		Current:      []ID{posID, velID},
 		AddedRemoved: 1,
 	}, events[len(events)-1])
 
@@ -1500,30 +1621,28 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, EntityEvent{
 		Entity:       e0,
 		OldMask:      All(posID, velID),
-		NewMask:      Mask{},
 		Removed:      []ID{posID, velID},
-		Current:      nil,
 		AddedRemoved: -1,
 	}, events[len(events)-1])
 
-	e0 = w.NewEntityWith(Component{posID, &Position{}}, Component{velID, &Velocity{}})
+	e0 = w.NewEntityWith(Component{posID, &Position{}}, Component{velID, &Velocity{}}, Component{relID, &relationComp{}})
 	assert.Equal(t, 5, len(events))
 	assert.Equal(t, EntityEvent{
-		Entity:       e0,
-		NewMask:      All(posID, velID),
-		Added:        []ID{posID, velID},
-		Current:      []ID{posID, velID},
-		AddedRemoved: 1,
+		Entity:          e0,
+		Added:           []ID{posID, velID, relID},
+		NewRelation:     &relID,
+		AddedRemoved:    1,
+		RelationChanged: true,
 	}, events[len(events)-1])
 
 	w.Add(e0, rotID)
 	assert.Equal(t, 6, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:       e0,
-		OldMask:      All(posID, velID),
-		NewMask:      All(posID, velID, rotID),
+		OldMask:      All(posID, velID, relID),
 		Added:        []ID{rotID},
-		Current:      []ID{posID, velID, rotID},
+		OldRelation:  &relID,
+		NewRelation:  &relID,
 		AddedRemoved: 0,
 	}, events[len(events)-1])
 
@@ -1531,13 +1650,169 @@ func TestWorldListener(t *testing.T) {
 	assert.Equal(t, 7, len(events))
 	assert.Equal(t, EntityEvent{
 		Entity:       e0,
-		OldMask:      All(posID, velID, rotID),
-		NewMask:      All(velID, rotID),
+		OldMask:      All(posID, velID, rotID, relID),
 		Removed:      []ID{posID},
-		Current:      []ID{velID, rotID},
+		OldRelation:  &relID,
+		NewRelation:  &relID,
 		AddedRemoved: 0,
 	}, 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,
+	}, 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,
+	}, events[len(events)-1])
+
+}
+
+func TestWorldListenerBuilder(t *testing.T) {
+	events := []EntityEvent{}
+	listen := func(e EntityEvent) {
+		events = append(events, e)
+	}
+
+	w := NewWorld()
+
+	w.SetListener(listen)
+
+	posID := ComponentID[Position](&w)
+	relID := ComponentID[relationComp](&w)
+
+	parent := w.NewEntity(posID)
+
+	builder := NewBuilder(&w, posID, relID).WithRelation(relID)
+	builder.NewBatch(10)
+
+	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,
+	}, events[len(events)-1])
+
+	query := builder.NewBatchQ(10)
+	query.Close()
+
+	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,
+	}, 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,
+	}, events[len(events)-1])
+
+	query = builder.NewBatchQ(10, parent)
+	query.Close()
+
+	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,
+	}, events[len(events)-1])
+
+	builder = NewBuilderWith(&w,
+		Component{ID: posID, Comp: &Position{}},
+		Component{ID: relID, Comp: &relationComp{}},
+	).WithRelation(relID)
+
+	builder.NewBatch(10)
+
+	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,
+	}, events[len(events)-1])
+
+	query = builder.NewBatchQ(10)
+	query.Close()
+
+	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,
+	}, 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,
+	}, events[len(events)-1])
+
+	query = builder.NewBatchQ(10, parent)
+	query.Close()
+
+	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,
+	}, events[len(events)-1])
 }
 
 type withSlice struct {