In-Game UI
Build HUDs, menus and cards from UINode elements (text, panels, buttons, images) grouped into screens, laid out for each window size, drawn as a DOM overlay above the canvas, and driven from scripts by name.
The model
A UINode is a screen-space widget on an object, like any other component. Four kinds cover the range:
- text - a label (score, timer, prompt). Click-through.
- panel - a filled box, for card backgrounds and dialogs. Click-through.
- button - a panel that takes clicks and fires a script handler.
- image - a sprite (a frame, an icon, a portrait) fitted to its box. Click-through.
A UIScreen on a parent object groups the UINodes under it into one screen: a HUD, a pause menu, a level select. A screen is shown and hidden as one thing, can be laid out again for other window sizes (layout variants), can scale with the window (design size) and can keep the camera's picture clear of the edges it covers (edge insets). UI with no screen above it (loose UI, every project made before screens) behaves as it always did.
UI is real DOM, not drawn geometry. Every frame a UIOverlay reconciles the UINodes into <div> and <button> elements over the WebGPU canvas, creating, restyling and removing elements to match the world. It restyles only the elements whose fields changed (a 640-element UI costs one field-by-field compare per element per frame). The same overlay class runs in the editor's Scene view, its Game view, the Screens stage and the exported player, and a UINode serialises like any other component, so the UI ships with the scene.
Anchoring and layout
Every UINode is pinned to one of nine anchor points, then offset from it by x / y pixels. A top-right element stays in the top-right whether the view is 390 or 2560 px wide.
The offset direction follows the anchor:
- On a left edge,
xpushes right; on a right edge,xpushes left. - On a top edge,
ypushes down; on a bottom edge,ypushes up. - A centred axis (
center, or the centred half oftop/bottom/left/right) sits at 50% and uses the offset as a nudge from the middle.
What the anchor measures from:
- The view (the game canvas): every element whose Follow parent is off, which is how all UI made before that option works.
- The parent element's box: an element whose Follow parent is on and that has a UI element above it in the Hierarchy. See Follow parent.
On a phone, a view edge is the safe area's edge, so a label anchored to the top clears a notch.
w and h are pixels; 0 means auto (sized to the content); a value in (0, 1] is a fraction of the view's width or height, or of the parent's box with Follow parent on. w: 1, h: 1 is a full-bleed backdrop. Every pixel value (offsets, sizes, font size, padding, corner radius, shadow) is multiplied by the element's UI scale.
Layout is one pure function, uiNodeStyle(node, scale, …) in packages/runtime/src/uiNode.ts, that maps a UINode to CSS. The editor and the player share it, and it is unit-tested without a DOM.
Follow parent
With Follow parent on (UINode.followParent), an element is placed inside the element of the nearest UI node above it in the Hierarchy, and the browser lays it out in that box:
- Its anchor,
xandymeasure from the parent's box, and a fractionalw/his a percentage of it. - No safe-area inset is added: the parent is already inside the safe area.
- It moves, turns, scales and hides with the parent. Drag a card and its buttons come with it; hide the card and its contents go too.
- A press on it is its own. The overlay ignores the press as it bubbles up through the parent, so a button inside a button card fires only the inner handler.
api.ui.hitTestnames the inner element.- A part whose
pressWithnames the button it sits inside is pressed in once, with the button, not twice.
An image with elements inside it draws its sprite on a separate layer, so the image's opacity, tint and 9-slice border do not reach the elements inside. A half-transparent backdrop image keeps fully opaque buttons on it.
Defaults:
| Where the element comes from | Follow parent |
|---|---|
| A save from before the option, or Add Component → UINode | Off |
| Create → UI → Text / Panel / Button / Image | On |
An element with no UI node above it (directly under a screen object that has no UINode of its own, for example) is laid out against the view either way. The Inspector shows the field on every UINode.
UIScreen has a Follow parent of its own, for a screen inside another screen (a settings page inside a menu card). On, that screen shows only while the screen above it shows and no UI element above it is hidden; off, it shows by its own flags. + New Screen makes screens with it on; Make a Screen from… leaves it off, so promoting an existing group changes nothing.
The UINode fields
| Field | Type | Default | Meaning |
|---|---|---|---|
kind | enum | text | text | panel | button | image. |
anchor | enum | top-left | One of the nine anchor points. |
ignoreSafeArea | bool | false | Measure an edge anchor from the screen's edge instead of the safe area's. Shown only for edge anchors. |
x, y | number | 20 | Pixel offset from the anchor, scaled with the UI. |
w, h | number | 0 | Size in px (scaled); 0 = auto; (0, 1] = a fraction of the view or the parent's box. |
z | number | 0 | Draw layer: higher sits on top; equal layers stack in scene order, the Hierarchy's order, where a lower row draws over the rows above it. An element that follows its parent draws over that parent. |
text | string | "Text" | The label or button caption. Newlines are kept. |
fontSize | number | 24 | Font size in px, scaled with the UI. |
color | color | 1,1,1 | Text colour. |
bg | color | 0,0,0 | Panel / button fill, when it has no sprite skin. |
opacity | number | 0.5 | The background's alpha (the fill or the skin); an image's alpha. Never the text's. |
visible | bool | true | Off hides the element (display: none), so it also stops taking presses. |
image | string | "" | A UI image or texture id. An image draws it; a panel or button draws it as a skin behind its text. |
fit | enum | contain | How a whole sprite fills the box: contain, cover, stretch. |
slice | number | 0 | 9-slice inset in source pixels, all four sides; 0 = off. |
sliceScale | number | 1 | Drawn border = slice × sliceScale (× the UI scale). |
tint | color | 1,1,1 | Multiplies the sprite's colour (alpha untouched). |
rotation | number | 0 | Clockwise degrees about the centre. |
scaleX, scaleY | number | 1 | Scale about the centre. |
font | string | "" | A font family; "" = the system UI font. |
shadow | number | 0 | Drop shadow distance in px, straight down; 0 = none. Follows the drawn outline (a sprite's transparent parts cast nothing). On a text node it replaces the default text shadow. |
shadowBlur, shadowColor, shadowOpacity | 0, 0,0,0, 0.5 | The drop shadow's softness, colour and alpha. | |
followParent | bool | false | Follow parent. On for UI made with Create → UI. |
wrap | bool | false | Break text into lines that fit w. |
textAlign | enum | auto | auto | left | center | right. |
textVAlign | enum | auto | auto | top | middle | bottom. |
fontWeight | number | 600 | 100 thin to 900 black; picks the matching face of a font family. |
textOpacity | number | 1 | The text's alpha; its shadow fades with it. |
textShadow | bool | true | The default text shadow; off for flat text. |
cornerRadius | number | 8 | A panel or button's corner radius in px (scaled); 0 = square. |
pressWith | string | "" | The name of the button this element is a part of: it is pressed in with it. |
The fields added for text and boxes (followParent to cornerRadius) are written to a save only when they differ from these defaults, and a save without them styles exactly as before.
Text and boxes
- Wrap.
wrapbreaks text at spaces to fit the element's width (white-space: pre-wrap, and a word longer than a line is broken). Newlines in the text still start a line. Withwat0it wraps at the width of the box it sits in. - Alignment.
textAlignplaces the lines across the box.autofollows the anchor for a text node (a right-anchored label lines up on the right) and centres a panel or button label.textVAlignplaces the text down the box whenhis set;autois the top for a text node and the middle in a panel or button. - Weight. Every label is weight 600 unless
fontWeightsays otherwise. With an imported family that has several faces, the weight picks the face (400 the Regular file, 700 the Bold one). - Text opacity.
textOpacityfades the text and its shadow.opacitystays the background's, so a label can fade over a panel that stays. - Text shadow. Text nodes get
0 1px 3pxat 60% black, and a label over a sprite skin0 1px 2pxat 55%, so white text stays readable over a busy game.textShadow: falseremoves it; ashadowdistance above 0 on a text node draws your own shadow instead. - Corners. An unskinned panel or button has
cornerRadiuspx corners (8 by default). A sprite skin draws its own corners. - Sprites. A panel or button with an
imagedraws it as a skin behind its text, on a child layer, soopacityandtintnever reach the label.sliceturns on 9-slicing through CSSborder-image.tintis an SVG colour-multiply filter. - Press feedback. A held button is drawn scaled to 0.94, sunk 1 px and darkened to 88% brightness, along with every element whose
pressWithnames it.
Creating UI
Right-click in the Hierarchy (or use its + button) → Create → UI:
- Text - a
top-leftlabel reading "Text". - Panel - a centred 260×140 box at 60% black.
- Button - a centred blue button captioned "Button".
- Image - a centred 128×128 image box; pick a sprite in the Inspector.
Each is made with Follow parent on and placed under the selected object, so creating a button with a card panel selected puts it in the card. Select it and tune it in the Inspector. Give the object a clear name: scripts address UI by name.
The sibling 2D submenu (Sprite, 2D Camera, Shape 2D) makes world-space objects drawn by the renderer under the UI, not overlay elements. See Camera.

Screens
Add UIScreen to an object to make it a screen; every UINode under it belongs to it. A node's screen is its nearest ancestor carrying UIScreen, itself included, so a screen root may also carry a UINode (a full-screen backdrop).
| Field | Default | Meaning |
|---|---|---|
visible | true | Off hides every UINode under the screen, whatever each one's own visible says. |
pinned | false | Survives api.ui.switchScreen(other): a HUD that stays up under a menu. |
minHeight, maxHeight | 0 | The view heights (CSS px) this screen is for; 0 = no limit. See Layout variants. |
minAspect, maxAspect | 0 | The view aspects (width / height) this screen is for; 0 = no limit. |
insetTop, insetRight, insetBottom, insetLeft | 0 | The edges this screen's controls hold, px scaled with its UI. See Edge insets. |
designWidth, designHeight | 0 | The view size this screen was laid out for. See Scaling with the window. |
followParent | false | Shows only while the screen above it shows and no UI element above it is hidden. On for + New Screen. |
The component header's enable checkbox hides the screen the same way visible does. A screen hidden by any of these is display: none for all its elements, so its buttons take no presses.
The Screens tab
The Screens tab lists every screen, plus a (Loose nodes) row for UI with no screen above it:
- + New Screen, Duplicate (the copy gets a free name, "Pause 2"), rename and delete.
- Make a Screen from: buttons promote an existing group of loose UI to a screen by adding
UIScreento the object they are already under. Nothing is reparented or renamed. - Tags: hidden, pinned, name clash (another screen with the same name and the same limits, so both draw at once) and N dup (element names used more than once outside layout variants).
- A screen that shares its name with another shows its window-size range after the name (
< 0.62,0.62–1,≥ 1,h ≥ 700), as in the Hierarchy. Same-named screens are listed in scene order. - A stage that draws the open screen alone and in full, hidden or not, at an aspect (Match Game view, 16:9, 4:3, 9:16) and zoom (Fit, 50%, 100%, 200%). A screen with both design fields set is staged at its design size.
- On the stage, click an element to select it, drag it to move, drag its eight handles to resize. Each gesture is one undo step.
In the Scene view, turning UI editing on (◱) shows a screen picker: All UI (ghosted), or one screen drawn alone.

Screen verbs
Scripts switch screens by the name of the object carrying UIScreen. Every verb acts on every screen with that name, so a screen's layout variants follow together and the one that fits the window is the one drawn.
| Call | Effect |
|---|---|
api.ui.showScreen(name) | visible = true. |
api.ui.hideScreen(name) | visible = false. |
api.ui.toggleScreen(name) | Flip it (from the first screen of the name). |
api.ui.switchScreen(name) | Show it and hide every other screen that is not pinned. An unknown name does nothing. |
api.ui.screenVisible(name) | Whether a screen of that name is showing at the current view size; false for one that does not exist. |
api.ui.show(name) on an element whose screen is hidden logs a warning once per name, saying which screen to show.
Layout variants
Give two or more screens the same name and different limits, and they are variants of one screen: the same page laid out for different window sizes. Exactly one variant is chosen for each view size, and it switches by itself as the window changes. Elements in different variants may share names, and every api.ui verb reaches every element with a name, so a script drives whichever variant is showing without knowing which.
The view is the overlay's size in CSS px (the window, in a shipped game), not the safe area. One rule picks the variant (chooseVariant in packages/runtime/src/uiScreen.ts):
- One contains the view. The variant whose limits contain the view (both ends inclusive) is chosen.
- A shared limit belongs to the variant above it. When several contain the view because their limits meet, a variant whose maximum the view is at drops out. Max height 560 on one and Min height 560 on the other meet without overlapping: a 560 px view gets the second.
- An overlap goes to the narrower variant. Of several that still contain the view: the one with more limits set (an override beats a variant with none), then the higher minimum height, the higher minimum aspect, the lower maximum height, the lower maximum aspect, then the first.
- A gap goes to the nearer variant. When none contains the view but it lies inside the span the variants cover together, the variant whose limits it is nearest to (measured as a ratio, for height and aspect alike) is chosen. Max height 559 and Min height 560 at 559.5 px, or Max aspect 0.999 and Min aspect 1 at 0.9995, show one of them.
- Outside the span, none. A view smaller than every variant's minimum height, for example, shows no variant.
Same-named screens with the same limits are copies, not variants: each shows when its limits fit. A screen alone under its name, or an unnamed one, simply shows within its own limits (inclusive) and hides outside them. A view not measured yet (0 by 0) fits every screen.
A scheme for phones and tablets in both orientations, four screens all named HUD, using shared boundary values:
| Variant | Limits | Design size | Gets |
|---|---|---|---|
| Phone portrait | Max aspect 0.62 | Design width 390 | Upright phones (aspect about 0.46) |
| Tablet portrait | Min aspect 0.62, Max aspect 1 | Design width 834 | Upright tablets (about 0.75), square windows go to landscape |
| Phone landscape | Min aspect 1, Max height 560 | Design height 390 | Phones on their side (under 560 px high) |
| Tablet / desktop landscape | Min aspect 1, Min height 560 | 1280 × 800 | Tablets on their side, desktop windows |
An iPad window can be any shape (Split View, Stage Manager), so cover every aspect. There is no width limit: express it through height and aspect. Variants are plain component fields, saved in scene and prefab JSON.
To make a variant today, Duplicate the screen, rename the copy back to the original name, then give each different limits.
Scaling with the window
Each element is drawn at a UI scale:
- A screen with a design size. With
designWidthanddesignHeight, the screen's UI (positions, sizes, text, insets) scales bymin(view width / designWidth, view height / designHeight); with one of them, by that one. A layout made for an 844 × 390 phone (both fields set) fills a 1440 × 900 window at 1.71× and shrinks to 0.79× on a 667 × 375 phone; with only Design height 390 it draws at 0.96× there. - Otherwise, the project's HUD scaling. Render Settings → UI / HUD → HUD scaling sets
RenderSettings.uiReferenceHeight(Off, 720p, 1080p, 1440p, 2160p). Above 0, every element scales byview height / uiReferenceHeight, like Unity's CanvasScaler "Scale With Screen Size" matched on height. Importing a HUD pack turns it on at 1080. - With neither, the UI is raw pixels: the same pixel size on every screen.
Loose UI always uses the project's HUD scaling. The scale is recomputed every frame, so a resized window rescales live. Elements stay pinned to their anchors; only offsets and sizes scale.
Edge insets and the game area
A screen's inset* fields say how much of each edge its controls hold: a status bar along the top, a tray along the bottom, a column of buttons down the right. While the screen shows, a Camera whose Fit is Contain frames its picture clear of them.
The game area (gameAreaInsets) is the view minus, on each edge, the safe area plus the largest inset of the screens showing at this size, each scaled with its screen's UI. Loose UI holds no edge.
- The safe area is what the device reserves (a notch, the home indicator). It moves edge-anchored elements in, automatically.
- Insets are what your UI reserves, measured from the safe-area edge, and only while that screen shows.
- Neither changes the UI scale.
A screen with insets re-frames the camera whenever it shows, so put insets on the screens that stay up (the HUD variants), not on a pause card. A camera turned to portrait (Camera.turn) has the insets turned with it.
The safe area
On a phone, part of the screen is covered: a notch or the Dynamic Island, rounded corners, the home indicator. The safe area is the rest, as four insets in CSS px (top, right, bottom, left). On an iPhone 16 in portrait they are 59, 0, 34, 0. The 3D view still fills the whole screen; only UI layout moves in.
- An edge anchor measures from the safe area's edge, the inset added after the UI scale.
- A centred axis does not move.
- An axis the element spans whole (a fractional
worhof1) does not move, so a full-screen dim stays full-bleed. ignoreSafeArea: truekeeps an edge-anchored element at the screen's edge, for a backdrop that runs under the notch.- An element that follows its parent gets no inset: its parent is already inside.
- The UI scale stays based on the whole view, so a HUD is the same size on two phones with the same screen, whatever their notches.
Scripts read the insets as api.input.safeArea. It is 0 on every side in the editor and a desktop browser, so the editor does not preview notches.
ts
// On a top bar's backdrop: anchor top, ignoreSafeArea on, w 1, h 64.
// It reaches the top of the screen behind the notch and keeps its 64 px under it.
export default class TopBarBackdrop {
private top = -1;
onUpdate(api: ScriptApi) {
const top = api.input.safeArea.top;
if (top === this.top) return;
this.top = top;
api.setField("UINode", "h", 64 + top);
}
}An element a script places from screen coordinates (api.worldToScreen, api.input.pointerX / pointerY) is already in screen coordinates: turn ignoreSafeArea on for it, or it is drawn off by the inset.
Where the insets come from, in order:
- The mobile app. The mobile export's shell passes them in the URL (
#awaken-safe-area=top,right,bottom,left) and callswindow.__awakenSetSafeArea(top, right, bottom, left)when they change, for example on a rotation. Each value must be a finite number of 0 or more, or the whole update is refused. - The browser's
env(safe-area-inset-*), read again onresizeandorientationchange. Both the mobile app's page and a web export's page declareviewport-fit=cover, which is what makes a browser report them. - 0: a desktop browser and the editor.
Images
An element's image id resolves to a UI image first, then to a texture:
- UI images are image files (PNG, JPEG, WebP, GIF, SVG) kept as their own bytes. They are never decoded or uploaded to the GPU, and every UI image ships in the exported game as the same file (
SceneFile.uiImages), so one named only in a script (api.ui.setImage(name, "coin")) still arrives. Import them on the Asset Browser's UI Images tab. - Textures work too (a PNG dropped elsewhere in the editor becomes a texture), but a texture is uploaded to the GPU and re-encoded on export. An export ships the textures a scene or prefab uses; a texture named only in a script ships only with Always include in exports ticked in its inspector.
Pick the image in the Inspector (a list of UI images and textures, which also takes one dragged from the Asset Browser), or swap it from a script with api.ui.setImage(name, id). fit places a whole sprite in the w × h box (contain letterboxes, cover fills and crops, stretch distorts); give an image both a w and an h, since its box is what the sprite is fitted into. An id that resolves to nothing draws an empty box. A script-made texture (api.texture.create) can be shown too.
Fonts
A UINode's font names a font family; "" uses the system UI font. Import font files (.ttf, .otf, .woff, .woff2) on the Asset Browser's Fonts tab, or drop one anywhere in the editor. Fonts that come inside an imported Unity or Godot pack land there too.
Each file is one face of a family, with these fields in its inspector:
| Field | Meaning |
|---|---|
| Family | The name a UINode's font names. Several faces may share one (a Regular file and a Bold file). Guessed from the file name: Fredoka-Bold.ttf is family Fredoka at 700. |
| Weight | Which weight this file is, 100 to 900, or Variable (every weight) for a variable font. |
| Style | Normal or italic. |
| Licence | Free text (e.g. "SIL OFL 1.1"), kept with the project and not shipped. |
Each face is registered as an @font-face with its family, weight and style, in the editor and in the player alike. A UINode's fontWeight and the browser then pick the face: text at 700 in Fredoka draws with the Bold file. The font field in the Inspector is a list of the project's families (and the system font), and takes a font dragged from the Asset Browser. A project made before faces had these fields reads each font as its own family at 400, normal, and renders as before.
Touch and phones
- The overlay and every element in it use
touch-action: none, so a drag that starts on a button is not taken over by the browser as a scroll (nopointercancel). - A pinch anywhere on the page is refused, and a web export's page is fixed, with no overscroll, no double-tap zoom, no long-press menu and no text selection outside text fields. A web export on a phone behaves like an installed app: see Exporting for mobile.
- Only the primary touch reaches scripts. There is no multi-touch API yet.
- A press on a UI element is also a game pointer press (
api.input), in the player and in the editor's Game view. A script that reads presses for the game callsapi.ui.hitTestfirst to ignore presses on its UI. - Panels and images are click-through. A modal card's backdrop that must swallow presses registers a handler for them (
api.ui.onPress("Backdrop", () => {})).
Driving UI from scripts
Scripts address elements and screens by their entity name. Every element verb (setText, setImage, show, hide, play, stop) acts on every UINode with that name. The full surface, presses, hitTest, rect and spawning UI at runtime are on UI from Scripts.
ts
export default class Game implements ScriptBehavior {
private score = 0;
onStart(api: ScriptApi) {
api.ui.onClick("Start", () => api.ui.switchScreen("HUD"));
api.ui.onClick("Pause", () => api.ui.showScreen("Pause Menu"));
}
onUpdate(api: ScriptApi) {
api.ui.setText("Score", `Score: ${this.score}`);
}
}How presses are dispatched
- A button element is a
<button>: its click fires the handler registered withapi.ui.onClickunder the button's current name, throughUIState.fire(name). onClickworks only on buttons. On a panel, text or image it never fires; useonPress(pointer down) oronPointer(a click, with where in the element it landed).- Registering
onPressoronPointerfor an element makes it take presses even when it is not a button. - There is one handler per name and kind: a second
onClick("Close", …)replaces the first. Handlers are cleared when Play stops and when a scene loads. - Handlers fire in the Game view and the shipped game. In the Scene view the overlay is for layout and does not take presses.
Keeping the handler table (UIState) apart from the DOM lets a test call fire("Start") without a browser.
UI animation
A UINode's numeric fields can be keyframed and played back, so a widget can fade, slide, pulse or spin:
- A UIClip (
packages/core/src/uiClip.ts): aname,duration,loopflag and a list of tracks. Each track has atarget(a descendant element's name,""= the widget root), aprop, and sorted{ t, v }keys sampled linearly. Animatable props:x,y,w,h,opacity,rotation,scaleX,scaleY,colorR/colorG/colorB. - A UIAnimator on the widget's root:
clip,playing,speed, and thetimeplayhead.
Each frame, before the overlay syncs, driveUIAnimators advances every playing animator by dt · speed, samples its clip and writes the values onto the target elements. UI animations keep running while api.timeScale is 0.
ts
api.ui.play("Reticle", "spin"); // attach or restart a UIAnimator playing the "spin" clip
api.ui.stop("Reticle"); // freeze itUIClips come in through imports (a Unity .anim becomes a UIClip). There is no in-editor clip authoring yet. They ship in the exported game (SceneFile.uiClips).
Editing UI in the Scene view
Screen-space elements ignore the 3D camera, so you edit them with an overlay layer instead of the transform gizmo. The ◱ toggle (Scene view, top-left, below the stats ⓘ button) turns UI editing on: click an element to select it, drag its body to move it, drag its handles to resize it. The Screens tab's stage uses the same layer.
- Each drag writes the element's
x/y/w/has one undo step, in the element's own units: a fractional side stays fractional, an auto side stays auto until you really resize it, and a drag is divided by the element's UI scale. - For an element that follows its parent, the parent's box is outlined (dashed) while the element is selected, and a resize is measured from that box.
- Clicking picks the element painted on top: higher
zfirst, then the smaller box.

See also
- UI from Scripts - verbs, presses,
rect, spawning and destroying UI. - Asset Browser - importing UI images and fonts.
- Camera - Fit Contain and the portrait turn, which frame around the game area.
- Render Settings - the project's HUD scaling (
uiReferenceHeight). - Exporting for mobile - safe areas and the app shell.