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. aslets you use one type twice, for example twoVec2components namedpositionandtarget.- Component types are objects whose fields are all
int,int32,float,float32,bool,byteorchar.
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.
MotionBindingsis made for you from the query. It connects each field in the query to the component you registered.- The last argument of
worldQueryRegisterTypedlists 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
| Type | What 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,joinorselect, can't call through a function value or interface, and can't call engine functions that create, destroy or look up entities.mathfunctions 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
nullwhen 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))andpositionComponent.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.