LeCodesdocs

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.

At a glance

TypeScript
class Health extends Aspect<'health', Sprite> {
  hp = 100
  hurt(n: number) {
    this.hp -= n
    if (this.hp <= 0) 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.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() 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.
Note

calling .aspect(Class, opts) on an already-attached aspect does not re-attach — it just Object.assigns the opts onto the existing instance. 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 update(dt)

An aspect that defines update(dt) is ticked every frame while attached (dt = seconds since the last frame). Updates run in one of two phases:

  • LATE (default) — after the physics step and transform sync, right before the frame draws. Reads of node.worldPosition are the final drawn position, so cameras and followers land exactly, with no one-frame lag.
  • EARLY (updateBeforePhysics = true) — before the physics step, so writes of velocity, forces, or kinematic transforms are consumed by the same frame's step (zero input latency).
TypeScript
class Steer extends Aspect<'steer', Sprite> {
  updateBeforePhysics = true      // this aspect FEEDS the simulation — run before the step
  order = -10                     // tick order within a phase: ascending; default 0, ties keep attach order
  update(dt: number) { /* write this.node.physics.velocity here */ }
}

Rule of thumb: aspects that write to the simulation go EARLY; aspects that read results (cameras, followers, UI sync) stay in the default LATE phase.

Note

updateBeforePhysics and order are read once, at attach time — set them as class fields (or in the attach opts), not later.

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.

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

  • Conventions — the chaining style aspects fit into
  • Events — nodes are also emitters (addEventListener)
  • Sprite — a typical aspect host