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
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 50Attach 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.
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. A variable typed before the chain (const n = new Node(), thenn.aspect(…)) does not gain it — keep the chain's result, or useget()/has().- 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).removeAspectdeletes it again. 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, deepest node first —onDetachruns (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.
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.
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.nodeis the node the aspect is attached to, typed to the second generic parameter (default: the 3DNode; 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 havingPhysics2D, and readsthis.node.physicswithout a guard.- 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. this.sceneis 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 readsundefinedat run time, although it is typedScene. 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:
- every
updateBefore(dt)— EARLY, before the physics step; - the fixed steps, 0–4 of them: every
updateFixed(dt), then one physics substep; - the engine: transform sync, animators;
- every
update(dt)— LATE, right before the frame draws.
update(dt)— LATE (the default home for game logic). 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. 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:dtis alwaysTime.fixedDt(1/60 s) on every machine, and underTime.scalethe number of calls per frame changes, neverdt(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 inupdateBefore/update.
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 optsRule 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.
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].
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 orderDeclared 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.
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.
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):
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 referenceListeners 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>
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
// ✗ 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