From a97f4ceecde7f38b771161a5e9cb9cc2ac931d50 Mon Sep 17 00:00:00 2001 From: Ramine Agoune Date: Fri, 18 Sep 2026 13:08:22 +0000 Subject: [PATCH 1/3] bench: CreateRemoveLarge (8 components) and CreateRemoveFragmented (256 archetypes) scenarios (#772) --- benchmark/data.go | 25 ++++++++++++++++ benchmark/volt_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/benchmark/data.go b/benchmark/data.go index 1b27090..85853ef 100644 --- a/benchmark/data.go +++ b/benchmark/data.go @@ -29,6 +29,31 @@ func (t testTag) GetComponentId() volt.ComponentId { return testTagId } +// Six more small components, to build "large" entities (8 components) like the +// create-large-entities scenario of go-ecs-benchmarks. +const ( + testC3Id = iota + 2 + testC4Id + testC5Id + testC6Id + testC7Id + testC8Id +) + +type testC3 struct{ v float64 } +type testC4 struct{ v float64 } +type testC5 struct{ v float64 } +type testC6 struct{ v float64 } +type testC7 struct{ v float64 } +type testC8 struct{ v float64 } + +func (testC3) GetComponentId() volt.ComponentId { return testC3Id } +func (testC4) GetComponentId() volt.ComponentId { return testC4Id } +func (testC5) GetComponentId() volt.ComponentId { return testC5Id } +func (testC6) GetComponentId() volt.ComponentId { return testC6Id } +func (testC7) GetComponentId() volt.ComponentId { return testC7Id } +func (testC8) GetComponentId() volt.ComponentId { return testC8Id } + func transformData(tr *testTransform) { tr.x += 1.0 tr.y += 2.0 diff --git a/benchmark/volt_test.go b/benchmark/volt_test.go index 77ff19e..671d5f3 100644 --- a/benchmark/volt_test.go +++ b/benchmark/volt_test.go @@ -177,3 +177,71 @@ func BenchmarkCreateRemoveVolt(b *testing.B) { b.ReportAllocs() } + +// BenchmarkCreateRemoveLargeVolt: remove+recreate cycle of entities carrying 8 +// components (the create-large-entities scenario of go-ecs-benchmarks). +func BenchmarkCreateRemoveLargeVolt(b *testing.B) { + world := volt.CreateWorld(ENTITIES_COUNT) + volt.RegisterComponent[testTransform](world, &volt.ComponentConfig[testTransform]{}) + volt.RegisterComponent[testTag](world, &volt.ComponentConfig[testTag]{}) + volt.RegisterComponent[testC3](world, &volt.ComponentConfig[testC3]{}) + volt.RegisterComponent[testC4](world, &volt.ComponentConfig[testC4]{}) + volt.RegisterComponent[testC5](world, &volt.ComponentConfig[testC5]{}) + volt.RegisterComponent[testC6](world, &volt.ComponentConfig[testC6]{}) + volt.RegisterComponent[testC7](world, &volt.ComponentConfig[testC7]{}) + volt.RegisterComponent[testC8](world, &volt.ComponentConfig[testC8]{}) + + create := func() volt.EntityId { + entityId, _ := volt.CreateEntityWithComponents8(world, testTransform{}, testTag{}, testC3{}, testC4{}, testC5{}, testC6{}, testC7{}, testC8{}) + return entityId + } + + entities := make([]volt.EntityId, ENTITIES_COUNT) + for i := range entities { + entities[i] = create() + } + + for b.Loop() { + for i, entityId := range entities { + world.RemoveEntity(entityId) + entities[i] = create() + } + } + + b.ReportAllocs() +} + +// BenchmarkCreateRemoveFragmentedVolt: the same remove+recreate cycle as +// BenchmarkCreateRemoveVolt, in a world that already holds 256 other +// archetypes (every subset of 8 tags). It exposes how the cost of resolving an +// entity's archetype scales with the number of archetypes in the world. +func BenchmarkCreateRemoveFragmentedVolt(b *testing.B) { + world := volt.CreateWorld(ENTITIES_COUNT) + volt.RegisterComponent[testTransform](world, &volt.ComponentConfig[testTransform]{}) + volt.RegisterComponent[testTag](world, &volt.ComponentConfig[testTag]{}) + + for subset := range 256 { + entityId := world.CreateEntity() + for bit := range 8 { + if subset&(1< Date: Fri, 18 Sep 2026 14:13:25 +0000 Subject: [PATCH 2/3] perf: resolve multi-component archetypes through a canonical key Adding several components at once (CreateEntityWithComponentsN, AddComponentsN) resolved the destination archetype by scanning every archetype and comparing sets element by element, and the variadic slice escaped to the heap on every call because a miss retained it as the new archetype's type: one allocation per entity, and a cost growing with the number of archetypes (x7 with 256 of them). An archetype type is now a sorted set, hashed (FNV-1a) into a map from key to archetype. The candidate set is sorted in a stack scratch and only cloned when an archetype is actually created. A key collision falls back to the scan, the first archetype keeps the key. BenchmarkCreateEntityVolt (5800X): 100 101 allocs/op -> ~114 (column growth and world setup only). --- archetype.go | 112 ++++++++++++++++++++++++++++--------------- archetype_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++ world.go | 6 ++- 3 files changed, 195 insertions(+), 41 deletions(-) diff --git a/archetype.go b/archetype.go index 26decaf..a11a6e5 100644 --- a/archetype.go +++ b/archetype.go @@ -4,15 +4,46 @@ import ( "slices" ) -func (world *World) createArchetype(componentsIds ...ComponentId) *archetype { - archetypeKey := archetypeId(len(world.archetypes)) - archetype := archetype{ - Id: archetypeKey, +// FNV-1a 64-bit parameters, used to hash a set of component ids into an +// archetype key. +const ( + fnv64Offset uint64 = 14695981039346656037 + fnv64Prime uint64 = 1099511628211 +) + +// maxInlineComponents bounds the stack scratch used to sort a set of component +// ids before looking its archetype up. Larger sets fall back to a heap copy. +const maxInlineComponents = 16 + +// archetypeKey hashes a sorted set of component ids. Two different sets may +// share a key: a lookup always confirms the archetype Type before trusting it. +func archetypeKey(sorted []ComponentId) uint64 { + hash := fnv64Offset + for _, componentId := range sorted { + hash ^= uint64(componentId) + hash *= fnv64Prime + } + + return hash +} + +// createArchetype registers a new archetype holding exactly the given set of +// components. The slice must be sorted; the archetype owns it from now on. +func (world *World) createArchetype(componentsIds componentsIds) *archetype { + id := archetypeId(len(world.archetypes)) + world.archetypes = append(world.archetypes, archetype{ + Id: id, Type: componentsIds, + }) + + // The first archetype registered under a key owns it; a later archetype + // whose set collides on the same key is found by scan instead. + key := archetypeKey(componentsIds) + if _, taken := world.archetypesByKey[key]; !taken { + world.archetypesByKey[key] = id } - world.archetypes = append(world.archetypes, archetype) - return &world.archetypes[archetypeKey] + return &world.archetypes[id] } func (world *World) getArchetype(entityRecord entityRecord) *archetype { @@ -33,50 +64,53 @@ func (world *World) setArchetype(entityRecord entityRecord, archetype *archetype world.entities[entityRecord.Id.Index()] = entityRecord } +// getArchetypeForComponentsIds returns the archetype holding exactly the given +// set of components, whatever their order, creating it if needed. func (world *World) getArchetypeForComponentsIds(componentsIds ...ComponentId) *archetype { - for i, archetype := range world.archetypes { - if len(archetype.Type) != len(componentsIds) { - continue - } + var scratch [maxInlineComponents]ComponentId - count := 0 - for _, componentId := range componentsIds { - if slices.Contains(archetype.Type, componentId) { - count++ - } else { - break - } - } + return world.archetypeForSet(append(scratch[:0], componentsIds...)) +} - if count == len(archetype.Type) { +// archetypeForSet resolves the archetype holding exactly the given set of +// components, creating it if needed. The set is a scratch copy: it is sorted in +// place and never retained, the archetype created on a miss owns its own copy. +func (world *World) archetypeForSet(set []ComponentId) *archetype { + slices.Sort(set) + + id, found := world.archetypesByKey[archetypeKey(set)] + if !found { + return world.createArchetype(slices.Clone(set)) + } + if slices.Equal(world.archetypes[id].Type, set) { + return &world.archetypes[id] + } + + // Key collision: another set owns the key, scan for this one. + for i := range world.archetypes { + if slices.Equal(world.archetypes[i].Type, set) { return &world.archetypes[i] } } - return world.createArchetype(componentsIds...) + return world.createArchetype(slices.Clone(set)) } +// getNextArchetype returns the archetype reached by adding componentsIds to the +// archetype the entity lives in. func (world *World) getNextArchetype(entityRecord entityRecord, componentsIds ...ComponentId) *archetype { - // Fast path: a single-component transition (AddComponent, AddTag, ...) is - // resolved through the archetype graph, avoiding both the linear scan over - // all archetypes and the slice rebuild done below. + // A single-component transition is resolved through the archetype graph. if len(componentsIds) == 1 { return world.archetypeAfterAdd(entityRecord.archetypeId, componentsIds[0]) } - var archetype *archetype - if entityRecord.archetypeId == 0 { - archetype = world.getArchetypeForComponentsIds(componentsIds...) - } else { - oldArchetype := world.getArchetype(entityRecord) - if oldArchetype != nil { - archetype = world.getArchetypeForComponentsIds(append(componentsIds, oldArchetype.Type...)...) - } else { - archetype = world.getArchetypeForComponentsIds(componentsIds...) - } + var scratch [maxInlineComponents]ComponentId + set := append(scratch[:0], componentsIds...) + if from := world.getArchetype(entityRecord); from != nil { + set = append(set, from.Type...) } - return archetype + return world.archetypeForSet(set) } // archetypeAfterAdd returns the archetype obtained by adding componentId to the @@ -86,11 +120,11 @@ func (world *World) archetypeAfterAdd(fromId archetypeId, componentId ComponentI return &world.archetypes[destId] } - // Cache miss: compute the destination once. getArchetypeForComponentsIds may - // create a new archetype and reallocate world.archetypes, so we resolve every - // archetype by index afterwards rather than holding a stale pointer. + // Cache miss: compute the destination once. archetypeForSet may create a new + // archetype and reallocate world.archetypes, so we resolve every archetype + // by index afterwards rather than holding a stale pointer. newType := append(slices.Clone(world.archetypes[fromId].Type), componentId) - destId := world.getArchetypeForComponentsIds(newType...).Id + destId := world.archetypeForSet(newType).Id world.linkArchetypes(fromId, destId, componentId) return &world.archetypes[destId] @@ -110,7 +144,7 @@ func (world *World) archetypeAfterRemove(fromId archetypeId, componentId Compone newType = append(newType, c) } } - destId := world.getArchetypeForComponentsIds(newType...).Id + destId := world.archetypeForSet(newType).Id // dest --add componentId--> from, and from --remove componentId--> dest. world.linkArchetypes(destId, fromId, componentId) diff --git a/archetype_test.go b/archetype_test.go index 900610b..b64bd05 100644 --- a/archetype_test.go +++ b/archetype_test.go @@ -93,3 +93,121 @@ func TestArchetypeGraph_EdgesAreReused(t *testing.T) { } } } + +// TestArchetypeSetIsOrderIndependent: the components of a set, given in any +// order, resolve to the same archetype, whether the entity is created with +// them or receives them afterwards. +func TestArchetypeSetIsOrderIndependent(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + e2, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + e3 := world.CreateEntity() + if err := AddComponents2(world, e3, testComponent2{}, testComponent1{}); err != nil { + t.Fatal(err) + } + + a1, a2, a3 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId, world.entities[e3.Index()].archetypeId + if a1 != a2 || a1 != a3 { + t.Fatalf("{c1,c2} resolved to archetypes %d, %d and %d depending on the order", a1, a2, a3) + } + if got := len(world.archetypes); got != 2 { + t.Fatalf("expected the empty archetype and {c1,c2} only, got %d archetypes", got) + } + for _, e := range []EntityId{e1, e2, e3} { + if GetComponent[testComponent1](world, e) == nil || GetComponent[testComponent2](world, e) == nil { + t.Fatalf("entity %d lost a component", e) + } + } +} + +// TestArchetypeSetBeyondInlineScratch: a set larger than the inline scratch +// (many tags plus components) still resolves, and resolves once. +func TestArchetypeSetBeyondInlineScratch(t *testing.T) { + const tags = maxInlineComponents + 1 + + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + build := func() EntityId { + e := world.CreateEntity() + for i := range tags { + if err := world.AddTag(TAGS_INDICES+TagId(i), e); err != nil { + t.Fatal(err) + } + } + if err := AddComponents2(world, e, testComponent1{}, testComponent2{}); err != nil { + t.Fatal(err) + } + + return e + } + e1, e2 := build(), build() + + a1, a2 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId + if a1 != a2 { + t.Fatalf("the same large set resolved to archetypes %d and %d", a1, a2) + } + if got := len(world.archetypes[a1].Type); got != tags+2 { + t.Fatalf("expected an archetype of %d components, got %d", tags+2, got) + } + for i := range tags { + if !world.HasTag(TAGS_INDICES+TagId(i), e1) { + t.Fatalf("entity lost tag %d", i) + } + } + if !world.HasComponents(e1, testComponent1Id, testComponent2Id) { + t.Fatal("entity lost its components") + } +} + +// TestArchetypeKeyCollisionFallsBackToScan: when two sets share a key, the +// second one is still resolved (by scan) to a single archetype of its own, and +// the first one keeps the key. +func TestArchetypeKeyCollisionFallsBackToScan(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + single := world.CreateEntity() + if err := AddComponent(world, single, testComponent1{}); err != nil { + t.Fatal(err) + } + owner := world.entities[single.Index()].archetypeId + + // Forge a collision: the key of {c1,c2} points at the {c1} archetype. + pair := []ComponentId{testComponent1Id, testComponent2Id} + world.archetypesByKey[archetypeKey(pair)] = owner + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + e2, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + + a1, a2 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId + if a1 == owner || a1 != a2 { + t.Fatalf("colliding set resolved to archetypes %d and %d (key owner %d)", a1, a2, owner) + } + if got := len(world.archetypes); got != 3 { + t.Fatalf("expected the empty archetype, {c1} and {c1,c2}, got %d archetypes", got) + } + if world.archetypesByKey[archetypeKey(pair)] != owner { + t.Fatal("the colliding archetype stole the key from its first owner") + } + if GetComponent[testComponent2](world, e1) == nil || GetComponent[testComponent1](world, single) == nil { + t.Fatal("a component was lost across the collision") + } +} diff --git a/world.go b/world.go index e527c71..d642d07 100644 --- a/world.go +++ b/world.go @@ -64,7 +64,7 @@ type componentsIds []ComponentId // Implementation of an archetype with its identifier, componentsIds, and entitiesIds type archetype struct { Id archetypeId - Type componentsIds + Type componentsIds // sorted set of the components (and tags) held entities []EntityId // Archetype graph: cached transitions to neighbour archetypes. @@ -94,6 +94,7 @@ type World struct { pool pool entities entities archetypes []archetype + archetypesByKey map[uint64]archetypeId // archetype of a set of components, by archetypeKey storage []storage entityAddedFn func(entityId EntityId) @@ -110,6 +111,7 @@ func CreateWorld(initialCapacity int) *World { pool: pool{}, entities: make(entities, 0, initialCapacity), archetypes: make([]archetype, 0, 1024), + archetypesByKey: make(map[uint64]archetypeId, 1024), storage: make([]storage, TAGS_INDICES), entityAddedFn: func(entityId EntityId) {}, entityRemovedFn: func(entityId EntityId) {}, @@ -117,7 +119,7 @@ func CreateWorld(initialCapacity int) *World { componentRemovedFn: func(entityId EntityId, componentId ComponentId) {}, } - world.createArchetype() + world.createArchetype(nil) return world } From ae56462c35ca3cc2f84e9c7b7081af4e6388b4d6 Mon Sep 17 00:00:00 2001 From: Ramine Agoune Date: Fri, 18 Sep 2026 14:42:42 +0000 Subject: [PATCH 3/3] perf: cache multi-component transitions Resolving the archetype reached by adding several components at once through the canonical key costs a sort, a hash, a map lookup and a type comparison on every call, while the same transition is asked again and again from the same call sites. A direct-mapped cache on the world remembers, per (source archetype, ids as given), the destination: a hit is one key comparison, with no sorting. The whole key is compared, so a slot collision is a miss, never a wrong archetype, and archetypes are never destroyed, so entries never go stale. BenchmarkCreateRemoveVolt (5800X): 7.7 ms/op -> 5.8 ms/op (v1.9.0 scan: 6.2). --- archetype.go | 63 +++++++++++++++++++++++++++++++++++++-- archetype_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++ world.go | 1 + 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/archetype.go b/archetype.go index a11a6e5..aa47785 100644 --- a/archetype.go +++ b/archetype.go @@ -15,6 +15,36 @@ const ( // ids before looking its archetype up. Larger sets fall back to a heap copy. const maxInlineComponents = 16 +// maxTransitionComponents is the largest number of components added in one +// call (AddComponents8): the ids of a transition fit inline in its cache entry. +const maxTransitionComponents = 8 + +// transitionCacheSize is the number of slots of the transition cache, a power +// of two so that a slot is a mask of the key. +const transitionCacheSize = 64 + +// transition remembers where adding a list of components to an archetype leads. +// The ids are kept as given by the caller, so a hit needs no sorting: two orders +// of the same set are two entries pointing at the same archetype. Archetypes are +// never destroyed, so an entry never goes stale. +type transition struct { + from archetypeId + ids [maxTransitionComponents]ComponentId + n uint8 + dest archetypeId +} + +// transitionSlot maps a transition to its slot in the cache. +func transitionSlot(fromId archetypeId, componentsIds []ComponentId) int { + hash := (fnv64Offset ^ uint64(fromId)) * fnv64Prime + for _, componentId := range componentsIds { + hash ^= uint64(componentId) + hash *= fnv64Prime + } + + return int(hash & (transitionCacheSize - 1)) +} + // archetypeKey hashes a sorted set of component ids. Two different sets may // share a key: a lookup always confirms the archetype Type before trusting it. func archetypeKey(sorted []ComponentId) uint64 { @@ -103,11 +133,40 @@ func (world *World) getNextArchetype(entityRecord entityRecord, componentsIds .. if len(componentsIds) == 1 { return world.archetypeAfterAdd(entityRecord.archetypeId, componentsIds[0]) } + if len(componentsIds) <= maxTransitionComponents { + return world.archetypeAfterAddN(entityRecord.archetypeId, componentsIds) + } + + return world.archetypeAfterAddSet(entityRecord.archetypeId, componentsIds) +} + +// archetypeAfterAddN resolves a multi-component transition through the +// transition cache. A hit costs one key comparison; a miss resolves the set and +// fills the slot, which the next transition hashing to it overwrites. The whole +// key is compared, so a slot collision is a miss, never a wrong archetype. +func (world *World) archetypeAfterAddN(fromId archetypeId, componentsIds []ComponentId) *archetype { + var key [maxTransitionComponents]ComponentId + copy(key[:], componentsIds) + n := uint8(len(componentsIds)) + + t := &world.transitions[transitionSlot(fromId, componentsIds)] + if t.from == fromId && t.n == n && t.ids == key { + return &world.archetypes[t.dest] + } + + dest := world.archetypeAfterAddSet(fromId, componentsIds) + *t = transition{from: fromId, ids: key, n: n, dest: dest.Id} + + return dest +} +// archetypeAfterAddSet resolves the archetype holding the components of the +// archetype fromId plus componentsIds, through the canonical key. +func (world *World) archetypeAfterAddSet(fromId archetypeId, componentsIds []ComponentId) *archetype { var scratch [maxInlineComponents]ComponentId set := append(scratch[:0], componentsIds...) - if from := world.getArchetype(entityRecord); from != nil { - set = append(set, from.Type...) + if int(fromId) < len(world.archetypes) { + set = append(set, world.archetypes[fromId].Type...) } return world.archetypeForSet(set) diff --git a/archetype_test.go b/archetype_test.go index b64bd05..02ebe43 100644 --- a/archetype_test.go +++ b/archetype_test.go @@ -211,3 +211,78 @@ func TestArchetypeKeyCollisionFallsBackToScan(t *testing.T) { t.Fatal("a component was lost across the collision") } } + +// TestTransitionCache: a multi-component transition is cached after its first +// resolution; a slot whose key differs is never trusted and gets refilled; the +// same set in another order is another entry, but the same archetype. +func TestTransitionCache(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + archetypeOf := func(e EntityId) archetypeId { return world.entities[e.Index()].archetypeId } + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + slot := transitionSlot(0, []ComponentId{testComponent1Id, testComponent2Id}) + if entry := world.transitions[slot]; entry.from != 0 || entry.n != 2 || entry.dest != archetypeOf(e1) { + t.Fatalf("the transition should be cached after its first resolution, got %+v", entry) + } + + // Corrupt the key of the slot and point it at the wrong archetype: the + // lookup must miss, resolve correctly, and refill the slot. + world.transitions[slot].ids[0] = testComponent2Id + world.transitions[slot].dest = 0 + e2, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + if archetypeOf(e2) != archetypeOf(e1) { + t.Fatalf("a slot whose key differs must not be trusted: got archetype %d, want %d", archetypeOf(e2), archetypeOf(e1)) + } + if world.transitions[slot].dest != archetypeOf(e1) { + t.Fatal("the miss should have refilled the slot") + } + + e3, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + if archetypeOf(e3) != archetypeOf(e1) { + t.Fatal("the same set in another order should reach the same archetype") + } + if got := len(world.archetypes); got != 2 { + t.Fatalf("expected the empty archetype and {c1,c2} only, got %d", got) + } +} + +// TestTransitionCacheFromNonEmptyArchetype: the transition is keyed by the +// archetype the entity starts from, so adding the same components to entities +// living in different archetypes leads to different, correct archetypes. +func TestTransitionCacheFromNonEmptyArchetype(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + bare := world.CreateEntity() + tagged := world.CreateEntity() + if err := world.AddTag(TAGS_INDICES, tagged); err != nil { + t.Fatal(err) + } + for _, e := range []EntityId{bare, tagged} { + if err := AddComponents2(world, e, testComponent1{}, testComponent2{}); err != nil { + t.Fatal(err) + } + } + + if world.entities[bare.Index()].archetypeId == world.entities[tagged.Index()].archetypeId { + t.Fatal("entities starting from different archetypes must not share the destination") + } + if !world.HasComponents(bare, testComponent1Id, testComponent2Id) || world.HasTag(TAGS_INDICES, bare) { + t.Fatal("the bare entity should own the two components and no tag") + } + if !world.HasComponents(tagged, testComponent1Id, testComponent2Id) || !world.HasTag(TAGS_INDICES, tagged) { + t.Fatal("the tagged entity should own the two components and keep its tag") + } +} diff --git a/world.go b/world.go index d642d07..572ef34 100644 --- a/world.go +++ b/world.go @@ -95,6 +95,7 @@ type World struct { entities entities archetypes []archetype archetypesByKey map[uint64]archetypeId // archetype of a set of components, by archetypeKey + transitions [transitionCacheSize]transition storage []storage entityAddedFn func(entityId EntityId)