Skip to content

UI from Scripts ​

Scripts drive the in-game interface through api.ui, addressing UI elements and screens by their entity name, and build UI at runtime by spawning UI prefabs into a screen.

Two layers put text and controls on screen during Play: UINode elements and screens you lay out in the editor (see In-Game UI), and a single HUD string (api.setHud) for quick status text. Both are covered here.

Names are the address ​

api.ui finds an element by the name of its object in the Hierarchy, and a screen by the name of the object carrying UIScreen.

  • Every element verb acts on every UINode with that name. A layout variant lays the same elements out again under the same names, so setText("Score", …) updates whichever variant is showing.
  • Every screen verb acts on every screen with that name (a screen and its variants).
  • A name that matches nothing is a silent no-op, so a renamed label never crashes a script.
  • Names are one global namespace: two buttons called "Close" on two screens are one address. The Screens tab tags such names N dup.

The UIApi ​

MemberSignatureDescription
ui.setText(name, text): voidSet the text of every element with the name.
ui.setImage(name, imageId): voidSet the sprite: a UI image id or a texture id.
ui.show(name): voidvisible = true. Warns once if the element's screen is hidden.
ui.hide(name): voidvisible = false.
ui.onClick(name, cb): voidClick handler for a button element. Never fires for a panel, text or image.
ui.onPress(name, cb): voidPointer-down handler for any element. Registering makes the element take presses.
ui.onPointer(name, cb(x, y, clicks)): voidClick handler for any element, with x / y as 0..1 across it and clicks the burst count (2 = double-click). Registering makes the element take presses.
ui.hitTest(x, y): string | nullThe name of the element a press at canvas point (x, y) would land on, or null when it would reach the game.
ui.rect(name): { x, y, width, height } | nullWhere the element is drawn, canvas CSS px. See Where an element is.
ui.play(name, clip): voidPlay a UI animation clip on the widget (adds or restarts its UIAnimator).
ui.stop(name): voidFreeze the widget's UI animation.
ui.showScreen(name): voidShow every screen with the name.
ui.hideScreen(name): voidHide every screen with the name.
ui.toggleScreen(name): voidFlip them (from the first one's state).
ui.switchScreen(name): voidShow it and hide every other screen that is not pinned. An unknown name does nothing.
ui.screenVisible(name): booleanWhether a screen of that name is showing at the current view size; false for one that does not exist.

A UI Text and a UI Button drawn over the game in Play, the button's UINode fields in the Inspector: what a script reaches by name through api.ui.

Text, visibility and screens ​

ts
export default class implements ScriptBehavior {
  onStart(api: ScriptApi) {
    api.state.lives ??= 3;
    api.ui.onClick("Resume", () => api.ui.hideScreen("Pause Menu"));
    api.ui.onClick("Pause", () => api.ui.switchScreen("Pause Menu")); // the pinned HUD stays up
  }
  onUpdate(api: ScriptApi) {
    api.ui.setText("Lives", `Lives: ${api.state.lives}`);
    if (api.state.lives <= 0 && !api.ui.screenVisible("Game Over")) api.ui.switchScreen("Game Over");
  }
}

api.ui.show on an element inside a hidden screen sets its flag but draws nothing, and logs [awaken] ui.show('Resume'): its screen 'Pause Menu' is hidden… once per name: show the screen instead.

Presses ​

  • onClick is for buttons: a button element is a real <button>, and its click fires the handler.
  • onPress fires on pointer down, on any element: the start of a drag out of a tray slot.
  • onPointer fires on a click, on any element, with where in it the click landed: a minimap, a colour picker.
  • There is one handler per name and kind: a second onClick("Close", …) from another script replaces the first. Handlers are cleared when Play stops and when a scene loads, and they outlive a destroyed element until then.
  • Panels, text and images are click-through until something registers onPress or onPointer for them. A modal card's backdrop that must swallow presses registers a handler: api.ui.onPress("Backdrop", () => {}).
  • A press on an element inside another (Follow parent) is the inner element's only.

A press on the UI is also a game pointer press (api.input), in the player and the editor's Game view alike. A script that reads presses for the game asks hitTest first:

ts
onUpdate(api: ScriptApi) {
  if (!api.input.wasPressed(0)) return;
  const x = api.input.pointerX, y = api.input.pointerY;
  if (api.ui.hitTest(x, y) !== null) return; // a press on the HUD, not on the board
  // …the game's own press at (x, y)…
}

hitTest counts only elements that take presses (buttons, and elements with an onPress or onPointer handler), names the innermost one, and answers with its current name, also after a script renamed it. Only the primary touch reaches scripts.

Swapping images ​

ts
api.ui.setImage("Weapon Icon", "icon_bow");

The id is a UI image id or a texture id, as the element's image field takes in the Inspector. Every UI image ships in the exported game. A texture named only in a script ships only with Always include in exports ticked in its inspector, because an export ships the textures its scenes and prefabs use and cannot see a name inside a script.

Playing UI animations ​

ts
api.ui.play("Game Over Panel", "slideIn"); // a UIClip, imported from a Unity .anim
api.ui.stop("Game Over Panel");

play gives the widget a UIAnimator (or reuses its own), points it at the clip and restarts it from 0. UI animations keep running while api.timeScale is 0, so a pause card can animate. See UI animation.

Where an element is ​

api.ui.rect(name) answers where an element is drawn: { x, y, width, height } in canvas CSS px from the game view's top-left corner, the space of api.input.pointerX / pointerY, api.input.viewWidth / viewHeight and api.screenToWorld.

  • It is measured from the overlay as laid out: anchor, the layout variant chosen for this window, the screen's scale, the safe area and the element it follows are all included.
  • A rotated or scaled element answers with the box around its turned shape.
  • With several elements of the name (layout variants), it answers for the one showing.
  • null when none is showing (hidden, in a hidden screen, or a variant for another window size) and when nothing draws UI (an edit-time script, a headless test).
  • An element not drawn yet (made this frame, or asked in onStart) is laid out on the spot.

Use it to fit the game around authored UI. This script, on an orthographic camera looking down -Z at a board centred on the origin, keeps the board centred in the part of the view to the right of a side panel, whichever layout variant is showing:

ts
export default class FrameBesidePanel implements ScriptBehavior {
  onUpdate(api: ScriptApi) {
    const panel = api.ui.rect("Side Panel");
    if (!panel) return; // not showing at this window size
    const w = api.input.viewWidth, h = api.input.viewHeight;
    // Where a view point lands on the board's plane (z = 0).
    const onBoard = (px: number, py: number) => {
      const ray = api.screenToWorld(px, py);
      if (!ray || ray.dir.z === 0) return null;
      const t = -ray.origin.z / ray.dir.z;
      return { x: ray.origin.x + ray.dir.x * t, y: ray.origin.y + ray.dir.y * t };
    };
    const mid = onBoard(w / 2, h / 2);
    const free = onBoard((panel.x + panel.width + w) / 2, h / 2); // the middle of the free area
    if (!mid || !free) return;
    const p = api.getPosition();
    api.setPosition(mid.x - free.x, p.y, p.z); // the board's centre now lands in the free area
  }
}

For UI that is always up, a Camera with Fit: Contain and the HUD screen's edge insets does the same without a script.

Building UI at runtime ​

Lay a repeated piece of UI out once (a level tile: a button with a label and an icon inside it, each with Follow parent on), save it with Save as Prefab, and spawn one per item:

CallWhat it does
api.spawn(prefab, { parent, name, position, rotation })Instantiates the prefab. parent puts the new root under that entity, so a UI prefab spawned into a screen is gated and scaled by it and, with Follow parent, laid out inside the parent element. name renames the new root. Returns the root, or null for an unknown prefab. A dead parent leaves it at the scene root.
api.destroy(e?)Destroys e (or this entity, with no argument) and everything under it.
api.world.destroyEntity(e)The same, from the world.
ts
// On the screen that holds a "Grid" panel. "Level Tile" is a prefab: a button "Tile" with a text "Tile Label"
// inside it (Follow parent on for both).
export default class LevelGrid implements ScriptBehavior {
  private tiles: Entity[] = [];

  onStart(api: ScriptApi) {
    const UI = api.world.registry.get("UINode");
    const grid = api.find("Grid");
    if (grid === null) return;
    const levels = ["Harbour", "Reef", "Trench", "Abyss"];
    levels.forEach((title, i) => {
      const tile = api.spawn("Level Tile", { parent: grid, name: `Tile ${i}` });
      if (tile === null) return;
      const node = api.world.get(tile, UI);
      node.x = 20 + (i % 2) * 140; // inside Grid's box, because the tile follows its parent
      node.y = 20 + Math.floor(i / 2) * 140;
      // The tile's parts keep the prefab's names: give each this tile's own, and point its pressWith at the new name.
      const rename = (e: Entity) => {
        for (const part of api.world.getChildren(e)) {
          api.world.setName(part, `${api.world.getName(part)} ${i}`);
          const n = api.world.get(part, UI);
          if (n?.pressWith) n.pressWith = `${n.pressWith} ${i}`;
          rename(part);
        }
      };
      rename(tile);
      api.ui.setText(`Tile Label ${i}`, title);
      api.ui.onClick(`Tile ${i}`, () => api.loadScene(title));
      this.tiles.push(tile);
    });
  }

  clear(api: ScriptApi) {
    for (const t of this.tiles) api.destroy(t); // each tile goes with its label
    this.tiles = [];
  }
}

A field written directly (node.x = …) is drawn on the next frame. In a shipped game that has gone idle (no input and nothing moving), the next frame can be up to 100 ms away; api.requestDraw() asks for one now.

The World methods scripts can use ​

api.world is the live ECS world. These members are declared in the Code panel's IntelliSense:

MemberSignatureDescription
createEntity(): EntityA new, empty entity.
destroyEntity(e): voidDestroy an entity and everything under it.
isAlive(e): booleanWhether the handle still names a live entity.
getName / setName(e) / (e, name)An entity's name; api.ui and api.find address by it.
get(e, type): anyThe component of that type on the entity (write its fields directly), or undefined.
add(e, type, init?): anyAdd a component with its defaults, overridden by init.
has / remove(e, type)Whether it carries the component / take it off.
query(...types): Entity[]Every entity carrying all of these component types.
findNamed(name, type): Entity | nullThe first entity carrying type with this name (names are indexed).
findAllNamed(name, type): Entity[]Every one: an element and its layout variants.
getParent / getChildren(e)The hierarchy; 0 is the scene root.
setParent(child, parent): voidReparent (0 = the root). An element under a screen is gated and scaled by it.
isActive / setActive(e) / (e, active)The object's active flag. The UI overlay does not read it: hide UI with visible or its screen.
isStatic(e): booleanThe static flag.
markMoved(e): voidTell the renderer a script moved this Transform.
registry.get(componentName)The component type to pass to get / add / query, e.g. registry.get("UINode").

setHud - the quick overlay ​

For simple status text you do not need a UINode. api.setHud(text) sets one on-screen string:

ts
onUpdate(api: ScriptApi) {
  api.setHud(`Score ${api.state.score ?? 0}   ·   Time ${api.time.toFixed(1)}s`);
}

It is one string with no styling or layout, cleared when Play stops. Use UINode elements for positioned panels, several labels or buttons.

Play matches the shipped game ​

The UI service scripts talk to (handlers, hitTest, rect) is the same in the editor's Game view and the standalone Player, and both run the same overlay. A menu wired with api.ui.onClick behaves the same when you press Play and when a player opens your exported game.

See also ​

  • In-Game UI - elements, screens, layout variants, Follow parent and styling
  • ScriptApi Reference - api.ui, api.spawn and api.world in the full API
  • Input - api.input, the pointer and the safe area
  • Prefabs - saving the UI you spawn
  • Camera - Fit Contain and the game area

Awaken: browser-native WebGPU game engine.