Skip to main content

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​

PieceWhat it isExample
EntityA handle for one thing in the world. It has no data of its own.one bullet
ComponentA small record of plain data that entities carry.Velocity { x, y }
ArchetypeA 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
SystemA function the engine runs over every entity a query matches, every step.move everything by its velocity
ServiceA function that runs once per step at a safe point, for work that changes the world's structure.spawn a wave of enemies
PhaseA 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:

NameFieldsUsed for
PositionX, PositionY, PositionZvalueWhere the entity is.
RotationradiansWhich way it faces (yaw).
PitchradiansTilt up or down.
Healthcurrent, maxHit points.
DamageamountDamage 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's float is 64-bit. Arithmetic that mixes the two gives a float, so storing the result back into a float32 field needs float32(...). Literal numbers convert automatically. If your own component is used together with positions in a hot loop, give it float32 fields too, as Velocity does here.
  • New entities start zeroed. Every field is 0, 0.0 or false until you set it.
  • Refer to another entity by its ID. A component can't hold an Entity, so store entity.id() in an int field and turn it back with Entity(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; readComponent gives a read-only one. They return null if 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 Moving comes with a MovingBindings object. Fill it with the registered component for each field.
  • The query matches every archetype that has those components, whatever else it has. A Bullet, an Asteroid and a Ship all move with this one system.
  • e.x.value reads and writes the component directly. Writing a field the profile marks read is a compile error, which keeps parallel batches from stepping on each other.
  • dt is the step length in seconds. The default simulation phase runs at a fixed rate (60 steps a second unless simulation.fixed_hz says 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 canElsewhere only (setup code or a service)
Read and write the components of the entities in its batchCreate entities (worldEntityCreate)
Read components of other entities, if declaredAttach entities (worldAttachmentSet)
Write other entities' components, in a serial systemMove an entity with worldEntitySetPosition
Destroy entitiesPush physics bodies (worldPhysicsApplyForce, ...)
Call plain BT and math codeList 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 with worldPhaseFirst(), worldPhaseLast(), worldPhaseBefore(phase) or worldPhaseAfter(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 are worldSimulationPhase() (fixed-step, where systems go by default) and worldPresentationPhase() (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())]) (or worldResourceWriteExclusive() 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 access profile, so it registers its query with worldQueryRegister and 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 with fn 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 from update in a parallel system.
  • Get each handle array and the batch length once, before the loop. Calling worldBatchLength in 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, string or Entity.
  • 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.
  • select with else takes whatever is waiting without blocking, so the service empties its channel each step and moves on. Without else it 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 ApplyDamage runs after RemoveDead in 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). With followRotation false the 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 worldAttachmentSet again 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 AttachmentPresentation component in its archetype.
  • If the child's archetype lacks Attachment, the call still returns true but 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:

FieldMeaning
parentRow number of this entity's parent in the same batch, or -1.
depthHow many parents up to the root.
groupStartRow number of the first entity in this entity's group.
groupLengthHow 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's parent is 0 (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() and batch.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 Entity is a handle, not a number. Compare handles with == and !=; convert with entity.id() and Entity(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 readComponent and writeComponent.
  • 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​

SymptomCause
call or suspension is not borrow-safe while a composite view is liveAn 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 fieldA float stored into a float32 field. Wrap it in float32(...).
[bt-world] ECS system 'Name' failed ...; disabledThe 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 scriptIt ran outside gameMain or a scene load hook.
A system never runsNo archetype has all the query's components, or one has an excluded component.
An attached entity doesn't followIts archetype (or the parent's) lacks Attachment, or a sprite child lacks AttachmentPresentation.
writeComponent returns null in a systemThe 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​