Skip to content

Systems ​

A system is a function plus the query it runs over. Define it with system(), export it, and schedule it in src/main.ts with app.add(Phase, MySystem).

Three forms ​

Per entity. A query list; the function runs once for every matching entity, with a row of that entity's trait values:

ts
/** Integrates Velocity into Transform. */
export const Movement = system([Mut(Transform), Velocity], ([t, v], { time }) => {
  t.x += v.x * time.step
  t.z += v.z * time.step
})

The row has one entry per plain trait or Mut(T), in order. Tags, Not, Or and change filters give no entry. ctx.entity is the current entity.

Named queries. An object of query lists; the function runs once per phase run with a view per query:

ts
/** Each enemy chases the player. */
export const Chase = system(
  { player: [Transform, IsPlayer], enemies: [Mut(Velocity), Transform, IsEnemy] },
  ({ player, enemies }) => {
    const p = player.first()
    if (!p) return
    const target = p.get(Transform)!
    enemies.each(([v, t]) => {
      v.x = Math.sign(target.x - t.x) * 2
      v.z = Math.sign(target.z - t.z) * 2
    })
  },
)

A view has .length, .entities, .first(), .forEach((entity, i) => ...), .each(([...row], entity) => ...) and is iterable. .each gives rows with the same access rules as the per-entity form. Several named queries may each take Mut(T); the system's write set is their union.

No query. Just the context, once per phase run:

ts
/** Spawns a wave of enemies every 10 seconds. */
export const Waves = system(({ commands, res, time }) => {
  if (time.tick % stepsFor(10, time.step) !== 0) return
  const rng = res.mut(Rng)
  for (let i = 0; i < 5; i++) commands.spawn(Enemy, { at: vec3(rng.float(-10, 10), 0, -12) })
})

The context ​

Every system gets the context as its last argument:

Field
timestep (fixed dt), dt (this phase's dt), elapsed (simulated seconds), frame, tick, alpha
resres.get(Score) reads (read-only), res.mut(Score) writes, res.has(R)
commandsspawn, despawn, add, remove, run; applied at the end of the phase
eventsemit(E, payload), read(E) (each reader sees each event once)
worldThe Koota world for ad-hoc reads (world.query(...)); read-only in dev
entityThe current entity (per-entity form only)

time.dt is time.step inside Fixed and the clamped frame delta elsewhere. time.alpha is how far the frame is between the last two fixed steps, which rendering uses to interpolate.

Access is in the types ​

Mut(T) is the system's write set. Every other trait in its queries arrives read-only:

ts
system([Mut(Transform), Velocity], ([t, v]) => {
  t.x += v.x      // fine
  v.x = 0         // compile error: v is Readonly
})

In dev builds the same rule is checked at runtime, so a cast doesn't get around it. The other ways to write are checked too:

You wroteWhat happensFix
a read-only row fieldreadonly-write errorMut(T) in the query
entity.set(T, {...}) without Mut(T) in any queryundeclared-write erroradd Mut(T)
a field of the copy entity.get(T) returnswrite-to-copy errorwrite through the row, or entity.set
res.get(R).field = xreadonly-resource errorres.mut(R)
entity.destroy(), entity.add(), world.spawn()structural-in-system / world-write errorcommands

In the named-query form, q.forEach(e => ...) gives entities, not rows. Write those with e.set(T, {...}), which needs Mut(T) in one of the system's queries.

The scheduler uses the same write sets: two systems in one phase that both take Mut(T) must have an order between them. See The schedule.

Order ​

Order only matters inside a phase, and file order never matters:

ts
.add(Fixed, chain(PlayerMove, Movement, CollectCoins))     // in this order
.add(Fixed, Knockback.after(Movement))                     // after Movement
.add(Fixed, Aim.before(Fire), Fire)                        // before Fire
.add(Fixed, Shoot.after(Aim).before(Recoil))               // both

Systems with no constraint between them keep the order they were added in.

Run conditions ​

runIf(condition, ...systems) runs systems only while the condition holds. A condition is any function of { res, time } that returns a boolean:

ts
.add(Fixed, runIf(inState(Screen, 'playing'), chain(PlayerMove, Movement, Collide)))
.add(Update, runIf(({ res }) => res.get(Score).value >= 10, ShowWinBanner))
.add(Update, runIf(not(inState(Screen, 'paused')), AnimateWater))

Gates nest (every condition must hold), and a gated chain still runs in order. Resource reads inside conditions are recorded, and kiln describe prints each system's conditions. Name a custom condition for kiln describe with withLabel(cond, 'scoreAtLeast10'). The built-in state conditions are on States.

A skipped system doesn't read events, so it only sees events emitted while it was off if they are still alive when it runs again (events live for two runs of the phase that emitted them).

Options ​

system(query, fn, options) takes:

Option
nameOverrides the export name
writes / readsTraits the system touches outside its queries (for plugins)
uncheckedSkips dev write guards and hands over the raw world (plugin internals only)

MySystem.named('Name') renames a system after the fact.