Skip to content

UI & 2D games ​

Galatrix is a 3D engine that also makes 2D games and on-screen UI first-class — the same editor builds both. This guide covers sprites (the building block of a 2D game), the two UI systems (a visual Canvas you design by hand, and a script-driven HUD), and the shared pieces they lean on — fonts, masks, and the Canvas Scaler that keeps a layout looking right on every screen.

Open the editor with ?example=uidemo for a ready-made canvas to poke at.

Everything here works in all four runtimes — the editor play-test, a published multiplayer game, and a standalone build all render the same UI and sprites from the same map data.

Sprites — the 2D building block ​

A sprite is a flat, unlit, textured quad that always faces the camera (a billboard). Add one from the Hierarchy + → Sprite, or just drag a texture from the Project panel into the viewport — that drops a sprite entity at the point you release, named after the texture.

Select a sprite and the Inspector's Sprite (2D) section configures it:

  • Texture — a block texture or any imported texture asset (the Tint multiplies it).
  • Opacity, Flip X / Y, Billboard (face the camera — turn it off to lock the sprite to its transform rotation, e.g. a floor decal), and Sorting Order (see sorting).
  • PPU and Pivot — how the quad is sized and where it sits (below).
  • Sheet Cols / Rows / Frame — sprite-sheet framing, for animation.

PPU and pivot ​

By default a sprite is a 1×1 quad sized by the entity's Scale. Set PPU (Pixels Per Unit; the import default is 100) and the quad instead sizes itself from the texture's pixel dimensions — worldSize = pixels ÷ PPU — with Scale multiplying on top. A 64×32 texture at PPU 100 becomes exactly 0.64×0.32 world units, so all your art shares one consistent scale.

Pivot [x, y] (0–1) picks which point of the sprite sits at the entity position. The default [0.5, 0.5] is centered; [0.5, 0] puts the position at the bottom-center — the classic "feet on the ground" setup for a 2D character, so moving the entity moves its feet.

Sheet sprites size from one frame's pixels, not the whole atlas.

Sprite animation ​

A sprite sheet is a grid of equal frames in one texture. Set Sheet Cols × Rows on the Sprite (2D) component and it shows one cell at a time; Frame picks which (frame 0 is the top-left, counting left-to-right, top-to-bottom).

For flipbook animation add a Sprite Animator component. It holds named clips, each a frame range:

FieldMeaning
nameclip name (scripts switch by this)
from / tofirst and last frame (from > to plays backwards)
fpsframes per second
looploop (default) or hold the last frame

The animator plays clips locally in every runtime. A script switches clips with setComponent:

js
class PlayerAnim extends Behaviour {
  onUpdate() {
    const moving = /* … */ false
    this.entity.setComponent('spriteAnimator', { play: moving ? 'run' : 'idle' })
  }
}

Runtime playback state lives outside the saved data, so playing an animation never dirties your map.

The 2D scene view ​

The toolbar's 2D button (next to Snap) swaps the viewport to an orthographic front view of the XY plane — the wheel zooms, middle/right-drag pans. Selection, the gizmos, and the measure tool all follow the 2D camera; toggle back and the 3D orbit camera is exactly where you left it. Use it to lay out a 2D scene without perspective getting in the way.

2D physics ​

For a side-scroller you want bodies that only move in the XY plane. Add a Rigid Body and click the ⬛ 2D Body (lock to XY plane) preset — it freezes Z position and X/Y rotation so a body falls, slides, and stacks in 2D while staying upright.

Sprite sorting ​

Sprites don't write depth, so overlapping sprites stack by Sorting Order (higher draws on top) rather than z-fighting by camera distance. World geometry still occludes sprites normally (they depth-test), so a sprite behind a wall is hidden.

Texture filtering ​

Inspect a texture in the Project panel (double-click, or right-click ▸ Inspect) to reach its sampling settings. They apply everywhere the texture is used.

SettingWhat to pick
Filter ModePoint for pixel art — crisp, nearest-neighbour. Bilinear blends texels. Trilinear also blends between mip levels, so no seam shows where the level changes.
Generate Mip MapsSmaller copies used at a distance. Off makes far-away surfaces shimmer as the camera moves. Keep on for 3D; off is fine for UI and pixel art.
Aniso LevelKeeps a surface sharp viewed edge-on — a floor running to the horizon. 4× by default; needs mip maps and a smooth filter mode.
Wrap ModeHow UVs past 0..1 sample. Repeat tiles, Clamp holds the edge pixel (an atlas must clamp), Mirror flips every other tile to hide the seam on a texture that does not tile cleanly. Tick Separate U / V for anything that tiles one way only — a waterfall repeats sideways and clamps vertically.
Alpha Is TransparencyFor cut-out textures. Stops the dark halo that appears around transparent edges as the texture shrinks.
Mip Maps Preserve CoverageFor alpha-tested textures. Stops foliage and fences thinning out and vanishing into the distance. Set Alpha Cutoff to match the material's alpha test.
Store Mip Maps In AssetSaves the mip chain with the texture instead of rebuilding it each time the map loads. Off by default.

New imports arrive as Trilinear with mip maps on, which is what a 3D texture wants. Pixel art wants Point — switch it once and mip maps follow. Textures imported before these settings existed stay on Point, so an older map renders exactly as it did; set one to Bilinear or Trilinear to opt it in.

Where mip maps live ​

Most engines bake the mip chain into the imported texture, which is why such a texture's size on disk grows by about a third when you tick Generate Mip Maps. Galatrix builds the chain when the map loads instead, so the map carries only the source image and ticking mip maps costs nothing to download. The size shown under the Apply button is that stored image — it does not move when you change Filter Mode, mip maps, aniso or wrap, because none of those change a pixel. Alongside it is the GPU memory the texture occupies once loaded, which does grow by a third with mip maps on, because a mip chain takes the same VRAM however it got there.

Store Mip Maps In Asset switches a single texture to that arrangement. The levels are encoded and shipped with the map, and the readout gains a + N KB mips term so the cost is visible where you choose it.

Read that number rather than assuming a third. A mip chain is a third more pixels, but the levels are encoded the same way the texture is, and small images compress worse per pixel than large ones — on a heavily compressed source the chain can come to more than the image it belongs to. The readout is the answer for the texture in front of you.

Leave it off for most textures. A plain chain costs the GPU essentially nothing to generate, so baking one just adds bytes to every download. It earns its size in two cases:

  • The texture uses Alpha Is Transparency or Preserve Coverage. Those chains are not built by the GPU at all — they are JavaScript: a colour-bleed pass and a coverage search per level, over every texel, on every load, for every player. Measured on a 512×512 cut-out that is about 150 ms of main-thread time per texture; stored, the same texture costs about 14 ms to decode. Ten such textures is the difference between a second and a half of frozen page and a seventh of one.
  • You need the mips to be identical everywhere. A baked chain is fixed bytes; driver-generated mips are only approximately the same across GPUs.

Baking changes nothing about how the texture looks: the stored chain is the same one the runtime would have built. Turning it back off drops the stored levels and returns to building at load.

Tilemaps ​

A tilemap builds a 2D level out of a grid of repeating tiles taken from a single sprite sheet — the standard 2D way to make a platformer or top-down map without placing hundreds of individual objects. Open the Tilemap window (Windows → Tilemap), click + New Tilemap, and pick a Tileset (any imported image that is a grid of tiles) plus its Sheet dimensions (columns × rows) and cell size (world units per tile).

Painting ​

Pick a tool, then left-drag in the viewport to paint onto the tilemap's plane:

  • 🖌 Paint — stamp the selected tile (brush size 1–16 cells).
  • 🧽 Erase — clear cells back to empty.
  • 🪣 Fill — flood-fill a contiguous region with the selected tile.
  • 💧 Pick — sample the tile under the cursor into the palette.

The palette shows every tile in the sheet: click to select one to paint; shift-click to mark a tile solid (it turns red). Painting builds a single merged mesh with per-tile UVs, so a big level is still one draw call. The whole grid is stored compactly (run-length-encoded per chunk) and rides in the map — nothing is written to disk as separate files.

Solid tiles and collision ​

A tile only collides if you mark it solid. At play time the solid cells are greedy-merged into as few box colliders as possible, so the player stands on ground/platform tiles and bumps walls, while decorative tiles (coins, foliage, background) render but never block. Collision is identical in the editor play-test, in standalone builds, and in multiplayer — the server builds the same boxes from the same shared code, so what you paint is what everyone collides with. A tilemap is a normal object in the Hierarchy: move it to place the whole level, and a map can hold several.

For a true 2D feel, pair a tilemap with an orthographic camera and a characterController player whose Z is pinned to 0 (see the 2D Platformer Kit). Load the worked example with ?example=tilemap: a side-view level whose floor, platforms, and walls are all tiles, with solid ground and decorative coins/bushes, and a small editable Platformer script driving a z-locked player.

With the AI editor ​

The assistant can build tilemaps too: createTilemap (name, position, tileset, sheet dimensions, cell size, and which tile indices are solid) then paintTiles (fill a rect of cells or an explicit cells list with a tile, or erase). For example, "make a 2D platformer floor with stone walls" creates the tilemap and paints the ground and walls in cell coordinates.

Two ways to build UI ​

Galatrix has two UI systems. They can coexist in one game:

Visual CanvasScript HUD
Built withthe UI Editor (drag & drop) + Hierarchyscript calls (ui.createX)
Lives asuiCanvas + uiElement entities in the mapruntime-only, created by a script
Best forHUDs you design once and placedynamic UI (scores, inventories, dialogs that change)
Interactionwire a control to a script functioncallbacks in the options (onClick, onValueChanged)

Both render on top of the game, scale identically (see Canvas Scaler), and work in every runtime.

The visual Canvas ​

Create a Canvas from the Hierarchy + → UI → Canvas, then add elements under it — Label, Panel, Bar, Image, Button, Toggle, Slider, Text Input, Dropdown. Design them in the UI Editor window (drag to move, handles to resize) and fine-tune in the Inspector. It's a WYSIWYG layout: what you place is what players see.

Anchors — position and stretch ​

Every element has a position (0–1 of the screen) and an anchor — the pivot the position refers to. The Inspector's Anchors grid is a 3×3 preset picker plus a stretch row and column:

  • A point anchor pins the element at a screen fraction — top-left, center, bottom-right, etc. It keeps its fixed size as the window resizes.
  • A stretch anchor ties the element's edges to the screen so it grows and shrinks with the viewport. When stretched, the Margins fields (Left / Top / Right / Bottom, in px) inset it from those edges.

Each axis is independent, so a bottom bar can stretch across the full width (Stretch X) while staying a fixed height pinned to the bottom (point Y).

Interactive controls ​

Buttons and the value controls (slider, toggle, text input, dropdown) can drive your game. In the Inspector's Action row set:

  • Function — the script function to call. A button calls it on click; a control calls it on value change, passing the new value (a number for slider/dropdown, boolean for toggle, string for input).
  • On Entity — the entity whose script has that function (leave empty to call it on every script in the scene, like a BroadcastMessage).
js
class Shop extends Behaviour {
  onBuy() { /* the Buy button's Function */ }
  onVolume(v) { /* the volume slider's Function — v is the new 0..1 value */ }
}

Actions run where scripts run: locally in the editor/standalone, on the server in multiplayer (so the UI stays authoritative).

Fonts, wrapping, masks ​

  • Labels take a Font (see Fonts), and a Wrap toggle flows long text onto multiple lines within a width instead of one clipped line.
  • Images take a Mask — Circle (a round avatar/minimap crop) or Rounded (corner radius) — and stretch to fill their frame.

Authoring aids ​

The UI Preview window (the visual editor) has a few helpers:

  • Multi-resolution preview — a device dropdown (Phone / Tablet / Desktop / …) reshapes the preview to that aspect so you can see how the Canvas Scaler and stretch anchors respond, without changing the Canvas's own resolution. Each device is also a real screen size (Phone 9:16 is 360 x 640, Phone 9:19.5 is 390 x 844, the tablets 1024 x 768, the others 1080 tall), so a Min Scale floor from Game Settings shows there as it draws on that device.
  • Smart guides — dragging an element snaps it to the canvas thirds/edges and to other elements, drawing a guide line; hold Alt to move freely.
  • Align & Distribute — multi-select 2+ elements and use the Align strip (X/Y Min·Center·Max), or Distribute (3+) for even spacing.
  • UI prefabs — select an element and Save as Prefab (Inspector); re-stamp it into any Canvas from the Hierarchy's + → UI → UI Prefabs. Children, controls, and action wiring all come along.

The script HUD ​

For UI that changes at runtime, scripts build it with the ui API. Each create… returns a handle to update or remove the element later.

js
class HUD extends Behaviour {
  onStart() {
    this.score = ui.createLabel({ text: 'Score: 0', position: [0.5, 0.05], fontSize: 32 })
    this.hp = ui.createBar({ value: 1, position: [0.05, 0.05], anchor: [0, 0], barColor: '#f38ba8' })
    ui.createButton({ text: 'Jump', position: [0.9, 0.9], onClick: () => this.jump() })
  }
  addPoint() { this.points++; this.score.setText(`Score: ${this.points}`) }
}

The factories:

CallMakes
ui.createLabeltext (supports wrap + maxWidth, fontFamily, bestFit)
ui.createBara progress / health bar (value 0–1)
ui.createPanela background panel
ui.createImagean image from a project texture ({ texture: 'Coin' }, plus mask)
ui.createButtona button (onClick)
ui.createSlider / createScrollbar / createToggle / createInput / createDropdownvalue controls (onValueChanged)
ui.createContainera layout group (layout group + scroll rect); .add(...children) nests elements

Every factory takes visible: false to make the element hidden from the start (a menu, a prompt, a tooltip shown later); handle.visible = true or handle.set({ visible: true }) shows it. In multiplayer the element reaches each player's screen hidden, a late joiner's included.

A scrollbar (ui.createScrollbar) is a draggable sized handle in a track. Its onValueChanged fires with a 0..1 position as you drag; handleSize sets the handle's fraction of the track and direction picks the axis ('horizontal' / 'vertical').

Best Fit (ui.createLabel({ bestFit: true, width, height })) shrinks the font so the text fits its box — text auto-size. It needs a bounded box, so give the label a width and height (or, on the Canvas, a fixed-size / stretched frame); the font size becomes the ceiling and bestFitMin the floor.

Rich text (richText: true) reads <b>, <i>, <u>, <s> (struck through), <color=#f00> and <size=24>; <u> and <s> together draw both lines. A label folds runs of spaces into one and a line break into a space, as web text does; keepSpaces: true keeps them as written, so 'a b' keeps its gap and 'line one\nline two' is two lines. A label that does not wrap still does not wrap.

js
ui.createLabel({ text: 'Price: <s>200</s> 150', richText: true })
ui.createLabel({ text: 'Name      Score\nAlex      12', keepSpaces: true, fontFamily: 'monospace' })

ui.showMessage(text) shows a quick floating message. See the Script API reference for every option.

ui.setVisible(false) hides the whole script HUD at once, for a "hide the GUI" key; ui.setVisible(true) brings back exactly what was up, because each element keeps its own visible flag. Messages, the hotbar and chat are not part of the script HUD and stay. Pass a player id to hide it for that player only; ui.isVisible() reads the switch.

js
class HideGui extends Behaviour {
  onUpdate() {
    if (input.getKeyDown('f1')) ui.setVisible(!ui.isVisible())
  }
}

While the cursor is captured in play, the browser's own Ctrl+letter, Ctrl+digit and F1 to F10 shortcuts are kept from running, so a game can bind those keys (Ctrl+V still pastes; F11 and F12 stay the browser's). Ctrl+W cannot be kept by any page, so the browser asks before leaving instead.

Images by name, and masks ​

ui.createImage({ texture }) takes a texture asset by name (or id) — the client resolves it to the image locally, so multiplayer never streams the pixels through the HUD. Add mask: 'circle' for a round crop or mask: 'rounded' (with borderRadius) for rounded corners.

An image drawn larger than its picture follows the texture's Filter Mode: a texture set to Point keeps whole pixels, so pixel-art icons stay sharp; the other modes smooth it. filtering: 'point' or filtering: 'linear' chooses for one image, and is the way to choose for an image made from a url. Without either, an image is smoothed.

js
ui.createImage({ url: 'data:image/png;base64,…', width: 64, height: 64, filtering: 'point' })

Containers and clipping ​

A container arranges its children (vertical / horizontal / grid), optionally scrolls them (scroll: 'y'), and can clip them to its box (clip: true — a rect mask), which composes with borderRadius for a rounded window.

Scrolling from a script ​

A container's handle moves its box and reads where it stands, in the same px as the HUD's sizes:

CallDoes
box.scrollTo(x, y)Scrolls to that position (clamped to the content). null for an axis keeps it.
box.scrollIntoView(child)Scrolls the least that shows that element (a child, or one nested deeper) whole. Nothing moves when it is already in view.
box.getScroll(){ x, y, maxX, maxY }: scrolled so far, and the most it can scroll (0 when the content fits).
box.onScroll = (s) => …Called as the box scrolls, by the player or by the calls above: at most 5 times a second while it moves, and always with where it came to rest. s.player says whose screen it is.
js
onStart() {
  this.list = ui.createContainer({ scroll: 'y', width: 300, height: 240, gap: 4 })
  this.rows = names.map((n) => { const r = ui.createButton({ text: n, onClick: () => this.join(n) }); this.list.add(r); return r })
  this.picked = 0
}
onUpdate() {
  if (input.getKeyDown('arrowdown')) { this.picked = Math.min(this.picked + 1, this.rows.length - 1); this.list.scrollIntoView(this.rows[this.picked]) }
}

In the play-test, a standalone build and a client script, getScroll() reads the box as drawn (a scrollTo not drawn yet reads as its target). In multiplayer a server script's scrollTo and scrollIntoView move the box on the screens of the players who see it (an element made with a playerId, that player's), and its getScroll() is the last position a screen reported. A screen reports while the box has an onScroll; before any report, getScroll() is the last scrollTo.

Text fields and Enter ​

A text field (ui.createInput) tells its text when the player presses Enter in it or leaves it, and the second argument of onValueChanged says which: info.reason is 'enter' or 'blur'. So Enter can be a form's Done:

js
ui.createInput({ placeholder: 'Server address', onValueChanged: (text, info) => {
  this.address = text
  if (info.reason === 'enter') this.join()
} })

A callback that takes info also hears Enter when the text is unchanged (Enter in a field filled in earlier still submits). A callback with one argument hears changes only. A multiline field keeps Enter as a new line, so it reports when the player leaves it.

Pointer & drag events ​

Any HUD handle takes an onPointer callback — an EventTrigger for the script HUD. It fires for the pointer's whole lifecycle over the element: enter, exit, down, up, click, and a drag cycle beginDrag → drag → endDrag, plus drop on the element the pointer is released over (for drag-and-drop). A press that never moves past a small threshold stays a plain click. While a press is held, dragEnter and dragExit fire on the elements the pointer moves onto and off.

The callback receives the event name and an info object saying who and where:

FieldMeaning
info.playerThe player whose pointer it is — the same object a button's onClick gets in multiplayer.
info.elementIdThe element this handler belongs to.
info.targetIdOn endDrag: the pointer-enabled element the pointer was released over (absent when released over nothing). On drop: this element.
info.sourceIdOn drop: the element that was being dragged. On dragEnter and dragExit: the element whose press is held.
info.buttonThe mouse button: 0 left, 1 middle, 2 right, 3 and 4 the side buttons. On down, up and click, the button pressed or released; on beginDrag, drag, endDrag, drop, dragEnter and dragExit, the button the press began with. Absent on enter and exit. A touch or a pen is 0.

A handler that only takes the event keeps working.

js
onStart() {
  const slot = ui.createPanel({ position: [0.5, 0.5], width: 96, height: 96 })
  slot.onPointer = (e, info) => { if (e === 'drop') this.acceptItem(info.sourceId, info.player) }
}

Mouse buttons ​

Every mouse button reaches onPointer, and info.button says which. A button pressed while another is held is a press of its own, with its own down, up and click. Over an element with onPointer, and over any HUD button, a right click opens no browser menu, a middle press does not start the browser's scrolling and a side button does not go back or forward a page, so every button is the game's there. A press that began somewhere else and is let go over the element is none of its events. A button's onClick fires for the left button only; give the button an onPointer as well to hear the others.

js
slot.onPointer = (e, info) => {
  if (e !== 'down') return
  if (info.button === 2) this.takeHalf(info.player)       // right: half the stack
  else if (info.button === 1) this.copyStack(info.player) // middle: a copy
  else this.takeAll(info.player)
}

Like the value callbacks, onPointer runs where scripts run — locally in the editor/standalone, on the server in multiplayer. On the visual Canvas the same events come from an element's pointer function (set in the Inspector), which receives the event name.

Dragging ​

A drag-and-drop grid (an inventory, a hotbar, a crafting table) is one onPointer per slot. The slot that was dragged hears beginDrag → drag → endDrag (with info.targetId = where it landed); the slot it was released over hears drop (with info.sourceId = what landed on it). Move the item on drop, using info.sourceId and info.elementId, and use info.player to pick whose inventory it is:

js
onStart() {
  this.slots = []
  for (let i = 0; i < 9; i++) {
    const slot = ui.createPanel({ position: [0.3 + i * 0.05, 0.9], width: 48, height: 48 })
    slot.onPointer = (e, info) => {
      if (e !== 'drop' || !info.sourceId) return
      const from = this.slots.findIndex((s) => s.id === info.sourceId)
      const to = this.slots.findIndex((s) => s.id === info.elementId)
      if (from >= 0 && to >= 0) this.swapItems(info.player.id, from, to)
    }
    this.slots.push(slot)
  }
}

drag fires while the pointer moves but carries no coordinates, so it is only useful as a "still dragging" heartbeat — the client throttles it to 5 per second, in the editor as well as online, so a drag behaves the same before and after publishing. In multiplayer the hover and drag stream (enter, exit, drag) and the gesture edges (down, up, click, beginDrag, endDrag, drop) have separate message budgets: however long a drag or a hover lasts, the release and the final drop always reach the server. A drag cancelled by the browser (a scroll, an edge swipe) ends with up and endDrag only — no drop, since nothing was completed.

Sweeping across elements ​

While a press is held, every pointer-enabled element the pointer moves onto hears dragEnter, and hears dragExit when the pointer moves off it, both with info.sourceId = the pressed element. This starts at the press, not at the drag threshold, so a slow sweep across neighbouring slots names each one. The pressed element hears no dragEnter at first (its down says the press is there) but does when the pointer comes back onto it. On the release, or a cancel, the last element entered hears its dragExit before up, endDrag and drop. Spreading a stack over every slot a held press passes:

js
slot.onPointer = (e, info) => {
  if (e === 'down') this.sweep = [info.elementId]
  else if (e === 'dragEnter' && this.sweep && !this.sweep.includes(info.elementId)) this.sweep.push(info.elementId)
  else if (e === 'up') { this.spreadStack(info.player, this.sweep); this.sweep = null }
}

The client reports at most 25 crossings a second (a fast flick may skip a slot in between; where the pointer comes to rest is always reported), on a message budget of their own in multiplayer.

Touch screens ​

On a phone or a tablet, a HUD element that takes the pointer (a button, a slider, a toggle, a dropdown, a scrollbar, anything with onPointer) keeps its finger: the browser does not turn a finger held or dragged on it into a page scroll or a zoom, so a held button stays held and an inventory drag runs to its drop. Inside a scrolling container the controls keep the container's scroll axis, so a list of buttons still scrolls with a swipe along it. A text field keeps the device's own touch handling (the caret, a selection). If the device does take a touch, the element hears a release: up, and endDrag during a drag.

A touch on a HUD control while a HUD text field is being typed in lets the field report first: its onValueChanged runs before the control's events, so a Done button reads the text just typed.

Turning the view from a held button ​

A button takes its finger whole, so a thumb holding it cannot also turn the view. Give a button, or an element with onPointer, passLook: true, and the finger held on it turns the view as a swipe on the right half of the screen does, while the element still gets its press, release and pointer events: hold Use to draw a bow and aim with the same thumb.

js
onStart() {
  this.use = ui.createButton({ text: 'Use', position: [0.85, 0.75], passLook: true, onClick: () => {} })
  this.use.onPointer = (e, info) => {
    if (e === 'down') this.draw(info.player)
    if (e === 'up') this.loose(info.player)
  }
}

Aiming moves the thumb, and a button's onClick fires only for a release over the button, so read a hold from onPointer's down and up. The option can be set later too (slot.passLook = true). It turns the default swipe look (not input.setLookMode('stick')); the camera and input.getLookDelta() both see the turn. A mouse is unaffected, and so is the editor's play-test unless Simulate Touch Controls is on. In multiplayer the option travels with the element and the view turns on the player's own device. Off by default.

Keyboard & gamepad navigation ​

Every interactive control — from either UI system — can be driven without a mouse, through the EventSystem. Tab moves between controls; the arrow keys move by layout (the nearest control in that direction, like Automatic navigation); Enter / Space activate the selected one; Escape deselects. A gamepad works the same — the D-pad / left stick navigate, A submits, B cancels. The selected control shows a clear highlight ring.

It never steals gameplay input: arrows and the stick only navigate once a UI control is actually selected (Tab into the UI, or the gamepad picks the first control when nothing else has focus), so in-game movement keys are untouched. A selected slider or scrollbar consumes the arrows along its own axis (they adjust it); the cross axis still navigates. This works in every runtime — the editor play-test, standalone, and published multiplayer.

Fonts ​

Import a real font file (.ttf / .otf / .woff / .woff2) from Project → + New → Import Font. Its bytes travel inside the map — like every texture, model, and sound — so a published game or standalone export carries the font with nothing external to fetch.

Reference it by name: it appears in the Inspector's Font dropdown under Project Fonts, and in scripts as ui.createLabel({ fontFamily: 'MyFont' }). Built-in web-safe stacks (Serif, Monospace, and so on) are always available too.

The Canvas Scaler ​

Both UI systems author against a 1920×1080 reference and scale with the viewport (a Canvas Scaler, log-blend match 0.5) — a HUD holds its proportions on every screen, and at 1080p the scale is exactly 1. Every pixel dimension you set (font size, widths, margins) is in reference pixels and scales automatically; normalized positions (0–1) don't need scaling. You rarely think about it — it just means your UI won't be tiny on a 4K monitor or huge on a phone.

On a phone that scale is small: about 0.4 on a 390 x 844 screen, upright or sideways, so an 18 px label reads as 7 px and a 44 px button is 17 px across. A map built for phones can set hudMinScale in Game Settings (Script HUD, Min Scale), for example 0.65: the script HUD and every Scale With Screen Size canvas then never draw smaller than that. The floor applies on every screen, so a desktop window above it keeps its own scale, and a script that places things by the scale itself uses Math.max(Math.sqrt(W / 1920 * H / 1080), hudMinScale). Elements are bigger relative to the screen with a floor, so check the layout at phone size when you turn it on: pick a Phone device in the UI Preview.

The 2D Platformer Kit ​

The fastest way to see the whole 2D stack working together is the 2D Platformer kit (Packs → Built-in Kits → 2D Platformer → Import, then stamp its 2D Platformer Demo scene). It's a complete side-scroller — run/jump player, spinning collectible coins with a HUD counter, and a goal flag — built entirely from editable map scripts, so it doubles as a worked example: flipbook sprite sheets, a character-controller constrained to the 2D plane, a pixel-perfect orthographic follow camera, and a script HUD.

One detail worth knowing if you build your own character controller: a characterController entity fires onTriggerEnter / onTriggerStay / onTriggerExit when it overlaps a trigger collider, exactly like the built-in player — that's how the kit's coins detect the player. The visible sprite is a child of the controller entity, nudged forward in Z so the platform boxes never draw over it.

Publishing ​

Nothing extra to do. Sprites, canvases, fonts, and script HUDs all save with the map and render in the editor play-test, published multiplayer, and standalone builds. The export prune keeps only the textures and fonts your map actually references (by id or by name, so a texture used only as ui.createImage({ texture: 'Coin' }) survives) and drops the rest to keep the build lean.