Repository: mlange-42/arche
Pull request #54 (2023-02-11 21:13:45)

Filter logic


Patch:
diff --git a/ecs/bitmask.go b/ecs/bitmask.go
index 187a8b6..37c183f 100644
--- a/ecs/bitmask.go
+++ b/ecs/bitmask.go
@@ -1,69 +1,11 @@
 package ecs
 
+import "internal/base"
+
 // bitMask is a bitmask.
-type bitMask uint64
+type bitMask = base.BitMask
 
 // MaskTotalBits is the size of Mask in bits.
 //
 // It is the maximum number of component types that may exist in any [World].
 const MaskTotalBits = 64
-
-var nibbleToBitsSet = [16]uint{0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}
-
-// newMask creates a new bitmask from a list of IDs.
-//
-// If any ID is bigger or equal [MaskTotalBits], it'll not be added to the mask.
-//
-// Implementation taken from https://github.com/marioolofo/go-gameengine-ecs.
-func newMask(ids ...ID) bitMask {
-	var mask bitMask
-	for _, id := range ids {
-		mask.Set(id, true)
-	}
-	return mask
-}
-
-// Get reports if bit index defined by ID is true or false.
-//
-// The return will be always false for bit >= [MaskTotalBits].
-func (e bitMask) Get(bit ID) bool {
-	mask := bitMask(1 << bit)
-	return e&mask == mask
-}
-
-// Set sets the state of bit index to true or false.
-//
-// This function has no effect for bit >= [MaskTotalBits].
-func (e *bitMask) Set(bit ID, value bool) {
-	if value {
-		*e |= bitMask(1 << bit)
-	} else {
-		*e &= bitMask(^(1 << bit))
-	}
-}
-
-// Reset changes the state of all bits to false.
-func (e *bitMask) Reset() {
-	*e = 0
-}
-
-// Contains reports if other mask is a subset of this mask.
-func (e bitMask) Contains(other bitMask) bool {
-	return e&other == other
-}
-
-// ContainsAny reports if any bit of other mask is a subset of this mask.
-func (e bitMask) ContainsAny(other bitMask) bool {
-	return e&other != 0
-}
-
-// TotalBitsSet returns how many bits are set in this mask.
-func (e bitMask) TotalBitsSet() uint {
-	var count uint
-
-	for e != 0 {
-		count += nibbleToBitsSet[e&0xf]
-		e >>= 4
-	}
-	return count
-}
diff --git a/ecs/filter.go b/ecs/filter.go
new file mode 100644
index 0000000..1fc1eee
--- /dev/null
+++ b/ecs/filter.go
@@ -0,0 +1,72 @@
+package ecs
+
+import "internal/base"
+
+// Mask is a mask for a combination of components.
+type Mask struct {
+	mask bitMask
+}
+
+// All matches all the given components.
+//
+// Like [And] for combining individual components.
+func All(comps ...ID) Mask {
+	return Mask{base.NewMask(comps...)}
+}
+
+// OneOf matches any of the two components.
+//
+// Like [Or] for combining individual components.
+func OneOf(compA ID, compB ID) Or {
+	return Or{Mask{base.NewMask(compA)}, Mask{base.NewMask(compB)}}
+}
+
+type filter interface {
+	Matches(mask Mask) bool
+}
+
+// And is a filter for ANDing together components
+type And struct {
+	a filter
+	b filter
+}
+
+// Or is a filter for ORing together components
+type Or struct {
+	a filter
+	b filter
+}
+
+// XOr is a filter for XORing together components
+type XOr struct {
+	a filter
+	b filter
+}
+
+// Not is a filter for excluding components
+type Not Mask
+
+// Matches matches a filter against a mask
+func (f Mask) Matches(mask Mask) bool {
+	return mask.mask.Contains(f.mask)
+}
+
+// Matches matches a filter against a mask
+func (f *And) Matches(mask Mask) bool {
+	return f.a.Matches(mask) && f.b.Matches(mask)
+}
+
+// Matches matches a filter against a mask
+func (f *Or) Matches(mask Mask) bool {
+	return f.a.Matches(mask) || f.b.Matches(mask)
+}
+
+// Matches matches a filter against a mask
+func (f *XOr) Matches(mask Mask) bool {
+	return f.a.Matches(mask) != f.b.Matches(mask)
+}
+
+// Matches matches a filter against a mask
+func (f Not) Matches(mask Mask) bool {
+	return !mask.mask.ContainsAny(f.mask)
+}
diff --git a/ecs/query.go b/ecs/query.go
index 2bffb0f..ab1e0e8 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -1,13 +1,14 @@
 package ecs
 
 import (
+	"internal/base"
 	"unsafe"
 )
 
-// Query is an iterator to iterate entities.
+// Query is a simple iterator to iterate entities.
 //
 // Create queries through the [World] using [World.Query].
-// See also the generic variants [Query1], [Query2], [Query3], ...
+// See also the generic alternatives [Query1], [Query2], [Query3], ...
 //
 // # Example:
 //
@@ -17,23 +18,21 @@ import (
 //	    pos.X += 1.0
 //	}
 type Query struct {
-	world     *World
-	mask      bitMask
-	exclude   bitMask
-	archetype archetypeIter
-	index     int
-	done      bool
-	lockBit   uint8
+	queryIter
+	mask    bitMask
+	exclude bitMask
 }
 
 // newQuery creates a new Query
 func newQuery(world *World, mask, exclude bitMask, lockBit uint8) Query {
 	return Query{
-		world:   world,
+		queryIter: queryIter{
+			world:   world,
+			index:   -1,
+			lockBit: lockBit,
+		},
 		mask:    mask,
 		exclude: exclude,
-		index:   -1,
-		lockBit: lockBit,
 	}
 }
 
@@ -44,7 +43,7 @@ func newQuery(world *World, mask, exclude bitMask, lockBit uint8) Query {
 //
 //	query := world.Query(idA, isB).Not(idC, isD)
 func (q Query) Not(comps ...ID) Query {
-	q.exclude = newMask(comps...)
+	q.exclude = base.NewMask(comps...)
 	return q
 }
 
@@ -60,22 +59,80 @@ func (q *Query) Next() bool {
 		return true
 	}
 	q.done = true
-	q.world.closeQuery(q)
+	q.world.closeQuery(&q.queryIter)
 	return false
 }
 
+// Filter is an advanced iterator to iterate entities.
+//
+// Create filter through the [World] using [World.Query].
+// There is no generic alternative for filters.
+//
+// # Example:
+//
+//	filter := world.Filter(
+//	    And {
+//	        All(idA, idB, idC),
+//	        Not(OneOf(idD, idE)),
+//	    }
+//	)
+//	for query.Next() {
+//	    pos := (*position)(query.Get(posID))
+//	    pos.X += 1.0
+//	}
+type Filter struct {
+	queryIter
+	filter filter
+}
+
+// newFilter creates a new Filter
+func newFilter(world *World, filter filter, lockBit uint8) Filter {
+	return Filter{
+		queryIter: queryIter{
+			world:   world,
+			index:   -1,
+			lockBit: lockBit,
+		},
+		filter: filter,
+	}
+}
+
+// Next proceeds to the next [Entity] in the Query.
+func (q *Filter) Next() bool {
+	if q.archetype.Next() {
+		return true
+	}
+	i, a, ok := q.world.nextArchetypeFilter(q.filter, q.index)
+	q.index = i
+	if ok {
+		q.archetype = a
+		return true
+	}
+	q.done = true
+	q.world.closeQuery(&q.queryIter)
+	return false
+}
+
+type queryIter struct {
+	world     *World
+	archetype archetypeIter
+	index     int
+	done      bool
+	lockBit   uint8
+}
+
 // Has returns whether the current [Entity] has the given component
-func (q *Query) Has(comp ID) bool {
+func (q *queryIter) Has(comp ID) bool {
 	return q.archetype.Has(comp)
 }
 
 // Get returns the pointer to the given component at the iterator's current [Entity]
-func (q *Query) Get(comp ID) unsafe.Pointer {
+func (q *queryIter) Get(comp ID) unsafe.Pointer {
 	return q.archetype.Get(comp)
 }
 
 // Entity returns the [Entity] at the iterator's position
-func (q *Query) Entity() Entity {
+func (q *queryIter) Entity() Entity {
 	return q.archetype.Entity()
 }
 
@@ -83,7 +140,7 @@ func (q *Query) Entity() Entity {
 //
 // Automatically called when iteration finishes.
 // Needs to be called only if breaking out of the query iteration.
-func (q *Query) Close() {
+func (q *queryIter) Close() {
 	q.done = true
 	q.world.closeQuery(q)
 }
diff --git a/ecs/query_generic.go b/ecs/query_generic.go
index ef4b364..5e28c2e 100644
--- a/ecs/query_generic.go
+++ b/ecs/query_generic.go
@@ -1,9 +1,6 @@
 package ecs
 
-// Mask is a mask for a combination of components.
-type Mask struct {
-	mask bitMask
-}
+import "internal/base"
 
 // Q0 is a generic query for no components.
 //
@@ -512,26 +509,26 @@ func (q *Q8[A, B, C, D, E, F, G, H]) Get8() *H {
 
 // Mask1 creates a component [Mask] for one component type.
 func Mask1[A any](w *World) Mask {
-	return Mask{newMask(ComponentID[A](w))}
+	return Mask{base.NewMask(ComponentID[A](w))}
 }
 
 // Mask2 creates a component [Mask] for two component types.
 func Mask2[A any, B any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w),
 	)}
 }
 
 // Mask3 creates a component [Mask] for three component types.
 func Mask3[A any, B any, C any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 	)}
 }
 
 // Mask4 creates a component [Mask] for four component types.
 func Mask4[A any, B any, C any, D any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 		ComponentID[D](w),
 	)}
@@ -539,7 +536,7 @@ func Mask4[A any, B any, C any, D any](w *World) Mask {
 
 // Mask5 creates a component [Mask] for five component types.
 func Mask5[A any, B any, C any, D any, E any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 		ComponentID[D](w), ComponentID[E](w),
 	)}
@@ -547,7 +544,7 @@ func Mask5[A any, B any, C any, D any, E any](w *World) Mask {
 
 // Mask6 creates a component [Mask] for six component types.
 func Mask6[A any, B any, C any, D any, E any, F any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 		ComponentID[D](w), ComponentID[E](w), ComponentID[F](w),
 	)}
@@ -555,7 +552,7 @@ func Mask6[A any, B any, C any, D any, E any, F any](w *World) Mask {
 
 // Mask7 creates a component [Mask] for seven component types.
 func Mask7[A any, B any, C any, D any, E any, F any, G any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 		ComponentID[D](w), ComponentID[E](w), ComponentID[F](w),
 		ComponentID[G](w),
@@ -564,7 +561,7 @@ func Mask7[A any, B any, C any, D any, E any, F any, G any](w *World) Mask {
 
 // Mask8 creates a component [Mask] for eight component types.
 func Mask8[A any, B any, C any, D any, E any, F any, G any, H any](w *World) Mask {
-	return Mask{newMask(
+	return Mask{base.NewMask(
 		ComponentID[A](w), ComponentID[B](w), ComponentID[C](w),
 		ComponentID[D](w), ComponentID[E](w), ComponentID[F](w),
 		ComponentID[G](w), ComponentID[H](w),
diff --git a/ecs/types.go b/ecs/types.go
index 6ef441d..24e4411 100644
--- a/ecs/types.go
+++ b/ecs/types.go
@@ -1,15 +1,16 @@
 package ecs
 
 import (
+	"internal/base"
 	"reflect"
 	"unsafe"
 )
 
 // eid is the entity identifier/index type
-type eid uint32
+type eid = base.Eid
 
 // ID is the component identifier type
-type ID uint8
+type ID = base.ID
 
 // Component is a Component ID/Component pointer pair
 type Component struct {
diff --git a/ecs/world.go b/ecs/world.go
index 1ab8d7c..c1de933 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -1,6 +1,7 @@
 package ecs
 
 import (
+	"internal/base"
 	"reflect"
 	"unsafe"
 )
@@ -86,9 +87,9 @@ func (w *World) RemEntity(entity Entity) {
 //
 // Locks the world to prevent changes to component compositions.
 //
-// See also the generic variants [Query1], [Query2], [Query3], ...
+// See also the generic alternatives [Query1], [Query2], [Query3], ...
 func (w *World) Query(comps ...ID) Query {
-	mask := newMask(comps...)
+	mask := base.NewMask(comps...)
 	lock := w.bitPool.Get()
 	w.locks.Set(ID(lock), true)
 	return newQuery(w, mask, 0, lock)
@@ -100,6 +101,17 @@ func (w *World) query(mask, exclude bitMask) Query {
 	return newQuery(w, mask, exclude, lock)
 }
 
+// Filter creates an advanced [Filter] iterator.
+//
+// Locks the world to prevent changes to component compositions.
+//
+// There is no generic alternative for filters.
+func (w *World) Filter(filter filter) Filter {
+	lock := w.bitPool.Get()
+	w.locks.Set(ID(lock), true)
+	return newFilter(w, filter, lock)
+}
+
 // Alive reports whether an entity is still alive.
 func (w *World) Alive(entity Entity) bool {
 	return w.entityPool.Alive(entity)
@@ -357,8 +369,22 @@ func (w *World) nextArchetype(mask, exclude bitMask, index int) (int, archetypeI
 	return len, archetypeIter{}, false
 }
 
+func (w *World) nextArchetypeFilter(filter filter, index int) (int, archetypeIter, bool) {
+	len := w.archetypes.Len()
+	if index >= len {
+		panic("exceeded end of query")
+	}
+	for i := index + 1; i < len; i++ {
+		a := w.archetypes.Get(i)
+		if a.Len() > 0 && filter.Matches(Mask{a.mask}) {
+			return i, newArchetypeIter(a), true
+		}
+	}
+	return len, archetypeIter{}, false
+}
+
 // closeQuery closes a query and unlocks the world
-func (w *World) closeQuery(query *Query) {
+func (w *World) closeQuery(query *queryIter) {
 	l := query.lockBit
 	if !w.locks.Get(ID(l)) {
 		panic("unbalanced query unlock")
diff --git a/go.mod b/go.mod
index 6ef6626..06080ad 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,15 @@ module github.com/mlange-42/arche
 
 go 1.20
 
-require github.com/stretchr/testify v1.8.1
+require (
+	github.com/stretchr/testify v1.8.1
+
+	internal/base v0.0.0
+)
+
+replace (
+	internal/base => ./internal/base
+)
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
diff --git a/internal/base/bitmask.go b/internal/base/bitmask.go
new file mode 100644
index 0000000..5d4301a
--- /dev/null
+++ b/internal/base/bitmask.go
@@ -0,0 +1,64 @@
+package base
+
+// BitMask is a bitmask.
+type BitMask uint64
+
+var nibbleToBitsSet = [16]uint{0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}
+
+// NewMask creates a new bitmask from a list of IDs.
+//
+// If any ID is bigger or equal [MaskTotalBits], it'll not be added to the mask.
+//
+// Implementation taken from https://github.com/marioolofo/go-gameengine-ecs.
+func NewMask(ids ...ID) BitMask {
+	var mask BitMask
+	for _, id := range ids {
+		mask.Set(id, true)
+	}
+	return mask
+}
+
+// Get reports if bit index defined by ID is true or false.
+//
+// The return will be always false for bit >= [MaskTotalBits].
+func (e BitMask) Get(bit ID) bool {
+	mask := BitMask(1 << bit)
+	return e&mask == mask
+}
+
+// Set sets the state of bit index to true or false.
+//
+// This function has no effect for bit >= [MaskTotalBits].
+func (e *BitMask) Set(bit ID, value bool) {
+	if value {
+		*e |= BitMask(1 << bit)
+	} else {
+		*e &= BitMask(^(1 << bit))
+	}
+}
+
+// Reset changes the state of all bits to false.
+func (e *BitMask) Reset() {
+	*e = 0
+}
+
+// Contains reports if other mask is a subset of this mask.
+func (e BitMask) Contains(other BitMask) bool {
+	return e&other == other
+}
+
+// ContainsAny reports if any bit of other mask is a subset of this mask.
+func (e BitMask) ContainsAny(other BitMask) bool {
+	return e&other != 0
+}
+
+// TotalBitsSet returns how many bits are set in this mask.
+func (e BitMask) TotalBitsSet() uint {
+	var count uint
+
+	for e != 0 {
+		count += nibbleToBitsSet[e&0xf]
+		e >>= 4
+	}
+	return count
+}
diff --git a/internal/base/go.mod b/internal/base/go.mod
new file mode 100644
index 0000000..950ea20
--- /dev/null
+++ b/internal/base/go.mod
@@ -0,0 +1,3 @@
+module base
+
+go 1.20
diff --git a/internal/base/types.go b/internal/base/types.go
new file mode 100644
index 0000000..1bae03c
--- /dev/null
+++ b/internal/base/types.go
@@ -0,0 +1,7 @@
+package base
+
+// Eid is the entity identifier/index type
+type Eid uint32
+
+// ID is the component identifier type
+type ID uint8


Test patch:
diff --git a/benchmark/iterate/arche_test.go b/benchmark/iterate/arche_test.go
index 561643f..3c8851f 100644
--- a/benchmark/iterate/arche_test.go
+++ b/benchmark/iterate/arche_test.go
@@ -30,7 +30,7 @@ func runArcheQuery(b *testing.B, count int) {
 	}
 }
 
-func runArcheQueryPointerHeap(b *testing.B, count int) {
+func runArcheQueryFilter(b *testing.B, count int) {
 	b.StopTimer()
 	world := ecs.NewWorld()
 
@@ -45,7 +45,7 @@ func runArcheQueryPointerHeap(b *testing.B, count int) {
 
 	for i := 0; i < b.N; i++ {
 		b.StopTimer()
-		query := createQuery(&world, posID, rotID)
+		query := world.Filter(ecs.All(posID, rotID))
 		b.StartTimer()
 		for query.Next() {
 			pos := (*position)(query.Get(posID))
@@ -54,11 +54,6 @@ func runArcheQueryPointerHeap(b *testing.B, count int) {
 	}
 }
 
-func createQuery(w *ecs.World, comps ...ecs.ID) *ecs.Query {
-	q := w.Query(comps...)
-	return &q
-}
-
 func runArcheQueryGeneric(b *testing.B, count int) {
 	b.StopTimer()
 	world := ecs.NewWorld()
@@ -240,16 +235,16 @@ func BenchmarkArcheIterQueryID_100_000(b *testing.B) {
 	runArcheQuery(b, 100000)
 }
 
-func BenchmarkArcheIterQueryIDPointer_1_000(b *testing.B) {
-	runArcheQueryPointerHeap(b, 1000)
+func BenchmarkArcheIterQueryFilter_1_000(b *testing.B) {
+	runArcheQueryFilter(b, 1000)
 }
 
-func BenchmarkArcheIterQueryIDPointer_10_000(b *testing.B) {
-	runArcheQueryPointerHeap(b, 10000)
+func BenchmarkArcheIterQueryFilter_10_000(b *testing.B) {
+	runArcheQueryFilter(b, 10000)
 }
 
-func BenchmarkArcheIterQueryIDPointer_100_000(b *testing.B) {
-	runArcheQueryPointerHeap(b, 100000)
+func BenchmarkArcheIterQueryFilter_100_000(b *testing.B) {
+	runArcheQueryFilter(b, 100000)
 }
 
 func BenchmarkArcheIterQueryGeneric_1_000(b *testing.B) {
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index 2bfe965..5fc375e 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -1,13 +1,14 @@
 package ecs
 
 import (
+	"internal/base"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
 func TestBitMask(t *testing.T) {
-	mask := newMask(ID(1), ID(2), ID(13), ID(27))
+	mask := base.NewMask(ID(1), ID(2), ID(13), ID(27))
 
 	assert.Equal(t, uint(4), mask.TotalBitsSet())
 
@@ -18,26 +19,4 @@ func TestBitMask(t *testing.T) {
 
 	assert.False(t, mask.Get(0))
 	assert.False(t, mask.Get(3))
-
-	mask.Set(ID(0), true)
-	mask.Set(ID(1), false)
-
-	assert.True(t, mask.Get(0))
-	assert.False(t, mask.Get(1))
-
-	other1 := newMask(ID(1), ID(2), ID(32))
-	other2 := newMask(ID(0), ID(2))
-
-	assert.False(t, mask.Contains(other1))
-	assert.True(t, mask.Contains(other2))
-
-	mask.Reset()
-	assert.Equal(t, uint(0), mask.TotalBitsSet())
-
-	mask = newMask(ID(1), ID(2), ID(13), ID(27))
-	other1 = newMask(ID(1), ID(32))
-	other2 = newMask(ID(0), ID(32))
-
-	assert.True(t, mask.ContainsAny(other1))
-	assert.False(t, mask.ContainsAny(other2))
 }
diff --git a/ecs/filter_test.go b/ecs/filter_test.go
new file mode 100644
index 0000000..061e468
--- /dev/null
+++ b/ecs/filter_test.go
@@ -0,0 +1,69 @@
+package ecs
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLogicFilters(t *testing.T) {
+	w := NewWorld()
+
+	hasA := Mask1[testStruct0](&w)
+	hasB := Mask1[testStruct1](&w)
+	hasAll := Mask2[testStruct0, testStruct1](&w)
+	hasNone := Mask{}
+
+	var filter filter
+	filter = hasA
+	assert.True(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = &Or{hasA, hasB}
+	assert.True(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.True(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = &XOr{hasA, hasB}
+	assert.False(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.True(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = &hasAll
+	assert.True(t, match(filter, hasAll))
+	assert.False(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = &And{hasA, hasB}
+	assert.True(t, match(filter, hasAll))
+	assert.False(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = Not(hasB)
+	assert.False(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.True(t, match(filter, hasNone))
+
+	filter = &And{hasA, Not(hasB)}
+	assert.False(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.False(t, match(filter, hasNone))
+
+	filter = &Or{hasAll, Not(hasB)}
+	assert.True(t, match(filter, hasAll))
+	assert.True(t, match(filter, hasA))
+	assert.False(t, match(filter, hasB))
+	assert.True(t, match(filter, hasNone))
+}
+
+func match(f filter, m Mask) bool {
+	return f.Matches(m)
+}
diff --git a/ecs/query_generic_test.go b/ecs/query_generic_test.go
index 12daf4f..2e77aa9 100644
--- a/ecs/query_generic_test.go
+++ b/ecs/query_generic_test.go
@@ -1,6 +1,7 @@
 package ecs
 
 import (
+	"internal/base"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -466,45 +467,45 @@ func TestGenericMask(t *testing.T) {
 
 	assert.Equal(t,
 		Mask1[testStruct0](&w).mask,
-		newMask(0),
+		base.NewMask(0),
 	)
 
 	assert.Equal(t,
 		Mask2[testStruct0, testStruct1](&w).mask,
-		newMask(0, 1),
+		base.NewMask(0, 1),
 	)
 
 	assert.Equal(t,
 		Mask3[testStruct0, testStruct1, testStruct2](&w).mask,
-		newMask(0, 1, 2),
+		base.NewMask(0, 1, 2),
 	)
 
 	assert.Equal(t,
 		Mask4[testStruct0, testStruct1, testStruct2, testStruct3](&w).mask,
-		newMask(0, 1, 2, 3),
+		base.NewMask(0, 1, 2, 3),
 	)
 
 	assert.Equal(t,
 		Mask5[testStruct0, testStruct1, testStruct2, testStruct3,
 			testStruct4](&w).mask,
-		newMask(0, 1, 2, 3, 4),
+		base.NewMask(0, 1, 2, 3, 4),
 	)
 
 	assert.Equal(t,
 		Mask6[testStruct0, testStruct1, testStruct2, testStruct3,
 			testStruct4, testStruct5](&w).mask,
-		newMask(0, 1, 2, 3, 4, 5),
+		base.NewMask(0, 1, 2, 3, 4, 5),
 	)
 
 	assert.Equal(t,
 		Mask7[testStruct0, testStruct1, testStruct2, testStruct3,
 			testStruct4, testStruct5, testStruct6](&w).mask,
-		newMask(0, 1, 2, 3, 4, 5, 6),
+		base.NewMask(0, 1, 2, 3, 4, 5, 6),
 	)
 
 	assert.Equal(t,
 		Mask8[testStruct0, testStruct1, testStruct2, testStruct3,
 			testStruct4, testStruct5, testStruct6, testStruct7](&w).mask,
-		newMask(0, 1, 2, 3, 4, 5, 6, 7),
+		base.NewMask(0, 1, 2, 3, 4, 5, 6, 7),
 	)
 }
diff --git a/ecs/world_test.go b/ecs/world_test.go
index ecdd546..9ed1ea4 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -2,6 +2,7 @@ package ecs
 
 import (
 	"fmt"
+	"internal/base"
 	"reflect"
 	"testing"
 
@@ -61,10 +62,10 @@ func TestWorldComponents(t *testing.T) {
 
 	w.Remove(e2, posID)
 
-	maskNone := newMask()
-	maskPos := newMask(posID)
-	maskRot := newMask(rotID)
-	maskPosRot := newMask(posID, rotID)
+	maskNone := base.NewMask()
+	maskPos := base.NewMask(posID)
+	maskRot := base.NewMask(rotID)
+	maskPosRot := base.NewMask(posID, rotID)
 
 	archNone, ok := w.findArchetype(maskNone)
 	assert.True(t, ok)
diff --git a/internal/base/bitmask_test.go b/internal/base/bitmask_test.go
new file mode 100644
index 0000000..6d15b46
--- /dev/null
+++ b/internal/base/bitmask_test.go
@@ -0,0 +1,43 @@
+package base
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBitMask(t *testing.T) {
+	mask := NewMask(ID(1), ID(2), ID(13), ID(27))
+
+	assert.Equal(t, uint(4), mask.TotalBitsSet())
+
+	assert.True(t, mask.Get(1))
+	assert.True(t, mask.Get(2))
+	assert.True(t, mask.Get(13))
+	assert.True(t, mask.Get(27))
+
+	assert.False(t, mask.Get(0))
+	assert.False(t, mask.Get(3))
+
+	mask.Set(ID(0), true)
+	mask.Set(ID(1), false)
+
+	assert.True(t, mask.Get(0))
+	assert.False(t, mask.Get(1))
+
+	other1 := NewMask(ID(1), ID(2), ID(32))
+	other2 := NewMask(ID(0), ID(2))
+
+	assert.False(t, mask.Contains(other1))
+	assert.True(t, mask.Contains(other2))
+
+	mask.Reset()
+	assert.Equal(t, uint(0), mask.TotalBitsSet())
+
+	mask = NewMask(ID(1), ID(2), ID(13), ID(27))
+	other1 = NewMask(ID(1), ID(32))
+	other2 = NewMask(ID(0), ID(32))
+
+	assert.True(t, mask.ContainsAny(other1))
+	assert.False(t, mask.ContainsAny(other2))
+}