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
// 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 })],
},
},
})// 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.tssheet:{ sheet, name?, clip?, direction? }.nameis optional when the sheet declares exactly one sprite. Sheets are ART only — a node that should collide getsaspects: [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, andencodeCells(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 anAutotile2Daspect 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; attachCameraFollowto the tracked node for that. The top-levelcamera: { position, zoom }prop is the node-less alternative (a camera node wins).make—make(factoryFn, { ...literal args }): a code-built subtree (factory returns aNode2D, or a promise of one), run after every node exists soref()args resolve. The returned node is parented under this one and added to the scene with its children.- none — a plain group / marker node (
Trigger2Dzones, spawn points).
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*rowsNode 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 toScene2D.env.gravity— presence callsPhysics2D.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: seedsCameraFollow.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
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 }).
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.