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

> **Source:** [https://lorbic.com/structure-of-arrays-ecs-bitmasks-golang/](https://lorbic.com/structure-of-arrays-ecs-bitmasks-golang/)
> **Author:** [Vikash Patel](https://vikashpatel.net)
> **Published:** August 08, 2026
> **Reading Time:** 4 min
> 
> *This is the raw Markdown source of the article from the [Lorbic Technical Journal](https://lorbic.com/).*

---


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:

```mermaid
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:

{{< code lang="go" >}}
// Array of Structures (AoS)
type Entity struct {
    ID       uint32
    Position Point
    Render   Sprite
    Walkable bool
}

var Entities []Entity
{{< /code >}}

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:

{{< code lang="go" >}}
// Structure of Arrays (SoA)
type World struct {
    Masks     [1000]uint32
    Positions [1000]Position
    Sprites   [1000]Sprite
}
{{< /code >}}

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

{{< code lang="go" file="internal/components/components.go" >}}
type ComponentMask uint32

const (
	MaskNone         ComponentMask = 0
	MaskPosition     ComponentMask = 1 << iota
	MaskSprite
	MaskPlayerControl
	MaskGlyph
	MaskSolid
	MaskInteractable
)
{{< /code >}}

An entity's active composition is set using bitwise `OR`:

```go
// Entity 5 has Position and Sprite components
Masks[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
required := MaskPosition | MaskPlayerControl

// Returns true if entity 5 has both required components
hasComponents := (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.

{{< code lang="go" file="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)
}
{{< /code >}}

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:

{{< code lang="go" file="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
        }
    }
}
{{< /code >}}

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.

