Open to Work: Senior Backend & Distributed Systems Engineer (Go / Python) View LinkedIn →

Part 4: Refactoring to SoA ECS: Bitmasks and Flat Component Arrays

Refactoring entity storage from pointer-heavy OOP structs to a Structure of Arrays (SoA) ECS using bitwise masks for entity identity and zero-allocation system loops.

WORDS: 815 | CODE BLOCKS: 8 | EXT. LINKS: 0
// TL;DR
  • 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:

graph TD subgraph AoS ["Array of Structures (AoS) - Pointer Chasing"] E0["Entity 0: [ID | Position | Sprite | Walkable]"] --> E1["Entity 1: [ID | Position | Sprite | Walkable]"] E1 --> E2["Entity 2: [ID | Position | Sprite | Walkable]"] end subgraph SoA ["Structure of Arrays (SoA) - Cache Line Friendly"] Masks["Masks Array: [M0, M1, M2, ...]"] Positions["Positions Array: [P0, P1, P2, ...] (Contiguous)"] Sprites["Sprites Array: [S0, S1, S2, ...]"] end

In the classic Array of Structures (AoS) approach, each entity holds all its fields:

go

// 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:

go

// 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).

go 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:

go
1// Entity 5 has Position and Sprite components
2Masks[5] = MaskPosition | MaskSprite // binary: 00000011

When a system wants to process controllable entities with a position, it queries the mask with a single bitwise AND check:

go
1required := MaskPosition | MaskPlayerControl
2
3// Returns true if entity 5 has both required components
4hasComponents := (Masks[e] & required) == required

This 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.

go internal/ecs/world.go

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:

go internal/systems/input.go

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]Position reserves 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.