Skip to main content

Component Views

Most game logic does the same thing to many entities: move everything that has a position and a velocity, age every particle, heal every regenerating unit. Component views let you write that loop in plain BT:

for (view<Motion, Integrate> entity in entities) {
entity.position.x = entity.position.x + entity.velocity.x * dt;
}

You list the components a system uses once, say which of them it changes, and then read and write them as ordinary fields. The compiler checks every line against what you declared, so a system can't change data it promised only to read.

This page covers the language side. The entities, components and systems guide shows how views fit into a whole game.

Choose the entities: query​

A query picks out the entities a system works on: every entity that has all the components listed. Each line gives a component's type and the name you'll use for it in the loop:

object Position { float x; float y; }
object Velocity { float x; float y; }

query Motion {
Position as position;
Velocity as velocity;
}

Motion matches every entity that has both a Position and a Velocity, whatever other components it also has.

  • Without as, the type name is also the field name.
  • as lets you use one type twice, for example two Vec2 components named position and target.
  • Component types are objects whose fields are all int, int32, float, float32, bool, byte or char.

Say what the system changes: access​

An access profile marks each of the query's components as read or write:

access Integrate for Motion {
write position;
read velocity;
}

write allows reading and writing; read allows reading only. Every component in the query must be listed exactly once. Assigning to a read component is a compile error, including inside helper functions you pass it to.

One query can have several profiles, one per system that uses it.

Write the system​

A system is a function the engine calls with a batch of matching entities. worldBatchViews turns the batch into rows you can loop over:

include "cturtle/world";

object Position { float x; float y; }
object Velocity { float x; float y; }

query Motion {
Position as position;
Velocity as velocity;
}

access Integrate for Motion {
write position;
read velocity;
}

fn updateMotion(WorldBatch batch, float dt) -> void {
rows<Motion, Integrate> entities =
worldBatchViews(batch, typeof(Motion), typeof(Integrate))?;
for (view<Motion, Integrate> entity in entities) {
entity.position.x = entity.position.x + entity.velocity.x * dt;
entity.position.y = entity.position.y + entity.velocity.y * dt;
}
}

tree installMotion() -> void {
Component position = worldComponentRegister("Position", typeof(Position))?;
Component velocity = worldComponentRegister("Velocity", typeof(Velocity))?;
WorldArchetype mover = worldArchetypeRegister("Mover", [position, velocity])?;

WorldQuery motion = worldQueryRegisterTyped(
"Motion", typeof(Motion),
MotionBindings { position: position, velocity: velocity },
new array<Component>())?;
worldSystemRegister(worldSystemDesc("IntegrateMotion", motion)
.withAccess(typeof(Integrate))
.withCallback(updateMotion))?;

Entity entity = worldEntityCreate(mover)?;
component<Velocity>? initialVelocity =
entity.writeComponent(velocity, typeof(Velocity));
(initialVelocity.x = 10.0)?;
(initialVelocity.y = 2.0)?;
}

Call installMotion() from your game's setup code.

  • MotionBindings is made for you from the query. It connects each field in the query to the component you registered.
  • The last argument of worldQueryRegisterTyped lists components to exclude: entities that have any of them are skipped. Here it is empty.
  • .withAccess(typeof(Integrate)) tells the engine which components the system reads and writes, so it can run systems that don't conflict at the same time.

The ECS guide covers the other system settings: batch size, ordering and phases.

The view types​

TypeWhat it is
rows<Motion, Integrate>The batch's matching entities, to loop over or index.
view<Motion, Integrate>One entity, with a field for each component.
borrow<Integrate.position>One component of one entity, writable here because Integrate says write.
borrow<Integrate.velocity>One component of one entity, read-only.

Loop and use helpers​

for (view<Motion, Integrate> entity in entities) visits each entity in the batch. break and continue work as usual. entities[i]? gets one row by position; that position only means something inside this batch, so don't store it.

Helper functions can take a whole view or a single component:

infallible fn advancePosition(borrow<Integrate.position> position,
borrow<Integrate.velocity> velocity,
float dt) -> void {
position.x = position.x + velocity.x * dt;
position.y = position.y + velocity.y * dt;
}

fn advanceBatch(WorldBatch batch, float dt) -> void {
rows<Motion, Integrate> entities =
worldBatchViews(batch, typeof(Motion), typeof(Integrate))?;
for (view<Motion, Integrate> entity in entities) {
advancePosition(entity.position, entity.velocity, dt);
}
}

Writing velocity.x inside advancePosition would be a compile error, because Integrate only allows reading velocity.

Rules while you hold rows​

Rows, views and borrows are only valid during the system call:

  • Keep them in local variables and pass them to helper functions. Don't store them in object fields or collections, return them, or use them in a task.
  • A function that holds rows can't use branch, join or select, can't call through a function value or interface, and can't call engine functions that create, destroy or look up entities. math functions and your own ordinary functions are fine.
  • This applies to the whole function, even the lines before you get the rows.

To remove or spawn entities based on what a loop finds, do the loop in a helper that records what to do, then act on it after the helper returns. The ECS guide shows this in Remove entities from a system.

One entity at a time​

Outside a system loop, for example in setup code or when handling an event, read and write a single entity's component with readComponent and writeComponent:

const component<Velocity>? velocity = target.readComponent(velocityComponent, typeof(Velocity));
component<Position>? position = target.writeComponent(positionComponent, typeof(Position));
(position.x = position.x? + velocity.x?)?;
  • Both return null when the entity no longer exists or doesn't have that component, which is why each field access uses ?.
  • The last argument must be written as typeof(T).
  • target.alive() tells you whether the entity still exists; target.hasComponent(component)? whether it has a component.
  • The same calls are available from the component's side: velocityComponent.read(target, typeof(Velocity)) and positionComponent.write(target, typeof(Position)).

These calls look up the entity each time, so use them for single entities and use a system to process many. They follow the same rule as other engine calls: not in a function that holds rows.

Entity is a handle, not a number: an int and an Entity don't convert into each other on their own. Entity(id) makes a handle from an int ID and entity.id() gives the ID back. Handles can only be compared with == and !=. See Integer handles.