Repository: mlange-42/arche (go)

Issue:
Batch-modification of Entities
Modification of entities (add/remove components) could be passible in batches, using a filter.

Advantages:

* Modify many entities without the need to do it outside of a query
* Potential speedup as with entity creation


Pull request description:
Batch-exchange and add with target
* Re-introduce `World.Batch` for batch-processing of entities (add/remove/exchange)
* New method `Builder.Add` for adding components with a target to entities

Fixes #263

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae5564a..9e5bdea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,8 @@
 * Archetypes with entity relations are removed on `World.Reset` (#247)
 * Capacity increment can be configured separately for relation archetypes (#257)
 * 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)
 
 ### Performance
 
diff --git a/ecs/archetype.go b/ecs/archetype.go
index 9a7ac86..537f14b 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -59,7 +59,7 @@ func (a *archetypeNode) Archetypes() archetypes {
 	if a.archetype == nil {
 		return nil
 	}
-	return batchArchetype{Archetype: a.archetype, StartIndex: 0}
+	return singleArchetype{Archetype: a.archetype}
 }
 
 // GetArchetype returns the archetype for the given relation target.
@@ -357,13 +357,13 @@ func (a *archetype) Alloc(entity Entity) uintptr {
 	return idx
 }
 
-// Add adds storage to the archetype
+// AllocN allocates storage for the given number of entities.
 func (a *archetype) AllocN(count uint32) {
 	a.extend(count)
 	a.len += count
 }
 
-// Add adds an entity with components to the archetype
+// Add adds an entity with components to the archetype.
 func (a *archetype) Add(entity Entity, components ...Component) uintptr {
 	if len(components) != len(a.graphNode.Ids) {
 		panic("Invalid number of components")
diff --git a/ecs/archetypes.go b/ecs/archetypes.go
index f17b98c..2abe2cd 100644
--- a/ecs/archetypes.go
+++ b/ecs/archetypes.go
@@ -14,11 +14,30 @@ type archetypes interface {
 
 // Implementation of an archetype iterator for a single archetype.
 // Implements [archetypes].
+type singleArchetype struct {
+	Archetype *archetype
+}
+
+// Get returns the value at the given index.
+func (s singleArchetype) Get(index int32) *archetype {
+	return s.Archetype
+}
+
+// Len returns the current number of items in the paged array.
+func (s singleArchetype) Len() int32 {
+	return 1
+}
+
+// Implementation of an archetype iterator for a single archetype and partial iteration.
+// Implements [archetypes].
 //
 // Used for the [Query] returned by entity batch creation methods.
 type batchArchetype struct {
-	Archetype  *archetype
-	StartIndex uint32
+	Archetype    *archetype
+	StartIndex   uint32
+	OldArchetype *archetype
+	Added        []ID
+	Removed      []ID
 }
 
 // Get returns the value at the given index.
diff --git a/ecs/batch.go b/ecs/batch.go
new file mode 100644
index 0000000..3e04c10
--- /dev/null
+++ b/ecs/batch.go
@@ -0,0 +1,60 @@
+package ecs
+
+// Batch is a helper to perform batched operations on the world.
+//
+// Create using [World.Batch].
+type Batch struct {
+	world *World
+}
+
+// Add adds components to 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 with components that can't be added because they are already present.
+//   - when called on a locked world. Do not use during [Query] iteration!
+//
+// See also [World.Exchange].
+func (b *Batch) Add(filter Filter, callback func(Query), comps ...ID) {
+	b.world.exchangeBatch(filter, comps, nil, callback)
+}
+
+// Remove removes components from 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 with components that can't be removed because they are not present.
+//   - when called on a locked world. Do not use during [Query] iteration!
+//
+// See also [World.Exchange].
+func (b *Batch) Remove(filter Filter, callback func(Query), comps ...ID) {
+	b.world.exchangeBatch(filter, nil, comps, 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,
+// one Query for each affected archetype.
+//
+// Panics:
+//   - when called with components that can't be added or removed because they are already present/not present, respectively.
+//   - when called on a locked world. Do not use during [Query] iteration!
+//
+// See also [World.Exchange].
+func (b *Batch) Exchange(filter Filter, add []ID, rem []ID, callback func(Query)) {
+	b.world.exchangeBatch(filter, add, rem, callback)
+}
+
+// RemoveEntities removes and recycles all entities matching a filter.
+//
+// Returns the number of removed entities.
+//
+// Panics when called on a locked world.
+// Do not use during [Query] iteration!
+func (b *Batch) RemoveEntities(filter Filter) int {
+	return b.world.removeEntities(filter)
+}
diff --git a/ecs/builder.go b/ecs/builder.go
index 49721f6..8cd4ab4 100644
--- a/ecs/builder.go
+++ b/ecs/builder.go
@@ -45,7 +45,7 @@ func (b *Builder) WithRelation(comp ID) *Builder {
 func (b *Builder) New(target ...Entity) Entity {
 	if len(target) > 0 {
 		if !b.hasTarget {
-			panic("entity builder has no target")
+			panic("can't set target entity: builder has no relation")
 		}
 		if b.comps == nil {
 			return b.world.newEntityTarget(b.targetID, target[0], b.ids...)
@@ -65,7 +65,7 @@ func (b *Builder) New(target ...Entity) Entity {
 func (b *Builder) NewBatch(count int, target ...Entity) {
 	if len(target) > 0 {
 		if !b.hasTarget {
-			panic("entity builder has no target")
+			panic("can't set target entity: builder has no relation")
 		}
 		if b.comps == nil {
 			b.world.newEntities(count, int8(b.targetID), target[0], b.ids...)
@@ -88,7 +88,7 @@ func (b *Builder) NewBatch(count int, target ...Entity) {
 func (b *Builder) NewQuery(count int, target ...Entity) Query {
 	if len(target) > 0 {
 		if !b.hasTarget {
-			panic("entity builder has no target")
+			panic("can't set target entity: builder has no relation")
 		}
 		if b.comps == nil {
 			return b.world.newEntitiesQuery(count, int8(b.targetID), target[0], b.ids...)
@@ -100,3 +100,26 @@ func (b *Builder) NewQuery(count int, target ...Entity) Query {
 	}
 	return b.world.newEntitiesWithQuery(count, -1, Entity{}, b.comps...)
 }
+
+// Add the builder's components to an entity.
+//
+// The optional argument can be used to set the target [Entity] for the Builder's [Relation].
+// See [Builder.WithRelation].
+func (b *Builder) Add(entity Entity, target ...Entity) {
+	if len(target) > 0 {
+		if !b.hasTarget {
+			panic("can't set target entity: builder has no relation")
+		}
+		if b.comps == nil {
+			b.world.exchange(entity, b.ids, nil, int8(b.targetID), target[0])
+			return
+		}
+		b.world.assign(entity, int8(b.targetID), target[0], b.comps...)
+		return
+	}
+	if b.comps == nil {
+		b.world.Exchange(entity, b.ids, nil)
+		return
+	}
+	b.world.Assign(entity, b.comps...)
+}
diff --git a/ecs/query.go b/ecs/query.go
index 6c5ba91..ef40760 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -55,29 +55,30 @@ func newArchesQuery(world *World, filter Filter, lockBit uint8, archetypes arche
 }
 
 // newQuery creates a query on a single archetype
-func newArchQuery(world *World, lockBit uint8, archetype *archetype, start uint32) Query {
-	if start > 0 {
+func newArchQuery(world *World, lockBit uint8, archetype batchArchetype) Query {
+	arch := archetype.Archetype
+	if archetype.StartIndex > 0 {
 		return Query{
 			filter:         nil,
 			isFiltered:     true,
 			world:          world,
-			archetypes:     batchArchetype{archetype, start},
-			access:         &archetype.archetypeAccess,
+			archetypes:     archetype,
+			access:         &arch.archetypeAccess,
 			archIndex:      0,
 			lockBit:        lockBit,
-			count:          int32(archetype.Len() - start),
-			entityIndex:    uintptr(start - 1),
-			entityIndexMax: uintptr(archetype.Len()) - 1,
+			count:          int32(arch.Len() - archetype.StartIndex),
+			entityIndex:    uintptr(archetype.StartIndex - 1),
+			entityIndexMax: uintptr(arch.Len() - 1),
 		}
 	}
 	return Query{
 		filter:     nil,
 		isFiltered: true,
 		world:      world,
-		archetypes: batchArchetype{archetype, start},
+		archetypes: archetype,
 		archIndex:  -1,
 		lockBit:    lockBit,
-		count:      int32(archetype.Len()),
+		count:      int32(arch.Len()),
 	}
 }
 
diff --git a/ecs/world.go b/ecs/world.go
index 772c9c2..597dc8b 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -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, arch, startIdx)
+	return newArchQuery(w, lock, batchArchetype{arch, startIdx, nil, arch.Components(), nil})
 }
 
 // Creates new entities with component values without returning a query over them.
@@ -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, arch, startIdx)
+	return newArchQuery(w, lock, batchArchetype{arch, startIdx, nil, arch.Components(), nil})
 }
 
 // RemoveEntity removes and recycles an [Entity].
@@ -356,7 +356,7 @@ func (w *World) RemoveEntity(entity Entity) {
 //
 // Panics when called on a locked world.
 // Do not use during [Query] iteration!
-func (w *World) RemoveEntities(filter Filter) int {
+func (w *World) removeEntities(filter Filter) int {
 	w.checkLocked()
 
 	lock := w.lock()
@@ -508,13 +508,17 @@ func (w *World) Add(entity Entity, comps ...ID) {
 //
 // See also the generic variants under [github.com/mlange-42/arche/generic.Map1], etc.
 func (w *World) Assign(entity Entity, comps ...Component) {
+	w.assign(entity, -1, Entity{}, comps...)
+}
+
+func (w *World) assign(entity Entity, relation int8, target Entity, comps ...Component) {
 	len := len(comps)
 	if len == 0 {
 		panic("no components given to assign")
 	}
 	if len == 1 {
 		c := comps[0]
-		w.Exchange(entity, []ID{c.ID}, nil)
+		w.exchange(entity, []ID{c.ID}, nil, relation, target)
 		w.copyTo(entity, c.ID, c.Comp)
 		return
 	}
@@ -522,7 +526,7 @@ func (w *World) Assign(entity Entity, comps ...Component) {
 	for i, c := range comps {
 		ids[i] = c.ID
 	}
-	w.Exchange(entity, ids, nil)
+	w.exchange(entity, ids, nil, relation, target)
 	for _, c := range comps {
 		w.copyTo(entity, c.ID, c.Comp)
 	}
@@ -565,6 +569,10 @@ func (w *World) Remove(entity Entity, comps ...ID) {
 //
 // See also the generic variants under [github.com/mlange-42/arche/generic.Exchange].
 func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
+	w.exchange(entity, add, rem, -1, Entity{})
+}
+
+func (w *World) exchange(entity Entity, add []ID, rem []ID, relation int8, target Entity) {
 	w.checkLocked()
 
 	if !w.entityPool.Alive(entity) {
@@ -576,24 +584,24 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 	}
 	index := &w.entities[entity.id]
 	oldArch := index.arch
-	mask := oldArch.Mask
-	oldMask := mask
-	for _, comp := range add {
-		if oldArch.HasComponent(comp) {
-			panic("entity already has this component, can't add")
+
+	oldMask := oldArch.Mask
+	mask := w.getExchangeMask(oldArch, add, rem)
+
+	if relation >= 0 {
+		if !mask.Get(ID(relation)) {
+			panic("can't add relation: resulting entity has no relation")
 		}
-		mask.Set(comp, true)
-	}
-	for _, comp := range rem {
-		if !oldArch.HasComponent(comp) {
-			panic("entity does not have this component, can't remove")
+		if !w.registry.IsRelation.Get(ID(relation)) {
+			panic("can't add relation: this is not a relation component")
 		}
-		mask.Set(comp, false)
+	} else {
+		target = oldArch.Relation
 	}
 
 	oldIDs := oldArch.Components()
 
-	arch := w.findOrCreateArchetype(oldArch, add, rem, oldArch.Relation)
+	arch := w.findOrCreateArchetype(oldArch, add, rem, target)
 	newIndex := arch.Alloc(entity)
 
 	for _, id := range oldIDs {
@@ -618,6 +626,102 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 	}
 }
 
+func (w *World) getExchangeMask(arch *archetype, add []ID, rem []ID) Mask {
+	mask := arch.Mask
+	for _, comp := range add {
+		if arch.HasComponent(comp) {
+			panic(fmt.Sprintf("entity already has component of type %v, can't add", w.registry.Types[comp]))
+		}
+		mask.Set(comp, true)
+	}
+	for _, comp := range rem {
+		if !arch.HasComponent(comp) {
+			panic(fmt.Sprintf("entity does not have a component of type %v, can't remove", w.registry.Types[comp]))
+		}
+		mask.Set(comp, false)
+	}
+	return mask
+}
+
+// ExchangeBatch exchanges components 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 with components that can't be added or removed because they are already present/not present, respectively.
+//   - when called on a locked world. Do not use during [Query] iteration!
+//
+// See also [World.Exchange].
+func (w *World) exchangeBatch(filter Filter, add []ID, rem []ID, callback func(Query)) {
+	if len(add) == 0 && len(rem) == 0 {
+		return
+	}
+
+	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 := w.exchangeArch(arch, archLen, add, rem)
+		if callback == nil {
+			if w.listener != nil {
+				w.notifyQuery(&batchArchetype{newArch, start, arch, add, rem})
+			}
+		} else {
+			lock := w.lock()
+			query := newArchQuery(w, lock, batchArchetype{newArch, start, arch, add, rem})
+			callback(query)
+		}
+	}
+}
+
+func (w *World) exchangeArch(oldArch *archetype, oldArchLen uint32, add []ID, rem []ID) (*archetype, uint32) {
+	mask := w.getExchangeMask(oldArch, add, rem)
+	oldIDs := oldArch.Components()
+	arch := w.findOrCreateArchetype(oldArch, add, rem, oldArch.Relation)
+
+	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)
+		w.entities[entity.id] = entityIndex{arch: arch, index: uintptr(idx), isTarget: index.isTarget}
+
+		for _, id := range oldIDs {
+			if mask.Get(id) {
+				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)
+}
+
 // getRelation returns the target entity for an entity relation.
 //
 // Panics:
@@ -788,6 +892,14 @@ func (w *World) Cache() *Cache {
 	return &w.filterCache
 }
 
+// Batch creates a [Batch] processing helper.
+//
+// It provides the functionality manipulate large numbers of entities in batches,
+// in a more efficient way.
+func (w *World) Batch() *Batch {
+	return &Batch{w}
+}
+
 // Relations returns the [Relations] of the world, for accessing entity [Relation] targets.
 //
 // See [Relations] for details.
@@ -805,7 +917,7 @@ func (w *World) IsLocked() bool {
 // Can be used for fast checks of the entity composition, e.g. using a [Filter].
 func (w *World) Mask(entity Entity) Mask {
 	if !w.entityPool.Alive(entity) {
-		panic("can't exchange components on a dead entity")
+		panic("can't get mask for a dead entity")
 	}
 	return w.entities[entity.id].arch.Mask
 }
@@ -1227,10 +1339,16 @@ func (w *World) closeQuery(query *Query) {
 func (w *World) notifyQuery(batchArch *batchArchetype) {
 	arch := batchArch.Archetype
 	var i uintptr
-	len := uintptr(arch.Len())
-	event := EntityEvent{Entity{}, Mask{}, arch.Mask, arch.graphNode.Ids, nil, arch.graphNode.Ids, 1}
+	event := EntityEvent{Entity{}, Mask{}, arch.Mask, batchArch.Added, batchArch.Removed, arch.graphNode.Ids, 1}
+
+	oldArch := batchArch.OldArchetype
+	if oldArch != nil {
+		event.OldMask = oldArch.graphNode.mask
+		event.AddedRemoved = 0
+	}
 
-	for i = uintptr(batchArch.StartIndex); i < len; i++ {
+	start, end := uintptr(batchArch.StartIndex), uintptr(arch.Len())
+	for i = start; i < end; i++ {
 		entity := arch.GetEntity(i)
 		event.Entity = entity
 		w.listener(&event)
diff --git a/examples/batch_ops/main.go b/examples/batch_ops/main.go
index 217eb96..542749a 100644
--- a/examples/batch_ops/main.go
+++ b/examples/batch_ops/main.go
@@ -1,4 +1,4 @@
-// Demonstrates batch-creation and batch-removal of entities.
+// Demonstrates batch-creation, manipulation and removal of entities.
 //
 // Batch operations are an optimization for creating and removing many entities in one go.
 package main
@@ -50,13 +50,18 @@ func run() {
 		pos.Y = 1.0
 	}
 
+	// Batch-remove components.
+	world.Batch().Remove(ecs.All(posID, velID), nil, velID)
+	// Batch-add components.
+	world.Batch().Add(ecs.All(posID), nil, velID)
+
 	// Batch-remove all entities with exactly the given components.
 	filterExcl := ecs.All(posID, velID).Exclusive()
-	world.RemoveEntities(&filterExcl)
+	world.Batch().RemoveEntities(&filterExcl)
 
 	// Batch-remove all entities with the given components (and potentially further components).
 	filter := ecs.All(posID, velID)
-	world.RemoveEntities(&filter)
+	world.Batch().RemoveEntities(&filter)
 }
 
 // Uses the type-safe generic API.
diff --git a/generic/generate/map.go.txt b/generic/generate/map.go.txt
index 9a82a86..a984292 100644
--- a/generic/generate/map.go.txt
+++ b/generic/generate/map.go.txt
@@ -163,7 +163,7 @@ func (m *Map{{ .Index }}{{ .Types }}) Remove(entity ecs.Entity) {
 func (m *Map{{ .Index }}{{ .Types }}) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
\ No newline at end of file
diff --git a/generic/map_generated.go b/generic/map_generated.go
index 83593fa..3621591 100644
--- a/generic/map_generated.go
+++ b/generic/map_generated.go
@@ -167,9 +167,9 @@ func (m *Map1[A]) Remove(entity ecs.Entity) {
 func (m *Map1[A]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -343,9 +343,9 @@ func (m *Map2[A, B]) Remove(entity ecs.Entity) {
 func (m *Map2[A, B]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -527,9 +527,9 @@ func (m *Map3[A, B, C]) Remove(entity ecs.Entity) {
 func (m *Map3[A, B, C]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -719,9 +719,9 @@ func (m *Map4[A, B, C, D]) Remove(entity ecs.Entity) {
 func (m *Map4[A, B, C, D]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -919,9 +919,9 @@ func (m *Map5[A, B, C, D, E]) Remove(entity ecs.Entity) {
 func (m *Map5[A, B, C, D, E]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -1127,9 +1127,9 @@ func (m *Map6[A, B, C, D, E, F]) Remove(entity ecs.Entity) {
 func (m *Map6[A, B, C, D, E, F]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -1343,9 +1343,9 @@ func (m *Map7[A, B, C, D, E, F, G]) Remove(entity ecs.Entity) {
 func (m *Map7[A, B, C, D, E, F, G]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }
 
 //////////////////////////////////////////////////////////////////////////
@@ -1567,7 +1567,7 @@ func (m *Map8[A, B, C, D, E, F, G, H]) Remove(entity ecs.Entity) {
 func (m *Map8[A, B, C, D, E, F, G, H]) RemoveEntities(exclusive bool) int {
 	if exclusive {
 		filter := m.mask.Exclusive()
-		return m.world.RemoveEntities(&filter)
+		return m.world.Batch().RemoveEntities(&filter)
 	}
-	return m.world.RemoveEntities(m.mask)
+	return m.world.Batch().RemoveEntities(m.mask)
 }


Test patch:
diff --git a/ecs/builder_test.go b/ecs/builder_test.go
index 4fb88e1..e8cdf46 100644
--- a/ecs/builder_test.go
+++ b/ecs/builder_test.go
@@ -21,9 +21,13 @@ func TestBuilder(t *testing.T) {
 	assert.True(t, w.Has(e1, posID))
 	assert.True(t, w.Has(e1, velID))
 
+	e2 := w.NewEntity()
+	b1.Add(e2)
+
 	assert.Panics(t, func() { b1.New(target) })
 	assert.Panics(t, func() { b1.NewBatch(10, target) })
 	assert.Panics(t, func() { b1.NewQuery(10, target) })
+	assert.Panics(t, func() { b1.Add(e1, target) })
 
 	b1.NewBatch(10)
 	q := b1.NewQuery(10)
@@ -35,9 +39,14 @@ func TestBuilder(t *testing.T) {
 	e1 = b1.New()
 	assert.True(t, w.Has(e1, posID))
 
+	e2 = w.NewEntity()
+	b1.Add(e2)
+	e2 = w.NewEntity()
+
 	assert.Panics(t, func() { b1.New(target) })
 	assert.Panics(t, func() { b1.NewBatch(10, target) })
 	assert.Panics(t, func() { b1.NewQuery(10, target) })
+	assert.Panics(t, func() { b1.Add(e2, target) })
 
 	b1.NewBatch(10)
 	q = b1.NewQuery(10)
@@ -47,7 +56,11 @@ func TestBuilder(t *testing.T) {
 	b1 = NewBuilder(&w, posID, velID, relID).WithRelation(relID)
 
 	b1.New()
-	e2 := b1.New(target)
+	e2 = b1.New(target)
+	assert.Equal(t, target, w.Relations().Get(e2, relID))
+
+	e2 = w.NewEntity()
+	b1.Add(e2, target)
 	assert.Equal(t, target, w.Relations().Get(e2, relID))
 
 	b1.NewBatch(10, target)
@@ -66,6 +79,10 @@ func TestBuilder(t *testing.T) {
 	e2 = b1.New(target)
 	assert.Equal(t, target, w.Relations().Get(e2, relID))
 
+	e2 = w.NewEntity()
+	b1.Add(e2, target)
+	assert.Equal(t, target, w.Relations().Get(e2, relID))
+
 	b1.NewBatch(10, target)
 	q = b1.NewQuery(10, target)
 	assert.Equal(t, 10, q.Count())
diff --git a/ecs/relation_stress_test.go b/ecs/relation_stress_test.go
index 7928587..368afe3 100644
--- a/ecs/relation_stress_test.go
+++ b/ecs/relation_stress_test.go
@@ -44,7 +44,7 @@ func TestRelationStress(t *testing.T) {
 				Filter: ecs.All(relID),
 				Target: parent,
 			}
-			removed := world.RemoveEntities(&childFilter)
+			removed := world.Batch().RemoveEntities(&childFilter)
 			world.RemoveEntity(parent)
 			assert.Equal(t, numChildren, removed)
 
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index 6959205..b553d0e 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -161,7 +161,7 @@ func BenchmarkRemoveEntitiesBatch_10_000(b *testing.B) {
 		q := world.newEntitiesQuery(10000, -1, Entity{}, posID, velID)
 		q.Close()
 		b.StartTimer()
-		world.RemoveEntities(All(posID, velID))
+		world.Batch().RemoveEntities(All(posID, velID))
 	}
 }
 
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 3fd957c..20f30f7 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -215,6 +215,8 @@ func TestWorldExchange(t *testing.T) {
 	posID := ComponentID[Position](&w)
 	velID := ComponentID[Velocity](&w)
 	rotID := ComponentID[rotation](&w)
+	rel1ID := ComponentID[testRelationA](&w)
+	rel2ID := ComponentID[testRelationB](&w)
 
 	e0 := w.NewEntity()
 	e1 := w.NewEntity()
@@ -252,6 +254,98 @@ func TestWorldExchange(t *testing.T) {
 	w.RemoveEntity(e0)
 	_ = w.NewEntity()
 	assert.Panics(t, func() { w.Exchange(e0, []ID{posID}, []ID{}) })
+
+	target := w.NewEntity()
+	e0 = w.NewEntity(rel1ID)
+
+	assert.Panics(t, func() { w.exchange(e0, []ID{rel2ID}, nil, int8(rel2ID), target) })
+	assert.Panics(t, func() { w.exchange(e0, []ID{posID}, nil, int8(posID), target) })
+
+	w.Remove(e0, rel1ID)
+	assert.Panics(t, func() { w.exchange(e0, []ID{posID}, nil, int8(rel1ID), target) })
+}
+
+func TestWorldExchangeBatch(t *testing.T) {
+	w := NewWorld()
+
+	events := []EntityEvent{}
+	w.SetListener(func(e *EntityEvent) {
+		events = append(events, *e)
+	})
+
+	posID := ComponentID[Position](&w)
+	velID := ComponentID[Velocity](&w)
+	relID := ComponentID[testRelationA](&w)
+
+	target1 := w.NewEntity(velID)
+	target2 := w.NewEntity(velID)
+
+	builder := NewBuilder(&w, posID, relID).WithRelation(relID)
+	builder.NewBatch(100, target1)
+	builder.NewBatch(100, target2)
+
+	cnt := 0
+	filter := All(posID, relID)
+	w.Batch().Exchange(filter, []ID{velID}, []ID{posID}, func(q Query) {
+		assert.Equal(t, 100, q.Count())
+		for q.Next() {
+			assert.True(t, q.Has(velID))
+			assert.True(t, q.Has(relID))
+			assert.False(t, q.Has(posID))
+		}
+		cnt++
+	})
+	assert.Equal(t, 2, cnt)
+
+	query := w.Query(All(posID))
+	assert.Equal(t, 0, query.Count())
+	query.Close()
+
+	cnt = 0
+	filter2 := RelationFilter{Filter: All(relID), Target: 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))
+			assert.True(t, q.Has(relID))
+			assert.False(t, q.Has(velID))
+			assert.Equal(t, target1, q.Relation(relID))
+		}
+		cnt++
+	})
+	assert.Equal(t, 1, cnt)
+
+	query = w.Query(All(posID))
+	assert.Equal(t, 100, query.Count())
+	query.Close()
+
+	w.Batch().Exchange(All(posID), nil, nil, nil)
+
+	w.Batch().Exchange(&RelationFilter{Filter: All(relID), Target: target2}, nil, []ID{relID}, nil)
+	w.Batch().Exchange(All(relID), nil, []ID{relID}, nil)
+
+	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, []ID{velID}, events[202].Added)
+	assert.Equal(t, []ID{posID}, events[202].Removed)
+
+	filter = All(velID)
+	w.Batch().Remove(filter, nil, velID)
+
+	filter = All(velID)
+	q := w.Query(filter)
+	assert.Equal(t, 0, q.Count())
+	q.Close()
+
+	filter = All()
+	w.Batch().Add(filter, nil, velID)
 }
 
 func TestWorldAssignSet(t *testing.T) {
@@ -574,7 +668,7 @@ func TestWorldRemoveEntities(t *testing.T) {
 	query.Close()
 
 	filter := All(posID).Exclusive()
-	cnt := world.RemoveEntities(&filter)
+	cnt := world.Batch().RemoveEntities(&filter)
 	assert.Equal(t, 100, cnt)
 	assert.Equal(t, 300, len(events))
 
@@ -703,8 +797,8 @@ func TestWorldRelationRemove(t *testing.T) {
 	assert.Equal(t, targ3, world.graph.Get(2).archetypes.Get(1).Relation)
 	assert.Equal(t, int32(1), world.archetypes.Len())
 
-	world.RemoveEntities(All())
-	world.RemoveEntities(All())
+	world.Batch().RemoveEntities(All())
+	world.Batch().RemoveEntities(All())
 
 	assert.Equal(t, int32(2), world.graph.Get(2).archetypes.Len())
 	assert.True(t, world.graph.Get(2).archetypes.Get(0).IsActive())
@@ -1033,7 +1127,7 @@ func TestWorldStats(t *testing.T) {
 	assert.Equal(t, 5, node.Archetypes[2].Size)
 
 	f := All(relID).Exclusive()
-	w.RemoveEntities(&f)
+	w.Batch().RemoveEntities(&f)
 	w.RemoveEntity(e0)
 	stats = w.Stats()
 
@@ -1115,17 +1209,17 @@ func TestWorldBatchRemove(t *testing.T) {
 
 	filter := All(rotID).Exclusive()
 	filter2 := world.Cache().Register(&filter)
-	world.RemoveEntities(&filter)
+	world.Batch().RemoveEntities(&filter)
 	world.Cache().Unregister(&filter2)
 
-	world.RemoveEntities(
+	world.Batch().RemoveEntities(
 		&RelationFilter{
 			Filter: All(rotID, relID),
 			Target: target1,
 		},
 	)
 
-	world.RemoveEntities(
+	world.Batch().RemoveEntities(
 		&RelationFilter{
 			Filter: All(rotID, relID),
 			Target: target2,
@@ -1133,9 +1227,9 @@ func TestWorldBatchRemove(t *testing.T) {
 	)
 
 	filter = All().Exclusive()
-	world.RemoveEntities(&filter)
+	world.Batch().RemoveEntities(&filter)
 
-	world.RemoveEntities(
+	world.Batch().RemoveEntities(
 		&RelationFilter{
 			Filter: All(rotID, relID),
 			Target: target3,