Aspects

An aspect is the behaviour of ONE node — a door, an enemy, a pickup: its state, its per-frame update and its events, attached with node.aspect(Class). Behaviour of the whole scene (input, a game mode, a spawner, the HUD) is a System; neither is a hand-written setLoop / tick() loop.

  • give a node its own behaviour: an enemy, a door, a pickup
  • run code on a node every frame
  • feed a physics body before the step, follow it after
  • make one behaviour update after another in the same frame
  • react when an enemy dies or a barrel explodes
  • find every live instance of a behaviour
  • keep a pause menu animating while the game is paused

Platforms: iOS, Android, desktop, the web player, headless tests; the web (Web Lite) on 3D nodes only — it has no 2D.

At a glance

TypeScript
class Health extends Aspect<'health', Sprite, { died: (who: Sprite) => void }> {
  hp = 100                                     // class fields = configurable defaults
  hurt(n: number) {
    this.hp -= n
    if (this.hp <= 0) { this.emit('died', this.node); this.node.destroy() }
  }
}
class Bob extends Aspect<'bob', Sprite> {
  t = 0
  update(dt: number) {                         // every frame; dt in game seconds
    this.t += dt
    this.node.y = 100 + Math.sin(this.t * 4) * 8
  }
}

const scene = new Scene2D()
const hero = new Sprite({ size: [32, 48], color: '#4f7cff' })
  .aspect(Health, { hp: 80 })                  // attach + configure; chains — returns the node
  .aspect(Bob)
scene.add(hero)
scene.open()
hero.health.on('died', () => console.log('hero down'))   // each aspect adds a named accessor
hero.health.hurt(30)                           // hp 50

Attach a behaviour to a node: node.aspect, get, has, removeAspect

node.aspect(Class, opts) attaches a behaviour to a node and configures it. The built-ins (SpriteAnimation, Shape2D, Physics2D) are aspects too and attach the same way.

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. A variable typed before the chain (const n = new Node(), then n.aspect(…)) does not gain it — keep the chain's result, or use get() / has().
  • 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). removeAspect deletes it again.
  • 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, deepest node first — onDetach runs (physics bodies released), updaters stop, listeners drop — so a destroyed node never ticks again. A node the runtime frees underneath (an ancestor destroyed or dropped) detaches its aspects too.
Note

calling .aspect(Class, opts) on an already-attached aspect does not re-attach — it Object.assigns the opts onto the existing instance and calls onReconfigure (without opts, nothing happens). On the first attach the opts are assigned before onAttach, which runs once.

Write your own behaviour: class … extends Aspect

A behaviour is a class that extends Aspect<name, node kind, events>: its fields are its settings, its methods the lifecycle and the per-frame phases.

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)
  }
}
const hero = new Sprite({ size: [32, 48], color: '#4f7cff' })
const pet = new Sprite({ size: [16, 16], color: '#ffcc00' })
  .aspect(Follow, { target: hero, speed: 6 })
  • this.node is the node the aspect is attached to, typed to the second generic parameter (default: the 3D Node; a 2D aspect names its kind, Aspect<'x', Sprite>). Attaching to a wrong node kind is a compile error. The target may itself carry aspects — Aspect<'steer', With<Sprite, Physics2D>> only attaches to a sprite already typed as having Physics2D, and reads this.node.physics without a guard.
  • 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.
  • this.scene is set only on a system and on a generator aspect (static editor = { rebuild: true }) placed by a scene file. On any other node aspect it reads undefined at run time, although it is typed Scene. An aspect that needs the scene gets it in the opts: node.aspect(CameraFollow, { scene }).

Run code every frame: update, updateBefore, updateFixed

The phase is chosen by which method you define; each runs while attached, in lockstep with the engine's frame. One frame, in order:

  1. every updateBefore(dt) — EARLY, before the physics step;
  2. the fixed steps, 0–4 of them: every updateFixed(dt), then one physics substep;
  3. the engine: transform sync, animators;
  4. every update(dt) — LATE, right before the frame draws.
  • update(dt) — LATE (the default home for game logic). 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. What you feed the simulation here (velocity, controller.move(), forces, kinematic transforms) is consumed by this frame's step: zero input latency. Reads here see last frame's settled state.
  • updateFixed(dt) — FIXED. The simulation's own clock: dt is always Time.fixedDt (1/60 s) on every machine, and under Time.scale the number of calls per frame changes, never dt (at 16 ms frames: 57 calls per 60 frames at scale 1, 29 at 0.5). A step comes once 1/60 s of game time has piled up, so some frames have none; a stall past four steps drops the backlog. The home of simulation that must match another machine (a multiplayer player) or must not drift with the frame rate; controller.move() / velocity writes feed the substep that follows. Anything that looks at the screen stays in updateBefore / update.
TypeScript
class Steer extends Aspect<'steer', With<Sprite, Physics2D>> {
  dir = 1
  updateBefore(dt: number) { this.node.physics.velocity = [this.dir * 120, 0] }   // FEEDS the sim
}
class CameraFollow extends Aspect<'cameraFollow', Node> {
  update(dt: number) { this.scene.camera.position = this.node.worldPosition }     // READS the result
}
const world = new Scene()
const rig = new Node().aspect(CameraFollow, { scene: world })   // this.scene: handed over in the opts

Rule of thumb: aspects that write to the simulation define updateBefore (or updateFixed); aspects that read results (cameras, followers, UI sync) define update. A class may define several phases, but when a behaviour needs two (feed the sim, then pose something after the animators), two aspects on the same node read better; the late one reads the early one through the node's accessor.

Note

one engine drives the phases: the one whose scene the app created first. A 3D game that also builds a Scene2D (a minimap) ticks every aspect, 2D ones too, in the 3D frame. When the 2D engine drives them, the fixed steps run right after the early phase, before the 2D physics step (the SDK keeps the 1/60 accumulator itself there). Inside updateFixed, Time.dt still reads the frame's delta — use the dt argument.

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.

Pause and slow motion: dt, updateWhilePaused

dt is game time: seconds since the last frame (at most 0.05 — the engines clamp a stall) scaled by Time.scale, and 0 while Time.paused — a paused frame skips every updater except those with updateWhilePaused = true (a HUD fade, the pause menu), which then read Time.unscaledDt for wall-clock motion. No fixed step happens while paused, not even for those.

Update order: one behaviour after another — static after / before, updateOrder {#order-within-a-phase}

To move the player after the input is read in the same frame, name the class it runs after: static after = [Controls].

TypeScript
class Controls extends System<'controls'> {
  moveX = 0
  updateBefore() { this.moveX = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) }   // poll input
}
class Player extends Aspect<'player'> {
  static after = [Controls]        // rank: after everything named here…
  controls!: Controls              // handed over in the attach opts
  speedX = 0
  updateBefore() { this.speedX = this.controls.moveX * 5 }
}
class Telemetry extends System<'telemetry'> {
  updateBefore() { /* sample the frame */ }
}
class Stamina extends System<'stamina'> {
  static after = [Player]
  static before = [Telemetry]      // …and before everything named here
  updateOrder = 5                  // numeric tie-break inside a rank (ascending, default 0)
  updateBefore() { /* drain while running */ }
}

const scene = new Scene().system(Telemetry).system(Stamina).system(Controls)
scene.add(new Node().aspect(Player, { controls: scene.controls }))
// the early phase runs Controls → Player → Stamina → Telemetry, whatever the attach order

Declared constraints come first, then updateOrder, 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. updateOrder 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); in a cycle the edge that closes it is ignored, with one [aspects] ordering cycle … warning. Aspects and systems share the lists, so after can name either.

Note

updateOrder is read when the phase list is (re)sorted — after an attach — so set it as a class field. updateWhilePaused 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.

React to what an aspect does: events {#events-what-an-aspect-tells-the-world}

Other code learns that an enemy died or a barrel exploded through the aspect's events: the third generic parameter is the event map. emit is protected — only the aspect itself fires its events; anyone holding the instance can on / off (both chain):

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) }
}
const crate = new Node().aspect(Barrel)
const onExplode = (b: Barrel, power: number) => console.log('boom', power, b.node.worldPosition.y)
crate.barrel.on('explode', onExplode)
crate.barrel.hit(40)                            // boom 3 0
crate.barrel.off('explode', onExplode)          // remove by the same function reference

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

Type a node that carries aspects: With<N, A>

TypeScript
function knockBack(boss: With<Sprite, Health | Physics2D>) {   // a Sprite KNOWN to have both aspects
  boss.health.hurt(10)                                          // no guard needed
  boss.physics.velocity = [0, -200]
}

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, and as an aspect's target (Aspect<'steer', With<Sprite, Physics2D>>, above).

Pitfalls

TypeScript
// ✗ the names before 2026-08-26 are plain fields now: they compile and do nothing
order = 5; runWhilePaused = true; updateBeforePhysics = true
// ✓
updateOrder = 5; updateWhilePaused = true   // and define updateBefore(dt) for the early phase

// ✗ this.scene in a node aspect attached by hand: undefined, the first update throws
new Node().aspect(CameraFollow)
// ✓ hand the scene over
new Node().aspect(CameraFollow, { scene })

See also

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