Device
device is what the app knows about the phone or computer it runs on: the platform, the user's
language, the screen size, the network, haptics and the motion sensor — a plain global object.
Localization is built on device.language; the SDK has no translation module.
- choose touch or mouse controls on a phone, a desktop or the web
- show the app in the user's language and translate its texts
- format numbers, prices and dates for the user's language
- mirror the layout for Arabic or Hebrew
- lay out for the screen size and again when the phone rotates or the window is resized
- show a banner when the device goes offline
- vibrate the phone when a button is tapped
- steer or look around by tilting the phone
- show the frame rate on a phone
Platforms: every host; haptics and the motion sensor only on iOS and Android (the web: one plain buzz, no motion) — check device.motion.available.
At a glance
const info = UIText('').named('info')
const show = () => {
info.text = `${device.platform}, ${device.language}, ${device.width} × ${device.height}, ${device.online ? 'online' : 'offline'}`
}
show() // every value is readable at any time
device.addEventListener('resize', show) // a rotation or a window resize (logical px)
device.addEventListener('offline', show)
device.addEventListener('online', show)
const buzz = UIButton(UIText('Buzz')).named('buzz').onClick(() => device.vibrate('light'))
UIScreen(UIColumn(info, buzz)).open()Phone, desktop or web: device.platform
Check whether the app runs on a phone, a desktop or in a browser to choose its controls:
device.platform names the input model, not the OS.
device.platform // "ios" | "android" | "desktop" | "web" (read-only)Windows, macOS and Linux all report "desktop". A browser reports "web" — a phone's browser and
a computer's alike, so a web build offers both kinds of control — and so do headless tests
(lecodes render / lecodes test).
const hint =
device.platform === 'ios' || device.platform === 'android' ? 'Drag the stick to move'
: device.platform === 'desktop' ? 'W A S D to move, the mouse to look'
: 'Drag the stick, or use W A S D' // "web": a phone's browser or a computer's
UIScreen(UIText(hint).named('hint')).open()Localization
The user's language: device.language
device.language is the user's language as a lower-case code without a region — "en", "de",
"ru", "zh" — the key to pick the app's texts and formats by. It is read from the host on every
access; no event reports a change.
device.language // "en", "de", "ru", "zh" … (read-only)| Host | device.language |
|---|---|
| iOS | the user's first preferred language, cut to two letters |
| Android | the app's locale as Android reports it (Locale.language), not cut: it can be "iw" for Hebrew and "in" for Indonesian ("he" / "id" elsewhere), or three letters ("fil") |
| the web, the web player | the browser's language, cut to two letters; the web player also takes ?lang= in its URL |
| desktop (Windows, macOS, Linux), headless tests | always "en" |
there is no region or script: British and American English both read "en",
Simplified and Traditional Chinese both "zh". Headless tests always run in "en" — test the
other languages through the app's own language switch (below).
Translating the app's texts
To translate the app's texts into several languages, keep a table of strings per language and pick
it by device.language, with English as the fallback — the SDK has no translation module; this is
the pattern. A stored signal keeps the user's own choice of language across launches.
const STRINGS: Record<string, Record<string, string>> = {
en: { greeting: 'Hello!', switch: 'Русский' },
ru: { greeting: 'Привет!', switch: 'English' },
}
const OLD_CODES: Record<string, string> = { iw: 'he', in: 'id' } // Android's codes for Hebrew, Indonesian
const deviceLang = OLD_CODES[device.language] ?? device.language
// the device's language until the user picks one; the pick is kept on the device
const lang = signal(STRINGS[deviceLang] ? deviceLang : 'en').stored('lang')
const t = (key: string) => STRINGS[lang.value]?.[key] ?? STRINGS.en[key] ?? key
UIScreen(
UIColumn(
UIText(() => t('greeting')),
UIButton(UIText(() => t('switch'))).named('language')
.onClick(() => { lang.value = lang.value === 'ru' ? 'en' : 'ru' }),
),
).open()t(key) falls back to English for a language or a key the table lacks, and
UIText(() => t('greeting')) re-renders when lang changes. Pass the same lang.value to the
number and date
formats.
keep the arrow: UIText(t('greeting')) reads the text once and never updates — the
compiler makes a binding only of a direct read of a signal's .value
(signals).
Formatting numbers and prices for a locale
Format numbers and prices with their currency for the user's locale in your own code: the SDK has
no number or currency formatter, and Intl does not exist on Android, Windows and Linux — there
toLocaleString('de-DE') prints plain digits (1234567.5) and new Intl.NumberFormat() throws.
iOS, macOS, the browsers and headless tests have Intl, so a test does not show the difference.
const SEPARATORS: Record<string, [group: string, decimal: string]> = {
en: [',', '.'], de: ['.', ','], ru: ['\u00a0', ','],
}
const formatNumber = (n: number, decimals: number, lang: string): string => {
const fixed = Math.abs(n).toFixed(decimals) // "1234567.50" — plain JS, every host
const [whole, frac] = fixed.split('.')
const [group, decimal] = SEPARATORS[lang] ?? SEPARATORS.en
const sign = n < 0 && +fixed !== 0 ? '-' : ''
return sign + whole.replace(/\B(?=(\d{3})+(?!\d))/g, group) + (frac ? decimal + frac : '')
}
const formatPrice = (amount: number, symbol: string, lang: string) =>
lang === 'en' ? symbol + formatNumber(amount, 2, lang) : `${formatNumber(amount, 2, lang)} ${symbol}`
console.log(formatPrice(1234567.5, '€', 'en')) // €1,234,567.50
console.log(formatPrice(1234567.5, '€', 'de')) // 1.234.567,50 €
console.log(formatPrice(-9.99, '₽', 'ru')) // -9,99 ₽Dates in the user's language
Show dates in the user's language with date().format() and timeAgo() (Dates):
they speak English and Russian only — without a locale they follow device.language, and every
other language gets English month names. Pass the app's own language, and keep a pattern per
language — digits where date() has no month names:
const PATTERNS: Record<string, string> = { en: 'MMM D, YYYY', ru: 'D MMMM YYYY', de: 'DD.MM.YYYY' }
const formatDate = (when: number, lang: string) =>
date(when).format(PATTERNS[lang] ?? PATTERNS.en, lang === 'ru' ? 'ru' : 'en')
const day = new Date(2026, 6, 10).getTime()
console.log(formatDate(day, 'en')) // Jul 10, 2026
console.log(formatDate(day, 'ru')) // 10 июля 2026
console.log(formatDate(day, 'de')) // 10.07.2026 — digits: date() has no German month namesa week starts on Sunday (startOf('week')) and every date is the device's local time;
there is no time-zone API.
Right-to-left languages
Arabic, Hebrew, Persian and Urdu read right to left, but layouts are always left to right: a row puts its first child on the left on every host. Mirror a screen for these languages yourself — reverse a row's children and align its texts right:
const rtl = ['ar', 'he', 'iw', 'fa', 'ur'].includes(device.language) // Arabic, Hebrew, Persian, Urdu
const avatar = UIBox().style({ width: 40, height: 40, borderRadius: 20, backgroundColor: '#4f7cff' })
const name = UIText('Dana Levi').style({ flex: 1, textAlign: rtl ? 'right' : 'left' })
UIScreen(
UIRow(rtl ? [name, avatar] : [avatar, name]).style({ gap: 12, alignItems: 'center' }), // mirrored by hand
).open()flexDirection: 'row-reverse' (and 'column-reverse') mirrors a container's children without
reordering them.
iOS, Android and the browsers shape Arabic and Hebrew text and order it within a line; desktop (Windows, macOS, Linux) draws it unshaped, letter by letter from left to right — check right-to-left text on a phone.
Screen size and rotation: device.width, device.height, resize
Lay the app out for the screen it has: device.width × device.height is the display in logical
px, and the resize event reports the new size after a rotation or a window resize.
device.width // logical px (read-only)
device.height // logical px (read-only)
device.addEventListener('resize', (width, height) => { }) // logical px
device.removeEventListener('resize', callback) // the same function referenceBoth are logical px (like clientX / clientY), never physical pixels. resize fires on
every change; whether one also comes at startup depends on the host (headless tests and the
windowless server send one), so At a glance reads the size once and again in
resize.
both can read 0 before the host has reported a size — a phone may run the app's code
before its first layout, which is why the size is read again in resize. Headless tests report
390 × 844 at once (--width / --height change it).
To restyle a screen in landscape, a $landscape style class needs no code
(classes); to lock the orientation, use app.setOrientation (app).
Pixel density: device.pixelRatio
device.pixelRatio // physical px per logical px: 1 on standard displays, 2–3 on phones (read-only)Draw a Canvas at the screen's density so it stays crisp:
new Canvas(w, h, { pixelRatio: device.pixelRatio }). Phones report the screen's scale, the
desktop its window's UI scale, the web the browser's devicePixelRatio; headless tests read 1.
Connectivity: device.online + online / offline events
Show a banner when the device goes offline, and sync again when it is back: device.online is the
current state, and the offline / online events report each change.
const online = signal(device.online)
device.addEventListener('offline', () => { online.value = false })
device.addEventListener('online', () => { online.value = true })
const banner = UIText('No connection — changes will be sent later').named('offline')
.style({ display: () => (online.value ? 'none' : 'flex'), backgroundColor: '#b3261e', color: '#ffffff', padding: 8 })
UIScreen(UIColumn(banner, UIText('Inbox'))).open()online is a best-effort hint (navigator.onLine semantics): false only when the platform is
sure there is no network; true does not guarantee the internet is reachable — still handle
fetch failures.
iOS, Android, desktop and the web report changes (the desktop checks every 2 s).
Headless tests and the windowless server read true and never fire the events.
Precise touch for drawing: device.setPreciseTouch
device.setPreciseTouch(enabled: boolean) // off by defaultSmoother strokes for drawing in a 2D scene: with precise touch on, a fast stroke reaches the scene's pointer events at the touch screen's full rate (iOS coalesced touches, ≈120–240 Hz) instead of once per display frame (~60 Hz). Leave it off for tap and button UIs.
iOS only, and only for the pointer events of a 2D scene (Scene2D)
— UI and 3D touches are unchanged. A silent no-op on Android, desktop and the web.
Haptics
Give haptic feedback when a button is pressed, a value changes or an action succeeds:
device.vibrate(style) fires one short haptic of a semantic style (default "medium").
const status = UIText('')
const like = UIButton(UIText('Like')).named('like')
.onClick(() => device.vibrate('light')) // a light tap under the finger
const save = UIButton(UIText('Save')).named('save')
.onClick(() => { device.vibrate('success'); status.text = 'Saved' }) // an outcome: a short "done" pattern
UIScreen(UIColumn(like, save, status)).open()| Group | Styles | Meaning |
|---|---|---|
| Impact | "light" "medium" "heavy" "soft" "rigid" |
a physical "tap" of varying weight |
| Notification | "success" "warning" "error" |
an outcome cue |
| Selection | "selection" |
a light tick for a changing value |
The styles are semantic rather than a duration on purpose: that is the vocabulary that feels native
on both iOS (UIFeedbackGenerator) and Android (predefined vibration effects) — iOS has no public
API for a vibration of arbitrary length.
iOS and Android. The web gives every style the same short buzz, where the browser can vibrate at all; desktop and headless tests do nothing. A device without a vibration motor (an iPad) stays silent, and the user's system haptics setting is respected. A gamepad's rumble is Input's.
Motion
Tilt the phone to steer, or turn it to look around: device.motion is the fused motion sensor
(gyroscope + accelerometer). Start it, then read gravity (the tilt) or attitude (the phone's
orientation) every frame in a system's update.
iOS and Android only. On desktop, the web and in headless tests available is
false, start() resolves false, gravity reads (0, 0, 0) and attitude reads
Quat.identity — give those hosts touch or keyboard controls.
class TiltSteer extends System<'tiltSteer'> {
private car!: Mesh
onAttach() {
this.car = Mesh.box({ material: Material.lit({ color: '#e33' }), position: [0, 0.5, -4] })
this.scene.add(Light.sun(), this.car)
}
update(dt: number) {
this.car.x += device.motion.gravity.x * 4 * dt // gravity.x: -1 tilted left … 1 tilted right
}
}
new Scene().system(TiltSteer).open()
device.motion.start().then((ok) => { if (!ok) toast('Tilt steering needs a phone') })A 360° look-around camera (a magic window, a panorama) puts the phone's orientation on the camera:
class Panorama extends System<'panorama'> {
update() { this.scene.camera.quaternion = device.motion.attitude } // a 360° camera, one line
}
const scene = new Scene().system(Panorama)
scene.open()
device.motion.start().then((ok) => { if (!ok) toast('No motion sensor') }) // begin updates (battery)device.motion.recenter() // make "here" the forward direction (yaw-only)
device.motion.stop() // release the sensor| Member | Type | Notes |
|---|---|---|
start(options?) |
Promise<boolean> |
Begin updates. false = no sensor / permission denied. |
stop() |
void |
Stop updates, release the sensor. |
recenter() |
void |
Zero the current heading (pitch/roll stay gravity-referenced). No-op in the "device" frame. |
available |
boolean |
Does the device have a gyro at all? |
enabled |
boolean |
Are updates currently running? |
attitude |
Quat |
Current orientation. World frame by default — assign straight to a node/camera quaternion. |
gravity |
Vec3 |
Gravity direction in screen space (x → right, y → down), in g: −1 … 1 per axis. Read x/y for a 2D tilt. |
start(options) takes { interval?: number, frame?: "world" | "device" }. interval (seconds,
default 1/60) is a floor — the sensor fuses at ≥ that rate in the background and you poll the
freshest sample each frame, so the sensor rate need not match your frame rate. frame defaults to
"world": attitude/gravity arrive already converted into the engine's Y-up world (and, for
gravity, screen) space and are orientation-aware — rotate the phone to landscape and the camera
stays upright automatically. Pass "device" for the raw sensor frame if you want to do the math
yourself.
attitude is drift-corrected, so a panorama horizon stays level indefinitely.
HDR display: device.hdr
Brighter highlights on an HDR screen: device.hdr is the display's extended dynamic range. The
engine can render past SDR white — 1.0 is the white of the UI and of a diffuse white surface,
and the sun, emissives and speculars climb up to headroom times that — once a host gives it a
surface with headroom.
no host provides an extended-range surface yet — the macOS host's layer is
SDR too. On every host available is false, headroom and maxHeadroom read 1 and
hdrchange never fires. strength / paperWhite are clamped and stored (the getters read them
back) until one does.
const fireball = new Particles({ emissive: device.hdr.available ? 8 : 3.5 }) // post-exposure units: above 1 clips on SDR
device.hdr.strength = 1 // how much of the picture reaches for the peak
device.hdr.paperWhite = 1.5 // where white lands, × SDR white
device.addEventListener('hdrchange', (h) => console.log('rendering to', h, '× SDR white'))| Member | Type | Notes |
|---|---|---|
available |
boolean |
Can the surface show anything above SDR white at all (maxHeadroom > 1)? Branch content on this. |
maxHeadroom |
number |
The display's peak as a multiple of SDR white; 1 on SDR. |
headroom |
number |
What the engine renders to right now, quantised to half-stops; ≥ 1. The hdrchange event reports a change (checked 4× a second while a listener is attached). |
strength |
number |
0..1 (clamped), default 0.35. 0 touches only what SDR clipped — faithful to the SDR grade; 1 lifts nearly everything toward the peak. |
paperWhite |
number |
1..8 (clamped), default 1. Where white lands, × SDR white — the "HDR brightness" of a console calibration screen. Scales the whole picture; highlights still climb above it. |
strength decides how much of the picture reaches for the peak; paperWhite decides where the
picture starts. Both are engine-wide and settable up front through
SceneOptions.hdr.
Particle emissive is in post-exposure units (1 = white on screen) and everything above 1 clips on
SDR — so a fireball authored at 3.5 for SDR is the same fireball on HDR, only unclipped. Raise it
when available for a white-hot core.
Stats overlay: device.statsOverlay
Show the frame rate on a phone: device.statsOverlay = true draws the host's own line of frame
numbers over everything; false takes it away.
device.statsOverlay = true // the host draws its line of frame numbers over everything
console.log('overlay shown:', device.statsOverlay) // false where the host has none (the web, headless)The line, for a build on a device:
74 fps 64% even cpu 1.0| Part | Meaning |
|---|---|
fps |
3D frames per second |
even |
the share of those frames that stayed on screen the most common number of display refreshes. An average hides this: 66 fps 70% even is a mix of short and long frames — visible judder — while 60 fps 100% even is smooth |
cpu |
milliseconds the main thread spent per frame (your code, layout, submitting the frame). A low fps next to a small cpu means the GPU is the limit |
The host measures and draws the line itself, about once a second; none of it runs in your code, so
turning it on does not change the numbers it shows. It belongs to the host rather than to the project
— it stays on when another project is opened, until it is set to false.
Android shows the whole line; iOS shows fps and cpu (no even); desktop shows its
frame counter (the one lecodes desktop run --fps draws). Without a 3D scene the phones show
cpu only. The web, the windowless server and headless tests have no overlay: the setter does
nothing and the property reads false.
See also
- Dates —
date().format,timeAgo: English and Russian - Signals —
.stored(key)and the reactive text the translations use - Pointer events & gestures — the events precise touch feeds
- Input — keyboard, mouse, gamepads and their rumble
- Conventions — logical px, the
addEventListenerpattern