LeCodesdocs

Scene files — defineScene

A scene as data: one defineScene({...}) call, conventionally the default export of a .scene.ts file. The argument is a plain literal — nodes keyed by name, each with one source block (mesh / model / light, or none = group node), a transform, an optional material, aspects: [use(Ctor, props), …] and children. It lowers to ordinary SDK calls (Mesh factories, node.aspect(), scene.add), so a scene file runs on every platform like any other code. The le.codes visual scene editor reads and writes these files; they're equally fine to write by hand.

At a glance

TypeScript
// city.scene.ts
import { Spinner } from './spinner'

export default defineScene({
  env: { skybox: '#10131a', bloom: true },
  camera: { position: [0, 6, 12], target: [0, 0, 0] },
  nodes: {
    ground: {
      mesh: { kind: 'box', size: [20, 1, 20] },
      material: { lit: { color: '#444444' } },
      position: [0, -0.5, 0],
      aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
    },
    hero: {
      model: asset('./hero.glb'),
      position: [2, 0, 0],
      aspects: [use(Spinner, { speed: 2 })],
      children: {
        halo: { mesh: { kind: 'sphere', radius: 0.2 }, position: [0, 2, 0] },
      },
    },
    sun: { light: { kind: 'sun', shadowsQuality: 2 } },
  },
})
TypeScript
// main.ts
import city from './city.scene'

const { scene, nodes } = await city.open()
nodes.hero.anim.play('idle')        // nodes.<name> is typed by its source block
nodes.ground.physics                // …and carries its use()'d aspects

The handle

defineScene returns a SceneHandle:

Member What it does
load() Instantiate the scene (async — models fetch). Idempotent: one instance per handle.
open() load() + make the scene active. Returns the same { scene, nodes }.
def The literal you passed in.

nodes is a flat name → node map across the whole tree (children included), each entry typed by its source: meshMesh, modelModel, lightLight, none → Node — with the use(...)d aspects reflected in the type. Node names must be unique per scene file.

Node entries

Key Meaning
mesh { kind: 'box' | 'sphere' | 'cylinder' | 'plane', …geometry options }
model GLB url — asset('./hero.glb')
light { kind: 'sun', …SunOptions }
make A code-built subtree — make(factoryFn, { …literal args }) (below)
prefab Another scene file used as a reusable composition — its imported handle (below)
camera {} — this node IS the scene camera (below)
material For a mesh: { lit: { color?, roughness?, metallic? } }, { unlit: { color? } }, { shadow: color }, or a shared Material instance
position, eulerAngles, scale, visible Transform / visibility
locked Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect
castShadows, receiveShadows Mesh render flags
aspects [use(Ctor, props), …] — attach order matters (e.g. Shape before Physics)
children Nested node entries, parented under this node
overrides Model/prefab sources: transform overrides for the internal nodes (below)

At most one source block per node. Every def node joins the scene's draw set (membership and parenting are separate — see node.md).

overrides — posing a GLB's internal nodes

A model node may reposition the nodes inside its GLB without touching the file. Keys are part paths/-joined node names from the model root down; each value takes position, eulerAngles, scale, visible:

TypeScript
robot: {
  model: asset('./robot.glb'),
  position: [2, 0, 0],
  overrides: {
    'Body/Head': { eulerAngles: [0, 30, 0] },
    'Body/ArmL': { position: [-0.7, 0.2, 0], visible: false },
  },
},

A segment is the child's name; duplicates among siblings disambiguate as name[i], unnamed nodes as [i] (index within that same-named group). The scene editor writes these paths for you — expand a model node in its tree and drag a part. Paths that no longer resolve (the GLB changed) are skipped silently. Overrides apply once at load, after the model instantiates; a playing animation clip will overwrite the transforms of the joints it drives.

Camera nodes

camera: {} makes a node the scene camera: when the scene runs, the view follows the node's world transform every frame (position + orientation; the camera looks down the node's local −Z). Because it's an ordinary node, everything composes — parent it, animate it with the scenario aspects (FollowPath on a camera node is a flythrough), or drive it from your own aspect:

TypeScript
camera: { camera: {}, position: [0, 5, 10], eulerAngles: [-26, 0, 0] },

The first camera node in file order wins; camera nodes inside a prefab are ignored (a prefab's own camera:/env: blocks already are). The older top-level camera: { position, target } block still works as a fallback when no camera node exists — it seeds the view once and nothing tracks it afterwards. In the visual editor a camera node renders as a frustum marker, the editor's own orbit camera stays independent, the Set from current view inspector button poses the node from your viewpoint, and the last camera node can't be deleted.

Empties (plain group nodes)

A node with no source block is a group — an invisible transform. The editor calls these Empty and draws a small axis-cross marker for them while editing (markers are editor-only, never part of the running scene). Empties are the working material of no-code scenes: MoveTo targets, FollowPath waypoints (children of a path empty), LookAt subjects, and plain folders for organizing children.

The generated-file header

Every write from the visual editor starts the file with a two-line comment saying the file is editor-managed. Hand edits remain first-class — values outside the literal grammar are preserved verbatim and shown read-only in the inspector — but formatting, key order, and number rounding (4 decimals) are normalized on the next editor write. Hand-written scene files gain the header the first time the editor writes them.

make(fn, args) — code-built nodes

When a subtree is naturally the output of a function — a fence line, a spiral stair, a procedural cluster — reference the factory instead of hand-writing nodes:

TypeScript
// props.ts — a plain function returning a Node
export const buildFence = (args: { posts?: number, gap?: number }): Node => { … }

// city.scene.ts
fence: {
  make: make(buildFence, { posts: 6, gap: 1.2 }),
  position: [-5.5, 0, 3.5],
},

The factory runs after every scene node exists (so ref() args resolve, forward references included) and may be async; its returned subtree mounts under the def node. The split of ownership is the point:

  • The def owns the transform. Moving/rotating the node is an ordinary transform edit — the factory is never re-called for it.
  • The args own the content. Args must be literal data (the same grammar as aspect props, ref() included); in the scene editor each arg is an inspector field, and editing one re-calls the factory live — no compile, since the function is already in the running bundle. A ref() arg also re-calls when the referenced node changes.

Keep factories pure builders: same args → same subtree, no side effects outside the returned nodes. make mirrors use(Ctor, props) deliberately — callee by identifier, literal props — which is what keeps the call editable; a call written any other way (computed args, inline function) is preserved verbatim but shows as "set in code".

prefab — scene-in-scene

A .scene.ts file already is a data-described composition — so scene files can instance each other. Import another scene's handle and use it as a source block:

TypeScript
import streetlamp from './streetlamp.scene'

lamp: {
  prefab: streetlamp,
  position: [4, 0, 2],
  overrides: { head: { visible: false } },
},

Each instance builds the prefab's nodes fresh under a plain wrapper node — instances are independent, and editing the prefab file changes every instance on the next load. Semantics:

  • The wrapper is an ordinary node — transform, visible, locked, aspects, children all work; the instance's internals live under it.
  • ref()s inside a prefab resolve file-locally, per instance — a prefab's aspects and make() factories see that instance's own nodes, never the instancing scene's. The prefab's internals don't appear in the instancing handle's flat nodes map (names can't collide across instances).
  • overrides poses the internals exactly like a GLB's — the same part-path grammar, addressed by the prefab's node names ('pole/bulb'); nested models inside the prefab drill further. In the scene editor, expand a prefab node in the tree and drag a part — the edit persists as an override.
  • The prefab's env/camera are ignored when instanced — the instancing file owns the scene.
  • Prefabs can nest; an import cycle (a scene instancing itself, directly or transitively) is detected at load and that instance is skipped with a console error.

use(Ctor, props)

References an aspect by class — the import is the registration, and props is typechecked against the aspect's fields exactly like node.aspect(Ctor, props). Behavior never lives in the scene file: write a custom aspect in a normal .ts file and attach it with use(...).

ref(name) — node references in aspect props

An aspect prop can point at another scene node by name:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
}

// in the scene file — `road` may reference nodes declared below it
road:   { aspects: [use(Road, { from: ref('pointA'), to: ref('pointB') })] },
pointA: { position: [1, 0, 0] },
pointB: { position: [5, 0, 0] },

Nodes are instantiated first and aspects attach after, so declaration order doesn't matter. ref() markers resolve to the live nodes at attach (top-level props and one array level deep — waypoints: [ref('a'), ref('b')] works); an unknown name resolves to null, so type such fields Node | null. Scene files only — hand-written code passes nodes directly: node.aspect(Road, { from: nodes.pointA }). The scene editor shows ref fields as a node dropdown with a pick-in-viewport button (a Node | null type annotation is enough; editor: 'node' in static fields also works).

Inspector metadata (static fields)

The visual editor infers an editor widget for each public field from its default value (number, boolean switch, '#rrggbb' color, [x, y, z] vec3, string). An optional static fields on the aspect class refines that — ranges, labels, dropdown options, or hiding a field:

TypeScript
class Spinner extends Aspect<'spinner'> {
  speed = 1
  mode: 'local' | 'world' = 'local'

  static fields: FieldMeta<Spinner> = {
    speed: { min: 0, max: 20, step: 0.1 },
    mode: { options: ['local', 'world'] },
  }
}

Generator aspects (static editor)

An aspect that derives scene content from its props — a road between two nodes, a fence along a path — can opt into running while the scene is edited:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
  width = 2

  static editor = { rebuild: true }

  onAttach() { this.rebuild() }
  rebuild() {
    this.generated.clear()                      // idempotent: clear, then create
    if (!this.from || !this.to) return
    // …spawn meshes with this.generated.add(mesh)…
  }
}

The contract:

  • rebuild() regenerates the output under this.generated — a scene-added container child provided before onAttach/rebuild run (nodes add()ed to it join the draw set automatically; clear() destroys the previous output). Provided for scene-file attachments; hand-attached aspects don't get one.
  • Play mode: nothing special — onAttach runs normally and calls rebuild() itself.
  • Edit mode: the class is instantiated for real (unlike ordinary aspects, which stay inert data) — node/generated set, ref() props resolved, rebuild() called — but never onAttach (no physics, timers, or loops while editing). The editor then re-runs rebuild() whenever a prop changes in the inspector or a node referenced by a ref() field moves — drag pointA and the road follows. A throwing rebuild() logs and skips; it can't take the editor down.
  • Generated output is derived, never saved: it doesn't appear in the scene file or the editor's node tree; the file stores the recipe (the aspect entry), the viewport shows the result.

Custom inspector cards (static inspector)

For full control over how an aspect looks in the scene editor's inspector, declare a static inspector(ui, aspect) — an immediate-mode function (think imgui): it re-runs on every edit or interaction and describes the card with InspectorUI calls. Without it, the editor shows the inferred fields; ui.auto() emits those same fields, so a custom card usually starts there and appends status lines, buttons, or dynamic dropdowns:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
  width = 2
  static editor = { rebuild: true }

  static inspector(ui: InspectorUI, road: Road) {
    ui.auto()                                    // the inferred fields, as usual
    if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
    else ui.warn('Assign both endpoints')
    if (ui.button('Shuffle')) road.rebuild()     // true on the click's run — act right there
  }
}

The InspectorUI vocabulary: fields — number / slider / text / color / switch / select(key, options) / vec2 / vec3 / vec4 / node (each emits a widget AND returns the current value); actions & text — button(label) (true on the run that consumes the click), header, info, warn; and auto(...keys) for the inferred fields.

Binding rule — one vocabulary, two lifetimes: a field whose key is a declared class field is doc-bound (the editor persists edits to the scene file, with undo); any other key is transient editor state, kept per card and never written anywhere (a preview clip choice, a brush size). select options may be computed fresh every run, so live data makes dropdowns for free.

The aspect argument is the live editor instance for generator classes (static editor), or a preview instance (with node set, refs resolved, never attached) for plain aspects. A throwing inspector logs and renders a warning line — it can't take the editor down. Model nodes get a built-in Animation card (clip / speed / loop, Play/Stop) built on this same protocol.

Editor-only code (EDITOR + *.editor.ts)

EDITOR is a compile-time boolean: true in scene-editor bundles, false in shipped ones — and in production the compiler const-folds it, so if (EDITOR) { … } branches (and everything only they reference) are removed entirely. Gate debug helpers, editor warnings, or expensive validation with it at zero shipped cost:

TypeScript
if (EDITOR && this.segments > 500) console.warn("road: very dense — consider fewer segments")

*.editor.ts files are the file-level form: they are compiled and executed only in editor bundles (imported automatically, after the scene file), and are invisible to production compiles — a production build never even parses them, and they are skipped by entrypoint detection. Editor plugins (windows, viewport tools) live in these files — see editor-plugins.md.

Edit mode (how the scene editor runs a scene file)

When the le.codes scene editor runs a scene bundle it sets a host flag before execution: sources are instantiated for real (the viewport shows the scene), but aspects are held as data without attaching — no onAttach side effects, no update() ticks, physics stays inert. Pressing Play runs the project normally. Hand-written code never sees this mode.