Repository: mlange-42/arche
Pull request #265 (2023-05-03 14:39:53)

Batch-set entity relation, RelationFilter to function
* add possibility to batch-set entity relations
* make `RelationFilter` a function instead of a struct

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

* **91** of **93**   **(97.85%)**  changed or added relevant lines in **5** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.05%**) to **99.946%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [ecs/world.go](https://coveralls.io/builds/59580047/source?filename=ecs%2Fworld.go#L834) | 70 | 72 | 97.22%
<!-- | **Total:** | **91** | **93** | **97.85%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/59580047/badge)](https://coveralls.io/builds/59580047) |
| :-- | --: |
| Change from base [Build 4873167893](https://coveralls.io/builds/59579869): |  -0.05% |
| Covered Lines: | 3728 |
| Relevant Lines: | 3730 |

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


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e5bdea..015ee1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@
 * Adds methods for faster, unchecked entity relation access (#259)
 * Re-introduce `World.Batch` for batch-processing of entities (add/remove/exchange) (#264)
 * New method `Builder.Add` for adding components with a target to entities (#264)
+* New method `Batch.SetRelation` for batch-setting entity relations (#265)
+* Sends an `EntityEvent` to the world listener on relation target changes (#265)
 
 ### Performance
 
diff --git a/ecs/archetypes.go b/ecs/archetypes.go
index 2abe2cd..4eeb51d 100644
--- a/ecs/archetypes.go
+++ b/ecs/archetypes.go
@@ -35,6 +35,7 @@ func (s singleArchetype) Len() int32 {
 type batchArchetype struct {
 	Archetype    *archetype
 	StartIndex   uint32
+	EndIndex     uint32
 	OldArchetype *archetype
 	Added        []ID
 	Removed      []ID
diff --git a/ecs/batch.go b/ecs/batch.go
index 3e04c10..5b8afb2 100644
--- a/ecs/batch.go
+++ b/ecs/batch.go
@@ -35,6 +35,21 @@ func (b *Batch) Remove(filter Filter, callback func(Query), comps ...ID) {
 	b.world.exchangeBatch(filter, nil, comps, callback)
 }
 
+// SetRelation sets the [Relation] target for many entities, matching a filter.
+//
+// If the callback argument is given, it is called with a [Query] over the affected entities,
+// one Query for each affected archetype.
+//
+// Panics:
+//   - when called for a missing component.
+//   - when called for a component that is not a relation.
+//   - when called on a locked world. Do not use during [Query] iteration!
+//
+// See also [World.SetRelation].
+func (b *Batch) SetRelation(filter Filter, comp ID, target Entity, callback func(Query)) {
+	b.world.setRelationBatch(filter, comp, target, callback)
+}
+
 // Exchange exchanges components for many entities, matching a filter.
 //
 // If the callback argument is given, it is called with a [Query] over the affected entities,
diff --git a/ecs/event.go b/ecs/event.go
index f375200..8098bdc 100644
--- a/ecs/event.go
+++ b/ecs/event.go
@@ -23,6 +23,8 @@ type EntityEvent struct {
 	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.
 }
 
 // EntityAdded reports whether the entity was newly added.
diff --git a/ecs/filter.go b/ecs/filter.go
index 5430e57..2587c0a 100644
--- a/ecs/filter.go
+++ b/ecs/filter.go
@@ -24,15 +24,24 @@ func (f *MaskFilter) Matches(bits Mask, relation *Entity) bool {
 }
 
 // RelationFilter is a [Filter] for a [Relation] target, in addition to components.
-//
-// See [Relation] for details and examples.
-type RelationFilter struct {
+type relationFilter struct {
 	Filter Filter // Components filter.
 	Target Entity // Relation target entity.
 }
 
+// RelationFilter creates a new [Relation] filter.
+// It is a [Filter] for a [Relation] target, in addition to components.
+//
+// See [Relation] for details and examples.
+func RelationFilter(filter Filter, target Entity) Filter {
+	return &relationFilter{
+		Filter: filter,
+		Target: target,
+	}
+}
+
 // Matches matches a filter against a mask.
-func (f *RelationFilter) Matches(bits Mask, relation *Entity) bool {
+func (f *relationFilter) Matches(bits Mask, relation *Entity) bool {
 	return f.Filter.Matches(bits, relation) && (relation == nil || f.Target == *relation)
 }
 
diff --git a/ecs/query.go b/ecs/query.go
index ef40760..d0ac888 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -66,9 +66,9 @@ func newArchQuery(world *World, lockBit uint8, archetype batchArchetype) Query {
 			access:         &arch.archetypeAccess,
 			archIndex:      0,
 			lockBit:        lockBit,
-			count:          int32(arch.Len() - archetype.StartIndex),
+			count:          int32(archetype.EndIndex - archetype.StartIndex),
 			entityIndex:    uintptr(archetype.StartIndex - 1),
-			entityIndexMax: uintptr(arch.Len() - 1),
+			entityIndexMax: uintptr(archetype.EndIndex - 1),
 		}
 	}
 	return Query{
@@ -78,7 +78,7 @@ func newArchQuery(world *World, lockBit uint8, archetype batchArchetype) Query {
 		archetypes: archetype,
 		archIndex:  -1,
 		lockBit:    lockBit,
-		count:      int32(arch.Len()),
+		count:      int32(archetype.EndIndex),
 	}
 }
 
@@ -200,7 +200,11 @@ func (q *Query) nextArchetypeSimple() bool {
 		if a.IsActive() && (q.isFiltered || a.Matches(q.filter)) && aLen > 0 {
 			q.access = &a.archetypeAccess
 			q.entityIndex = 0
-			q.entityIndexMax = uintptr(aLen) - 1
+			if batch, ok := q.archetypes.(batchArchetype); ok {
+				q.entityIndexMax = uintptr(batch.EndIndex) - 1
+			} else {
+				q.entityIndexMax = uintptr(aLen) - 1
+			}
 			return true
 		}
 	}
@@ -223,7 +227,7 @@ func (q *Query) nextNode() bool {
 		}
 
 		if arches.Len() > 1 {
-			if rf, ok := q.filter.(*RelationFilter); ok {
+			if rf, ok := q.filter.(*relationFilter); ok {
 				target := rf.Target
 				if arch, ok := n.archetypeMap[target]; ok && arch.Len() > 0 {
 					q.archetypes = nil
@@ -284,7 +288,7 @@ func (q *Query) countEntities() int {
 		nArch := arches.Len()
 
 		if nArch > 1 {
-			if rf, ok := q.filter.(*RelationFilter); ok {
+			if rf, ok := q.filter.(*relationFilter); ok {
 				target := rf.Target
 				if arch, ok := nd.archetypeMap[target]; ok {
 					count += arch.Len()
diff --git a/ecs/util.go b/ecs/util.go
index 092d297..561b473 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -1,5 +1,10 @@
 package ecs
 
+import (
+	"fmt"
+	"strings"
+)
+
 // Page size of pagedSlice type
 const pageSize = 32
 
@@ -96,3 +101,32 @@ func (p *pagedSlice[T]) Set(index int32, value T) {
 func (p *pagedSlice[T]) Len() int32 {
 	return p.len
 }
+
+// Prints world nodes and archetypes.
+func debugPrintWorld(w *World) string {
+	sb := strings.Builder{}
+
+	ln := w.graph.Len()
+	var i int32
+	for i = 0; i < ln; i++ {
+		nd := w.graph.Get(i)
+		if !nd.IsActive() {
+			fmt.Fprint(&sb, "Node ??? (inactive)\n")
+			continue
+		}
+		nodeArches := nd.Archetypes()
+		ln2 := int32(nodeArches.Len())
+		fmt.Fprintf(&sb, "Node %v (%d arch), relation: %t\n", nd.Ids, ln2, nd.HasRelation())
+		var j int32
+		for j = 0; j < ln2; j++ {
+			a := nodeArches.Get(j)
+			if a.IsActive() {
+				fmt.Fprintf(&sb, "   Arch %v (%d entities)\n", a.Relation, a.Len())
+			} else {
+				fmt.Fprintf(&sb, "   Arch %v (inactive)\n", a.Relation)
+			}
+		}
+	}
+
+	return sb.String()
+}
diff --git a/ecs/world.go b/ecs/world.go
index 597dc8b..e2f68c9 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -145,7 +145,7 @@ 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.graphNode.Ids, 1})
+		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 	}
 	return entity
 }
@@ -183,7 +183,7 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 	}
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.graphNode.Ids, 1})
+		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 	}
 	return entity
 }
@@ -210,7 +210,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.graphNode.Ids, 1})
+		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 	}
 	return entity
 }
@@ -243,7 +243,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.graphNode.Ids, 1})
+		w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 	}
 	return entity
 }
@@ -259,7 +259,7 @@ func (w *World) newEntities(count int, targetID int8, target Entity, comps ...ID
 		for i = 0; i < cnt; i++ {
 			idx := startIdx + i
 			entity := arch.GetEntity(uintptr(idx))
-			w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.graphNode.Ids, 1})
+			w.listener(&EntityEvent{entity, Mask{}, arch.Mask, comps, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 		}
 	}
 
@@ -271,7 +271,7 @@ func (w *World) newEntities(count int, targetID int8, target Entity, comps ...ID
 func (w *World) newEntitiesQuery(count int, targetID int8, target Entity, comps ...ID) Query {
 	arch, startIdx := w.newEntitiesNoNotify(count, targetID, target, comps...)
 	lock := w.lock()
-	return newArchQuery(w, lock, batchArchetype{arch, startIdx, nil, arch.Components(), nil})
+	return newArchQuery(w, lock, batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
 }
 
 // Creates new entities with component values without returning a query over them.
@@ -290,7 +290,7 @@ func (w *World) newEntitiesWith(count int, targetID int8, target Entity, comps .
 		for i = 0; i < cnt; i++ {
 			idx := startIdx + i
 			entity := arch.GetEntity(uintptr(idx))
-			w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.graphNode.Ids, 1})
+			w.listener(&EntityEvent{entity, Mask{}, arch.Mask, ids, nil, arch.graphNode.Ids, 1, Entity{}, arch.Relation, false})
 		}
 	}
 
@@ -307,7 +307,7 @@ func (w *World) newEntitiesWithQuery(count int, targetID int8, target Entity, co
 
 	arch, startIdx := w.newEntitiesWithNoNotify(count, targetID, target, ids, comps...)
 	lock := w.lock()
-	return newArchQuery(w, lock, batchArchetype{arch, startIdx, nil, arch.Components(), nil})
+	return newArchQuery(w, lock, batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
 }
 
 // RemoveEntity removes and recycles an [Entity].
@@ -328,7 +328,7 @@ func (w *World) RemoveEntity(entity Entity) {
 
 	if w.listener != nil {
 		lock := w.lock()
-		w.listener(&EntityEvent{entity, oldArch.Mask, Mask{}, nil, oldArch.graphNode.Ids, nil, -1})
+		w.listener(&EntityEvent{entity, oldArch.Mask, Mask{}, nil, oldArch.graphNode.Ids, nil, -1, oldArch.Relation, Entity{}, false})
 		w.unlock(lock)
 	}
 
@@ -395,7 +395,7 @@ func (w *World) removeEntities(filter Filter) int {
 			for j = 0; j < len; j++ {
 				entity := arch.GetEntity(j)
 				if w.listener != nil {
-					w.listener(&EntityEvent{entity, arch.Mask, Mask{}, nil, node.Ids, nil, -1})
+					w.listener(&EntityEvent{entity, arch.Mask, Mask{}, nil, node.Ids, nil, -1, arch.Relation, Entity{}, false})
 				}
 				index := &w.entities[entity.id]
 				index.arch = nil
@@ -622,7 +622,7 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation int8, targe
 	w.cleanupArchetype(oldArch)
 
 	if w.listener != nil {
-		w.listener(&EntityEvent{entity, oldMask, arch.Mask, add, rem, arch.graphNode.Ids, 0})
+		w.listener(&EntityEvent{entity, oldMask, arch.Mask, add, rem, arch.graphNode.Ids, 0, oldArch.Relation, arch.Relation, false})
 	}
 }
 
@@ -654,6 +654,8 @@ func (w *World) getExchangeMask(arch *archetype, add []ID, rem []ID) Mask {
 //
 // See also [World.Exchange].
 func (w *World) exchangeBatch(filter Filter, add []ID, rem []ID, callback func(Query)) {
+	w.checkLocked()
+
 	if len(add) == 0 && len(rem) == 0 {
 		return
 	}
@@ -677,12 +679,13 @@ func (w *World) exchangeBatch(filter Filter, add []ID, rem []ID, callback func(Q
 		newArch, start := w.exchangeArch(arch, archLen, add, rem)
 		if callback == nil {
 			if w.listener != nil {
-				w.notifyQuery(&batchArchetype{newArch, start, arch, add, rem})
+				w.notifyQuery(&batchArchetype{newArch, start, newArch.Len(), arch, add, rem})
 			}
 		} else {
 			lock := w.lock()
-			query := newArchQuery(w, lock, batchArchetype{newArch, start, arch, add, rem})
+			query := newArchQuery(w, lock, batchArchetype{newArch, start, newArch.Len(), arch, add, rem})
 			callback(query)
+			w.checkLocked()
 		}
 	}
 }
@@ -700,9 +703,10 @@ func (w *World) exchangeArch(oldArch *archetype, oldArchLen uint32, add []ID, re
 	for i = 0; i < count; i++ {
 		idx := startIdx + i
 		entity := oldArch.GetEntity(i)
-		index := w.entities[entity.id]
+		index := &w.entities[entity.id]
 		arch.SetEntity(uintptr(idx), entity)
-		w.entities[entity.id] = entityIndex{arch: arch, index: uintptr(idx), isTarget: index.isTarget}
+		index.arch = arch
+		index.index = uintptr(idx)
 
 		for _, id := range oldIDs {
 			if mask.Get(id) {
@@ -800,6 +804,89 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 	w.entities[target.id].isTarget = true
 
 	w.cleanupArchetype(oldArch)
+
+	if w.listener != nil {
+		w.listener(&EntityEvent{entity, arch.Mask, arch.Mask, nil, nil, arch.graphNode.Ids, 0, oldArch.Relation, arch.Relation, true})
+	}
+}
+
+func (w *World) setRelationBatch(filter Filter, comp ID, target Entity, callback func(Query)) {
+	w.checkLocked()
+
+	if !target.IsZero() && !w.entityPool.Alive(target) {
+		panic("can't make a dead entity a relation target")
+	}
+
+	arches := w.getArchetypes(filter)
+	ln := arches.Len()
+	lengths := make([]uint32, arches.Len())
+	var i int32
+	for i = 0; i < ln; i++ {
+		lengths[i] = arches.Get(i).Len()
+	}
+
+	for i = 0; i < ln; i++ {
+		arch := arches.Get(i)
+		archLen := lengths[i]
+
+		if archLen == 0 {
+			continue
+		}
+
+		newArch, start, end := w.setRelationArch(arch, archLen, comp, target)
+		if callback == nil {
+			if w.listener != nil {
+				w.notifyQuery(&batchArchetype{newArch, start, end, arch, nil, nil})
+			}
+		} else {
+			lock := w.lock()
+			query := newArchQuery(w, lock, batchArchetype{newArch, start, end, arch, nil, nil})
+			callback(query)
+			w.checkLocked()
+		}
+	}
+}
+
+func (w *World) setRelationArch(oldArch *archetype, oldArchLen uint32, comp ID, target Entity) (*archetype, uint32, uint32) {
+	w.checkRelation(oldArch, comp)
+
+	if oldArch.Relation == target {
+		return oldArch, 0, oldArchLen
+	}
+	oldIDs := oldArch.Components()
+
+	arch := oldArch.graphNode.GetArchetype(target)
+	if arch == nil {
+		arch = w.createArchetype(oldArch.graphNode, target, true)
+	}
+
+	startIdx := uintptr(arch.Len())
+	count := uintptr(oldArchLen)
+	arch.AllocN(uint32(count))
+
+	var i uintptr
+	for i = 0; i < count; i++ {
+		idx := startIdx + i
+		entity := oldArch.GetEntity(i)
+		index := &w.entities[entity.id]
+		arch.SetEntity(uintptr(idx), entity)
+		index.arch = arch
+		index.index = uintptr(idx)
+
+		for _, id := range oldIDs {
+			comp := oldArch.Get(i, id)
+			arch.SetPointer(idx, id, comp)
+		}
+	}
+
+	// Theoretically, it could be oldArchLen < oldArch.Len(),
+	// which means we can't reset the archetype.
+	// However, this should not be possible as processing an entity twice
+	// would mean an illegal component addition/removal.
+	oldArch.Reset()
+	w.cleanupArchetype(oldArch)
+
+	return arch, uint32(startIdx), arch.Len()
 }
 
 func (w *World) checkRelation(arch *archetype, comp ID) {
@@ -1263,7 +1350,7 @@ func (w *World) getArchetypes(filter Filter) archetypePointers {
 		nodeArches := nd.Archetypes()
 		ln2 := int32(nodeArches.Len())
 		if ln2 > 1 {
-			if rf, ok := filter.(*RelationFilter); ok {
+			if rf, ok := filter.(*relationFilter); ok {
 				target := rf.Target
 				if arch, ok := nd.archetypeMap[target]; ok {
 					arches = append(arches, arch)
@@ -1339,15 +1426,21 @@ func (w *World) closeQuery(query *Query) {
 func (w *World) notifyQuery(batchArch *batchArchetype) {
 	arch := batchArch.Archetype
 	var i uintptr
-	event := EntityEvent{Entity{}, Mask{}, arch.Mask, batchArch.Added, batchArch.Removed, arch.graphNode.Ids, 1}
+
+	event := EntityEvent{
+		Entity{}, Mask{}, arch.Mask, batchArch.Added, batchArch.Removed, arch.graphNode.Ids, 1,
+		Entity{}, arch.Relation, false,
+	}
 
 	oldArch := batchArch.OldArchetype
 	if oldArch != nil {
 		event.OldMask = oldArch.graphNode.mask
 		event.AddedRemoved = 0
+		event.OldTarget = oldArch.Relation
+		event.TargetChanged = event.OldMask == event.NewMask
 	}
 
-	start, end := uintptr(batchArch.StartIndex), uintptr(arch.Len())
+	start, end := uintptr(batchArch.StartIndex), uintptr(batchArch.EndIndex)
 	for i = start; i < end; i++ {
 		entity := arch.GetEntity(i)
 		event.Entity = entity
diff --git a/examples/relations/main.go b/examples/relations/main.go
index 6af40e1..537fbac 100644
--- a/examples/relations/main.go
+++ b/examples/relations/main.go
@@ -45,13 +45,10 @@ func run() {
 	childBuilder.NewBatch(10, parent2)
 
 	// Create a filter for a relation target.
-	filter := ecs.RelationFilter{
-		Filter: ecs.All(childID),
-		Target: parent1,
-	}
+	filter := ecs.RelationFilter(ecs.All(childID), parent1)
 
 	// Create a query from it.
-	query := world.Query(&filter)
+	query := world.Query(filter)
 
 	// Iterate the query.
 	for query.Next() {
diff --git a/generic/compiled.go b/generic/compiled.go
index bd8545d..a595f4c 100644
--- a/generic/compiled.go
+++ b/generic/compiled.go
@@ -63,10 +63,7 @@ func (q *compiledQuery) Compile(w *ecs.World, include, optional, exclude []Comp,
 			}
 		}
 
-		q.filter = &ecs.RelationFilter{
-			Filter: &q.maskFilter,
-			Target: target,
-		}
+		q.filter = ecs.RelationFilter(&q.maskFilter, target)
 	}
 	q.targetCompiled = true
 	q.compiled = true


Test patch:
diff --git a/benchmark/arche/relations/relation_test.go b/benchmark/arche/relations/relation_test.go
index 9e29667..1124629 100644
--- a/benchmark/arche/relations/relation_test.go
+++ b/benchmark/arche/relations/relation_test.go
@@ -45,9 +45,9 @@ func benchmarkRelation(b *testing.B, numParents int, numChildren int) {
 			parData := query.Get()
 			par := query.Entity()
 
-			cf := ecs.RelationFilter{Filter: &childF, Target: par}
+			cf := ecs.RelationFilter(&childF, par)
 
-			childQuery := world.Query(&cf)
+			childQuery := world.Query(cf)
 			for childQuery.Next() {
 				child := (*ChildRelation)(childQuery.Get(childID))
 				parData.Value += child.Value
diff --git a/ecs/event_test.go b/ecs/event_test.go
index 1705334..677771d 100644
--- a/ecs/event_test.go
+++ b/ecs/event_test.go
@@ -80,5 +80,5 @@ func ExampleEntityEvent() {
 	world.SetListener(listener)
 
 	world.NewEntity()
-	// Output: &{{1 0} {0 0} {0 0} [] [] [] 1}
+	// Output: &{{1 0} {0 0} {0 0} [] [] [] 1 {0 0} {0 0} false}
 }
diff --git a/ecs/relation_examples_test.go b/ecs/relation_examples_test.go
index d3ef031..1074cf7 100644
--- a/ecs/relation_examples_test.go
+++ b/ecs/relation_examples_test.go
@@ -25,11 +25,9 @@ func ExampleRelation() {
 	_ = world.Relations().Get(child, childID)
 
 	// Filter for the relation.
-	filter := ecs.RelationFilter{
-		Filter: ecs.All(childID),
-		Target: parent,
-	}
-	query := world.Query(&filter)
+	filter := ecs.RelationFilter(ecs.All(childID), parent)
+
+	query := world.Query(filter)
 	for query.Next() {
 		fmt.Println(
 			query.Entity(),
diff --git a/ecs/relation_stress_test.go b/ecs/relation_stress_test.go
index 368afe3..105f3da 100644
--- a/ecs/relation_stress_test.go
+++ b/ecs/relation_stress_test.go
@@ -40,11 +40,8 @@ func TestRelationStress(t *testing.T) {
 			parIdx := rand.Intn(numParents)
 			parent := parents[parIdx]
 
-			childFilter := ecs.RelationFilter{
-				Filter: ecs.All(relID),
-				Target: parent,
-			}
-			removed := world.Batch().RemoveEntities(&childFilter)
+			childFilter := ecs.RelationFilter(ecs.All(relID), parent)
+			removed := world.Batch().RemoveEntities(childFilter)
 			world.RemoveEntity(parent)
 			assert.Equal(t, numChildren, removed)
 
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index 3cc05a1..d4782e9 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -232,7 +232,7 @@ func ExampleWorld_SetListener() {
 	world.SetListener(listener)
 
 	world.NewEntity()
-	// Output: &{{1 0} {0 0} {0 0} [] [] [] 1}
+	// Output: &{{1 0} {0 0} {0 0} [] [] [] 1 {0 0} {0 0} false}
 }
 
 func ExampleWorld_Stats() {
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 20f30f7..0b83293 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -302,8 +302,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 	query.Close()
 
 	cnt = 0
-	filter2 := RelationFilter{Filter: All(relID), Target: target1}
-	w.Batch().Exchange(&filter2, []ID{posID}, []ID{velID}, func(q Query) {
+	filter2 := RelationFilter(All(relID), target1)
+	w.Batch().Exchange(filter2, []ID{posID}, []ID{velID}, func(q Query) {
 		assert.Equal(t, 100, q.Count())
 		for q.Next() {
 			assert.True(t, q.Has(posID))
@@ -321,7 +321,7 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	w.Batch().Exchange(All(posID), nil, nil, nil)
 
-	w.Batch().Exchange(&RelationFilter{Filter: All(relID), Target: target2}, nil, []ID{relID}, nil)
+	w.Batch().Exchange(RelationFilter(All(relID), target2), nil, []ID{relID}, nil)
 	w.Batch().Exchange(All(relID), nil, []ID{relID}, nil)
 
 	w.Batch().RemoveEntities(All(posID))
@@ -346,6 +346,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	filter = All()
 	w.Batch().Add(filter, nil, velID)
+
+	assert.Panics(t, func() { w.Batch().Remove(All(velID), func(q Query) {}, velID) })
 }
 
 func TestWorldAssignSet(t *testing.T) {
@@ -684,6 +686,11 @@ func TestWorldRemoveEntities(t *testing.T) {
 func TestWorldRelationSet(t *testing.T) {
 	world := NewWorld()
 
+	events := []EntityEvent{}
+	world.SetListener(func(e *EntityEvent) {
+		events = append(events, *e)
+	})
+
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
 	rel2ID := ComponentID[testRelationB](&world)
@@ -739,11 +746,76 @@ func TestWorldRelationSet(t *testing.T) {
 	assert.Equal(t, int32(2), world.graph.Get(2).archetypes.Len())
 	assert.True(t, world.graph.Get(2).archetypes.Get(0).IsActive())
 	assert.False(t, world.graph.Get(2).archetypes.Get(1).IsActive())
+
+	assert.Equal(t, 9, len(events))
+}
+
+func TestWorldRelationSetBatch(t *testing.T) {
+	world := NewWorld()
+
+	events := []EntityEvent{}
+	world.SetListener(func(e *EntityEvent) {
+		events = append(events, *e)
+	})
+
+	posID := ComponentID[Position](&world)
+	rotID := ComponentID[rotation](&world)
+	relID := ComponentID[testRelationA](&world)
+
+	targ1 := world.NewEntity(posID)
+	targ2 := world.NewEntity(posID)
+	targ3 := world.NewEntity(posID)
+
+	builder := NewBuilder(&world, rotID, relID).WithRelation(relID)
+	builder.NewBatch(100, targ1)
+	builder.NewBatch(100, targ2)
+	builder.NewBatch(100, targ3)
+
+	world.Batch().SetRelation(RelationFilter(All(relID), targ2), relID, targ1, func(q Query) {
+		assert.Equal(t, 100, q.Count())
+		for q.Next() {
+			assert.Equal(t, targ1, q.Relation(relID))
+		}
+	})
+
+	total := 0
+	world.Batch().SetRelation(All(relID), relID, targ3, func(q Query) {
+		total += q.Count()
+		for q.Next() {
+			assert.Equal(t, targ3, q.Relation(relID))
+		}
+	})
+	assert.Equal(t, 300, total)
+
+	world.Batch().SetRelation(RelationFilter(All(relID), targ3), relID, Entity{}, func(q Query) {
+		assert.Equal(t, 300, q.Count())
+		for q.Next() {
+			assert.True(t, q.Relation(relID).IsZero())
+		}
+	})
+
+	world.Batch().SetRelation(RelationFilter(All(relID), Entity{}), relID, targ1, nil)
+
+	world.RemoveEntity(targ3)
+	assert.Panics(t, func() {
+		world.Batch().SetRelation(All(relID), relID, targ3, nil)
+	})
+
+	assert.Equal(t, 1304, len(events))
+
+	assert.Panics(t, func() {
+		world.Batch().SetRelation(All(relID), relID, targ2, func(q Query) {})
+	})
+
+	fmt.Println(debugPrintWorld(&world))
 }
 
 func TestWorldRelationRemove(t *testing.T) {
 	world := NewWorld()
 
+	events := []EntityEvent{}
+	world.SetListener(func(e *EntityEvent) { events = append(events, *e) })
+
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
 
@@ -754,8 +826,8 @@ func TestWorldRelationRemove(t *testing.T) {
 	e1 := world.NewEntity(relID, rotID)
 	e2 := world.NewEntity(relID, rotID)
 
-	filter := RelationFilter{Filter: All(relID), Target: targ}
-	world.Cache().Register(&filter)
+	filter := RelationFilter(All(relID), targ)
+	world.Cache().Register(filter)
 
 	assert.Equal(t, int32(3), world.graph.Len())
 	assert.Equal(t, int32(1), world.graph.Get(2).archetypes.Len())
@@ -840,18 +912,18 @@ func TestWorldRelationQuery(t *testing.T) {
 	}
 	assert.Equal(t, 8, cnt)
 
-	filter2 := RelationFilter{Filter: All(relID), Target: targ1}
-	query = world.Query(&filter2)
+	filter2 := RelationFilter(All(relID), targ1)
+	query = world.Query(filter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 
-	filter2 = RelationFilter{Filter: All(relID), Target: targ2}
-	query = world.Query(&filter2)
+	filter2 = RelationFilter(All(relID), targ2)
+	query = world.Query(filter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 
-	filter2 = RelationFilter{Filter: All(relID), Target: targ3}
-	query = world.Query(&filter2)
+	filter2 = RelationFilter(All(relID), targ3)
+	query = world.Query(filter2)
 	assert.Equal(t, 0, query.Count())
 	cnt = 0
 	for query.Next() {
@@ -897,22 +969,22 @@ func TestWorldRelationQueryCached(t *testing.T) {
 	assert.Equal(t, 8, cnt)
 	world.Cache().Unregister(&regFilter)
 
-	filter2 := RelationFilter{Filter: All(relID), Target: targ1}
-	regFilter2 := world.Cache().Register(&filter2)
+	filter2 := RelationFilter(All(relID), targ1)
+	regFilter2 := world.Cache().Register(filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 	world.Cache().Unregister(&regFilter2)
 
-	filter2 = RelationFilter{Filter: All(relID), Target: targ2}
-	regFilter2 = world.Cache().Register(&filter2)
+	filter2 = RelationFilter(All(relID), targ2)
+	regFilter2 = world.Cache().Register(filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 	world.Cache().Unregister(&regFilter2)
 
-	filter2 = RelationFilter{Filter: All(relID), Target: targ3}
-	regFilter2 = world.Cache().Register(&filter2)
+	filter2 = RelationFilter(All(relID), targ3)
+	regFilter2 = world.Cache().Register(filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 0, query.Count())
 	query.Close()
@@ -941,8 +1013,8 @@ func TestWorldRelation(t *testing.T) {
 	assert.Equal(t, 25, parQuery.Count())
 	for parQuery.Next() {
 		targ := (*Position)(parQuery.Get(posID))
-		filter := RelationFilter{Filter: All(relID), Target: parQuery.Entity()}
-		query := world.Query(&filter)
+		filter := RelationFilter(All(relID), parQuery.Entity())
+		query := world.Query(filter)
 		assert.Equal(t, 100, query.Count())
 		for query.Next() {
 			targ.Y++
@@ -1213,27 +1285,18 @@ func TestWorldBatchRemove(t *testing.T) {
 	world.Cache().Unregister(&filter2)
 
 	world.Batch().RemoveEntities(
-		&RelationFilter{
-			Filter: All(rotID, relID),
-			Target: target1,
-		},
+		RelationFilter(All(rotID, relID), target1),
 	)
 
 	world.Batch().RemoveEntities(
-		&RelationFilter{
-			Filter: All(rotID, relID),
-			Target: target2,
-		},
+		RelationFilter(All(rotID, relID), target2),
 	)
 
 	filter = All().Exclusive()
 	world.Batch().RemoveEntities(&filter)
 
 	world.Batch().RemoveEntities(
-		&RelationFilter{
-			Filter: All(rotID, relID),
-			Target: target3,
-		},
+		RelationFilter(All(rotID, relID), target3),
 	)
 
 	query := world.Query(All())