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
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
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); chainableaspect()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 astatic aspectname; for your own aspects the build extracts the name from the first generic parameter (Aspect<'health', …>→node.health). has()is a type guard: insideif (node.has(Physics2D)),node.physicsis typed as present.
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
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.nodeis 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(), nevernew— don't write a constructor. Initialize inonAttach(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.worldPositionare 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).
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.
updateBeforePhysics and order are read once, at attach time — set them as
class fields (or in the attach opts), not later.
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
let boss: With<Sprite, Health | Physics2D> // a Sprite KNOWN to have both aspects
boss.health.hurt(10) // no guard neededUnion 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