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
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
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 orderaspect()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.Aspect.all(Barrel)is the registry a game used to hand-roll asstatic 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 —onDetachruns (physics bodies released), updaters stop, listeners drop — so a destroyed node never ticks again.
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
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 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 ofnode.worldPositionare 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.
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 runsupdate()in the early phase. PreferupdateBefore().
Order within a phase
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.
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.
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:
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 referenceListeners 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
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
- 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