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:
export const scoreLabel = (score: Signal<number>) =>
UIText(() => `Score: ${score.value}`).style({ fontSize: 24 })main.ts:
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:
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'smodel:… — 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
.jsonpath 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:
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
dtis seconds since the previous frame (~0.016 at 60 fps): an aspect's or system'supdate(dt)/updateBefore(dt)is game time (Time.scaleapplied, 0 while paused); the host'ssetLoop(dt => …)is unscaled. Frame-independent motion isposition += speed * dt. setTimeout/setIntervaltake milliseconds.- Tweens are milliseconds:
durationanddelayofanimate()and of.animateTo()/.animateFrom()on UI elements, nodes, lights and the camera;Timelinepositions. - 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.
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 → 2Coordinates 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, 3Dnode.eulerAngles,Quat.fromEuler); raw-angle arguments are radians (Quat.fromAxisAngle,Mat4.rotate*,Vec2.rotate,Vec3.rotateX/rotateY/rotateZ).DEG2RAD/RAD2DEGare compile-time constants for converting. - Screen coordinates (
clientX/clientY,device.width/height) are logical px (like iOS pt / Android dp), never physical pixels;device.pixelRatiois 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").
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 30Colors: 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).
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.
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=45Events: 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:
clickfires on pointer-up over the target;touchstartfires on pointer-down and its event object canev.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:
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 withlecodes 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, whetherapp.jsonlists them or not; its iOS App Clip also leaves out the map. - headless tests —
lecodes renderandlecodes test: the runtime in Bun, without a GPU or plugins (--simruns 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:
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:
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
onOpenmust be stopped inonClose(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-audioSoundholds its decoded clip untilsound.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 3DMaterialand a 3DTexturehave 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,DEG2RADand the other host globals - math/vectors.md —
Vec2/Vec3and the component writes - core/aspects.md — aspects and systems, the phases that receive
dt