Scene2D
A 2D world for a game: the nodes it draws (sprites, tilemaps, 2D physics) plus an orthographic
Camera2D; open() shows it, one scene at a time. Not to be confused with
Scene, the 3D world; app screens are UIScreen.
- start a 2D game: create a scene and show it
- set the background color, or crisp pixel-art vs smooth texture filtering
- draw characters behind trees in a top-down game (y-sort)
- react to taps and drags anywhere in the 2D world
- find the sprite under a tap
- show the score or a pause button over the 2D game
- show a live minimap or a second 2D view inside the UI
- add scene-wide game logic (systems)
Platforms: iOS, Android, desktop, the web player, the le.codes viewer app; not on the web (Web
Lite, lecodes web), where new Scene2D() throws — check Scene2D.supported. Headless tests run
a scene but never draw it.
At a glance
async function main() {
const scene = new Scene2D({ background: '#10131a' })
scene.layer(1).ySort() // layer 1 sorts top-down by Y
const tex = await Texture2D.load(asset('./hero.png'))
const hero = new Sprite({ texture: tex, anchor: [0.5, 1], layer: 1 })
scene.add(hero)
scene.addEventListener('click', ev => {
console.log('tapped', ev.target?.id ?? 'empty space', 'at world', ev.worldX, ev.worldY)
})
scene.open()
}
main()Loading happens inside an async function — see no top-level await.
Create a scene: background color and texture filter {#creating}
new Scene2D(options?: {
background?: ColorInput // clear color
filter?: 'nearest' | 'linear' // texture sampling; default nearest (crisp pixel art)
})
scene.background = '#10131a' // settable later too
scene.camera // the scene's Camera2D (readonly)filter is a global renderer setting — one sampler for all textures, not
per-texture. It is (re)applied on open(), so the currently-open scene's choice wins. Use
'linear' for hi-res / non-pixel-art assets; omit to leave the current setting.
Show the scene and close it: open / close
scene.open(options?: { transition? }): this // become the visible destination; replaces a screen / another scene
scene.close(options?: { transition? }): this // stop showing it
scene.destroy(): void // detach its systems, close, free the native scene
Scene2D.active // static: the currently open Scene2D, or null
scene.onOpen(cb: () => void): this // became the visible destination (a pop back to it too)
scene.onClose(cb: () => void): this // stopped being visible (closed, replaced, covered)
scene.onBack(cb: () => void): this // system back while the scene is currenttransition defaults to 'none'. The scene itself never moves: the screen it replaces, or that
comes over it, plays the transition — see transitions.
the 2D engine — physics, sprite animation — advances only while a scene is open or
shown in a UIImage. Until then bodies hang where they were created.
on Android the 2D engine starts with the first texture upload (Texture2D.load, a
canvas texture): until then a scene draws nothing, not even its background, and does not step.
destroy() frees the scene, not its nodes: destroy() the nodes you are done with.
Add sprites and nodes to the scene: add / remove {#nodes}
scene.add(...nodes: Node2D[]): this
scene.remove(...nodes: Node2D[]): thisPuts exactly these nodes into (or takes them out of) the draw set. Membership is per node:
parenting (node.add(child)) builds the transform hierarchy only, so a
child is drawn — and found by pick() — once it is in the scene itself:
scene.add(cart, wheelA, wheelB). .scene2d.ts files add every node for you.
A node in a scene (or under a parent) is held by the engine: dropping your reference does not free
it. remove() hands a root back to its JS handle; node.destroy() frees it.
Characters behind trees: draw order, layers and ySort
scene.layer(n).ySort(enabled = true): LayerHandleNodes pick their layer with node.layer (higher layers draw on top; within a layer, node.z
orders). Calling .ySort() on a layer switches it to top-down depth sorting: sprites lower on
screen overlap sprites higher up — the classic "characters walk behind trees" look. On a Y-sorted
layer z is ignored.
Y-sort compares node positions, i.e. anchor points. Give characters and props on a
Y-sorted layer a bottom-center anchor (anchor: [0.5, 1] — the "feet") so depth sorts where
they touch the ground.
Find the sprite at a point: pick {#picking}
scene.pick(worldPoint: Vec2Like): Node2D | nullTopmost visible sprite of this scene's draw set whose world bounds (the axis-aligned box
around its quad) contain the point — highest layer, then highest z. A body-free hit-test, no
physics needed. Pair with scene.camera.screenToWorld() to convert a tap position.
For automatic pointer dispatch to nodes, give the node a physics body instead — see picking in 2D physics.
Taps and drags anywhere in the scene: click / touchstart {#scene-level-pointer-events}
scene.addEventListener('click', (ev: ClickEvent) => void) // pointer-up anywhere in the scene
scene.addEventListener('touchstart', (ev: TouchStartEvent) => void) // pointer-down; ev.track() for drags
scene.removeEventListener(channel, callback)Only the active scene (Scene2D.active) receives pointer events. ev.target is the node whose
body is under the pointer — the topmost Physics2D, Trigger2D or character by draw order — or
null for empty space; ev.worldX / ev.worldY give the point in world space. The hit node's
own listener runs first. Every click then reaches the scene listeners; a touchstart reaches
them only while nobody has called ev.track({...}) yet — the node, or the first scene listener,
that tracks takes the gesture. See pointer events & gestures.
in headless tests the 2D view is never sized, so worldX / worldY of a scripted tap
are wrong and it hits the wrong node.
Scene-wide game logic: scene.system
scene.system(Class, opts?): scene & { name: instance } // attach / reconfigure a System<'name', Scene2D>
scene.get(Class) · scene.has(Class) · scene.removeSystem(Class)Scene-wide logic (input, spawners, the HUD) — see systems. destroy()
detaches them first.
A HUD over the scene: scene.widgets
The score, a health bar or a pause button over a 2D game are the scene's widgets: laid out over it while it is shown; a tap outside a widget's box reaches the scene.
const score = signal(0)
const scene = new Scene2D({ background: '#10131a' })
scene.addEventListener('click', () => { score.value++ })
const hud = UIWidget(UIText(() => `Score: ${score.value}`).style({ color: 'white', fontSize: 20 }))
.style({ top: 'safe-top', left: 16 })
scene.widgets(hud) // laid out over the scene; a tap outside it reaches the scene
scene.open()A live 2D view inside the UI (minimap): UIImage(scene) {#inside-ui}
const minimap = UIImage(scene).style({ width: 160, height: 120 })A UIImage whose source is a scene draws it live into its box every frame, through the scene's
own camera — a minimap, a portrait. The scene needs no open() and keeps simulating; the image
has no intrinsic size, so give it a width and a height. Drawn on iOS, Android and desktop; the web
player and headless tests leave the box empty.
See also
- Node2D — transform, hierarchy, layer/z, events
- Camera2D — zoom, screen↔world conversion
- Sprite — textured nodes, anchors
- 2D physics — what makes a node pointer-hittable
- Pointer events & gestures —
ClickEvent,ev.track()