- Entities are plain identifiers that represent “objects” in your game, e.g. players, projectiles, mobs, etc.
- Components contain the data of the properties of your entities, for example: a projectile entity contains the position and velocity components.
- Systems are the game logic that operates on your entities, for example: a physics system acts on all entities that have mass and position components, or a regeneration system acts on all entities that have a health component.
The World
Before getting into how to use ECS, we’ll briefly cover theWorld type. This is your game world. It holds all your entities, components, and systems together. Here’s what a typical main.go looks like:
main.go
TickRatesets how many times per second the game loop runs. A tick represents a single state change in Cardinal.EpochFrequencysets how many ticks to include in an epoch. An epoch is a group of ticks that will be persisted to a blockchain.
Components
Components are plain Go structs that implement theComponent interface. This interface requires a single Name() method that returns a unique string identifier.
Component names must start with a letter or underscore, and contain only letters, digits, and
underscores (e.g.
Health, player_health).Create[T] that names an unregistered component panics.
What a Component May Hold
Components are copied.Get hands you a copy and Set stores one, and every snapshot is built
from what went through Set. That only works if a copy is independent of the original, so a
component may hold values only:
- Numbers, booleans, and strings
- Structs of those
- Fixed-size arrays such as
[8]Vec2, at any depth immutable.Slice[T]for a list with no fixed bound (see below)time.Time, the one sanctioned exception. It carries a location pointer, but it travels as a protobuf Timestamp, so a restored value comes back in UTC with no monotonic reading. Compare times withEqual, never==.
[]T), interfaces, channels, or funcs.
A plain slice inside a copy still shares its backing array with the stored component, so writing
through it changes the world without a Set. world sdk generate refuses such fields and prints
the fix for each one. The same rule applies to commands and events, which cross the same generator.
A list with a known upper bound is a fixed array plus a count:
Lists Without a Bound: immutable.Slice
When the length has no bound you can defend, hold the list in immutable.Slice[T] from
github.com/argus-labs/world-engine/pkg/immutable. On the wire it is the same repeated field a
[]T would be. The difference is that the backing array is hidden: no other package can index into
it, reslice it, or hold on to it, and every reader hands back a copy of the element.
slices package closely. Operations are named for their result rather than for
an action on the receiver — Reverse becomes Reversed, and Sort and SortFunc become Sorted
and SortedFunc.
Reads such as At, All, IndexFunc, and MinFunc are methods, and so are derivations such as
Append, Insert, With, Without, Sub, Filter, Delete, Reversed, and SortedFunc.
Anything that needs a type constraint is a package function, for example Equal, Contains,
Sorted, Min, Map, and Concat.
Derive, then Set the component that holds it:
immutable.SliceOf(items...) builds a Slice from a plain slice and copies it, so later changes to
that slice never reach the component. To go the other way — a plain []T for an API that needs one —
range over Values, or use immutable.Collect to build a fresh Slice from the result: there is no
Clone method, since a copy taken that way could look like it protects the original while
derivations write through it regardless.
immutable.Slice needs a World CLI new enough to generate it. An older one reports the field as an
unsupported type when you run world sdk generate.The element type follows the same rule as any field: values only. A Slice directly inside another
Slice has no protobuf form, so wrap the inner one in a named struct, for example
immutable.Slice[Row] where Row holds immutable.Slice[Cell].Tag Components
Components don’t need to contain data. You can use empty structs as “tags” to mark entities:Systems
A system is any type with aRun(w *cardinal.World) method. The world is passed in on every run and gives the system access to queries, commands, events, entities, and the current tick. (We’ll cover these in more detail soon.) Fields on the struct hold the system’s own dependencies, such as a runtime or config.
This is the simplest possible system:
RegisterSystem accepts a system instance and passes the world it was registered with to every Run call.
Registered systems run sequentially within each tick phase, in registration order.
System Hooks
You can control when a system executes during a tick by specifying a hook when you register the system, for example:
Each of these corresponds to a tick phase, except
Init, which runs only once in the first tick.
Queries
A query selects entities by component type. Call it on the world from insideRun:
w.Contains[T]()matches entities with every component inT, allowing extras.w.Exact[T]()matches entities with exactly the components inT.
T is an archetype: a struct whose fields are component types. The struct carries
no entity data; it names a set of components. Every component in it must be registered with
w.RegisterComponent[C]() before StartGame. Nothing registers components implicitly.
A query that names an unregistered component panics. Queries return world-bound Entity
handles.
StartGame, including across plugins. Every Register* method
panics once the world has started, so a system cannot register anything from Run. The first
Contains[T]() or Exact[T]() call for an archetype resolves its component set with
reflection and caches it on the world. Later calls for the same T are a map lookup, so
querying inside Run on every tick costs no reflection. The search keeps selecting current
entities as components change.
Creating an Entity
Use the world’s genericCreate[T] method:
Create[T] panics
if any component in T was not registered with w.RegisterComponent. A query’s Create()
does the same for the query’s archetype: w.Contains[Mob]().Create().
Entity Handles and Stored IDs
AnEntity carries its numeric ID and owning world. Its methods need no extra world argument.
Use entity.ID() in components, commands, physics lookups, and persistent data. To access
that ID in a system, bind it to the world:
EntityID remains a numeric value. Store IDs rather than runtime handles. After snapshot
restoration, w.Entity(savedID) binds an ID to the restored world. IDs belong to one
world and can be reused after destruction or reset, so discard handles when that happens.
Access handles from systems on the world’s execution goroutine.
Iterating Over Entities
Iter yields each matching Entity:
Get[T] returns a copy. Write changes back with Set, subject to the
component ownership rules in What a Component May Hold.
Use Filter(func(Entity) bool) and Limit to narrow results.
Single() returns (Entity, error) and requires exactly one match.
Getting a Specific Entity
GetByID validates the query’s component requirements before returning a handle:
w.Entity(entityID) when no archetype check is needed. Binding does not itself
check existence. Alive() and Has[T]() return false for absent entities.
Adding and Removing Components
Set infers the component type from its argument and adds or replaces it:
w.RegisterComponent[T]() like any other component.
They do not need to be part of the query that found the entity, and nothing declares them
on the system.
Get, Set, and Remove panic for invalid operations, including access to a destroyed
entity or an unregistered component. Get also panics for an absent component.
Removing a registered component that is already absent is a no-op.
Adding or removing components changes an entity’s archetype. Collect handles before making
structural changes while iterating a query, so moving rows cannot skip other matches.
Destroying an Entity
Destroy removes the entity and all its components. It returns true only if an entity was removed:
Entity is not alive. Its Has and Destroy methods return false, and component
reads or writes panic.