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

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

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

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

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

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.

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

Note

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.

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

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

a 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:

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

Note

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.

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

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

Note

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

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

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

Note

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

TypeScript
device.setPreciseTouch(enabled: boolean)    // off by default

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

Note

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").

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

Note

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.

Note

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.

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

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

Not implemented

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.

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

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

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

Note

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 addEventListener pattern