Conventions

The rules every page of the reference assumes: how a project is put together (globals, main.ts, file paths), the units, axes, colors and vectors, events and chaining, and the hosts an app runs on with their feature checks. The host globals (setTimeout, asset(), DEG2RAD) are on globals.md.

  • use code from another file of the project, or a package from npm
  • show an image or play a sound from the project folder
  • fix "top-level await is not supported"
  • seconds or milliseconds: dt, timers and animation durations
  • degrees or radians, and which way Y points in 2D, 3D and the UI
  • change one axis of a node's position, scale or rotation
  • know which platforms an app runs on: iOS, Android, desktop, the web, the web player, the le.codes viewer app
  • check that a feature exists on this device before offering it (isSupported, supported)

Imports: the project's own files only, no npm packages

The whole SDK — Scene, Sprite, UIScreen, Vec3, fetch, … — is a set of globals injected at build time: code never imports it. An import names a file of the project by a relative path; a bare package name (import _ from 'lodash') fails the build (Unknown library "lodash") — an app has no npm.

hud.ts:

TypeScript
export const scoreLabel = (score: Signal<number>) =>
  UIText(() => `Score: ${score.value}`).style({ fontSize: 24 })

main.ts:

TypeScript
import { scoreLabel } from './hud'

const score = signal(0)

const screen = UIScreen(
  scoreLabel(score),
  UIButton(UIText('+1')).named('plus').onClick(() => { score.value++ }),
)
screen.open()

A .json file imports as its data (import levels from './levels.json'). Two imports are not relative paths: a component library added to the project on le.codes (Settings → Libraries) is imported by its name, and a plugin of the project's own (written by lecodes plugin gen) imports lecodes-sdk/plugin.

The entry point: main.ts

main.ts is where the app starts. In a project of several files it imports the others and opens the first screen or scene (Router.init(home), scene.open()); every other file must be reachable from it through imports (a file that only registers something still needs an import './register'). Without a main.ts, the compiler picks the file no other file imports (the import-graph root); if several qualify, one without exports wins, then the largest — make it explicit with main.ts or --entry. Editor plugins (*.editor.ts), server modules (*.server.ts) and anything under design/ are never picked.

Images, sounds and other files of the project: paths and asset()

To show an image or play a sound that is in the project folder, pass its path as a string where the SDK takes a file:

TypeScript
const click = new AudioPlayer('sounds/click.wav')   // a path from the project folder

const screen = UIScreen(
  UIImage('images/logo.png').style({ width: 120, height: 120 }),
  UIButton(UIText('Tap me')).onClick(() => {
    click.time = 0
    click.play()
  }),
)
screen.open()
  • "images/logo.png" is looked up from the project root, then from the file's folder; "./logo.png" from the file's folder, then the root; "/sounds/tap.mp3" from the root.
  • asset('./path') means the same — a compile-time rewrite into the import of the file, so the path must be a literal.
  • A path that names a file is that file wherever it stands — in a table of rows ({ photo: "images/espresso.jpg" }) too.
  • A path with a / in it that names no file fails the compile (file not found: images/logo.png (main.ts:4)) where the SDK takes a file — UIImage(…), .style({ bgImage }), new AudioPlayer(…), Model.load(…), Sound.load(…), a scene's model: … — and is the plain string it says anywhere else (a path in the app's own files, a storage key). A bare "logo.png" that names no file stays a string even there; asset() of a missing file always fails.
  • A .json path stays a string: its asset is its data (asset('./levels.json') is the parsed JSON).
  • import hero from './hero.png' compiles too, but the project's IDE types declare no such modules, so it does not type-check.
  • Remote https://… URLs are plain strings.

See globals.md for what asset() returns per file type.

No top-level await

The app bundle runs as a plain script, not a module: await at the top level of any file is a compile error (top-level await is not supported). Put the awaiting code in an async function and call it:

TypeScript
const scene = new Scene2D()
scene.open()

const main = async () => {
  const texture = await Texture2D.load('https://example.com/hero.png')
  scene.add(new Sprite({ texture }))
}
main()

Snippets on reference pages that show await Model.load(…) or await fetch(…) on their own are fragments of such a function.

Time: seconds or milliseconds {#time}

Frame deltas and animation clips count in seconds, timers and tweens in milliseconds:

  • Per-frame dt is seconds since the previous frame (~0.016 at 60 fps): an aspect's or system's update(dt) / updateBefore(dt) is game time (Time.scale applied, 0 while paused); the host's setLoop(dt => …) is unscaled. Frame-independent motion is position += speed * dt.
  • setTimeout / setInterval take milliseconds.
  • Tweens are milliseconds: duration and delay of animate() and of .animateTo() / .animateFrom() on UI elements, nodes, lights and the camera; Timeline positions.
  • Skeletal animation is seconds: clip times, fades, windows and events of an Animator (model.anim), and the IK / locomotion tunings. Media and voice positions (player.time, voice.time) are seconds too.
TypeScript
const scene = new Scene()
const box = Mesh.box({ material: Material.lit({ color: '#e33' }), position: [0, 0, -4] })
scene.add(box)
scene.open()

class Drift extends System<'drift'> {
  update(dt: number) {     // seconds since the previous frame, game time
    box.x += 2 * dt        // 2 units per second at any frame rate
  }
}
scene.system(Drift)

box.animateTo({ scale: 1.5, duration: 300 })                               // tweens: milliseconds
setTimeout(() => console.log(`x after 1 s: ${box.x.toFixed(0)}`), 1000)    // timers: milliseconds → 2

Coordinates and angles: Y up or down, degrees or radians {#space-units-angles}

Which way the axes point, what one unit is and which angles are degrees, in 2D, 3D and the UI:

2D (Scene2D) 3D (Scene) UI / screen
Axes X right, Y up right-handed, Y up, forward = −Z X right, Y down
Unit 1 world unit = 1 logical px at zoom 1 ≈ meters logical px (dp/pt)
Angles rotation in degrees, CCW eulerAngles in degrees —
  • Angle properties and factories are degrees (2D node.rotation, 3D node.eulerAngles, Quat.fromEuler); raw-angle arguments are radians (Quat.fromAxisAngle, Mat4.rotate*, Vec2.rotate, Vec3.rotateX / rotateY / rotateZ). DEG2RAD / RAD2DEG are compile-time constants for converting.
  • Screen coordinates (clientX/clientY, device.width/height) are logical px (like iOS pt / Android dp), never physical pixels; device.pixelRatio is the physical-per-logical ratio.
  • One sign trap: 2D world Y points up, but screen Y and sprite anchor space point down (anchor: [0.5, 1] = bottom-center = "feet").
TypeScript
const scene = new Scene()
const arrow = Mesh.box({ material: Material.lit({ color: '#4f7cff' }), position: [0, 0, -4] })
scene.add(arrow)
scene.open()

arrow.eulerAngles = [0, 90, 0]                                   // a property: degrees
console.log(`yaw ${arrow.eulerAngles.y.toFixed(0)}`)             // yaw 90

arrow.quaternion = Quat.fromAxisAngle([0, 1, 0], Math.PI / 4)   // an angle argument: radians
console.log(`yaw ${arrow.eulerAngles.y.toFixed(0)}`)             // yaw 45

arrow.quaternion = Quat.fromAxisAngle([0, 1, 0], 30 * DEG2RAD)  // degrees → radians
console.log(`yaw ${arrow.eulerAngles.y.toFixed(0)}`)             // yaw 30

Colors: CSS strings, 0xRRGGBB numbers, [r, g, b, a] {#colors}

Anywhere a color is accepted — UI styles, sprites, materials, lights, particles — the same values work (ColorInput, one parser for the whole SDK and every engine): any CSS color string ("#e33", "#ff3333cc", "rgb(255 51 51 / 80%)", "hsl(0 100% 60%)", "red", "transparent"); a packed int 0xff3333 (opaque 24-bit RGB — alpha can't be passed as a number); normalized float arrays [r, g, b] / [r, g, b, a] (0..1).

Note

a number outside 0..0xFFFFFF throws with the value, and so does a string that is not a color — except in a UI style, where an unparseable string draws transparent (see ui/styling.md). var(--name) theme references work in UI styles only (see ui/theme.md).

Vectors are copies: changing one axis of a position, scale or rotation {#math-value-semantics}

To move a node along one axis, write node.position.x = 3 out in full or use node.x; to change one axis of its scale or rotation, change a copy and assign the whole vector back.

Vec2 / Vec3 / Quat / Mat4 are mutable structs with pure methods: fields are settable (v.x = 3) and set() / copy() write in place, but every other method returns a NEW value and never mutates its source. Each is interchangeable with a raw array — [0, 1, 0] works wherever a Vec3 is accepted (16 numbers for a Mat4) — and Vec2 / Vec3 / Quat are iterable.

Node transform getters return fresh copies (value semantics, like Unity). The direct spelling node.position.x = 3 (or += 1) is compiled to a single-axis write that the node applies like node.x = 3 (chisel's comp_write pass — the same for a 2D camera's position, a 3D body's velocity / angularVelocity and a character's velocity; see math/vectors.md). Nothing else is rewritten: node.scale.x = 2, node.eulerAngles.y = 90, a 2D body's velocity.x = 5 and a copy you stored first write to a copy and do nothing.

TypeScript
const box = Mesh.box({ material: Material.lit({ color: '#e33' }) })
const scene = new Scene().add(box)
scene.open()

box.position.x = 3                // ✓ position: one axis, written exactly like this
box.y = 1                         // ✓ the x / y / z setters
const p = box.position; p.z = -5  // ✗ does nothing: p is a copy
box.scale.x = 2                   // ✗ does nothing: only position is rewritten
console.log(`x=${box.x} y=${box.y} z=${box.z} sx=${box.scale.x.toFixed(1)}`)   // x=3 y=1 z=0 sx=1.0

box.scale = [2, 1, 1]                                      // ✓ assign the whole vector
const e = box.eulerAngles; e.y += 45; box.eulerAngles = e  // ✓ change a copy, assign it back
console.log(`sx=${box.scale.x.toFixed(1)} yaw=${box.eulerAngles.y.toFixed(0)}`)   // sx=2.0 yaw=45

Events: addEventListener, an aspect's on, a UI element's onClick {#events}

  • Scenes, nodes, device, app, Router, WebSocket, media players, game-audio voices: addEventListener(channel, cb) / removeEventListener(channel, cb) — see core/events.md.
  • Aspects and systems emit their own events: node.barrel.on('explode', cb) / .off(…) — see core/aspects.md.
  • UI elements instead use chainable on* methods (.onClick(cb), .onChange(cb)).
  • Pointer events: click fires on pointer-up over the target; touchstart fires on pointer-down and its event object can ev.track({...}) to capture the rest of the gesture. See runtime/touch.md.

Chaining: options objects, setters and aspects

Constructors take an options object; setters that configure return this, so building reads as one chain. UI factories chain .style() and on*. Node capabilities attach as aspects and also chain:

TypeScript
const scene = new Scene2D()
scene.open()

const main = async () => {
  const texture = await Texture2D.load('images/hero.png')
  const hero = new Sprite({ texture })
    .aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { walk: [1, 2, 3] } })
    .aspect(Shape2D, { box: [16, 8] })
    .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
  scene.add(hero)
  hero.anim.play('walk')          // each aspect adds a named accessor
}
main()

See core/aspects.md.

Physics owns dynamic transforms

Once a node has a dynamic body (Physics / Physics2D), the physics step writes its transform. Do not set node.position per frame — drive the body with velocity / applyImpulse, and a character with move(). Reading node.position / node.worldPosition is always correct; the SDK re-syncs from native automatically.

Writing node.position is the way to place a body, a sensor or a character: the write is routed to the engine object that mirrors the node, so the collider goes with it. It is a teleport, not a drive — a body keeps its velocity across it, a character drops its latched velocity (the fall speed included); in 2D the write does not wake a sleeping body (set awake = true). A tween (animateTo) on a physics node is not routed to the body: the collider stays behind — see 3d/physics.md and 2d/physics2d.md.

Platforms an app runs on: iOS, Android, desktop, the web, the web player, the le.codes viewer app

One bundle runs on all of these platforms, with the features each one has; every Platforms line names them with these words:

  • iOS and Android — a phone or a tablet: an app built from the project with lecodes app, or the le.codes viewer app.
  • desktop — Windows, macOS and Linux: lecodes desktop run, or the app packaged with lecodes app build desktop.
  • the web — Web Lite, the app as a web page (lecodes web dev, lecodes web build): what a site ships.
  • the web player — the preview player: the runtime as wasm in a browser, in the previews of le.codes and Focus and in lecodes dev --web.
  • the le.codes viewer app — the iOS / Android app that runs a project from le.codes by its link or QR code (and lecodes dev): where an app built in Focus runs on a phone. It has the full engine and every first-party plugin but WebRTC, whether app.json lists them or not; its iOS App Clip also leaves out the map.
  • headless tests — lecodes render and lecodes test: the runtime in Bun, without a GPU or plugins (--sim runs a flow on the windowless desktop host instead).

A page whose API is not on every platform has one line under its tasks list — where it runs, where it does not, and the check — for example:

Platforms

iOS, Android, the web and the web player; not on desktop or in headless tests — check Geolocation.isSupported.

A page without that line runs on every platform; a section that differs from its page says so in a note.

Host gating — features that can be absent

Not every feature is on every platform: where one can be missing, its page's Platforms line says so, and most such features have a check to read before offering them.

Check a feature before offering it: isSupported, supported

Read a feature's check before showing what needs it, and offer another way when it is false:

TypeScript
const where = UIText('')
const screen = UIScreen(where).style({ padding: 24 })
screen.open()

if (Geolocation.isSupported) {
  where.text = 'Locating…'
  Geolocation.getCurrent().then(
    pos => { where.text = `${pos.latitude.toFixed(4)}, ${pos.longitude.toFixed(4)}` },
    () => { where.text = 'Location is off' },             // denied, unavailable, timed out
  )
} else {
  where.text = 'Location is not available here'          // desktop, headless tests
}

The engine's checks:

Check Feature False on
Physics.supported 3D physics: Shape, bodies, Trigger, CharacterController, Ragdoll, Vehicle, tapping a 3D node the 3d engine of an app built with lecodes app (below), wherever Scene.supported is false
Scene.supported the 3D engine: Scene, nodes, models the 2d / core engines of an app built with lecodes app — new Scene() throws there
Scene2D.supported the 2D engine: Scene2D, sprites, tilemaps the web (Web Lite), the 3d / core engines — new Scene2D() throws there
Physics2D.supported 2D physics wherever Scene2D.supported is false — it reports the build, true wherever there is a 2D engine
Vehicle.supported, Ragdoll.supported a car, a ragdoll wherever Physics.supported is false, and an older host; where true a host can still refuse the settings (car.vehicle.id 0, ragdoll.activate() false — the web and headless tests today)
Locomotion.supported, DynamicBone.supported the locomotion selector, dynamic bones the web (Web Lite), headless tests — the aspect attaches, warns once, the body stays idle / the chain stays empty
audio.supported game audio: Sound, audio.play, AudioSource Android, the web, the web player, the iOS 3d engine — sounds stay silent; an app's sounds play through AudioPlayer (runtime/media.md)
files.supported the app's own files: files Android, the web, the web player, headless tests — every call rejects
Net.supported the multiplayer transport: Net.listen / Net.connect the web, the web player, headless tests, the 2d / 3d / core engines — listen() returns false, connect() rejects, offline play still runs; Net.launch is how a desktop exe was started (--server / --connect), null elsewhere
NavMesh.load() resolving null navigation the web, the 3d engine — agents stay still
device.motion.available the tilt sensors desktop, the web, the web player, headless tests, a phone without a gyro — start() resolves false
device.hdr.available an HDR display every host today
Input.gamepad(i).rumble() returning false gamepad rumble everywhere but a USB pad on macOS

A plugin's check is true where the host has the plugin and app.json "plugins" lists it (the le.codes viewer app has them all but WebRTC):

Check Feature False on
ARScene.isSupported AR desktop, headless tests (the web's AR turns the camera, it does not move it)
AudioRecorder.isSupported recording the microphone desktop, headless tests
Browser.isSupported the system browser sheet desktop, headless tests
WebView.isSupported, WebView.supports(method) a web page inside the app; supports = the host's half has that method desktop, headless tests
CameraView.isSupported the camera desktop, headless tests
Geolocation.isSupported location desktop, headless tests
MapView.isSupported a map desktop, headless tests, the iOS App Clip
Push.isSupported push notifications desktop, the web, the web player, headless tests
QRScanner.isSupported scanning QR codes desktop, headless tests, Safari and Firefox
SpeechRecognizer.isSupported dictation desktop, headless tests, Firefox
RTCPeerConnection.isSupported, mediaDevices.isSupported, RTCVideoView.isSupported WebRTC calls desktop, headless tests, the le.codes viewer app
Service.isSupported(name), NativeView.isSupported(name) (NativeView.version(name): 0) a service or a view of the app's own native code every host that registered none of that name

Without the check a plugin fails loudly: its promise methods reject, a view's open() throws (MapView, WebView, NativeView) or rejects (CameraView, QRScanner, ARScene), and new RTCPeerConnection() throws; a listener (Push.addEventListener) is kept and never called. device.vibrate(), device.setPreciseTouch() and device.statsOverlay (reads false) need no check: they do nothing where the host has none. Lightmap.baking / NavMesh.baking are not host checks: they are true while lecodes lightmap bake / lecodes navmesh bake runs the app.

Apps built with lecodes app: the engine variant

An iOS or Android app built with lecodes app ships the smallest engine its bundle needs (a desktop build always has the full one): a 3D app that creates no Scene2D gets the 3d engine, a 2D app 2d, a UI app core. The 3d engine has no 3D physics, multiplayer transport, navigation or (on iOS) game audio, and 2d / core no transport or navigation — so a bundle that creates a body, a character, a vehicle or a ragdoll, casts a ray, names Net, loads a navmesh or plays game audio is put on full by itself (the le.codes viewer app always has it). A forced "ios": { "variant": … } / "android": { "variant": … } in app.json wins, and lecodes app sync warns when the forced engine lacks something the bundle uses.

Lifecycle

  • Anything started in a screen's onOpen must be stopped in onClose (clearLoop, clearInterval, ws.close()), or it keeps running across navigation.
  • Objects wrapping native resources free them explicitly where a method exists: node.destroy() (2D/3D nodes), Texture2D.destroy(); a game-audio Sound holds its decoded clip until sound.dispose(). A media player goes by itself once nothing references it and it is not playing (stop() what must not outlive a screen). A fetch response, a 3D Material and a 3D Texture have no free: each lives while you hold it (a material also while a mesh draws it, a texture also while a material samples it) and goes by itself once nothing does.

See also

  • index.md — what the platform does not have
  • globals.md — asset(), setTimeout, DEG2RAD and the other host globals
  • math/vectors.md — Vec2 / Vec3 and the component writes
  • core/aspects.md — aspects and systems, the phases that receive dt