ECS
The game/ecs package provides an Entity-Component System (ECS) framework for managing game-world objects and their data. It is archetype-based, meaning entities that share the same set of component types are stored together for efficient iteration.
Core Concepts
| Concept | Description |
|---|---|
| Scope | Registry of component types. Shared across scenes that use the same component vocabulary. |
| Scene | Central container for entities and their components. |
| ID | Versioned handle to a live entity. Becomes stale automatically when the entity is deleted. |
| Component | Plain Go struct value attached to an entity. |
| ComponentType | Typed descriptor for a component, obtained at registration time. |
| Condition | Predicate over an entity's component set, used for queries and subscriptions. |
Setup
Registering Component Types
Component types are registered in a Scope before any Scene is created from it. Registration is typically done with package-level variables so that ComponentType descriptors are accessible throughout the codebase.
var scope = ecs.NewScope()
var (
PositionType = ecs.Type[Position](scope)
VelocityType = ecs.Type[Velocity](scope)
HealthType = ecs.Type[Health](scope)
)
type Position struct {
X, Y, Z float32
}
type Velocity struct {
X, Y, Z float32
}
type Health struct {
Current, Max int
}
A scope is locked once it is passed to NewScene. Attempting to register additional types after that point panics. A single scope can back multiple scenes, but each scene maintains its own independent entity table.
Limit: A scope supports at most 256 component types.
Creating a Scene
scene := ecs.NewScene(scope)
defer scene.Delete()
Call scene.Delete() when the scene is no longer needed to release all resources.
Entities
Creating Entities
CreateEntity allocates a new entity and returns its ID. Pass a callback to add initial components atomically:
id := scene.CreateEntity(func(op *ecs.EditOperation) {
ecs.SetComponent(op, PositionType, Position{X: 1, Y: 0, Z: 0})
ecs.SetComponent(op, VelocityType, Velocity{X: 0, Y: 0, Z: 5})
})
Pass nil to create an entity with no components:
id := scene.CreateEntity(nil)
Deleting Entities
scene.DeleteEntity(id)
After deletion the ID becomes stale and should not be used. Few methods in the library (e.g. HasEntity) accept a stale ID and won't panic.
Checking Existence and Component Membership
alive := scene.HasEntity(id)
isPhysical := scene.CheckEntity(id, ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(VelocityType),
))
Reading Component Data
Reading a Single Entity
ReadEntity calls the provided function with a ReadOperation scoped to that entity. The operation is valid only for the duration of the call.
scene.ReadEntity(id, func(op *ecs.ReadOperation) {
pos := ecs.GetComponent(op, PositionType) // returns *Position or nil
if pos != nil {
fmt.Println(pos.X, pos.Y, pos.Z)
}
})
GetComponent returns a pointer to the component value, or nil if the entity does not have that component. InjectComponent is a convenience wrapper that writes the pointer into a variable:
scene.ReadEntity(id, func(op *ecs.ReadOperation) {
var pos *Position
ecs.InjectComponent(op, PositionType, &pos)
if pos != nil {
// use pos
}
})
Querying Multiple Entities
QueryEntities iterates every entity that satisfies a condition. Return false from the callback to stop early.
movingEntities := ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(VelocityType),
)
scene.QueryEntities(movingEntities, func(id ecs.ID, op *ecs.ReadOperation) bool {
pos := ecs.GetComponent(op, PositionType)
vel := ecs.GetComponent(op, VelocityType)
fmt.Printf("%v: pos=%v vel=%v\n", id, pos, vel)
return true // continue
})
QueryEntitiesIter provides the same traversal as a Go range iterator:
for id, op := range scene.QueryEntitiesIter(movingEntities) {
pos := ecs.GetComponent(op, PositionType)
_ = pos
}
Editing Component Data
Editing a Single Entity
EditEntity calls the provided function with an EditOperation for the entity. Two operations are available:
| Function | Effect |
|---|---|
SetComponent |
Adds the component if the entity does not yet have one of that type, or replaces its value if it does. |
UnsetComponent |
Removes the component. No-op if the entity does not have one of that type. |
scene.EditEntity(id, func(op *ecs.EditOperation) {
ecs.SetComponent(op, HealthType, Health{Current: 100, Max: 100})
})
scene.EditEntity(id, func(op *ecs.EditOperation) {
ecs.SetComponent(op, VelocityType, Velocity{X: 0, Y: 10, Z: 0})
})
scene.EditEntity(id, func(op *ecs.EditOperation) {
ecs.UnsetComponent(op, VelocityType)
})
Multiple operations can be staged in a single EditEntity call:
scene.EditEntity(id, func(op *ecs.EditOperation) {
ecs.UnsetComponent(op, VelocityType)
ecs.SetComponent(op, HealthType, Health{Current: 50, Max: 100})
})
When multiple
SetComponentorUnsetComponentcalls target the same component type within oneEditEntity, only the last one takes effect. CallingSetComponenton a component the entity already has is an in-place value update that does not move the entity to a different archetype.
Conditions
Conditions are predicates over an entity's component set. They are used for queries, subscriptions, and CheckEntity.
// Entity must have Position.
ecs.HasComponent(PositionType)
// Entity must not have Velocity.
ecs.LacksComponent(VelocityType)
// Entity must have Position and Health, but not Velocity.
ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(HealthType),
ecs.LacksComponent(VelocityType),
)
Conditions panics if the combined condition is contradictory (e.g., HasComponent and LacksComponent for the same type).
Exclusive Conditions
Exclusive() derives a condition that additionally requires the entity to have no other components beyond those already required. It is useful for targeting a very specific archetype:
// Entity must have exactly Position and Velocity, and nothing else.
exact := ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(VelocityType),
).Exclusive()
Subscriptions
Subscriptions fire a callback whenever an entity transitions into or out of satisfying a condition. This is useful for initialising or tearing down subsystem resources (e.g., physics bodies, render objects) in response to component changes.
// Called when an entity gains both Position and Velocity.
sub := scene.SubscribeEnter(
ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(VelocityType),
),
func(id ecs.ID) {
fmt.Println("entity became dynamic:", id)
},
)
// Called when an entity no longer satisfies the condition.
scene.SubscribeExit(
ecs.Conditions(
ecs.HasComponent(PositionType),
ecs.HasComponent(VelocityType),
),
func(id ecs.ID) {
fmt.Println("entity left dynamic group:", id)
},
)
// Cancel a subscription when it is no longer needed.
sub.Delete()
Callbacks are dispatched after structural changes are committed, not inline during the mutation. They fire in the order the subscriptions were registered; there is no priority mechanism.
Deferred Mutations During Queries
Structural changes — CreateEntity, DeleteEntity, and EditEntity calls that add or remove components — are safe to make during a query. They are buffered and applied once iteration completes.
toDelete := make([]ecs.ID, 0)
scene.QueryEntities(ecs.HasComponent(HealthType), func(id ecs.ID, op *ecs.ReadOperation) bool {
h := ecs.GetComponent(op, HealthType)
if h.Current <= 0 {
toDelete = append(toDelete, id)
}
return true
})
for _, id := range toDelete {
scene.DeleteEntity(id)
}
Alternatively, DeleteEntity (and EditEntity) may be called directly inside the query callback — the deletion will be buffered and executed after the query finishes:
scene.QueryEntities(ecs.HasComponent(HealthType), func(id ecs.ID, op *ecs.ReadOperation) bool {
h := ecs.GetComponent(op, HealthType)
if h.Current <= 0 {
scene.DeleteEntity(id) // safe; deferred until query completes
}
return true
})
Retaining Component Pointers with Freeze and Unfreeze
GetComponent returns a pointer directly into the scene's component storage. Within a ReadEntity or QueryEntities callback the pointer is always valid, but retaining it past the callback is only safe while no structural mutations (add or remove component, delete entity) are committed, since those operations may move the entity to a different archetype and invalidate the pointer.
Freeze and Unfreeze provide a bracket for exactly this use case. While the scene is frozen, all structural mutations are accepted and buffered but not applied. When Unfreeze is called the buffer is flushed. Any pointers retained during the freeze must be released before calling Unfreeze.
scene.Freeze()
var pos *Position
scene.ReadEntity(id, func(op *ecs.ReadOperation) {
pos = ecs.GetComponent(op, positionType)
})
// pos is safe to use here; any mutations are deferred.
doSomethingWith(pos)
scene.Unfreeze() // buffered mutations are committed; do not use pos after this
Freeze calls may be nested. Each call increments an internal depth counter; mutations are committed only when the depth returns to zero. Every Freeze must be paired with exactly one Unfreeze — an unbalanced Unfreeze panics.
scene.Freeze() // depth → 1
scene.Freeze() // depth → 2
// ... retain pointers, do work ...
scene.Unfreeze() // depth → 1, mutations still deferred
scene.Unfreeze() // depth → 0, mutations committed
Creating Entities While Frozen
CreateEntity, DeleteEntity, and EditEntity can all be called while the scene is frozen — their effects are simply deferred. However, entities created while frozen are not yet committed to any archetype. Their IDs are valid for HasEntity, DeleteEntity, and EditEntity, but calling ReadEntity or CheckEntity on them before Unfreeze panics.
scene.Freeze()
id := scene.CreateEntity(func(op *ecs.EditOperation) {
ecs.SetComponent(op, positionType, Position{X: 1, Y: 2})
})
scene.HasEntity(id) // true
scene.CheckEntity(id, ecs.HasComponent(positionType)) // panics — not yet committed
scene.ReadEntity(id, ...) // panics — not yet committed
scene.Unfreeze()
scene.CheckEntity(id, ecs.HasComponent(positionType)) // true — now committed
Subscription Dispatch While Frozen
Enter and exit subscription callbacks are part of the commit process. They are not fired during a buffered mutation — they fire when Unfreeze (or the end of a query) triggers the flush.
Systems
This package does not define a system interface or a scheduler. System ordering, execution, and lifecycle management are the responsibility of the consuming application.
Limitations
The following features are not currently provided by this ECS implementation:
- No change detection. There is no built-in mechanism to query only entities whose component data changed since the last frame. Systems must iterate all matching entities unconditionally.
- No parallel queries. The scene is not thread-safe. All operations on a scene must occur from a single goroutine.
Freeze/Unfreezedo not change this — they defer commits, not concurrent access. - No system scheduler. Ordering and parallelism are entirely up to the application.
- No entity relations. Modelling parent–child or other entity-to-entity relationships requires external bookkeeping (the
game/hierarchypackage may be used for scene-node hierarchies). - No prefabs or entity templates. There is no built-in way to stamp out entities from a template; construction helpers must be written by the application.