Entities and systems
The ecs package stores a Bunyip game's world. An entity is a handle, components are plain Go structs attached to it, and systems are functions that run over every entity carrying a given set of components. The Tetris guide uses it for a small game; the solar example drives four hundred asteroids with it.
Entities and components
type Position struct{ X, Y float32 }
type Velocity struct{ X, Y float32 }
w := ecs.NewWorld()
e := w.SpawnWith(Position{0, 0}, Velocity{1, 0})
w.Add(e, Health{10})
if p, ok := w.Get[Position](e); ok {
p.X += 5 // pointers into storage; write through them right away
}
w.Remove[Velocity](e)
w.Despawn(e)
Components are stored in typed columns. A type that reaches a world
only through SpawnWith, Commands or SetComponent has no type
parameter to build one from, so it starts in reflection-backed columns
and moves to typed ones the first time a generic call (Get, Add, a
query) names it. To store a type in typed columns from its first
SpawnWith, register it with ecs.Register (see Saving and loading);
registering costs nothing else.
Entity handles are generational. A handle to a despawned entity stays
invalid even after its slot is reused, so a stale handle never reads
another entity's data.
Use w.Alive(e) to test whether it exists; e.Valid() only tests for a
nonzero handle. Handles belong to their world and are not portable to
another world. Construct worlds with NewWorld; their zero value is
not initialized, and world access must be serialized.
Where the type is known only at run time, w.Components(e) lists the
types an entity carries, w.ComponentValues(e) returns shallow copies
in the same order, and w.SetComponent(e, v) writes one back from an
any. That is what an editor or the debug console's entity panel uses;
game code that knows the type calls ecs.World.Get and ecs.World.Add.
Slices, maps and pointers inside those copies still refer to the
entity's storage. Component pointers from Get and queries are only
valid until a structural change touches their table.
Queries
A query names the components it reads and walks every entity that has all of them. Make it once and keep it; it caches which tables match and only rescans when a new combination of components appears.
movers := w.Query2[Position, Velocity](ecs.Without[Frozen]())
movers.Each(func(e ecs.Entity, p *Position, v *Velocity) {
p.X += v.X * dt
p.Y += v.Y * dt
})
With and Without filters narrow a query without reading the extra
component. Each, Each2, Each3, Each4 and Count are shortcuts
that reuse a cached query per world and ordered component set.
Storage
Every distinct set of component types gets a table (an archetype) with one dense column per type. An entity lives in exactly one table. A query finds the tables containing its components and then walks their columns side by side. Repeated walks reuse their scratch storage after the matching table list and snapshots have grown. A world supports up to 256 distinct component types.
Adding or removing a component moves the entity to another table by copying its row, so structural changes cost more than reads. Make them in response to game events rather than every frame.
Changing the world while iterating
Rows are visited last to first, so the entity a query is currently
visiting may be despawned or given a new component inside the callback.
Changes to other entities go through World.Defer, whose Commands
buffer is applied after the enclosing closure returns.
The walk snapshots all matched table lengths, so moving the current
entity into another matched table does not visit it twice. Despawning
an entity with children changes other entities and must be deferred.
Do not start another walk of the same query within its callback; the
same restriction applies to nested Each helpers with the same
ordered component set.
Wrap the whole query walk so commands apply after it completes:
w.Defer(func(cmd *ecs.Commands) {
enemies.Each(func(e ecs.Entity, h *Health) {
if h.HP <= 0 {
cmd.Spawn(Corpse{}, Position{...})
cmd.Despawn(e)
}
})
})
The scope applies commands in order on a normal return, including an
early return. A panic discards pending commands and propagates. Each
nested scope finishes independently. This is not a transaction: direct
world writes and completed nested scopes are not rolled back. Do not
retain the scoped buffer or call its Apply method. Keep an explicit
Commands value and call Apply(w) when work must span several scopes.
Systems, resources and events
A system is a function func(w *ecs.World, dt float64). Register them
in the order they should run and call w.Update(dt) from the game's
Update. w.Stats() reports each system's time for the debug overlay,
w.SetSystemEnabled(name, false) turns one off without unregistering
it, so a debugger can pause part of the simulation, and w.Updates()
counts the updates the world has run.
Resources are singletons stored on the world by type, such as the
score, the rules, the input for this frame or a random number
generator. Systems fetch them with w.Resource[Score]().
Events pass data between systems without coupling them. A producer calls
w.Emit(Cleared{Rows: 2}); consumers later in the same Update read
w.Events[Cleared](). Events are cleared at the start of the next
Update, and Draw still sees what the last Update emitted.
w.SetResource(Score{})
w.AddSystem("rules", func(w *ecs.World, dt float64) {
w.Emit(Cleared{Rows: 2})
})
w.AddSystem("score", func(w *ecs.World, dt float64) {
for _, ev := range w.Events[Cleared]() {
w.Resource[Score]().Points += 100 * ev.Rows
}
})
w.Update(ctx.Delta) // runs both, in registration order
Hierarchy
SetParent links entities; WorldMatrix composes their
gfx.Transform components from the root down, and despawning a parent
despawns its children. The solar example's moons orbit their planets
this way. Each moon has a small orbit component relative to its parent,
and the hierarchy composes that with the planet's own position.
planet := w.SpawnWith(gfx.At(80, 0, 0))
moon := w.SpawnWith(gfx.At(4, 0, 0)) // relative to the planet
ecs.SetParent(w, moon, planet)
// Drawing composes the chain from the root down.
gr.DrawMesh(mesh, mat, ecs.WorldMatrix(w, moon).Mul(lin.Scale(size)))
for _, child := range ecs.ChildrenOf(w, planet) {
highlight(child)
}
w.Despawn(planet) // the moon goes with it
WorldMatrix climbs the parent chain each time it is called. To pay for
the chain once a frame instead of once an entity, call
UpdateWorldMatrices from a system after the ones that move transforms;
it walks every hierarchy from its roots down and caches a matrix per
entity, and WorldMatrix reads the cache instead of climbing. Reading
every entity's matrix over a ten thousand entity hierarchy four deep
takes about half as long with the pass as without it.
w.AddSystem("transforms", func(w *ecs.World, dt float64) {
ecs.UpdateWorldMatrices(w)
})
The cache lasts until the next World.Update, so Draw reads what the
last Update left. Spawning, SetParent and Despawn drop it, and a
transform written after the pass is not seen until the pass runs again.
WorldMatrix falls back to the walk whenever the cache is not fresh, so
code that never calls the pass behaves as it always has.
Saving and loading
A world saves as JSON. The file holds every live entity with its parent
links, each component encoded by encoding/json, and then the
resources. Components and resources are written under names you
register, so a save made by one build loads in the next even after a
type moves or is renamed. Register at start-up. An unregistered type
fails the save with an UnregisteredError naming every unregistered
type, or is left out with SaveOptions{SkipUnregistered: true}.
ecs.Register[Position]("Position")
ecs.Register[Health]("Health")
ecs.Register[Score]("Score") // resources too
var buf bytes.Buffer
if err := w.Save(&buf); err != nil { ... }
fresh := ecs.NewWorld()
if err := fresh.Load(&buf); err != nil { ... }
Load gives every entity a new handle and rewrites the references to
it: parent links, Children lists, and any Entity field it can see
inside a component or resource, including inside slices, maps and
pointers. A component that stores entities where reflection cannot
reach them can implement Remap(func(Entity) Entity) and rewrite them
itself. A reference to an entity the file does not hold becomes None.
Loading adds to the current world and replaces resources of matching
types; it does not clear existing entities. Unknown type names fail
before adding anything unless LoadOptions.SkipUnknown is set. A
component decode error may leave a partially loaded world, so load
into a fresh world when failure must leave the running one untouched.
gfx.Transform, gfx.Transform2 and ecs.Name are registered by
default.
Only exported fields are saved, as with any encoding/json value, and
a type with its own MarshalJSON is written that way.
Prefabs
A prefab is a template. It holds a set of component values, plus child prefabs for a hierarchy, and spawns as many independent copies as the game asks for. Build one in code, or read it from JSON in the same form a save uses for one entity's components:
tank := ecs.NewPrefab(Position{}, Health{10}, Team{Red}).
Child(ecs.NewPrefab(gfx.At(0, 1, 0), Turret{}))
e := tank.Spawn(w)
w.Add(e, Position{x, y}) // place it after
{"components": {"Health": {"HP": 10}, "Team": {"Side": 1}},
"children": [{"components": {"gfx.Transform": {"Position": {"Y": 1}}}}]}
ParsePrefab reads that form and json.Marshal writes it, so prefabs
can live in asset files beside the sprites they use. PrefabOf takes a
prefab from a live entity and its descendants, which is how an editor
would save what it built.
Scenes
A scene is a JSON document of entities to spawn as a unit: a level, a
room, an ambush, the contents of a shop. Where a prefab is one template
stamped over and over, a scene is a whole arrangement, with its parent
links, its cross-references and its own properties. ParseScene reads
one, asset.Scene reads one through the asset system, Encode writes
it, World.Instantiate spawns a copy, and World.ExportScene captures
live entities back into a document, so a game can round-trip what it
built.
{
"version": 1,
"name": "west camp",
"properties": {"music": "wind.ogg", "wave": 2},
"entities": [
{"name": "camp", "components": {"gfx.Transform": {"Position": {"X": 40}}}},
{"name": "chief", "parent": 1, "prefab": "orc",
"components": {"Health": {"HP": 40}}},
{"parent": 1, "prefab": "orc",
"components": {"gfx.Transform": {"Position": {"X": 2}}, "Follows": {"Leader": 2}}}
]
}
The document is versioned; version 1 is the only one there is.
properties is free-form and for the game's own use, decoded by
encoding/json, so a number arrives as a float64.
Entities are numbered from one in list order, and that number is the
only way the document links entities together. parent holds it, and so
does an Entity field inside a component, which is why zero means both
"no parent" and None. In the example above the third entity follows
the second. Building a scene in code, SceneRef turns a number into the
value to store in such a field.
name is a label, not a link. SceneInstance.Entity finds a spawned
entity by it, and Instantiate puts it on the entity as a Name
component so ExportScene can write it out again. Names are unique
within a scene.
Components use the same registered names and the same encoding as
World.Save, so the two formats stay in step. A name this build has not
registered fails Instantiate with an UnregisteredError, before
anything is spawned, unless InstantiateOptions{SkipUnknown: true} is
set.
Spawning and removing
scene, err := asset.Scene(fs, "levels/camp.json")
if err != nil { ... }
camp, err := w.Instantiate(scene, ecs.InstantiateOptions{Offset: lin.V3(120, 0, 0)})
if err != nil { ... }
chief, ok := camp.Entity("chief")
...
camp.Despawn(w) // every entity this copy spawned, and their children
Each call spawns fresh entities, so several copies of one scene coexist
and one copy's Despawn leaves the others alone. Roots are the
entities the scene left unparented and Spawned is every entity the
copy made, in scene order with each prefab's children after its root.
Name lookup does not check whether the entity is still alive; call
w.Alive when entities may have despawned. InstantiateOptions.Parent
hangs the roots under an entity you already have, and Offset moves each root by adding
to its gfx.Transform or gfx.Transform2 position; a root with neither
component is not moved.
Prefab references
An entity may name a prefab instead of listing every component. The
prefab comes from a PrefabLibrary, a map of names to prefabs, passed
in InstantiateOptions.Prefabs or stored on the world as a resource.
The entity's own components are written over the prefab's, so a document
holds only the components that differ from the template. An override
replaces a whole component rather than merging field by field, so the
parts a template varies belong in components of their own.
lib := ecs.PrefabLibrary{"orc": orcPrefab, "hut": hutPrefab}
w.SetResource(lib)
A reference the library cannot resolve is a MissingPrefabError naming
the prefab. The prefab format has unnamed children, so overrides apply
to the prefab's root; anything else an instance needs is an ordinary
scene entity parented to it.
Exporting
scene, err := w.ExportScene(camp.Roots...) // or no roots for the whole world
data, err := scene.Encode()
ExportScene walks the given roots and their descendants, or every
parentless entity when given none. Entity fields pointing inside the
captured set become the right numbers, and references out of it become
None. It writes components rather than prefab references, because a
live entity does not remember which prefab made it, so a scene exported
after instantiating one is flat but spawns the same thing. Encode
writes indented JSON, which reads and merges well in version control.
Building a scene in code takes the same shape:
s := ecs.NewScene("west camp")
s.SetProperty("wave", 2)
camp, _ := s.AddEntity("camp", gfx.At(40, 0, 0))
chief, _ := s.AddPrefab("chief", "orc", Health{HP: 40})
guard, _ := s.AddEntity("", gfx.At(2, 0, 0), Follows{Leader: ecs.SceneRef(chief)})
s.SetParent(chief, camp)
s.SetParent(guard, camp)
The solar example is the working reference: its sun, planets and moons
live in examples/solar/system.json, the moons reference the prefab in
examples/solar/moon.json, both are embedded with go:embed and read
through asset.Scene and asset.Prefab, and the asteroid belt is
spawned in code under the entity the scene named sun.
There is no scene editor yet. The format and the API are what an editor would write and read.
Cloning
Clone(w, e) makes a new entity carrying copies of e's components,
under the same parent; CloneTree copies the descendants as well and
keeps the hierarchy between the copies. A field that referred to
something in the copied tree refers to its copy afterwards.
tank := w.SpawnWith(Position{0, 0}, Health{10})
turret := w.SpawnWith(Position{0, 1})
ecs.SetParent(w, turret, tank)
second := ecs.CloneTree(w, tank) // the tank and its turret
if h, ok := w.Get[Health](second); ok {
h.HP = 3 // the original still has 10
}
spark := ecs.Clone(w, muzzleFlash) // one entity, no descendants
Copies are deep through exported fields: slices, maps, pointers and
interface values get their own storage, so an inventory slice in the
clone is not the original's. Unexported fields are copied as values,
so a slice or pointer kept in one is still shared. Cloning does not
invoke custom JSON methods, and Remapper rewrites entity references
rather than arbitrary private storage. Components such as cloth and
soft bodies keep private particle slices; construct those separately
for independent instances rather than cloning or spawning shared
templates of them.
Modelling advice
- Components are data, systems are behaviour. A component may have methods, but a component that reads or writes other entities belongs in a system instead.
- Prefer small components. A query reads only what it names, and an entity with many components still costs one table row.
- Tag components (empty structs) mark categories cheaply:
Frozen{},Enemy{},Selected{}. - Anything there is exactly one of is a resource, not an entity.