A small, zero-dependency ECS built on Go 1.27 generic methods. ~300 lines of stdlib-only code: no reflection in hot paths, no interface boxing, no allocations per frame.
w := ecs.NewWorld()
e := w.NewEntity()
w.Set(e, Position{X: 10}) // type inferred from the value
w.Set(e, Velocity{X: 1})
p := w.Get[Position](e) // *Position, or nil
w.Has[Velocity](e) // true
w.Remove[Velocity](e)
w.Destroy(e) // removes e and all its components
// Build once (e.g. in a system's constructor), reuse every frame:
q := w.Query2[Position, Velocity]()
q.Each(func(e ecs.Entity, p *Position, v *Velocity) {
p.X += v.X
p.Y += v.Y
})Components are plain structs. Any type works — there is no registration step; a component type's storage is created lazily on first use.
An Entity is a uint64: the low 32 bits are an index into the world's
tables, the high 32 bits are a version. When an entity is destroyed, its
index goes on a free list and its version is bumped. A recycled index produces
a new handle with a higher version, so any stale handle you kept around fails
the version check and reads as dead — it can never touch the entity that now
occupies the same index.
Each component type T gets one Storage[T] with three slices:
sparse: [entity index] -> dense slot (or none) one uint32 per entity slot
dense: [slot] -> T tightly packed, no holes
entities: [slot] -> entity index mirrors dense
- Data locality: all
Positionvalues in the world live back-to-back in one[]Position.Query.Eachis a linear walk over that contiguous slice — the access pattern CPUs prefetch perfectly. This is Struct-of-Arrays layout: a system touching onlyPositionnever pullsVelocityor sprite data through the cache. - O(1) everything:
Get/Set/Removeare two array indexings.Removeswaps the last dense element into the vacated slot, so the dense slice never fragments. - Multi-component queries (
Query2throughQuery5) walk the dense slice of the smallest storage and probe the others through their sparse arrays. The driver side is sequential; each probe is two array reads. Cost scales with the rarest component, not the world size.
The w.Get[T](e)-style API is a Go 1.27 generic method — the component
type is a compile-time type argument, so each instantiation compiles to direct
typed slice access. The only reflection is one map[reflect.Type] lookup to
find a storage, and queries do that once at construction, never during
iteration.
Systems often need to tell each other things — "this button was clicked",
"that enemy died" — and the tempting shortcuts (shared variables, one system
calling another) couple systems together and break the ECS model. Events[T]
is the decoupled alternative: a per-type, double-buffered queue that producers
push into and any number of consumers poll at their own pace.
type ButtonClicked struct{ Button ecs.Entity }
// Producer (in its constructor, then per frame):
clicks := w.Events[ButtonClicked]()
clicks.Send(ButtonClicked{Button: e})
// Consumer (each system makes its own reader in its constructor):
reader := w.Events[ButtonClicked]().Reader()
reader.Each(func(ev ButtonClicked) { ... })
// Once per frame, after all systems ran:
w.FlushEvents()Producer and consumer never reference each other — the event type is their
entire contract. w.Send(event) is the map-lookup convenience twin of
w.Set, for code outside hot paths.
The design is the one that won out across the ECS ecosystem (it is Bevy's
Events/EventReader, and what EnTT's author recommends over its own
callback dispatcher): pull, not push. Callbacks/observers run consumer
code in the middle of the producer's iteration — reentrancy hazards,
unpredictable ordering, per-event dispatch overhead. A polled queue keeps
every system running on the schedule you gave it, and processing N events is
one batched linear walk. The "events as entities" pattern (spawn an entity
per event, query it, destroy it) works with plain component storage but pays
entity churn and needs fragile manual cleanup; a dedicated queue does less
work and can't leak.
Semantics that follow from the double buffer:
- Events live for exactly two flushes. A consumer scheduled before the
producer still sees the event on the next frame — one frame late, never
lost. The flip side: a system must poll its reader every frame (or call
reader.Clear()to skip); anything unread after two flushes is gone. - Readers are independent cursors. Reading never removes events, so any number of systems can consume the same queue. Each reader tracks a monotonic sequence number, so buffer swaps can't corrupt its position. A fresh reader sees whatever is still buffered (up to two frames of history).
- Call
w.FlushEvents()exactly once per frame, after all systems. Forgetting it means queues grow without bound; calling it twice halves event lifetime to "this frame only". - Events sent from inside
Eachare deferred to the nextEachcall, so a system that reacts to events by sending more (same type) cannot loop forever. - Treat events as immutable messages.
Eachpasses them by value; every reader of a queue sees the same events. - Same concurrency rule as the rest of the world: not goroutine-safe, no
Sendinside anEachParallelpass.
Performance: Send is an append into a slice whose capacity is reused after
every flush, reading is a linear walk of packed memory, and FlushEvents is
one swap per event type. Steady state allocates nothing (see
BenchmarkEvents: ~3 ns/event, 0 allocs). Worlds that never touch events pay
nothing — the feature is invisible until the first w.Events[T]() call.
Running, go test -bench=. -benchmem ., 100 000 entities:
| Benchmark | Result |
|---|---|
Query2 full pass (pos += vel) |
183 µs/pass · 1.8 ns/entity · 0 allocs |
Query3 full pass (physics step) |
262 µs/pass · 2.6 ns/entity · 0 allocs |
Query2Sparse (only 10% match) |
17 µs/pass — cost tracks matches, not world size |
| Raw slice loop (no ECS, upper bound) | 0.76 ns/entity |
Get / Set existing |
~9 ns · 0 allocs |
create + 2×Set + Destroy |
35 ns · 0 allocs |
Events send + read + flush (1000/frame) |
2.9 ns/event · 0 allocs |
At 60 FPS a frame budget is 16.6 ms; a full two-component pass over 100k entities costs 0.18 ms. Nothing allocates, so the GC stays idle no matter how long the game runs.
Live entities have versions starting at 1, and the zero value Entity has
version 0. So var e ecs.Entity (or the exported ecs.Nil) is always dead:
Alive returns false, Get returns nil. Use Nil as a "no target" sentinel
in components that reference other entities.
e := w.NewEntity()
w.Destroy(e)
e2 := w.NewEntity() // may reuse e's index
w.Alive(e) // false — version mismatch
w.Get[Position](e) // nil, never e2's dataOperations on dead entities are harmless no-ops: Set, Remove, Destroy
do nothing; Get returns nil; Has returns false. You can hold Entity
values in components (e.g. Target ecs.Entity) across frames and just check
Alive before use.
The version counter is 32-bit, so a single index would need ~4.3 billion destroy/recreate cycles before its version wraps and a stale handle could falsely match. At 60 FPS that is over two years of destroying the same slot every frame — theoretical, but stated for completeness.
Get and query callbacks hand out pointers into the dense slice. They are
valid until the next structural change to that component type's storage:
Seton an entity that doesn't yet have the component may grow the slice (reallocating it — every old pointer now points at the stale array),Remove/Destroyswap-moves the last element (a surviving component's pointer may suddenly point at different data).
Mutating through the pointer immediately (the normal system pattern) is always
fine. Do not store *T across frames or across structural changes — store
the Entity and call Get again. Set on an entity that already has the
component only overwrites in place and invalidates nothing.
Iteration runs backwards over the dense slice, which gives precise rules:
- Safe: destroying the current entity, or removing any of the current
entity's components, inside
Each. The swap-remove pulls an already-visited element into the hole, so nothing is skipped or double-visited. - Safe:
Setof a component type not part of the query, and anyGet. - Avoid: destroying other entities (or removing their queried components) mid-iteration. Nothing crashes if each callback removes at most the current entity plus one other, but an already-visited entity can be swapped into an unvisited slot and be visited twice.
- Avoid: adding the queried component to new entities mid-iteration — whether the new entity is visited this pass is unspecified.
For bulk changes, collect handles during the pass and apply after:
var doomed []ecs.Entity
q.Each(func(e ecs.Entity, h *Health, _ *Position) {
if h.HP <= 0 {
doomed = append(doomed, e)
}
})
for _, e := range doomed {
w.Destroy(e)
}Swap-remove reorders the dense slice, so query order depends on the full
history of inserts and removes. It is deterministic — the same sequence of
operations always yields the same order, so replays are reproducible — but do
not rely on any particular order. Sort explicitly if rendering needs a stable
draw order (e.g. by a Layer component).
Set on an entity that already has the component replaces the value. There
is no separate Add/Replace pair and no "already exists" error.
w.Query2[A, B]() lazily creates empty storages as needed and caches direct
pointers to them. Storages live for the lifetime of the world, so a query
built at startup stays valid forever — it observes all later Sets. Build
queries in system constructors; never inside the frame loop (construction
does the reflection map lookups you want to keep out of hot paths).
A World is not goroutine-safe. Even read-only calls like Get can
mutate internal state (lazy storage creation), and Ebiten calls Update and
Draw from one goroutine anyway — run systems sequentially.
To parallelize within a system, use EachParallel(workers, fn): it
partitions the pass into contiguous ranges, one goroutine per worker.
workers <= 0 means one per CPU (runtime.GOMAXPROCS(0), re-read every pass
since the runtime may adjust it); 1 runs inline with no goroutines or
WaitGroup; and the count is capped so each worker gets at least a few
thousand entities, so small passes always run inline —
EachParallel(0, fn) is a safe default. No two workers ever see the same
entity, and the slot index passed to fn is a stable per-entity output slot
(e.g. write vertices at index*4). EachParallel drives from component A's
storage (not the smallest), iterates forward, and permits no structural
changes (Set new / Remove / Destroy / NewEntity) anywhere during
the pass. examples/gopher uses this for its bounce and vertex-building
passes — a 1M-entity frame drops from 10.6 ms to 1.8 ms on 16 cores.
Memory per component type is sizeof(T) per component (dense) plus
4 bytes per component (the entities mirror) plus 4 bytes of sparse per
entity index slot up to the highest index that ever had this component. Example: 1 000 000 entities that all have a 16-byte Position
≈ 16 MB dense + 4 MB entities + 4 MB sparse ≈ 24 MB. The world itself adds
4 bytes per entity (versions).
Practical guidance, in rough order of impact:
- Iterate with queries, not
Get. A query pass is ~1.8 ns/entity; callingw.Get[T](e)100k times costs ~9 ns each plus a reflection map hit per call.Getis for occasional lookups (collisions, UI), not for per-frame sweeps. - Cache storages in spawn-heavy code.
w.Setpays a reflection map lookup per call. A system that creates entities in bursts should grabst := w.Storage[Position]()once at construction and callst.Set(e, v)— same semantics, no lookup (~2× faster bursts; seeexamples/particles). - Keep components small and split by access pattern. The movement system
should stream
Position/Velocity— if those structs also carried sprite and AI data, every pass would drag dead weight through the cache. Many small components beat one fat one; that is the point of SoA. - Tag components are almost free.
type Frozen struct{}has a zero-size dense slice; a tag's cost is just the sparse + entities arrays. Use tags plusQuery2[Tag, Data]to carve subsets out of huge populations — the query walks the (small) tag storage, so cost tracks the subset. - Sparse memory follows the highest index, not the count. If only 10
entities have
Bossbut one of them has index 900 000,Boss's sparse slice holds 900 001 entries (~3.6 MB). Index recycling via the free list keeps indices compact automatically as long as you destroy what you spawn. Spawn long-lived common entities before rare high-index ones if this matters. - Component order in
Query2–Query5doesn't affect speed — the query always drives from the smallest storage. Order the type arguments for readability. - Pre-warm at load time if you must avoid growth stalls. Slices grow by doubling; a spawn burst of 100k entities triggers a handful of reallocs + copies. Spawning and destroying a throwaway population at load time leaves the capacity in place (storages keep capacity; only lengths shrink).
- Destroy cost scales with component-type count.
Destroyprobes every storage in the world (a few ns each). With dozens of component types and mass deaths every frame, prefer aDeadtag + one cleanup system so the cost is paid once, in one place.
| File | Contents |
|---|---|
entity.go |
Entity handle: index + version packing, Nil |
world.go |
World: entity lifecycle, generic Set/Get/Has/Remove |
storage.go |
Storage[T]: the sparse set |
query.go |
Query through Query5: cached, allocation-free iteration; EachParallel for multi-core passes |
events.go |
Events[T] / EventReader[T]: double-buffered event queues for system-to-system messaging |
ecs_test.go |
correctness tests, including the edge cases above |
events_test.go |
event lifetime, ordering, and zero-allocation tests |
bench_test.go |
the benchmarks quoted above |
| Example | Contents |
|---|---|
examples/simple |
a keyboard-controlled box (Ebiten): input, movement, and render systems |
examples/particles |
click-to-explode particle bursts (Ebiten): spawn on mouse click, gravity + drag physics, lifetime-driven destruction, and a Query4 render pass with fading motion streaks |
examples/gopher |
bunnymark-style stress test (Ebiten): -n gophers bounce off the view edges, hold the mouse to pour in more, FPS/TPS and per-system timings on screen |
examples/ui |
a login form (Ebiten) showing the events API: TextInput (label, placeholder, password, error) and Button (disabled, loading) components; the widget systems publish TextChanged/ButtonClicked events and a fully decoupled LoginSystem polls them |
Run one with go run ./examples/particles.