Repository: mlange-42/arche (go)

Issue:
Remove archetypes for dead entity relation targets
Currently, archetypes are only chreated and never removed. This is ok for usual archetypes.

However, entity relations were implemented in #231. This splits archetypes by relation target. With dynamic targets (i.e. targets are removed and added during the simulation), this can lead to accumulation of a large number of archetypes.

Archetypes for entity relations should be removed when the target entity dies/is dead.



Pull request:
Remove archetypes for dead entity relation targets
Fixes #234

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a26a93c..2915453 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 ### Features
 
 * Support for entity relations that can be queries like components (#231)
+* Relation archetypes are removed when they are empty *and* the target entity is dead (#238)
 
 ### Performance
 
diff --git a/ecs/archetype.go b/ecs/archetype.go
index cf0c648..ef00b3c 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -117,12 +117,13 @@ type archetype struct {
 	indices         idMap[uint32]   // Mapping from IDs to buffer indices.
 	buffers         []reflect.Value // Reflection arrays containing component data.
 	entityBuffer    reflect.Value   // Reflection array containing entity data.
+	index           int32           // Index of the archetype in the world.
 	len             uint32          // Current number of entities
 	cap             uint32          // Current capacity
 }
 
 // Init initializes an archetype
-func (a *archetype) Init(node *archetypeNode, forStorage bool, relation Entity, relationComp int8, components ...componentType) {
+func (a *archetype) Init(node *archetypeNode, index int32, forStorage bool, relation Entity, relationComp int8, components ...componentType) {
 	var mask Mask
 	if len(components) > 0 && len(node.Ids) == 0 {
 		node.Ids = make([]ID, len(components))
@@ -146,6 +147,7 @@ func (a *archetype) Init(node *archetypeNode, forStorage bool, relation Entity,
 	a.buffers = make([]reflect.Value, len(components))
 	a.layouts = make([]layout, MaskTotalBits)
 	a.indices = newIDMap[uint32]()
+	a.index = index
 
 	cap := 1
 	if forStorage {
@@ -312,6 +314,20 @@ func (a *archetype) Reset() {
 	}
 }
 
+func (a *archetype) Deactivate() {
+	a.Reset()
+	a.index = -1
+	a.graphNode = nil
+	a.layouts = nil
+	a.indices = newIDMap[uint32]()
+	a.buffers = nil
+	a.entityBuffer.SetZero()
+}
+
+func (a *archetype) IsActive() bool {
+	return a.index >= 0
+}
+
 // Components returns the component IDs for this archetype
 func (a *archetype) Components() []ID {
 	return a.graphNode.Ids
@@ -345,6 +361,7 @@ func (a *archetype) Stats(reg *componentRegistry[ID]) stats.ArchetypeStats {
 	memory := cap * (int(entitySize) + memPerEntity)
 
 	return stats.ArchetypeStats{
+		IsActive:        a.IsActive(),
 		Size:            int(a.Len()),
 		Capacity:        cap,
 		Components:      aCompCount,
@@ -356,13 +373,36 @@ func (a *archetype) Stats(reg *componentRegistry[ID]) stats.ArchetypeStats {
 }
 
 // UpdateStats updates statistics for an archetype
-func (a *archetype) UpdateStats(stats *stats.ArchetypeStats) {
+func (a *archetype) UpdateStats(stats *stats.ArchetypeStats, reg *componentRegistry[ID]) {
+	if stats.Dirty {
+		ids := a.Components()
+		aCompCount := len(ids)
+		aTypes := make([]reflect.Type, aCompCount)
+		for j, id := range ids {
+			aTypes[j], _ = reg.ComponentType(id)
+		}
+
+		memPerEntity := 0
+		for _, id := range a.graphNode.Ids {
+			lay := a.getLayout(id)
+			memPerEntity += int(lay.itemSize)
+		}
+
+		stats.IsActive = a.IsActive()
+		stats.Components = aCompCount
+		stats.ComponentIDs = ids
+		stats.ComponentTypes = aTypes
+		stats.MemoryPerEntity = memPerEntity
+		stats.Dirty = false
+	}
+
 	cap := int(a.Cap())
 	memory := cap * (int(entitySize) + stats.MemoryPerEntity)
 
 	stats.Size = int(a.Len())
 	stats.Capacity = cap
 	stats.Memory = memory
+
 }
 
 // copy from one pointer to another.
diff --git a/ecs/archetypes.go b/ecs/archetypes.go
index b5f872f..83149fd 100644
--- a/ecs/archetypes.go
+++ b/ecs/archetypes.go
@@ -43,6 +43,17 @@ func (a *archetypePointers) Add(arch *archetype) {
 	a.pointers = append(a.pointers, arch)
 }
 
+// Add adds an element.
+func (a *archetypePointers) Remove(arch *archetype) {
+	for i := 0; i < len(a.pointers); i++ {
+		arch2 := a.pointers[i]
+		if arch == arch2 {
+			a.pointers = append(a.pointers[:i], a.pointers[i+1:]...)
+			return
+		}
+	}
+}
+
 // Len returns the current number of items in the paged array.
 func (a *archetypePointers) Len() int32 {
 	return int32(len(a.pointers))
diff --git a/ecs/cache.go b/ecs/cache.go
index 0dceaae..1d21496 100644
--- a/ecs/cache.go
+++ b/ecs/cache.go
@@ -104,3 +104,16 @@ func (c *Cache) addArchetype(arch *archetype) {
 		}
 	}
 }
+
+// Removes an archetype.
+//
+// Iterates over all filters and removes the archetype from the resp. entry where the filter matches.
+func (c *Cache) removeArchetype(arch *archetype) {
+	ln := len(c.filters)
+	for i := 0; i < ln; i++ {
+		e := &c.filters[i]
+		if arch.Matches(e.Filter) {
+			e.Archetypes.Remove(arch)
+		}
+	}
+}
diff --git a/ecs/entity.go b/ecs/entity.go
index 2a599cd..e3868a6 100644
--- a/ecs/entity.go
+++ b/ecs/entity.go
@@ -40,6 +40,7 @@ func (e Entity) IsZero() bool {
 
 // entityIndex indicates where an entity is currently stored.
 type entityIndex struct {
-	arch  *archetype // Entity's current archetype
-	index uintptr    // Entity's current index in the archetype
+	arch     *archetype // Entity's current archetype
+	index    uintptr    // Entity's current index in the archetype
+	isTarget bool
 }
diff --git a/ecs/query.go b/ecs/query.go
index 67da031..4c2867a 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -156,7 +156,7 @@ func (q *Query) nextArchetype() bool {
 		q.archIndex++
 		a := q.archetypes.Get(q.archIndex)
 		aLen := a.Len()
-		if (q.isFiltered || a.Matches(q.filter)) && aLen > 0 {
+		if a.IsActive() && (q.isFiltered || a.Matches(q.filter)) && aLen > 0 {
 			q.access = &a.archetypeAccess
 			q.entityIndex = 0
 			q.entityIndexMax = uintptr(aLen) - 1
@@ -181,7 +181,7 @@ func (q *Query) countEntities() int {
 	var i int32
 	for i = 0; i < len; i++ {
 		a := q.archetypes.Get(i)
-		if q.isFiltered || a.Matches(q.filter) {
+		if a.IsActive() && (q.isFiltered || a.Matches(q.filter)) {
 			count += a.Len()
 		}
 	}
diff --git a/ecs/stats/stats.go b/ecs/stats/stats.go
index c8ace9d..5456249 100644
--- a/ecs/stats/stats.go
+++ b/ecs/stats/stats.go
@@ -37,6 +37,8 @@ type EntityStats struct {
 
 // ArchetypeStats provide statistics for an archetype.
 type ArchetypeStats struct {
+	// Whether the archetype is currently active
+	IsActive bool
 	// Number of entities in the archetype
 	Size int
 	// Capacity of the archetype
@@ -51,6 +53,8 @@ type ArchetypeStats struct {
 	Memory int
 	// Memory for components per entity
 	MemoryPerEntity int
+	// Whether the archetype has changed
+	Dirty bool
 }
 
 func (s *WorldStats) String() string {
diff --git a/ecs/util.go b/ecs/util.go
index 95b7ec5..092d297 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -87,6 +87,11 @@ func (p *pagedSlice[T]) Get(index int32) *T {
 	return &p.pages[index/pageSize][index%pageSize]
 }
 
+// Set sets the value at the given index.
+func (p *pagedSlice[T]) Set(index int32, value T) {
+	p.pages[index/pageSize][index%pageSize] = value
+}
+
 // Len returns the current number of items in the paged slice.
 func (p *pagedSlice[T]) Len() int32 {
 	return p.len
diff --git a/ecs/world.go b/ecs/world.go
index 0450fc6..ef61010 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -62,17 +62,19 @@ func AddResource[T any](w *World, res *T) ResID {
 
 // World is the central type holding [Entity] and component data, as well as resources.
 type World struct {
-	config      Config                    // World configuration.
-	listener    func(e *EntityEvent)      // Component change listener.
-	resources   Resources                 // World resources.
-	entities    []entityIndex             // Mapping from entities to archetype and index.
-	entityPool  entityPool                // Pool for entities.
-	archetypes  pagedSlice[archetype]     // The archetypes.
-	graph       pagedSlice[archetypeNode] // The archetype graph.
-	locks       lockMask                  // World locks.
-	registry    componentRegistry[ID]     // Component registry.
-	filterCache Cache                     // Cache for registered filters.
-	stats       stats.WorldStats          // Cached world statistics
+	config         Config                    // World configuration.
+	listener       func(e *EntityEvent)      // Component change listener.
+	resources      Resources                 // World resources.
+	entities       []entityIndex             // Mapping from entities to archetype and index.
+	entityPool     entityPool                // Pool for entities.
+	archetypes     pagedSlice[archetype]     // The archetypes.
+	freeArchetypes []int32                   // Indices of free archetypes.
+	graph          pagedSlice[archetypeNode] // The archetype graph.
+	relationNodes  []*archetypeNode          // Archetype nodes that have an entity relation.
+	locks          lockMask                  // World locks.
+	registry       componentRegistry[ID]     // Component registry.
+	filterCache    Cache                     // Cache for registered filters.
+	stats          stats.WorldStats          // Cached world statistics
 }
 
 // NewWorld creates a new [World] from an optional [Config].
@@ -97,16 +99,17 @@ func fromConfig(conf Config) World {
 	entities := make([]entityIndex, 1, conf.CapacityIncrement)
 	entities[0] = entityIndex{arch: nil, index: 0}
 	w := World{
-		config:      conf,
-		entities:    entities,
-		entityPool:  newEntityPool(uint32(conf.CapacityIncrement)),
-		registry:    newComponentRegistry(),
-		archetypes:  newPagedSlice[archetype](),
-		graph:       newPagedSlice[archetypeNode](),
-		locks:       lockMask{},
-		listener:    nil,
-		resources:   newResources(),
-		filterCache: newCache(),
+		config:        conf,
+		entities:      entities,
+		entityPool:    newEntityPool(uint32(conf.CapacityIncrement)),
+		registry:      newComponentRegistry(),
+		archetypes:    newPagedSlice[archetype](),
+		graph:         newPagedSlice[archetypeNode](),
+		relationNodes: []*archetypeNode{},
+		locks:         lockMask{},
+		listener:      nil,
+		resources:     newResources(),
+		filterCache:   newCache(),
 	}
 	node := w.createArchetypeNode(Mask{}, -1)
 	w.createArchetype(node, Entity{}, 0, false)
@@ -273,8 +276,14 @@ func (w *World) RemoveEntity(entity Entity) {
 		swapEntity := oldArch.GetEntity(index.index)
 		w.entities[swapEntity.id].index = index.index
 	}
-
 	index.arch = nil
+
+	if index.isTarget {
+		w.cleanupArchetypes(entity)
+		index.isTarget = false
+	}
+
+	w.cleanupArchetype(oldArch)
 }
 
 // removeEntities removes and recycles all entities matching a filter.
@@ -293,6 +302,10 @@ func (w *World) removeEntities(filter Filter) int {
 	for i = 0; i < numArches; i++ {
 		arch := w.archetypes.Get(i)
 
+		if !arch.IsActive() {
+			continue
+		}
+
 		if !arch.Matches(filter) {
 			continue
 		}
@@ -306,11 +319,19 @@ func (w *World) removeEntities(filter Filter) int {
 			if w.listener != nil {
 				w.listener(&EntityEvent{entity, arch.Mask, Mask{}, nil, arch.graphNode.Ids, nil, -1})
 			}
-			w.entities[entity.id].arch = nil
+			index := &w.entities[entity.id]
+			index.arch = nil
+
+			if index.isTarget {
+				w.cleanupArchetypes(entity)
+				index.isTarget = false
+			}
+
 			w.entityPool.Recycle(entity)
 		}
 
 		arch.Reset()
+		w.cleanupArchetype(arch)
 	}
 	w.unlock(lock)
 
@@ -510,7 +531,9 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 		swapEntity := oldArch.GetEntity(index.index)
 		w.entities[swapEntity.id].index = index.index
 	}
-	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex}
+	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex, isTarget: index.isTarget}
+
+	w.cleanupArchetype(oldArch)
 
 	if w.listener != nil {
 		w.listener(&EntityEvent{entity, oldMask, arch.Mask, add, rem, arch.graphNode.Ids, 0})
@@ -539,7 +562,10 @@ func (w *World) SetRelation(entity Entity, comp ID, target Entity) {
 	w.checkLocked()
 
 	if !w.entityPool.Alive(entity) {
-		panic("can't exchange components on a dead entity")
+		panic("can't set relation for a dead entity")
+	}
+	if !target.IsZero() && !w.entityPool.Alive(target) {
+		panic("can't make a dead entity a relation target")
 	}
 
 	index := &w.entities[entity.id]
@@ -573,7 +599,10 @@ func (w *World) SetRelation(entity Entity, comp ID, target Entity) {
 		swapEntity := oldArch.GetEntity(index.index)
 		w.entities[swapEntity.id].index = index.index
 	}
-	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex}
+	w.entities[entity.id] = entityIndex{arch: arch, index: newIndex, isTarget: index.isTarget}
+	w.entities[target.id].isTarget = true
+
+	w.cleanupArchetype(oldArch)
 }
 
 // Reset removes all entities and resources from the world.
@@ -691,7 +720,7 @@ func (w *World) Stats() *stats.WorldStats {
 	var i int32
 	for i = 0; i < cntOld; i++ {
 		arch := &w.stats.Archetypes[i]
-		w.archetypes.Get(i).UpdateStats(arch)
+		w.archetypes.Get(i).UpdateStats(arch, &w.registry)
 		memory += arch.Memory
 	}
 	for i = cntOld; i < cntNew; i++ {
@@ -899,6 +928,7 @@ func (w *World) findArchetypeSlow(mask Mask) (*archetypeNode, bool) {
 func (w *World) createArchetypeNode(mask Mask, relation int8) *archetypeNode {
 	w.graph.Add(newArchetypeNode(mask, relation, w.config.CapacityIncrement))
 	node := w.graph.Get(w.graph.Len() - 1)
+	w.relationNodes = append(w.relationNodes, node)
 	return node
 }
 
@@ -928,9 +958,23 @@ func (w *World) createArchetype(node *archetypeNode, target Entity, targetCompon
 		}
 	}
 
-	w.archetypes.Add(archetype{})
-	arch := w.archetypes.Get(w.archetypes.Len() - 1)
-	arch.Init(node, forStorage, target, targetComponent, types...)
+	var arch *archetype
+	var archIndex int32
+	lenFree := len(w.freeArchetypes)
+	if lenFree > 0 {
+		archIndex = w.freeArchetypes[lenFree-1]
+		arch = w.archetypes.Get(archIndex)
+		w.freeArchetypes = w.freeArchetypes[:lenFree-1]
+
+		if int(archIndex) < len(w.stats.Archetypes) {
+			w.stats.Archetypes[archIndex].Dirty = true
+		}
+	} else {
+		w.archetypes.Add(archetype{})
+		archIndex = w.archetypes.Len() - 1
+		arch = w.archetypes.Get(archIndex)
+	}
+	arch.Init(node, archIndex, forStorage, target, targetComponent, types...)
 
 	node.SetArchetype(target, arch)
 
@@ -945,13 +989,48 @@ func (w *World) getArchetypes(filter Filter) archetypePointers {
 	var i int32
 	for i = 0; i < ln; i++ {
 		a := w.archetypes.Get(i)
-		if a.Matches(filter) {
+		if a.IsActive() && a.Matches(filter) {
 			arches = append(arches, a)
 		}
 	}
 	return archetypePointers{arches}
 }
 
+// Removes the archetype if it is empty, and has a relation to a dead target.
+func (w *World) cleanupArchetype(arch *archetype) {
+	if arch.Len() > 0 || arch.graphNode.relation < 0 {
+		return
+	}
+	target := arch.Relation
+	if target.IsZero() || w.Alive(target) {
+		return
+	}
+
+	w.deleteArchetype(arch, target)
+}
+
+// Removes empty archetypes that have a target relation to the given entity.
+func (w *World) cleanupArchetypes(target Entity) {
+	for _, node := range w.relationNodes {
+		if arch, ok := node.archetypes[target]; ok && arch.Len() == 0 {
+			w.deleteArchetype(arch, target)
+		}
+	}
+}
+
+func (w *World) deleteArchetype(arch *archetype, target Entity) {
+	delete(arch.graphNode.archetypes, target)
+	idx := arch.index
+	w.freeArchetypes = append(w.freeArchetypes, idx)
+	w.archetypes.Get(idx).Deactivate()
+
+	w.filterCache.removeArchetype(arch)
+
+	if int(idx) < len(w.stats.Archetypes) {
+		w.stats.Archetypes[idx].Dirty = true
+	}
+}
+
 // componentID returns the ID for a component type, and registers it if not already registered.
 func (w *World) componentID(tp reflect.Type) ID {
 	return w.registry.ComponentID(tp)
diff --git a/generic/compiled.go b/generic/compiled.go
index 9378b31..bb850b0 100644
--- a/generic/compiled.go
+++ b/generic/compiled.go
@@ -36,7 +36,6 @@ func (q *compiledQuery) Compile(w *ecs.World, include, optional, exclude []Comp,
 		targetID := ecs.TypeID(w, targetType)
 		q.TargetID = int8(targetID)
 
-		fmt.Println(targetID, targetType)
 		if !q.maskFilter.Include.Get(targetID) {
 			panic(fmt.Sprintf("relation component %v not in filter", targetType))
 		}


Test patch:
diff --git a/ecs/archetype_test.go b/ecs/archetype_test.go
index 3e5137c..3ffed5e 100644
--- a/ecs/archetype_test.go
+++ b/ecs/archetype_test.go
@@ -15,7 +15,7 @@ func TestArchetype(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 32)
 	arch := archetype{}
-	arch.Init(&node, false, Entity{}, -1, comps...)
+	arch.Init(&node, 0, false, Entity{}, -1, comps...)
 
 	arch.Add(
 		newEntity(0),
@@ -73,11 +73,11 @@ func TestNewArchetype(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 32)
 	arch := archetype{}
-	arch.Init(&node, true, Entity{}, -1, comps...)
+	arch.Init(&node, 0, true, Entity{}, -1, comps...)
 	assert.Equal(t, 32, int(arch.Cap()))
 
 	arch = archetype{}
-	arch.Init(&node, false, Entity{}, -1, comps...)
+	arch.Init(&node, 0, false, Entity{}, -1, comps...)
 	assert.Equal(t, 1, int(arch.Cap()))
 
 	comps = []componentType{
@@ -87,7 +87,7 @@ func TestNewArchetype(t *testing.T) {
 	assert.Panics(t, func() {
 		node := newArchetypeNode(All(0, 1), -1, 32)
 		arch := archetype{}
-		arch.Init(&node, true, Entity{}, -1, comps...)
+		arch.Init(&node, 0, true, Entity{}, -1, comps...)
 	})
 }
 
@@ -99,7 +99,7 @@ func TestArchetypeExtend(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 8)
 	arch := archetype{}
-	arch.Init(&node, true, Entity{}, -1, comps...)
+	arch.Init(&node, 0, true, Entity{}, -1, comps...)
 
 	assert.Equal(t, 8, int(arch.Cap()))
 	assert.Equal(t, 0, int(arch.Len()))
@@ -121,7 +121,7 @@ func TestArchetypeAlloc(t *testing.T) {
 	}
 	node := newArchetypeNode(All(0, 1), -1, 8)
 	arch := archetype{}
-	arch.Init(&node, true, Entity{}, -1, comps...)
+	arch.Init(&node, 0, true, Entity{}, -1, comps...)
 
 	assert.Equal(t, 8, int(arch.Cap()))
 	assert.Equal(t, 0, int(arch.Len()))
@@ -146,7 +146,7 @@ func TestArchetypeAddGetSet(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 1)
 	a := archetype{}
-	a.Init(&node, true, Entity{}, -1, comps...)
+	a.Init(&node, 0, true, Entity{}, -1, comps...)
 
 	assert.Equal(t, 1, int(a.Cap()))
 	assert.Equal(t, 0, int(a.Len()))
@@ -181,7 +181,7 @@ func TestArchetypeReset(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 32)
 	arch := archetype{}
-	arch.Init(&node, false, Entity{}, -1, comps...)
+	arch.Init(&node, 0, false, Entity{}, -1, comps...)
 
 	arch.Add(
 		newEntity(0),
@@ -227,7 +227,7 @@ func TestArchetypeZero(t *testing.T) {
 
 	node := newArchetypeNode(All(0, 1), -1, 32)
 	arch := archetype{}
-	arch.Init(&node, false, Entity{}, -1, comps...)
+	arch.Init(&node, 0, false, Entity{}, -1, comps...)
 
 	arch.Alloc(newEntity(0))
 	arch.Alloc(newEntity(1))
@@ -259,7 +259,7 @@ func BenchmarkIterArchetype_1000(b *testing.B) {
 
 	node := newArchetypeNode(All(0), -1, 32)
 	arch := archetype{}
-	arch.Init(&node, true, Entity{}, -1, comps...)
+	arch.Init(&node, 0, true, Entity{}, -1, comps...)
 
 	for i := 0; i < 1000; i++ {
 		arch.Alloc(newEntity(eid(i)))
diff --git a/ecs/archetypes_test.go b/ecs/archetypes_test.go
index 1dce91d..b87b466 100644
--- a/ecs/archetypes_test.go
+++ b/ecs/archetypes_test.go
@@ -27,4 +27,10 @@ func TestArchetypePointers(t *testing.T) {
 	assert.Equal(t, unsafe.Pointer(&a1), unsafe.Pointer(pt.Get(0)))
 	assert.Equal(t, unsafe.Pointer(&a2), unsafe.Pointer(pt.Get(1)))
 	assert.Equal(t, unsafe.Pointer(&a3), unsafe.Pointer(pt.Get(2)))
+
+	assert.Equal(t, int32(48), pt.Len())
+
+	pt.Remove(&a2)
+	assert.Equal(t, int32(47), pt.Len())
+	assert.Equal(t, unsafe.Pointer(&a3), unsafe.Pointer(pt.Get(1)))
 }
diff --git a/ecs/util_test.go b/ecs/util_test.go
index 1de5b5f..fec9725 100644
--- a/ecs/util_test.go
+++ b/ecs/util_test.go
@@ -52,6 +52,9 @@ func TestPagedSlice(t *testing.T) {
 		assert.Equal(t, i, *a.Get(i))
 		assert.Equal(t, i+1, a.Len())
 	}
+
+	a.Set(3, 100)
+	assert.Equal(t, int32(100), *a.Get(3))
 }
 
 func TestPagedSlicePointerPersistence(t *testing.T) {
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 5caad4c..c254170 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -617,6 +617,64 @@ func TestWorldRelationSet(t *testing.T) {
 	world.RemoveEntity(e1)
 	assert.Panics(t, func() { world.GetRelation(e1, relID) })
 	assert.Panics(t, func() { world.SetRelation(e1, relID, targ) })
+
+	e3 := world.NewEntity(relID, rotID)
+	world.RemoveEntity(targ)
+	assert.Panics(t, func() { world.SetRelation(e3, relID, targ) })
+}
+
+func TestWorldRelationRemove(t *testing.T) {
+	world := NewWorld()
+
+	rotID := ComponentID[rotation](&world)
+	relID := ComponentID[testRelationA](&world)
+
+	targ := world.NewEntity()
+	targ2 := world.NewEntity()
+	targ3 := world.NewEntity()
+
+	e1 := world.NewEntity(relID, rotID)
+	e2 := world.NewEntity(relID, rotID)
+
+	filter := RelationFilter{Filter: All(relID), Target: targ}
+	world.Cache().Register(&filter)
+
+	assert.Equal(t, int32(3), world.graph.Len())
+	assert.Equal(t, int32(2), world.archetypes.Len())
+
+	world.SetRelation(e1, relID, targ)
+	world.SetRelation(e2, relID, targ)
+
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.RemoveEntity(targ)
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.SetRelation(e1, relID, Entity{})
+	world.SetRelation(e2, relID, Entity{})
+
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.SetRelation(e1, relID, targ2)
+	world.SetRelation(e2, relID, targ2)
+
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.SetRelation(e1, relID, Entity{})
+	world.SetRelation(e2, relID, Entity{})
+
+	_ = world.Stats()
+
+	world.RemoveEntity(targ2)
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.SetRelation(e1, relID, targ3)
+	world.SetRelation(e2, relID, targ3)
+
+	assert.Equal(t, int32(3), world.archetypes.Len())
+
+	world.Batch().RemoveEntities(All())
+	world.Batch().RemoveEntities(All())
 }
 
 func TestWorldRelationQuery(t *testing.T) {
@@ -625,10 +683,15 @@ func TestWorldRelationQuery(t *testing.T) {
 	rotID := ComponentID[rotation](&world)
 	relID := ComponentID[testRelationA](&world)
 
+	targ0 := world.NewEntityWith(Component{ID: rotID, Comp: &rotation{Angle: 0}})
+
 	targ1 := world.NewEntityWith(Component{ID: rotID, Comp: &rotation{Angle: 1}})
 	targ2 := world.NewEntityWith(Component{ID: rotID, Comp: &rotation{Angle: 2}})
 	targ3 := world.NewEntityWith(Component{ID: rotID, Comp: &rotation{Angle: 3}})
 
+	e1 := world.NewEntity(relID)
+	world.SetRelation(e1, relID, targ0)
+
 	for i := 0; i < 4; i++ {
 		e1 := world.NewEntity(relID)
 		world.SetRelation(e1, relID, targ1)
@@ -637,6 +700,9 @@ func TestWorldRelationQuery(t *testing.T) {
 		world.SetRelation(e2, relID, targ2)
 	}
 
+	world.RemoveEntity(e1)
+	world.RemoveEntity(targ0)
+
 	filter := All(relID)
 	query := world.Query(filter)
 	assert.Equal(t, 8, query.Count())
@@ -749,6 +815,9 @@ func TestWorldStats(t *testing.T) {
 	assert.Equal(t, 4, len(stats.Archetypes))
 	assert.Equal(t, 4, stats.Entities.Used)
 
+	w.stats.Archetypes[1].Dirty = true
+	stats = w.Stats()
+	assert.Equal(t, 4, len(stats.Archetypes))
 }
 
 func TestWorldResources(t *testing.T) {