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

TypeScript
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}

TypeScript
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)
Note

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

TypeScript
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 current

transition defaults to 'none'. The scene itself never moves: the screen it replaces, or that comes over it, plays the transition — see transitions.

Note

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.

Note

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}

TypeScript
scene.add(...nodes: Node2D[]): this
scene.remove(...nodes: Node2D[]): this

Puts 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

TypeScript
scene.layer(n).ySort(enabled = true): LayerHandle

Nodes 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.

Note

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}

TypeScript
scene.pick(worldPoint: Vec2Like): Node2D | null

Topmost 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}

TypeScript
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.

Note

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

TypeScript
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.

TypeScript
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}

TypeScript
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