Node

The base 3D scene node — an empty transform. Mesh, Model, Light and Camera all extend it, so everything here works on them. The native engine owns the authoritative transform; the SDK reads it back on every access (physics can rewrite it every frame), and every transform getter returns a fresh copy — see value semantics.

At a glance

TypeScript
const scene = new Scene()
scene.add(Light.sun())
scene.open()

const pivot = new Node()
pivot.position = [0, 1, -2]
pivot.eulerAngles = [0, 45, 0]          // degrees
pivot.add(Mesh.box({ material: Material.lit({ color: '#38f' }) }))
scene.add(pivot)                        // the box under it joins the scene too

pivot.animateTo({ eulerAngles: [0, 405, 0], duration: 4000, easing: 'linear', loop: true, loopMode: 'restart' })   // a turn every 4 s

new Node() takes no options — create it, then set its transform.

Local transform

TypeScript
node.position = [x, y, z]         // Vec3Like; world units ≈ meters
node.x = 3                        // scalar accessors: x / y / z
node.quaternion = Quat.fromEuler(0, 90, 0)
node.eulerAngles = [0, 90, 0]     // DEGREES (YXZ)
node.scale = 2                    // uniform, or [sx, sy, sz]
node.matrix = Mat4.compose([0, 1, 0], Quat.identity, 1)   // full local TRS

Getters return fresh Vec3 / Quat / Mat4 copies — mutating one never touches the node. One exception is compiled in: the direct spelling node.position.x = 3 is rewritten to node.x = 3 (the chisel comp_write pass), so it works — but a copy you STORED first is still a copy:

TypeScript
node.position.x = 3                                    // ✓ compiled to node.x = 3 (direct spelling only)
node.x = 3                                             // ✓ single-axis setter
const p = node.position; p.x = 3                       // ✗ silent no-op — a stored copy
const p = node.position; p.y += 1; node.position = p   // ✓ mutate a local, assign back

World space

TypeScript
node.worldMatrix           // get/set: full world transform (setting composes through the parent)
node.worldPosition         // Vec3 (read-only)
node.worldQuaternion       // Quat (read-only)
node.worldEulerAngles      // Vec3, degrees (read-only)
node.worldScale            // Vec3 (read-only)
node.forward               // Vec3: the node's forward (−Z) axis in world space (read-only)

A parent's transform applies to its children: a child at local [1, 0, 0] under a parent at [0, 1, -2] turned [0, 90, 0] sits at world [0, 1, -3] (a +90° yaw turns +X to −Z, and the parent's forward from −Z to −X).

Name & visibility

TypeScript
node.name = 'door'         // stored natively; bone() and Model child lookups match on it
node.visible = false       // hides the node and everything under it

visible reads the hierarchy: a node under a hidden ancestor reads false. name is a display name, unique only among siblings; scene files address nodes by path — /-joined names from the scene root (see scene-files.md).

Hierarchy

TypeScript
node.add(...children: Node[]): this                      // parent children under this node
node.setParent(parent: Node | null, worldPositionStays = false): this
node.parent                                              // Node | null
node.children                                            // Node[] (snapshot array)
node.childCount
node.getChild(index: number): Node | null
node.traverse(callback: (node: Node) => void): void      // this node, then all descendants
node.bone(name: string): Node | null                     // a descendant by name — a Model's bones included

With worldPositionStays: true, setParent keeps the node where it is in the world (its local transform is recomputed); with the default false the local transform is kept as-is — the node jumps to the same offset from its new parent (setParent(null) included).

bone(name) finds a descendant by exact name, then by the part after the last : / | (mixamorig:Hips answers bone('Hips')); a skinned joint wins over a plain node of the same name. Cached per name; null when absent. Parent a weapon to a hand with hero.bone('RightHand')?.add(sword).

Note

scene membership follows the parent. scene.add(node) puts a node and its current subtree in the draw set; a child added under a node that is already in a scene joins that scene, so add a hierarchy's root and parent the rest. See Scene.

lookAt

TypeScript
node.lookAt(point: Vec3Like, mode = '-z', ortho = [0, 1, 0]): this
// mode: 'z' | '-z' | 'x' | '-x' | 'y' | '-y' — which local axis points at the target

Orients the node so the chosen local axis (default '-z', the forward axis) points at a world-space point. ortho is the up-hint used to resolve roll. Scale is preserved.

Note

only '-z' and 'z' build a proper rotation. The 'x' / '-x' / 'y' / '-y' modes aim the requested axis correctly but return a mirrored basis (determinant −1 — the node is reflected); aim with '-z' and turn a child instead.

Tweens

TypeScript
node.animateTo(props & { duration?, delay?, easing?, loop?, … }): Animation
node.animateFrom(props & { … }): Animation      // from these values to the current ones
// props: position, scale, quaternion, eulerAngles (degrees) — an array of values is a keyframe list

Runs natively on the game clock — it pauses with the game; clock: 'ui' runs on wall time — with no JS per frame; duration is in milliseconds. eulerAngles interpolate per axis without shortest-arc, so [0, 720, 0] spins twice. The props every target animates: Timeline; the handle it returns: animate. A node a physics body or a character owns is not routed through it — see physics.

Materials of a renderable

TypeScript
node.material                                  // slot 0 — get/set (null until one is set)
node.setMaterial(material: Material, index = 0): this
node.getMaterial(index = 0): Material | null   // what was assigned through this API

Any renderable takes a Material per primitive slot — a Mesh, or an internal node of a loaded Model (index = the primitive's order in the glTF mesh). A GLB part's own glTF material stays native-side: material reads null until you replace it.

Aspects

Capabilities attach as aspects and chain (see aspects):

TypeScript
const ball = Mesh.sphere({ material: Material.lit() })
  .aspect(Shape, { sphere: 0.5 })                 // pick/collision shape → hittable, raycastable
  .aspect(Physics, { motion: 'dynamic' })         // rigid body → node.physics

See 3D physics & shapes for Shape, Physics, Trigger, CharacterController.

Events

TypeScript
node.addEventListener(channel, callback): void
node.removeEventListener(channel, callback): void
Channel Fires Needs
click pointer-up over the node a Shape aspect (pick body)
touchstart pointer-down over the node; ev.track() starts a drag a Shape aspect
enter / exit physics contact / trigger overlap began / ended ((other: Node)) Shape + Physics/Trigger

Without a Shape aspect a node is invisible to taps — only scene-level listeners will see the touch (with ev.target === null). The node's own listeners run before the scene's. Event objects and ev.track() drags: Pointer events & gestures.

Lifecycle

TypeScript
node.destroy(): void      // frees the node and its whole subtree now

A node lives while something holds it: your variable, a parent, or a scene. One in a scene or under a parent stays when you drop your variable; one in neither is freed — with its subtree — once you drop the last reference. destroy() is the early, explicit free: every aspect in the subtree detaches first (its onDetach runs — bodies released, updates stopped), then the entity tree goes. Every handle into the subtree is dead afterwards (id reads 0) — don't use it again. There is no dispose().

Pitfalls

TypeScript
// ✗ passing options to the constructor — it takes none; an object type-checks but the node has no engine entity
const n = new Node({ position: [0, 1, 0] })
// ✓ create, then set
const n = new Node(); n.position = [0, 1, 0]

// ✗ mutating a STORED transform copy
const p = node.position; p.x += speed * dt   // no-op: getters return copies
// ✓ (node.position.x += speed * dt written directly compiles to this)
node.x += speed * dt

// ✗ writing the transform of a dynamic physics body every frame (an aspect's update)
update() { this.node.position = target }      // fights the physics step
// ✓ drive the body: node.physics.velocity / applyImpulse (see physics.md)

See also