- Moving entity storage from slice of pointers to a Structure of Arrays (SoA) layout.
- Using 32-bit bitmask operations to check entity components instantly.
- Eliminating heap allocations in the game loop to bypass Go GC scans entirely.
When I started building Derelict Facility, my initial instinct for game actors was standard Object-Oriented design: create an Entity struct, add pointers for position, sprite, and stats, and store them in a slice ([]*Entity).
It worked fine for five entities. But as soon as I added automated doors, save terminals, and active power grids across a 1000-tile map, keeping track of separate heap pointers became a headache. My Go profiler showed GC pauses spiking while the CPU spent more time chasing heap pointers across non-contiguous memory than actually updating game state.
To fix this, I refactored the engine to a data-oriented Structure of Arrays (SoA) Entity-Component System (ECS) driven by bitmask indexing.
AoS vs. SoA Memory Layout
The diagram below compares how CPU cache lines load data in an Array of Structures (AoS) layout versus a Structure of Arrays (SoA) layout:
In the classic Array of Structures (AoS) approach, each entity holds all its fields:
// Array of Structures (AoS)
type Entity struct {
ID uint32
Position Point
Render Sprite
Walkable bool
}
var Entities []Entity
When the movement system loops through Entities to update coordinates, the CPU loads the entire Entity struct (including rendering and collision flags) into 64-byte L1 cache lines. You end up wasting up to 75% of your cache line bandwidth on data the movement loop never reads.
In a Structure of Arrays (SoA) layout, an entity is just an integer index (uint32). The actual component data lives in separate, flat, parallel slices:
// Structure of Arrays (SoA)
type World struct {
Masks [1000]uint32
Positions [1000]Position
Sprites [1000]Sprite
}
Now, when the movement system runs, it iterates exclusively over the flat Positions slice. The CPU hardware prefetcher streams contiguous Position memory sequentially into cache without loading unused render state.
Entity Identity: Component Bitmasks
How do we know which components are attached to entity index 5 without doing expensive map lookups? We use bitwise masks (internal/components/components.go).
type ComponentMask uint32
const (
MaskNone ComponentMask = 0
MaskPosition ComponentMask = 1 << iota
MaskSprite
MaskPlayerControl
MaskGlyph
MaskSolid
MaskInteractable
)
An entity’s active composition is set using bitwise OR:
1// Entity 5 has Position and Sprite components
2Masks[5] = MaskPosition | MaskSprite // binary: 00000011When a system wants to process controllable entities with a position, it queries the mask with a single bitwise AND check:
1required := MaskPosition | MaskPlayerControl
2
3// Returns true if entity 5 has both required components
4hasComponents := (Masks[e] & required) == requiredThis translates to a single CPU bitwise instruction.
World Registry Implementation
The registry (internal/ecs/world.go) manages these pre-allocated component arrays. Instead of dynamic slice reallocations on the fly, I capped entity capacity to a fixed array limit (1000 entities) initialized on boot.
type Entity uint32
type World struct {
NextEntityID Entity
FreeEntities []Entity
Masks [1000]components.ComponentMask
Positions [1000]components.Position
Sprites [1000]components.Sprite
}
func (w *World) CreateEntity() Entity {
var id Entity
if len(w.FreeEntities) > 0 {
id = w.FreeEntities[len(w.FreeEntities)-1]
w.FreeEntities = w.FreeEntities[:len(w.FreeEntities)-1]
} else {
id = w.NextEntityID
w.NextEntityID++
}
w.Masks[id] = components.MaskNone
return id
}
func (w *World) AddPosition(e Entity, pos components.Position) {
w.Positions[e] = pos
w.Masks[e] |= components.MaskPosition
}
func (w *World) DestroyEntity(e Entity) {
w.Masks[e] = components.MaskNone
w.FreeEntities = append(w.FreeEntities, e)
}
When DestroyEntity is called, we don’t zero out or reallocate array data. Unsetting w.Masks[e] = MaskNone tells systems to skip that index immediately. The ID is pushed onto FreeEntities for O(1) reuse on the next CreateEntity call.
Zero-Allocation System Loops
In standard Go code, returning slices of matching entity IDs inside a system loop creates garbage collection pressure on every single frame.
Operating directly on World memory avoids allocations entirely:
func UpdateMovement(w *ecs.World, dx, dy int) {
required := components.MaskPosition | components.MaskPlayerControl
for i := 0; i < int(w.NextEntityID); i++ {
mask := w.Masks[i]
if (mask & required) == required {
w.Positions[i].X += dx
w.Positions[i].Y += dy
}
}
}
Because this loop reads directly from fixed memory slices without allocating temporary objects, runtime allocations remain at 0 B/op.
The Practical Trade-offs
Choosing a simple pre-allocated flat array ECS comes with explicit trade-offs:
- Upfront RAM Footprint: Pre-allocating
[1000]Positionreserves memory up front even when only 10 entities are active. At ~120 KB total overhead for 1000 entities, this is a tiny price to pay to eliminate GC pauses. - Hard Entity Limits: If entity count exceeds 1000, the array bounds check will panic. For a grid simulation with known level bounds, this cap keeps the design clean and predictable.