Host globals

The globals every project has on every host, without an import: timers, the frame loop, console, the asset() macro and the angle constants. Per-frame game logic belongs in an Aspect or a System, not a hand-written setLoop; a timer started for a screen stops in the screen's onClose.

  • run code once after a delay, like hiding a message after 2 seconds (setTimeout)
  • repeat something every second — a countdown, a clock, polling (setInterval)
  • stop a timer or a frame loop (clearTimeout, clearInterval, clearLoop)
  • update a value on every frame outside a game scene (setLoop)
  • print a value or an object to the log while debugging (console.log)
  • use an image, a sound or a JSON data file from the project folder (asset())
  • convert an angle between degrees and radians (DEG2RAD, RAD2DEG)
  • reuse one set of styles on several elements (Style<T>)

Run code after a delay: setTimeout {#timers}

To do something once after a delay — hide a message, retry a request — use setTimeout. The delay is milliseconds, like the web (setLoop's dt is seconds); the returned id cancels it.

TypeScript
setTimeout(fn: () => void, ms: number): number     // runs fn once, ms from now
setInterval(fn: () => void, ms: number): number    // runs fn every ms until cleared
clearTimeout(id): void
clearInterval(id): void
TypeScript
const status = UIText('')
const save = UIButton(UIText('Save')).onClick(() => {
  status.text = 'Saved'
  setTimeout(() => { status.text = '' }, 2000)   // milliseconds: hide the message after 2 s
})
UIScreen(save, status).open()

Timers run on the host's clock: they neither slow down nor pause with Time.scale / Time.paused (time.md). A delay that must stop while the game is paused counts dt in an aspect or a system.

Note

on iOS and Android nothing runs while the app is in the background: no timer fires and no loop ticks until the app is back. Save what matters on pause (app.md).

Repeat every second — a countdown, a clock, polling: setInterval

To run code again and again at a fixed period — count down once per second, tick a clock, poll a server — use setInterval(fn, ms). It fires every ms milliseconds until clearInterval(id).

TypeScript
const label = UIText('3').style({ fontSize: 48 })
UIScreen(label).open()

let left = 3
const timer = setInterval(() => {
  left -= 1
  label.text = left > 0 ? `${left}` : 'Go!'
  if (left === 0) clearInterval(timer)        // stop it, or it fires every second forever
}, 1000)
Note

an interval keeps firing until it is cleared, whichever screen is shown. One that belongs to a screen starts in the screen's onOpen and is cleared in its onClose (screen-router.md).

Timer precision: one frame

On iOS, Android, desktop, the web player and in headless tests, timers run from the frame tick, so their resolution is one frame: setTimeout(fn, 0) runs on the next frame, and an interval shorter than a frame fires once per frame (a 1 ms interval: 60 calls a second at 60 fps). The delay is required there — setTimeout(fn) throws a TypeError — and the callback gets no extra arguments. On the web (Web Lite) they are the browser's timers.

Run code every frame: setLoop

To update something on every frame outside a game scene — a running clock, a value polled from the host — use setLoop. Game logic in a scene goes in an Aspect or a System instead.

TypeScript
setLoop(fn: (dt: number) => void): number
clearLoop(id: number): void

Fires every frame. dt is seconds since the previous frame (~0.016 at 60 fps) on the host's own clock — Time.scale and Time.paused don't touch it. On the web it is capped at 0.1 s (a tab that comes back from the background).

TypeScript
const clock = UIText('0.0 s')
let elapsed = 0
const loop = setLoop(dt => {                      // dt: seconds since the previous frame
  elapsed += dt
  clock.text = `${elapsed.toFixed(1)} s`
})
const stop = UIButton(UIText('Stop')).onClick(() => clearLoop(loop))
UIScreen(clock, stop).open()

An aspect's or a system's update(dt) runs in the engine's frame phases (before or after the physics step), receives the game clock (scaled, 0 while paused) and stops with its node or scene. setLoop is for what has neither — a UI readout, polling a host value.

Note

a loop keeps running until cleared. One started in a screen's onOpen is stopped in its onClose, or it leaks across navigation.

To see a value while debugging, print it with console.log — warn, info and error are the other levels.

TypeScript
console.log(...data) / console.warn(...) / console.info(...) / console.error(...)

The typed surface — no console.table, console.time, etc. On iOS, Android, desktop and in headless tests each call prints one line, its arguments converted to strings and joined with a space, so an object prints as [object Object] and an array as 1,2 — log JSON.stringify(value) to see a structure. An Error argument prints its stack on the following lines. On the web (Web Lite) console is the browser's own.

TypeScript
const player = { name: 'Ann', score: 3 }
console.log('score', player.score)          // score 3
console.log(player)                         // [object Object]
console.log(JSON.stringify(player))         // {"name":"Ann","score":3}
console.log([1, 2])                         // 1,2
console.warn('low health')

A flow test's expect.log matches these lines (lecodes render --logs prints them).

Load a file that ships with the project: asset()

To use an image, a sound, a model or a data file from the project folder, name it with asset('./path') — a compile-time macro the bundler rewrites into the import of that file.

TypeScript
asset(path: string): string      // compile-time macro — path must be a string LITERAL

The string it returns is a reference to the bundled resource — a URL in a published build, a host-local reference when a native host runs the project from disk — so treat it as opaque and hand it to what loads it (UIImage, new AudioPlayer, Texture2D.load, Model.load, fetch, …):

TypeScript
const tap = new AudioPlayer(asset('./sounds/tap.wav'))   // a reference to the bundled file

const screen = UIScreen(
  UIImage(asset('./images/logo.png')).style({ width: 120, height: 120 }),
  UIButton(UIText('Play')).onClick(() => {
    tap.time = 0
    tap.play()
  }),
)
screen.open()

It is the value import hero from './hero.png' gives too; the macro is the form that type-checks (the project types declare no image modules). Remote https://… URLs don't need it — pass them as plain strings.

JSON data and SVG images: what asset() returns

Two file kinds are not references, because one would be useless for them: a .json file yields its parsed data and a .svg file an SvgSource (for UIImage and bgImage) — both ship inside the bundle.

TypeScript
const levels = asset('./levels.json')   // the parsed data itself: [{ "name": "Forest", … }, …]
const logo = asset('./logo.svg')        // an SvgSource, for UIImage and bgImage

UIScreen(
  UIImage(logo).style({ width: 64, height: 64 }),
  UIText(`${levels.length} levels, the first: ${levels[0].name}`),
).open()

A file in a binary format of your own is fetched — await fetch(asset('./levels/1.bin')) — and read with res.bytes() (a Uint8Array, synchronous — a DataView over it parses the format; network.md); res.text() stops at the first zero byte. Data the app reads as values ships as .json.

A path without asset(): plain strings that name a file

The macro is not needed to name a file: a string literal that is the PATH of a resource file of the project — UIImage("images/hero.png"), "./hero.png" from the file, "/sounds/tap.mp3" from the root — is the same reference, resolved by the bundler the same way, wherever it stands (a table of rows the screens draw and a seed stores names its pictures that way). The lookup rules are in conventions.

A path with a / or a leading ./ that names NO file fails the compile with file not found: images/hero.png (main.ts:1) where the SDK takes a file — UIImage, img.src, bgImage / backgroundImage, a tab's icon, Texture.load, Texture2D.load, Model.load, AnimationClip.load, Sound.load, new AudioPlayer / new VideoPlayer, registerFont's url, fetchLocal, a scene's model / texture / ibl … — and stays a string anywhere else: files.write("photos/a.jpg"), a storage key, name.endsWith("/thumb.jpg"). A bare name that names no file stays a string. A .json path is left alone: its asset would be its data, not a reference.

asset() build errors: a missing file, a path in a variable

  • A path with no file behind it fails the build — asset not found: ./hero.png (main.ts:3). There is no runtime fallback, so a typo or a not-yet-generated output fails the compile instead of reaching the app as a broken reference.
  • Because it is a macro, asset(someVariable) fails the build too (asset() expects a string literal, found a dynamic argument). A call inside a comment or a string is text, not a macro.
  • Other text formats — .txt, .csv, .md, .xml, .yaml, … — yield the file's text as a string (const rows = asset('./levels.csv').split('\n')); parse it yourself.

The bundle runs as a plain script, so every await on this page sits in an async function — see no top-level await.

Degrees and radians: DEG2RAD, RAD2DEG

To convert an angle, multiply: degrees × DEG2RAD = radians, radians × RAD2DEG = degrees. Both are constants the compiler substitutes (Mathf.deg2rad() / Mathf.rad2deg() do the same — mathf.md).

TypeScript
DEG2RAD   // π / 180 — substituted at compile time
RAD2DEG   // 180 / π

Angle properties are degrees (3D eulerAngles, 2D rotation); raw angle arguments are radians (Quat.fromAxisAngle, Mat4.rotate*), and so is what Math.atan2 returns — see conventions.

TypeScript
const scene = new Scene()
const arrow = Mesh.box({ material: Material.lit({ color: '#4f7cff' }), position: [0, 0, -4] })
scene.add(arrow)
scene.open()

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

const turn = Math.atan2(1, 1) * RAD2DEG                         // Math returns radians → degrees
console.log(`turn ${turn.toFixed(0)}`)                          // turn 45

Reuse one set of styles: the Style<T> type

To share a set of styles between elements, keep it in a constant typed Style<T>: the style object el.style(...) of that element type accepts (every prop optional, $-class blocks included). A pure type — it exists for the editor's checking only.

TypeScript
const card: Style<UIColumn> = { bgColor: '#16181d', borderRadius: 12, px: 16, py: 12, gap: 4 }

UIScreen(
  UIColumn(UIText('Inbox'), UIText('12 new')).style(card),
  UIColumn(UIText('Sent'), UIText('3 today')).style(card),
).open()

Other compile-time macros: font(), assetIcon(), EDITOR

They live with their subject: font('id') installs a font and returns its family (fonts.md); assetIcon('pack:name') inlines an icon as an image source (content.md); EDITOR is true in scene-editor bundles and false, folded away, in shipped ones (scene files).

See also