Entities, components and systems
A cTurtle game keeps its moving, living things (players, enemies, bullets, pickups, effects) in the world. This guide shows how to build them from BT scripts: how to describe their data, create and remove them, and write the systems that update thousands of them every step.
It assumes you can read BT. If not, start with the
BT language guide. The functions used here come
from the cturtle/world and
cturtle/events packages and the
math library.
The pieces
| Piece | What it is | Example |
|---|---|---|
| Entity | A handle for one thing in the world. It has no data of its own. | one bullet |
| Component | A small record of plain data that entities carry. | Velocity { x, y } |
| Archetype | A named, fixed set of components. Every entity is created from one. | Bullet = position + Velocity + Lifetime |
| Query | "Every entity that has these components." | everything with a position and a Velocity |
| System | A function the engine runs over every entity a query matches, every step. | move everything by its velocity |
| Service | A function that runs once per step at a safe point, for work that changes the world's structure. | spawn a wave of enemies |
| Phase | A named stage of the step. Systems and services belong to one. | simulation, presentation |
The engine calls a system with the matching entities split into batches (256 at a time unless you choose otherwise) and runs batches on several threads at once. That is what makes systems fast, and it is why a system declares exactly which components it reads and writes.
Where setup code goes
Components, archetypes, queries, phases, systems and services are registered
once, while the game starts. Do it in your game's start-up tree,
gameMain, or in a scene
load hook. Registration functions don't
work anywhere else: called later, they fail or stop the script that called
them.
Each registration returns a small handle (Component, WorldArchetype,
WorldQuery, ...). Keep the handles you need later in the objects that use
them; don't look things up by name every step.
include "cturtle/world";
include "cturtle/events";
include "math";
tree gameMain() -> void {
setupMovement()?;
setupLifetimes()?;
setupSpawner()?;
setupDamage()?;
}
The rest of this guide fills in those setup functions.
Define a component
A component is a BT object with plain fields: int, int32, float,
float32, bool, byte or char. Strings, arrays, maps, enums and other
objects can't be stored in a component.
object Velocity { float32 x; float32 y; }
object Lifetime { float32 remaining; }
Register each one by name before using it:
fn registerVelocity() -> Component {
return worldComponentRegister("Velocity", typeof(Velocity))?;
}
Registering the same name with the same type again returns the same component, so two setup functions can both register a component they share.
The engine also has built-in components. Get them by name with
worldComponentGet:
| Name | Fields | Used for |
|---|---|---|
PositionX, PositionY, PositionZ | value | Where the entity is. |
Rotation | radians | Which way it faces (yaw). |
Pitch | radians | Tilt up or down. |
Health | current, max | Hit points. |
Damage | amount | Damage the entity deals. |
Attachment | (none you use directly) | Lets an entity follow another; see Attach one entity to another. |
The world API reference lists them all.
Things to know about component data:
- Built-in fields are
float32. BT'sfloatis 64-bit. Arithmetic that mixes the two gives afloat, so storing the result back into afloat32field needsfloat32(...). Literal numbers convert automatically. If your own component is used together with positions in a hot loop, give itfloat32fields too, asVelocitydoes here. - New entities start zeroed. Every field is
0,0.0orfalseuntil you set it. - Refer to another entity by its ID. A component can't hold an
Entity, so storeentity.id()in anintfield and turn it back withEntity(id). - Keep shared data out of components. Data that many entities share, such as an enemy type's speed and sprite loaded from a file, belongs in an ordinary BT object kept by the system that uses it. Store the index of the definition in the component.
Create entities
Group components into an archetype, then create entities from it:
object Bullets {
WorldArchetype archetype;
Component velocity;
Component lifetime;
fn spawn(float x, float y, float vx, float vy) -> Entity {
Entity bullet = worldEntityCreate(this.archetype)?;
worldEntitySetPosition(bullet, x, y, 0.0)?;
component<Velocity>? velocity = bullet.writeComponent(this.velocity, typeof(Velocity));
(velocity.x = float32(vx))?;
(velocity.y = float32(vy))?;
component<Lifetime>? life = bullet.writeComponent(this.lifetime, typeof(Lifetime));
(life.remaining = 2.0)?;
return bullet;
}
}
fn registerBullets() -> Bullets {
Component velocity = worldComponentRegister("Velocity", typeof(Velocity))?;
Component lifetime = worldComponentRegister("Lifetime", typeof(Lifetime))?;
WorldArchetype archetype = worldArchetypeRegister("Bullet", [
worldComponentGet("PositionX")?, worldComponentGet("PositionY")?,
worldComponentGet("PositionZ")?, velocity, lifetime])?;
return Bullets { archetype: archetype, velocity: velocity, lifetime: lifetime };
}
entity.writeComponent(component, typeof(T))gives a handle to one entity's component;readComponentgives a read-only one. They returnnullif the entity is gone or doesn't have that component.- Reading and writing fields through these handles can fail (the entity might
have been destroyed meanwhile), so each access takes
?. - An entity's set of components is fixed by its archetype. There is no call
to add or remove a component later. To turn a thing into a different kind of
thing, destroy it and create a new entity from another archetype, or keep a
field (such as
bool active) that your systems check. - An archetype is its set of components; the order you list them in doesn't matter. Registering a second name for exactly the same set gives back the first archetype. A world holds at most 256 different archetypes, the engine's own included, so use archetypes for kinds of things, not for passing states.
worldEntityCreate works in setup code and in services (see
Spawn entities from a service), not inside a
system.
Write a system
A system has three parts: a query that names the components it works on, an
access profile that says which of them it reads and which it writes, and a
function the engine calls with each batch.
query Moving {
PositionX as x;
PositionY as y;
Velocity as velocity;
}
access Move for Moving {
write x;
write y;
read velocity;
}
fn moveEntities(WorldBatch batch, float dt) -> void {
float32 step = float32(dt);
for (view<Moving, Move> e in batch.rows()) {
e.x.value += e.velocity.x * step;
e.y.value += e.velocity.y * step;
}
}
fn setupMovement() -> void {
Component velocity = worldComponentRegister("Velocity", typeof(Velocity))?;
WorldQuery moving = worldQueryRegisterTyped("Moving", typeof(Moving),
MovingBindings {
x: worldComponentGet("PositionX")?,
y: worldComponentGet("PositionY")?,
velocity: velocity,
},
new array<Component>())?;
worldSystemRegister(worldSystemDesc("MoveEntities", moving)
.withAccess(typeof(Move))
.withCallback(moveEntities))?;
}
- Every
query Movingcomes with aMovingBindingsobject. Fill it with the registered component for each field. - The query matches every archetype that has those components, whatever
else it has. A
Bullet, anAsteroidand aShipall move with this one system. e.x.valuereads and writes the component directly. Writing a field the profile marksreadis a compile error, which keeps parallel batches from stepping on each other.dtis the step length in seconds. The defaultsimulationphase runs at a fixed rate (60 steps a second unlesssimulation.fixed_hzsays otherwise).- System names must be unique.
The component views chapter of the
language guide covers query, access, rows and view in more depth.
Why a system instead of a loop over entities? A system gets its entities a
batch at a time and can work on several batches at once. readComponent and
writeComponent look up one entity per call. That is fine for a handful of
entities in setup code or a service, but use a system for anything that runs
over many entities every step.
Choose the batch size
.withBatchSize(n) changes how many entities each call receives (default
256). Every call has a fixed cost, so very small batches waste time; very
large ones leave threads idle when there are few entities. Keep the default
unless measuring shows otherwise.
Skip some entities
The last argument of worldQueryRegisterTyped lists components an entity must
not have. A component with one bool makes a cheap marker:
fn registerFrozenMarker() -> Component {
return worldComponentRegister("Frozen", typeof(WorldBoolComponent))?;
}
Passing [frozen] as the exclusion list makes a query ignore every archetype
that includes Frozen. Since components are fixed per archetype, this
selects kinds of entities, such as statues that never move, not entities that
freeze temporarily; use a field for that.
Remove entities from a system
worldEntityDestroy may be called from a system, but not from the same
function that holds the rows. While a function holds rows or a view, it
can call only plain BT code and math functions; engine calls that can change
the world are rejected with
call or suspension is not borrow-safe while a composite view is live.
So split the work: a helper walks the rows and notes which ones to remove; the system function does the removing after the helper returns.
query Expiring { Lifetime as life; }
access CountDown for Expiring { write life; }
fn countDown(WorldBatch batch, float32 step, array<int> expired) -> int {
int count = 0;
for (int row, view<Expiring, CountDown> e in batch.rows()) {
e.life.remaining -= step;
if (e.life.remaining <= 0.0) {
expired[count] = row;
count++;
}
}
return count;
}
fn expireEntities(WorldBatch batch, float dt) -> void {
array<int> expired = new array<int>(worldBatchLength(batch));
int count = countDown(batch, float32(dt), expired)?;
for (int i = 0; i < count; i++) {
worldEntityDestroy(batch.entity(expired[i]))?;
}
}
fn setupLifetimes() -> void {
Component lifetime = worldComponentRegister("Lifetime", typeof(Lifetime))?;
WorldQuery expiring = worldQueryRegisterTyped("Expiring", typeof(Expiring),
ExpiringBindings { life: lifetime }, new array<Component>())?;
worldSystemRegister(worldSystemDesc("ExpireEntities", expiring)
.withAccess(typeof(CountDown))
.withCallback(expireEntities))?;
}
The helper may write into an array it was given, but not grow one with
push. Get the batch's length (worldBatchLength) outside the helper and pass
it in, as here.
A destroyed entity is marked dead at once (entity.alive() returns false)
and removed when the current phase finishes. Other systems in the same phase
can still see it in their batches until then.
What a system may do
Systems run on several threads at once, so they are limited to work that is safe in parallel:
| Inside a system you can | Elsewhere only (setup code or a service) |
|---|---|
| Read and write the components of the entities in its batch | Create entities (worldEntityCreate) |
| Read components of other entities, if declared | Attach entities (worldAttachmentSet) |
| Write other entities' components, in a serial system | Move an entity with worldEntitySetPosition |
| Destroy entities | Push physics bodies (worldPhysicsApplyForce, ...) |
Call plain BT and math code | List entities (worldQuery, entity filters) |
Calling one of the right-hand functions inside a system either fails quietly
(worldEntitySetPosition returns false) or stops the system. A stopped
system stays off; the game's error output shows
[bt-world] ECS system 'Name' failed (...): message; disabled.
The usual pattern is: systems decide and record (write a field, count something, note a request), and a service acts on the records.
Spawn entities from a service
A service is an object with fn update(float dt) -> void. The engine calls it
once per step of its phase, after all of that phase's systems have finished,
from a single thread. It can create entities, attach them, use physics and
change anything in the world.
object Spawner {
Bullets bullets;
float cooldown;
fn update(float dt) -> void {
this.cooldown -= dt;
if (this.cooldown > 0.0) { return; }
this.cooldown = 0.25;
for (int i = 0; i < 8; i++) {
float angle = i * 0.785398;
this.bullets.spawn(0.0, 0.0, cos(angle) * 300.0, sin(angle) * 300.0)?;
}
}
}
fn setupSpawner() -> void {
WorldPhase spawning = worldPhaseAdd(worldPhaseConfig("Spawning",
worldFixedStepCadence(), worldPhaseAfter(worldSimulationPhase()?)))?;
worldServiceRegister("BulletSpawner", spawning,
Spawner { bullets: registerBullets()? })?;
}
- A phase runs its systems, applies the entity removals they asked for, then runs its services. Entities a service creates are ready for the next phase's systems.
- If a service fails, it is switched off and reported in the error output, the same as a system.
- Make the changes in batches: one service that spawns a whole wave is better than many small requests spread across the step.
Order systems and phases
Systems in the same phase run in whatever order and overlap that their declared access allows: two systems that write the same component never run at the same time, and systems that touch different components may run together.
- Order within a phase:
.withDependencies(["MoveEntities"])makes a system run after the named systems of the same phase. - Order between phases: phases run in a fixed list order.
worldPhaseAdd(worldPhaseConfig(name, cadence, placement))adds one, placed withworldPhaseFirst(),worldPhaseLast(),worldPhaseBefore(phase)orworldPhaseAfter(phase). Dependencies don't reach across phases. - How often:
worldFixedStepCadence()phases run at the fixed simulation rate;worldPerFrameCadence()phases run once per drawn frame. The built-in phases areworldSimulationPhase()(fixed-step, where systems go by default) andworldPresentationPhase()(per frame). Put gameplay in fixed-step phases; per-frame phases suit purely visual work..withPhase(phase)picks a system's phase. - Shared state other than components: if two systems use the same BT
object (a score table, a shared random generator), register a resource with
worldResourceRegister(name)and declare it on both with.withResources([worldResourceAccess(resource, worldResourceRead())])(orworldResourceWriteExclusive()for the one that changes it) so the engine doesn't run them at the same time.
Look at other entities from a system
A homing missile needs its target's position. A parallel system may read components of any entity as long as it declares them. Writing is limited to the entities in its own batch.
Because looking up another entity is an engine call, it can't happen in a
function that holds rows. For systems like this, use component handles for
the batch instead: worldBatchReadComponents and worldBatchWriteComponents
return one handle per row, and the function stays free to make other calls.
object Homing { int target; float32 speed; }
object SteerHoming {
Component homing;
Component velocity;
Component x;
Component y;
fn update(WorldBatch batch, float dt) -> void {
array<const component<Homing>>? seekers =
worldBatchReadComponents(batch, this.homing, typeof(Homing));
array<component<Velocity>>? velocities =
worldBatchWriteComponents(batch, this.velocity, typeof(Velocity));
array<const component<PositionX>>? xs =
worldBatchReadComponents(batch, this.x, typeof(PositionX));
array<const component<PositionY>>? ys =
worldBatchReadComponents(batch, this.y, typeof(PositionY));
int count = worldBatchLength(batch);
for (int row = 0; row < count; row++) {
const component<Homing> seeker = seekers[row];
Entity target = Entity(seeker.target?);
const component<PositionX>? tx = target.readComponent(this.x, typeof(PositionX));
const component<PositionY>? ty = target.readComponent(this.y, typeof(PositionY));
if (tx == null || ty == null) { continue; } // target is gone
float dx = tx.value? - xs[row].value?;
float dy = ty.value? - ys[row].value?;
float distance = sqrt(dx * dx + dy * dy);
if (distance < 0.001) { continue; }
component<Velocity> v = velocities[row];
(v.x = float32(dx / distance * seeker.speed?))?;
(v.y = float32(dy / distance * seeker.speed?))?;
}
}
}
fn setupHoming() -> void {
Component homing = worldComponentRegister("Homing", typeof(Homing))?;
Component velocity = worldComponentRegister("Velocity", typeof(Velocity))?;
Component x = worldComponentGet("PositionX")?;
Component y = worldComponentGet("PositionY")?;
WorldQuery seekers = worldQueryRegister("Seekers", [homing, velocity, x, y],
new array<Component>())?;
worldSystemRegister(worldSystemDesc("SteerHoming", seekers)
.withWrites([velocity])
.withHandler(SteerHoming { homing: homing, velocity: velocity, x: x, y: y }))?;
}
- This system has no
accessprofile, so it registers its query withworldQueryRegisterand lists what it writes with.withWrites. The query's own components are readable. Components it reads only on other entities go in.withReads([...]). .withHandler(object)takes any object withfn update(WorldBatch batch, float dt) -> void. Use it when the system needs to keep handles or settings;.withCallback(function)is for a plain function. Several batches can run the same handler at once, so don't change the handler's own fields fromupdatein a parallel system.- Get each handle array and the batch length once, before the loop. Calling
worldBatchLengthin the loop condition repeats the call for every row. - Per-row lookups of other entities cost more than reading the batch's own rows. If many entities need the same target (everyone chasing the player), have a service copy the target's position into a resource-protected object once per step and read that instead.
Combine results across many entities
To total, count or pick a best value across all matching entities, make the
system serial with .withSerialExecution(true). Its batches then run one
after another on one thread, so they can safely add into a shared object. A
serial system may also write components of entities outside its batch, if it
declares them with .withWrites.
object Threat { float32 level; }
query Threats { Threat as threat; }
access ReadThreat for Threats { read threat; }
object ThreatMeter {
float total;
fn update(WorldBatch batch, float dt) -> void {
for (view<Threats, ReadThreat> e in batch.rows()) {
this.total += e.threat.level;
}
}
}
object MusicDirector {
ThreatMeter meter;
float intensity;
infallible fn update(float dt) -> void {
this.intensity = this.meter.total / 100.0;
this.meter.total = 0.0; // start the next step's total
}
}
fn setupThreat() -> void {
Component threat = worldComponentRegister("Threat", typeof(Threat))?;
WorldQuery threats = worldQueryRegisterTyped("Threats", typeof(Threats),
ThreatsBindings { threat: threat }, new array<Component>())?;
ThreatMeter meter = ThreatMeter {};
worldSystemRegister(worldSystemDesc("MeasureThreat", threats)
.withAccess(typeof(ReadThreat))
.withSerialExecution(true)
.withHandler(meter))?;
worldServiceRegister("MusicDirector", worldSimulationPhase()?,
MusicDirector { meter: meter })?;
}
The service runs after the phase's systems, so it sees the finished total. Serial systems give up parallelism; keep them small and keep heavy per-entity work in parallel systems.
React to events
Events carry news between parts of the game: from engine to script, script to script, or gameplay to UI. An event kind is just a BT object type. Register it once, then subscribe to get a channel and fire to send to every subscriber.
object DamageDealt { Entity target; float amount; }
object DamageService {
Component health;
channel<DamageDealt> hits;
fn update(float dt) -> void {
bool drained = false;
while (!drained) {
select {
DamageDealt hit = this.hits.receive() { this.apply(hit)?; }
else { drained = true; }
}
}
}
fn apply(DamageDealt hit) -> void {
component<Health>? health = hit.target.writeComponent(this.health, typeof(Health));
if (health == null) { return; } // already gone
(health.current = float32(health.current? - hit.amount))?;
}
}
query Mortal { Health as health; }
access CheckDeath for Mortal { read health; }
fn findDead(WorldBatch batch, array<int> dead) -> int {
int count = 0;
for (int row, view<Mortal, CheckDeath> e in batch.rows()) {
if (e.health.current <= 0.0) {
dead[count] = row;
count++;
}
}
return count;
}
fn removeDead(WorldBatch batch, float dt) -> void {
array<int> dead = new array<int>(worldBatchLength(batch));
int count = findDead(batch, dead)?;
for (int i = 0; i < count; i++) {
worldEntityDestroy(batch.entity(dead[i]))?;
}
}
fn setupDamage() -> void {
Component health = worldComponentGet("Health")?;
eventRegister(typeof(DamageDealt), eventFifo())?;
worldServiceRegister("ApplyDamage", worldSimulationPhase()?, DamageService {
health: health,
hits: eventSubscribe(typeof(DamageDealt), 256)?,
})?;
WorldQuery mortal = worldQueryRegisterTyped("Mortal", typeof(Mortal),
MortalBindings { health: health }, new array<Component>())?;
worldSystemRegister(worldSystemDesc("RemoveDead", mortal)
.withAccess(typeof(CheckDeath))
.withCallback(removeDead))?;
}
fn hurt(Entity target, float amount) -> void {
eventFire(DamageDealt { target: target, amount: amount })?;
}
- An event object's fields may be
bool,int,float,stringorEntity. eventFifo()delivers every event in order.eventConflate()delivers only the newest one a subscriber hasn't read yet, which suits "current value" news such as a score display.selectwithelsetakes whatever is waiting without blocking, so the service empties its channel each step and moves on. Withoutelseit would wait for the next event and hold up the game.- The subscription's capacity (256 here) is how many events wait in the channel; FIFO events beyond that are queued rather than dropped.
- Fire and drain events from services, game managers and setup code. Inside systems, record what happened in components and let a service turn it into events.
- Because
ApplyDamageruns afterRemoveDeadin the same phase, an entity brought to zero this step is removed on the next step.
The engine fires its own events too, such as resolved collision batches; they
are listed in the events reference and are
subscribed to the same way, without eventRegister.
Attach one entity to another
To mount a turret on a vehicle, a light on a lamp post or a flame on a torch,
use the engine's attachment support. Both entities need the Attachment
component; then worldAttachmentSet ties the child to the parent with a
position and rotation relative to it:
object Vehicles {
WorldArchetype hull;
WorldArchetype turret;
fn spawn(float x, float y) -> Entity {
Entity hull = worldEntityCreate(this.hull)?;
worldEntitySetPosition(hull, x, y, 0.0)?;
Entity turret = worldEntityCreate(this.turret)?;
// 12 units ahead of the hull's centre, turning with it.
if (!worldAttachmentSet(turret, hull, 12.0, 0.0, 0.0, true, 0.0, 0.0)?) {
worldEntityDestroy(turret)?;
throw new error(1, "could not mount turret");
}
return hull;
}
}
fn registerVehicles() -> Vehicles {
array<Component> pose = [worldComponentGet("PositionX")?, worldComponentGet("PositionY")?,
worldComponentGet("PositionZ")?, worldComponentGet("Rotation")?,
worldComponentGet("Attachment")?];
return Vehicles {
hull: worldArchetypeRegister("Hull", pose)?,
turret: worldArchetypeRegister("Turret", pose)?,
};
}
- The arguments after the parent are the child's relative position (
x,y,z), whether it turns with the parent, and its relative rotation (yaw and pitch). WithfollowRotationfalsethe relative position stays world-aligned and the relative rotation is the child's whole rotation. - Children can have children: a barrel on a turret on a hull.
- Call
worldAttachmentSetagain to change the relative position or rotation, as often as every step.worldAttachmentDetach(child)lets go. - Attached positions are worked out in the simulation phase, after physics, and again just before drawing, so attached sprites stay locked to their parent on screen. If your own system moves the parent and gameplay code reads the child's position in the same step, move the parent in a phase placed before the simulation phase.
- A visible child made with the render package also needs the
AttachmentPresentationcomponent in its archetype. - If the child's archetype lacks
Attachment, the call still returnstruebut nothing follows. Check the archetype first when an attached entity stays put.
Animate an attached part
An attached child can turn on its own while it follows its parent. To spin a radar dish on a truck, keep the dish attached and change its relative rotation every step:
object RadarSpin {
Entity dish;
Entity truck;
float speed; // radians per second
float angle;
fn update(float dt) -> void {
this.angle += this.speed * dt;
worldAttachmentSet(this.dish, this.truck, 0.0, -4.0, 2.0, true, this.angle, 0.0)?;
}
}
fn spinRadar(Entity dish, Entity truck) -> void {
worldServiceRegister("RadarSpin", worldSimulationPhase()?,
RadarSpin { dish: dish, truck: truck, speed: 1.5, angle: 0.0 })?;
}
For many moving parts, use one service that updates all of them rather than one service per part.
Destroy the parts with the whole
Destroying a hull doesn't destroy its turret. Give the hull an owned collection of its parts and they are destroyed with it:
fn registerParts() -> Component {
// Each entity with this component can list up to 8 parts.
return worldEntityCollectionRegisterOwned("Parts", 8)?;
}
fn addPart(Entity owner, Component parts, Entity part) -> void {
if (!worldEntityCollectionAdd(owner, parts, part)) {
throw new error(1, "part list is full");
}
}
Add the collection component to the owner's archetype. The capacity is
reserved on every entity of that archetype, used or not, so keep it close to
what you need. worldEntityCollectionCount, worldEntityCollectionGet,
worldEntityCollectionRemove and worldEntityCollectionClear read and edit
the list. worldEntityCollectionRegister makes a plain list that doesn't
destroy its members.
Relate entities to each other
Some systems need entities together: a child next to its parent, every member of a squad, everything touching everything else. Add a relation to the system and the engine arranges its batches so related entities arrive in the same batch, in a useful order. Your system then reads the related rows directly instead of looking entities up.
A relational system needs a query with an access profile, plus a second query
for its rows that adds a WorldRelation field. Each row's WorldRelation
tells you:
| Field | Meaning |
|---|---|
parent | Row number of this entity's parent in the same batch, or -1. |
depth | How many parents up to the root. |
groupStart | Row number of the first entity in this entity's group. |
groupLength | How many entities are in the group. |
A whole group always arrives in one batch.
Parents and children
For positions and angles, use attachments. A parent-child system is for passing other state down a hierarchy. Here a truck powers its lights and the lights power their bulbs: a part is on only if its own switch is on and everything above it is powered.
object Power { int parent; bool switchedOn; bool powered; }
query Powered { Power as power; }
query PowerTree { Power as power; WorldRelation as rel; }
access SpreadPower for PowerTree { write power; read rel; }
// The parent function's row only needs the fields it reads.
query PowerLinks { Power as power; }
access ReadLinks for PowerLinks { read power; }
fn powerParent(view<PowerLinks, ReadLinks> row) -> Entity {
return Entity(row.power.parent);
}
fn spreadPower(WorldBatch batch, float dt) -> void {
rows<PowerTree, SpreadPower> parts = batch.rows();
for (view<PowerTree, SpreadPower> part in parts) {
bool supplied = true; // the truck itself
if (part.rel.parent >= 0) {
supplied = parts[part.rel.parent]?.power.powered;
}
part.power.powered = supplied && part.power.switchedOn;
}
}
fn setupPower() -> void {
Component power = worldComponentRegister("Power", typeof(Power))?;
WorldQuery powered = worldQueryRegisterTyped("Powered", typeof(Powered),
PoweredBindings { power: power },
new array<Component>())?;
worldSystemRegister(worldSystemDesc("SpreadPower", powered)
.withAccess(typeof(SpreadPower))
.withParent(powerParent)
.withCallback(spreadPower))?;
}
- The truck, its lights and their bulbs all carry
Power, so all match the query. The truck'sparentis0(no parent), which makes it a root. - A parent's row always comes before its children's, so by the time you reach a child its parent is already up to date, however deep the chain.
- The parent function returns the parent's
Entity. An entity that names no parent, a missing parent or itself is treated as a root, and a loop of parents is broken so that one of its members becomes a root.
Groups
.withGroupBy(keyOf) puts entities with the same key together. Here each
squad member steers toward its squad's centre:
object SquadMember { int squad; }
query SquadRows { SquadMember as member; PositionX as x; PositionY as y; Velocity as velocity; }
query SquadGroups {
SquadMember as member; PositionX as x; PositionY as y; Velocity as velocity;
WorldRelation as rel;
}
access Regroup for SquadGroups { read member; read x; read y; write velocity; read rel; }
query SquadKeys { SquadMember as member; }
access ReadSquad for SquadKeys { read member; }
fn squadOf(view<SquadKeys, ReadSquad> row) -> int { return row.member.squad; }
fn regroup(WorldBatch batch, float dt) -> void {
rows<SquadGroups, Regroup> members = batch.rows();
float32 centreX = 0.0;
float32 centreY = 0.0;
for (int row, view<SquadGroups, Regroup> m in members) {
if (row == m.rel.groupStart) { // first member: find this squad's centre
centreX = 0.0;
centreY = 0.0;
for (int i = 0; i < m.rel.groupLength; i++) {
view<SquadGroups, Regroup> other = members[row + i]?;
centreX += other.x.value;
centreY += other.y.value;
}
centreX /= float32(m.rel.groupLength);
centreY /= float32(m.rel.groupLength);
}
m.velocity.x += (centreX - m.x.value) * 0.5;
m.velocity.y += (centreY - m.y.value) * 0.5;
}
}
fn setupSquads() -> void {
Component squad = worldComponentRegister("SquadMember", typeof(SquadMember))?;
WorldQuery squads = worldQueryRegisterTyped("Squads", typeof(SquadRows),
SquadRowsBindings {
member: squad,
x: worldComponentGet("PositionX")?,
y: worldComponentGet("PositionY")?,
velocity: worldComponentRegister("Velocity", typeof(Velocity))?,
},
new array<Component>())?;
worldSystemRegister(worldSystemDesc("Regroup", squads)
.withAccess(typeof(Regroup))
.withGroupBy(squadOf)
.withCallback(regroup))?;
}
Keys may be int, float or Entity. Groups arrive in key order.
Things that touch
.withRelation(test) groups entities connected by a yes/no test on pairs:
if A touches B and B touches C, all three form one group. Testing every pair
gets slow quickly, so add a grid: .withCells(x, size),
.withCellsXY(x, y, size) or .withCellsXYZ(x, y, z, size) only test pairs in
the same or neighbouring cells. .withBucket(keyOf) only tests pairs with equal
keys.
object Blob { int cluster; float32 radius; }
query Blobs { Blob as blob; PositionX as x; PositionY as y; }
query BlobGroups { Blob as blob; PositionX as x; PositionY as y; WorldRelation as rel; }
access LabelBlobs for BlobGroups { write blob; read x; read y; read rel; }
query BlobPoints { Blob as blob; PositionX as x; PositionY as y; }
access ReadBlobs for BlobPoints { read blob; read x; read y; }
fn touching(view<BlobPoints, ReadBlobs> a, view<BlobPoints, ReadBlobs> b) -> bool {
float32 dx = a.x.value - b.x.value;
float32 dy = a.y.value - b.y.value;
float32 reach = a.blob.radius + b.blob.radius;
return dx * dx + dy * dy < reach * reach;
}
query BlobX { PositionX as x; }
access ReadX for BlobX { read x; }
query BlobY { PositionY as y; }
access ReadY for BlobY { read y; }
fn blobX(view<BlobX, ReadX> row) -> float { return row.x.value; }
fn blobY(view<BlobY, ReadY> row) -> float { return row.y.value; }
// Every blob learns which cluster it belongs to: the ID of the cluster's first entity.
fn labelClusters(WorldBatch batch, float dt) -> void {
for (view<BlobGroups, LabelBlobs> b in batch.rows()) {
b.blob.cluster = batch.entity(b.rel.groupStart).id();
}
}
fn setupBlobs() -> void {
Component blob = worldComponentRegister("Blob", typeof(Blob))?;
WorldQuery blobs = worldQueryRegisterTyped("Blobs", typeof(Blobs),
BlobsBindings {
blob: blob,
x: worldComponentGet("PositionX")?,
y: worldComponentGet("PositionY")?,
},
new array<Component>())?;
worldSystemRegister(worldSystemDesc("ClusterBlobs", blobs)
.withAccess(typeof(LabelBlobs))
.withRelation(touching)
.withCellsXY(blobX, blobY, 64.0) // at least the largest touching distance
.withCallback(labelClusters))?;
}
Make the cell size at least as large as the farthest distance your test can accept, or some touching pairs will never be tested.
Keep relation functions simple
- Parent, key and cell functions must depend only on their own row. The engine
rearranges the batches only when entities are added or removed or a field
those functions read changes. Pair tests count as reading every field of the
query. If a function depends on anything else, add
.withRelationRebuiltEachFrame(true). - These functions are called for every row or pair, so keep them to plain arithmetic on the row.
- In a relational system, the batch provides only
batch.rows()andbatch.entity(row). - The same entities and values always give the same arrangement, whatever the number of threads.
Find entities outside a system
A service sometimes needs a list of entities, for example every pickup to
show on a minimap. worldQuery([components]) returns an array<Entity> of
everything that has those components. It builds a fresh list every call, so
don't use it every step for large sets. For repeated use, create an entity
filter once in setup with
entityFilterComponents and call
entityFilterSelect(filter) when you need the list. A filter takes component
IDs (component.id) rather than Component handles.
Prefer a system over either one whenever the work is "for each matching entity, do something".
Make entities visible
Entities are drawn when they are created from a render archetype. The render package builds a sprite archetype that already has a position and rotation; pass your own components as extras so your systems match it:
include "cturtle/render";
fn registerBulletSprites(Component velocity, Component lifetime) -> WorldArchetype {
RenderMaterial material = renderMaterialGet("Bullet")?;
return renderSpriteArchetype("BulletSprite", material, [velocity, lifetime])?;
}
Create them with
renderSpriteSpawn instead of
worldEntityCreate. The movement and lifetime systems above then move and
remove them like any other entity.
Entity handles
- An
Entityis a handle, not a number. Compare handles with==and!=; convert withentity.id()andEntity(id)only when you have to store one in a component or save it. Entity(0)means "no entity".- A handle to a destroyed entity never points at a newer entity by mistake.
entity.alive()tells you whether it still exists;entity.hasComponent(component)?whether it has a component. - Reading or writing a field of a destroyed entity fails with error
-410, which?passes on or!!can handle.
Performance checklist
- Put per-entity work in systems over queries, not in loops of
readComponentandwriteComponent. - Declare exactly what each system reads and writes. The engine uses that to run systems side by side; over-declaring writes makes them wait for each other.
- Keep row loops to arithmetic. Do engine calls after the loop, in the system function, or in a service.
- Get component and archetype handles once during setup and keep them.
- Use serial systems only for totals and cross-entity writes.
- Group structural changes: spawn and attach in one service pass; destroy from systems and let the engine remove the entities together at the end of the phase.
- Use per-frame phases only for visuals; gameplay belongs in fixed-step phases.
Troubleshooting
| Symptom | Cause |
|---|---|
call or suspension is not borrow-safe while a composite view is live | An engine call (destroy, lookup, worldBatchLength, ...) in a function holding rows or a view. Move it to the calling function. |
assigned value has the wrong type on a component field | A float stored into a float32 field. Wrap it in float32(...). |
[bt-world] ECS system 'Name' failed ...; disabled | The system threw an error or called something not allowed in a system. Fix it and restart; a disabled system stays off. |
| A registration call stops the script | It ran outside gameMain or a scene load hook. |
| A system never runs | No archetype has all the query's components, or one has an excluded component. |
| An attached entity doesn't follow | Its archetype (or the parent's) lacks Attachment, or a sprite child lacks AttachmentPresentation. |
writeComponent returns null in a system | The entity isn't in this batch (parallel systems can only write their own rows), the component isn't declared, or the entity is gone. |
See also
- Component views in the BT language guide
cturtle/worldreferencecturtle/eventsreference- Query and access declarations
- Scene lifecycle hooks