10 KiB
Entity Removal & Deletion Lifecycle (Brainstorm & Rework)
Warning
Status: Needs Rework The entity deletion and removal logic below was extracted as-is from
Game/Plans/02_Entity_Allocation_And_Lifecycle.mdfor future brainstorming. The original swap-and-pop approach required complex mutual back-pointer fixups across both base and derived memory buffers.
1. Brainstorming Notes & Alternative Approaches
The original design relied on VectorArena::RemoveAtFast (swap-and-pop) for both Entities and by_type[kind].arena. While this maintained dense contiguous memory, swapping arbitrary elements in memory invalidated pointers in both directions, requiring runtime pointer patching (derived->Base = movedEntity; base->Derived = movedComponent).
Ideas for Simpler & More Robust Alternatives:
-
Active Flag / Tombstone (
is_activebool):- Keep deleted entities in place rather than moving or swapping them.
- Simply toggle
entity->is_active = false;. - Iterators and simulation loops skip inactive entities (
if (!entity->is_active) continue;). - Pointers to entities and derived components remain stable for their entire lifetime.
-
Intrusive Free List for Slot Recycling:
- Instead of shifting memory on deletion, vacant slots are linked into an intrusive free list (
first_free_index). - When an entity is destroyed:
entity->is_active = false;- Overwrite unused slot memory with
next_free_index.
- When allocating a new entity:
- Check if
first_free_index != indexMax. - Pop from the free list in
O(1); otherwise push back to the end of the arena.
- Check if
- Zero pointer invalidation, zero memmove/memcpy overhead during deletion.
- Instead of shifting memory on deletion, vacant slots are linked into an intrusive free list (
-
Stable Generational Handles / Slot Map:
- If external systems need references to entities that might be deleted, combine the free list with a generational counter to detect stale lookups safely.
-
Deferred Compaction / Garbage Collection:
- If contiguous packing is strictly necessary for SIMD/cache optimization, defer compaction to a designated level load or scene transition rather than doing it per-frame on individual deletes.
2. Extracted Original Removal Logic (As-Is from Plan 2)
2.1 In-Memory Removal vs. Immediate Disk Deletion Hazards
In game development, deleting an entity in the editor or during gameplay must never synchronously invoke disk deletion:
- Frame Rate Stutters: Blocking on synchronous OS filesystem APIs (
DeleteFileA) introduces multisecond frame freezes. - Transactional Safety: If the editor crashes or the user exits without saving, disk modifications cannot be rolled back.
- Undo/Redo Support: An editor action stack must allow recovering deleted entities before changes are permanently committed to disk.
Therefore, Juliet enforces a strict separation:
- Immediate in-memory destruction: Releases the entity from active simulation and registers its identifier in
World::PendingDeletions. - Deferred disk deletion: Executed strictly during explicit
SaveWorldoperations.
2.2 Fast In-Memory Removal (RemoveAtFast) & Mutual Pointer Fixup
VectorArena::RemoveAtFast utilizes swap-and-pop: the element at the target index is replaced by the last element in the vector, and Count is decremented.
void RemoveAtFast(index_t index)
{
Assert(Arena);
Assert(index < Count);
Assert(Count > 0);
Type* elementAdr = DataFirst + index;
if (DataLast != elementAdr)
{
Swap(DataLast, elementAdr);
}
--DataLast;
--Count;
}
The Pointer Invalidation Problem:
When Entity A (at index) is swapped with Entity Z (at DataLast), the physical address of Entity Z changes from DataLast to elementAdr.
If Entity Z has a derived struct derivedZ, derivedZ->Base previously pointed to DataLast. After RemoveAtFast, derivedZ->Base points to garbage or the freed slot!
The Pointer Fixup Protocol:
To preserve the mutual back-pointer invariant, DestroyEntity explicitly fixes up the swapped entity's derived back-pointer:
void DestroyEntity(EntityManager& manager, EntityID id)
{
Entity* baseArray = manager.Entities.DataPtr();
size_t count = manager.Entities.Size();
size_t targetIndex = indexMax;
for (size_t i = 0; i < count; ++i)
{
if (baseArray[i].ID == id)
{
targetIndex = i;
break;
}
}
if (targetIndex == indexMax)
{
return;
}
Entity* targetEntity = &baseArray[targetIndex];
Class* classPtr = targetEntity->derived_kind;
Assert(classPtr != nullptr);
// 1. Remove derived component from typed array via swap-and-pop
RemoveDerivedComponent(manager, classPtr, targetEntity->derived);
// 2. Remove base entity via swap-and-pop in VectorArena
bool wasLast = (targetIndex == count - 1);
manager.Entities.RemoveAtFast(targetIndex);
// 3. Pointer fixup: If an element was swapped into targetIndex, fix its back-pointer!
if (!wasLast && targetIndex < manager.Entities.Size())
{
Entity* movedEntity = &manager.Entities[targetIndex];
auto* derivedTemp = reinterpret_cast<entity_template*>(movedEntity->derived);
Assert(derivedTemp != nullptr);
derivedTemp->base = movedEntity;
}
}
2.3 O(1) Component Removal in typed_entity_array via Swap-and-Pop
To keep derived components packed contiguously for SIMD/cache iteration:
- Locate the component's index within
by_type[kind].arena. Because components have uniform strideclassPtr->size_of:\text{componentIndex} = \frac{\text{reinterpret\_cast<uint8*>(derivedPtr)} - \text{reinterpret\_cast<uint8*>(typedArray.array)}}{\text{classPtr->size\_of}} - If the component is not the last one in the typed arena:
- Copy the last component into the slot occupied by the deleted component.
- Update the moved component's
Base->Derivedpointer to point to its new slot.
- Decrement
typedArray.count. - Pop the arena allocation if it was the top of the stack, or decrement count to mark slot reclamation.
void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr)
{
Assert(classPtr != nullptr);
Assert(derivedPtr != nullptr);
typed_entity_array& typedArray = manager.by_type[classPtr->kind];
Assert(typedArray.count > 0);
Assert(typedArray.array != nullptr);
size_t stride = classPtr->size_of;
auto* targetByte = reinterpret_cast<uint8*>(derivedPtr);
auto* firstByte = reinterpret_cast<uint8*>(typedArray.array);
size_t componentIndex = static_cast<size_t>(targetByte - firstByte) / stride;
Assert(componentIndex < typedArray.count);
size_t lastIndex = typedArray.count - 1;
if (componentIndex != lastIndex)
{
uint8* lastByte = firstByte + (lastIndex * stride);
// Copy last component data into target slot
MemCopy(targetByte, lastByte, stride);
// Fixup the base pointer of the moved component
auto* movedDerived = reinterpret_cast<entity_template*>(targetByte);
Assert(movedDerived->base != nullptr);
movedDerived->base->derived = targetByte;
}
typedArray.count -= 1;
if (typedArray.count == 0)
{
typedArray.array = nullptr;
}
}
2.4 Tracking Deletions in World::PendingDeletions
In World.h, the World struct is extended with a pending deletions container:
struct World
{
Arena* WorldArena = nullptr;
EntityManager* EntityManager = nullptr;
VectorArena<EntityID, 1024> PendingDeletions;
};
When an entity is deleted in the world:
void RemoveWorldEntity(World& world, EntityID id)
{
Assert(world.EntityManager != nullptr);
// Record pending disk deletion
world.PendingDeletions.PushBack(id);
// Destroy in memory immediately
DestroyEntity(*world.EntityManager, id);
}
2.5 Disk File Cleanup during SaveWorld
During SaveWorld, before saving modified entities, the engine iterates over world.PendingDeletions and removes their associated .jasset files:
void ProcessPendingDeletions(World& world, NonNullPtr<Arena> scratchArena)
{
for (size_t i = 0; i < world.PendingDeletions.Size(); ++i)
{
EntityID id = world.PendingDeletions[i];
// Format relative asset path: Assets/Entities/{ID}.jasset
char filenameBuffer[64];
juliet_snprintf(filenameBuffer, sizeof(filenameBuffer), "Entities/%llu.jasset", id);
String assetPath = GetAssetPath(scratchArena, WrapString(filenameBuffer));
if (PlatformDeleteFile(assetPath))
{
Log(LogLevel::Message, LogCategory::Game, "Deleted entity asset: %s", CStr(assetPath));
}
}
world.PendingDeletions.Clear();
}
2.6 Associated Swap-and-Pop Unit Test
void TestSwapAndPopPointerFixup()
{
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestSwapAndPopPointerFixup...");
TempArena tempArena = scratch_begin(nullptr, 0);
World testWorld{};
InitWorld(&testWorld, tempArena.Arena);
InitEntityManager(&testWorld);
EntityManager& manager = *testWorld.EntityManager;
// Allocate 3 entities: E0, E1, E2
Inert* e0 = MakeEntity<Inert>(manager, 1.0f, 0.0f, 0.0f);
Inert* e1 = MakeEntity<Inert>(manager, 2.0f, 0.0f, 0.0f);
Inert* e2 = MakeEntity<Inert>(manager, 3.0f, 0.0f, 0.0f);
EntityID id0 = e0->base->ID;
EntityID id1 = e1->base->ID;
EntityID id2 = e2->base->ID;
Assert(manager.Entities.Size() == 3);
// Delete middle entity E1 (triggers swap with E2)
DestroyEntity(manager, id1);
Assert(manager.Entities.Size() == 2);
// Verify E2's mutual back-pointers are still completely intact
Entity* remaining0 = &manager.Entities[0];
Entity* remaining1 = &manager.Entities[1];
Assert(remaining0->ID == id0);
Assert(remaining1->ID == id2);
auto* derived0 = reinterpret_cast<entity_template*>(remaining0->derived);
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->derived);
Assert(derived0->base == remaining0);
Assert(derived1->base == remaining1);
ShutdownEntityManager();
ShutdownWorld(&testWorld);
scratch_end(tempArena);
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup");
}