Aspects

Attachable node capabilities — composition with the terseness of methods. Everything a node can do beyond its transform (animation, colliders, physics, your own game logic) attaches as an aspect instead of bloating the node class. Built-ins like SpriteAnimation, Shape2D, Physics2D are aspects; your behaviors extend Aspect the same way. Logic that has no single node to live on (input, FX pools, the HUD, the game mode) is a System — the same thing attached to the scene.

At a glance

TypeScript
class Health extends Aspect<'health', Sprite, { died: (who: Sprite) => void }> {
  hp = 100
  hurt(n: number) {
    this.hp -= n
    if (this.hp <= 0) { this.emit('died', this.node); this.node.destroy() }
  }
}

const hero = new Sprite({ texture })
  .aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { walk: [1, 2, 3] } })
  .aspect(Health, { hp: 80 })          // attach + configure; chains — returns the node
hero.anim.play('walk')                 // each aspect adds a named accessor
hero.health.on('died', () => gameOver())
hero.health.hurt(10)

Attaching, accessing, detaching

TypeScript
node.aspect(Class, opts?): node & { name: instance }   // attach + configure (or reconfigure)
node.get(Class): instance | undefined                  // safe access — undefined if not attached
node.has(Class): boolean                               // existence check AND type guard
node.removeAspect(Class): node                         // detach (runs onDetach); chainable
Aspect.all(Class): readonly instance[]                 // every live instance, attach order
  • aspect() returns the node typed as now-having that aspect, so the named accessor works without a guard right after the chain.
  • The named accessor (hero.anim, hero.health, …) comes from the aspect class: built-ins declare a static aspect name; for your own aspects the build extracts the name from the first generic parameter (Aspect<'health', …> → node.health).
  • has() is a type guard: inside if (node.has(Physics2D)), node.physics is typed as present.
  • Aspect.all(Barrel) is the registry a game used to hand-roll as static all[]: live and read-only — copy it ([...Aspect.all(Barrel)]) before a loop that detaches. Empty in the scene editor, where aspects are inert data.
  • node.destroy() detaches every aspect in the subtree first — onDetach runs (physics bodies released), updaters stop, listeners drop — so a destroyed node never ticks again.
Note

calling .aspect(Class, opts) on an already-attached aspect does not re-attach — it just Object.assigns the opts onto the existing instance and calls onReconfigure. onAttach runs once, on first attach only.

Writing a custom aspect

TypeScript
class Follow extends Aspect<'follow', Sprite> {   // <accessor name, target node kind>
  target?: Node2D
  speed = 4                                       // class fields = configurable defaults
  onAttach() { /* node is set; opts already assigned */ }
  onDetach() { /* cleanup: timers, listeners */ }
  update(dt: number) {                            // dt in seconds
    if (this.target) this.node.position = this.node.position.lerp(this.target.position, this.speed * dt)
  }
}
sprite.aspect(Follow, { target: hero, speed: 6 })
  • this.node is the node the aspect is attached to, typed to the second generic parameter. Attaching to a wrong node kind is a compile error.
  • Aspects are created by the engine via node.aspect(), never new — don't write a constructor. Initialize in onAttach (the node and the opts are set by then); declare configurable settings as class fields with defaults.

Per-frame updates: update(dt) and updateBefore(dt)

The phase is chosen by which method you define. Both run every frame while attached, in lockstep with the engine's frame (the native early/late hooks around the physics step):

  • update(dt) — LATE (the default home for game logic). Runs after the physics step, the transform sync and the animators, right before the frame draws. Reads of node.worldPosition are the final drawn position (cameras and followers land exactly, no one-frame lag), and what you write to a plain node is what this frame shows.
  • updateBefore(dt) — EARLY. Runs before the physics step, so what you feed the simulation (velocity, controller.move(), forces, kinematic transforms) is consumed by this frame's step: zero input latency. Reads here see last frame's settled state.
TypeScript
class Steer extends Aspect<'steer', Sprite> {
  updateBefore(dt: number) { this.node.physics.velocity = [this.dir * 120, 0] }   // FEEDS the sim
}
class CameraFollow extends Aspect<'follow', Node> {
  update(dt: number) { this.scene.camera.position = this.node.worldPosition }    // READS the result
}

Rule of thumb: aspects that write to the simulation define updateBefore; aspects that read results (cameras, followers, UI sync) define update. A class rarely needs both — when a behaviour does (feed the sim, then pose something after the animators), make it two aspects on the same node; the late one reads the early one through the node's accessor.

Every engine read is fresh per phase: node.worldPosition and friends go to the engine on each call, and the readouts the SDK caches per pass (a Vehicle's speed / rpm / wheel state) are re-read at the start of every phase. So a value read in updateBefore is last frame's settled state, and the same value read in update of the same frame is this frame's — nothing read early is ever served stale to a late reader.

dt is game time: seconds since the last frame scaled by Time.scale, and 0 while Time.paused — a paused frame skips every updater except those with runWhilePaused = true (a HUD fade, the pause menu), which then read Time.unscaledDt for wall-clock motion.

updateBeforePhysics = true (the older way to say "early") still works: it runs update() in the early phase. Prefer updateBefore().

Order within a phase

TypeScript
class Controls extends System<'controls'> { updateBefore(dt: number) { /* poll input */ } }
class Player extends Aspect<'player'> {
  static after = [Controls]        // rank: after everything named here…
  updateBefore(dt: number) { this.node.controller.move(this.scene.controls.moveX, this.scene.controls.moveY) }
}
class Hud extends System<'hud'> {
  static after = [Player]
  static before = [Telemetry]      // …and before everything named here
  order = 5                        // numeric tie-break inside a rank (ascending, default 0)
  update(dt: number) { … }
}

Declared constraints come first, then order, then attach order. A constraint is an edge between classes — a class with none has rank 0; one that names others ranks after all of them. order only breaks ties inside a rank, so a declared dependency always beats a number. Constraints naming a class that is not in the same phase are simply unconstrained there (early always precedes late); a cycle is warned once and ignored. Aspects and systems share the lists, so after can name either.

Note

order is read when the phase list is (re)sorted — at attach — so set it as a class field. runWhilePaused is checked every frame and may be toggled at any time.

Not implemented

updateWhenVisible is declared so aspects can opt in ahead of visibility culling, but it is currently a no-op — every registered aspect ticks regardless of on-screen state.

Events: what an aspect tells the world

The third generic parameter is the aspect's event map. emit is protected — only the aspect itself fires its events; anyone holding the instance can on / off:

TypeScript
class Barrel extends Aspect<'barrel', Node, { explode: (b: Barrel, power: number) => void }> {
  hp = 30
  hit(n: number) { this.hp -= n; if (this.hp <= 0) this.emit('explode', this, 3) }
}
node.barrel.on('explode', (b, power) => fx.blast(b.node.worldPosition, power))
node.barrel.off('explode', handler)          // remove by the same function reference

Listeners are dropped on detach (and on node.destroy()). Dispatch iterates a snapshot, so a listener may detach itself. This replaces the static onExplode = … hook pattern — there is one instance per node, and the game subscribes to the ones it cares about.

With<N, A> — typing a variable that carries aspects

TypeScript
let boss: With<Sprite, Health | Physics2D>    // a Sprite KNOWN to have both aspects
boss.health.hurt(10)                          // no guard needed

Union the aspects (not a tuple) — it reads as English and mirrors the runtime guard node.has(Health). Use it for fields and function parameters that receive nodes built elsewhere.

See also

  • Systems — the same lifecycle on a scene: scene.system(Fx)
  • Time — Time.scale, Time.paused, Time.dt
  • Conventions — the chaining style aspects fit into
  • Events — nodes are also emitters (addEventListener)
  • Sprite — a typical aspect host