- 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).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.A Slice has no JSON form of its own — the wire format is protobuf. Keep a json tag on the field if
you like, but do not expect a Slice to survive encoding/json.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
Systems are plain functions that take a single parameter and return an error. The parameter is a pointer to a user-defined struct type that embedsBaseSystemState. This struct defines a system’s dependencies and what it can access, e.g. components, commands, events, etc. (We’ll cover these in more detail soon.)
This is the simplest possible system:
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.
Searches
To work with entities and their components in your systems, you need to define a search. A search lets you find and manipulate entities with specific components. Add a search field to your system state struct using one of these types:Exact[T]: finds entities that have exactly the specified components, nothing more.Contains[T]: finds entities that have at least the specified components, but may have others.
Health component, but only enemies have an AIBehavior component. Using Contains with just Health would match both players and enemies. Using Exact with Health and AIBehavior would match only enemies.
The type parameter T is a struct that lists the components you want to search for. Each field must use Ref[C], where C is a component type:
Mobs that matches all entities with at least Health and Position components. We’ll use it to demonstrate entity operations below.
Creating an Entity
UseCreate to spawn a new entity with the components defined in your search. It returns the entity ID and a handle to access its components. All components are initialized to their zero values.
Create always creates an entity with exactly the components in your search, even if you’re using
Contains. The difference between Contains and Exact only affects which entities are matched
when iterating or querying.Destroying an Entity
UseDestroy to remove an entity and all its components from the world. Returns true if the entity existed and was destroyed:
Iterating Over Entities
UseIter to loop through all entities matching the search. It yields both the entity ID and a handle to access components:
Getting a Specific Entity
UseGetByID to retrieve a specific entity’s handle. Returns false if the entity doesn’t exist or doesn’t have the matching components in the search:
Reading and Writing Components
UseGet and Set on component references to read and write component data. Get returns a
copy, so nothing you do to it reaches the world until you Set it back (see
What a Component May Hold):
Adding a Component
Because systems must declare all components they access upfront, adding a component requires including it in your search definition. Define aContains[T] search with both the entity’s existing components and the component you want to add, then use Set to add the new component:
Because you’re using a
Contains search, it may match entities you don’t intend to modify. To
avoid this, add a tag component so you can distinguish your target entities
from the rest.Removing a Component
UseRemove to detach a component from an entity: