2D scenes (.scene2d.ts)

2D scenes as data: one file default-exports defineScene2d({...}) — nodes keyed by name, each with one source block (sprite / tilemap / camera / make, or none = group node), a transform, use(...) aspects and children. The same grammar and escapes as 3D .scene.ts files (asset, use, ref, make, $expr survives round-trips); the visual 2D scene editor (lecodes-2d-editor) reads and writes these files.

At a glance

TypeScript
// forest.scene2d.ts — the sheets are the ones on the sprite-sheets page
import props from './props.sprite'
import hero from './hero.sprite'

export default defineScene2d({
  env: { background: '#5a6b3b', pixelPerfect: true, gravity: [0, 0] },
  layers: { 1: { ySort: true } },
  nodes: {
    ground: {
      tilemap: {
        texture: asset('./tiles.png'), tile: 32, atlas: [8, 4],
        cells: cells('BgAAAAQAAAAHAAAAAAAAAAQAAAABAAAAAgAAAAAAAAABAAAAAQAAAAIAAAACAAAAAQAAAAEAAAAHAAAAAAAAAA=='),
      },
      layer: 0,
    },
    tree1: { sprite: { sheet: props, name: 'tree' }, position: [160, 96], layer: 1 },
    player: {
      sprite: { sheet: hero, clip: 'idle' },
      position: [80, 64], layer: 1,
      aspects: [use(CameraFollow, { smooth: 0.12 })],
    },
  },
})
TypeScript
// main.ts
import forest from './forest.scene2d'
forest.open().then(({ scene, nodes }) => {   // nodes.player is a typed Sprite (+ .anim)
  nodes.player.anim.play('walk', [1, 0])
})

The default export is a Scene2dHandle: load() instantiates once (idempotent — later calls return the same { scene, nodes }), open() loads and makes it the active 2D scene, def is the definition. nodes holds every node by its key, children included; each node's name is its key.

Source blocks

  • sprite — one named entry of an imported .sprite.ts sheet: { sheet, name?, clip?, direction? }. name is optional when the sheet declares exactly one sprite. Sheets are ART only — a node that should collide gets aspects: [use(Shape2D, { box: [14, 9] }), use(Physics2D, { motion: 'static' })] (the scene owns world semantics).
  • tilemap — { texture, tile, atlas, cells }. cells('…') is an opaque one-line payload (dims + RLE Int32 cells, base64); hand-written code may pass any { cols, rows, data } value instead, and encodeCells(cols, rows, data) builds the payload string (map generators). Row 0 is the top row; the node's position is the map's bottom-left. With an Autotile2D aspect on the node the cells are material ids, not atlas indices.
  • camera — a marker whose world position (+ zoom) seeds the starting view. It does not follow anything; attach CameraFollow to the tracked node for that. The top-level camera: { position, zoom } prop is the node-less alternative (a camera node wins).
  • make — make(factoryFn, { ...literal args }): a code-built subtree (factory returns a Node2D, or a promise of one), run after every node exists so ref() args resolve. The returned node is parented under this one and added to the scene with its children.
  • none — a plain group / marker node (Trigger2D zones, spawn points).
TypeScript
cells(payload: string): { cols, rows, data: Int32Array }   // decode; throws on a malformed payload
encodeCells(cols, rows, data: ArrayLike<number>): string   // encode; data.length must be cols*rows

Node props

position · rotation (deg, CCW) · scale (number or [x, y]) · layer · z · visible · color / opacity / flipX / flipY (sprite nodes) · locked (editor-only) · aspects · children.

env, layers, physics

  • env.background, env.filter — passed to Scene2D.
  • env.gravity — presence calls Physics2D.configure({ gravity }) before any body is created ([0, 0] for top-down). Omit to leave the world's gravity alone.
  • env.pixelPerfect — snap the camera to whole screen pixels: seeds CameraFollow.pixelSnap, and a camera node's starting position is rounded to whole units.
  • layers: { 1: { ySort: true } } — per-layer config; Y-sort makes lower-on-screen sprites overlap higher ones (top-down depth).

CameraFollow

TypeScript
node.aspect(CameraFollow, {
  scene?: Scene2D              // whose camera follows — the scene-file loader wires it in
  smooth?: number              // ease time constant, seconds; default 0.12; 0 = pinned to the node
  offset?: [x, y]              // camera offset from the node, world units; default [0, 0]
  pixelSnap?: boolean          // whole screen pixels; default true (env.pixelPerfect in a scene file)
})

The built-in follow camera — a normal aspect, attached to the node the camera should track. It runs in the late phase (update), after physics, so the camera lands on the position drawn this frame. pixelSnap is measured from the real projection, so it holds at any zoom. From hand-written code pass the scene yourself: hero.aspect(CameraFollow, { scene }).

Note

a project's type-check currently rejects node.aspect(CameraFollow, …) with a this-context error (an SDK typing defect); the code compiles and runs. use(CameraFollow, …) in a scene file type-checks.

Aspects are held as data in edit mode (nothing falls or fires while editing); clip playback still runs. Prefabs (scene-in-scene) are not in the 2D grammar yet.