Skip to content

Entities and traits ​

Kiln's world is a Koota ECS. An entity is an id. A trait is a piece of data an entity can have. Systems query entities by their traits and change them. Kiln re-exports Koota's trait, relation, Not and Or as they are, and wraps the rest (systems, commands, resources, events) so access can be checked.

Traits ​

ts
import { trait } from 'kiln'

/** Linear velocity in world units per second. */
export const Velocity = trait({ x: 0, y: 0, z: 0 })   // struct of arrays

/** The enemy's current path, one array per entity. */
export const Path = trait(() => [] as { x: number; z: number }[])   // one object per entity

/** Tag: the entity the input controls. */
export const IsPlayer = trait()                        // no data
FormStorageUse for
trait({ x: 0 })Struct of arrays: one array per field. Fast.Numbers, strings, booleans
trait(() => obj)One object per entityThree.js objects, arrays, maps, class instances
trait()NothingTags and markers

The object passed to a struct trait holds the defaults. Add a trait with its defaults by passing the trait itself, or override fields by calling it:

ts
commands.spawn(Transform, Velocity, IsPlayer)
commands.spawn(Transform({ x: 3, y: 1 }), Velocity({ x: -2 }))

Struct traits are what kiln run --snapshot, saves and recordings capture as JSON. Object traits appear in snapshots only when their value is JSON-friendly (or has toJSON); Three.js objects are skipped.

Transform ​

Transform is the one trait almost every entity has: position x y z, rotation as a quaternion qx qy qz qw, and scale sx sy sz. Gameplay writes it and the render plugin copies it to Three.js, interpolated between fixed steps.

ts
setYaw(t, angle)            // rotation about Y, radians
setEuler(t, x, y, z)        // XYZ Euler angles, radians
setAngle2D(t, angle)        // rotation about Z, for 2D mode
angle2D(t)                  // read the Z angle back

Math helpers come along: vec3, origin, distSq, dist, clamp, lerp. Compare distSq against a squared radius to skip the square root.

Relations ​

A relation links one entity to another. Kiln ships one, and you can define your own with Koota's relation:

ts
/** Scene hierarchy; the child's Transform is local to the parent. */
ChildOf   // relation({ exclusive: true, autoDestroy: 'orphan' })

/** Who this turret is aiming at. */
export const Targets = relation({ exclusive: true })

commands.spawn(Transform({ y: 1 }), Hat, ChildOf(player))
commands.add(turret, Targets(enemy))

ChildOf is the scene graph. A child's Transform is relative to its parent, and despawning a parent despawns its children. Unlike Object3D.add, the hierarchy survives snapshots and hot reload.

Queries ​

A query is a list of traits. An entity matches if it has all of them:

ts
[Mut(Transform), Velocity]          // has Transform and Velocity; writes Transform
[Transform, IsEnemy, Not(Dead)]     // has Transform and IsEnemy, but not Dead
[Transform, Or(IsCoin, IsGem)]      // has Transform and at least one of IsCoin, IsGem
[Transform, Changed(Health)]        // Health changed since this system last ran
[Added(IsEnemy)]                    // gained IsEnemy since this system last ran
[Removed(IsEnemy)]                  // lost IsEnemy (or was despawned) since this system last ran
  • Mut(T) marks a trait the system writes. See Systems.
  • Tags (IsEnemy), Not, Or and the change filters narrow the match but give no row entry.
  • Changed, Added and Removed get a separate tracker per system, so two systems watching the same trait never steal each other's changes.

Commands ​

Structural changes (spawning, despawning, adding and removing traits) never happen in the middle of a system. They go through commands, which queue them and apply them in order at the end of the current phase:

ts
const bullet = commands.spawn(Bullet, { at, dir })    // a prefab and its params
commands.spawn(Transform({ x: 1 }), Velocity)         // or a list of traits
commands.despawn(entity)                              // twice, or on a dead entity: a no-op
commands.add(entity, Stunned, Timer({ duration: 1 }))
commands.remove(entity, Stunned)
commands.run((world) => { /* any world change, applied in order with the rest */ })

commands.spawn returns a PendingEntity. Pass it to other commands in the same system (they apply in order, so they see it), or to a tween. Its .entity is set once commands have applied; calling .get() before then throws and explains why.

Inside a system, calling entity.destroy(), entity.add() or world.spawn() directly throws in dev builds with the commands call to use instead.

Outside systems ​

Tests and tools work on the world directly through the app:

ts
const p = app.spawn(Player, { at: origin })   // immediate, not queued
app.set(p, Transform, { x: 4 })
app.get(p, Transform)?.x                      // a copy of the values
app.has(p, Health)
app.query(IsEnemy, Not(Dead))                 // entities, right now
app.despawn(p)

Calling these from inside a system throws: systems use their context. See Scenario tests.