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 dimensionsworldSize = 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 MaskCircle (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.
  • 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

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.

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

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.

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.

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 beginDragdragendDrag, 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.

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.

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) }
}

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 beginDragdragendDrag (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.

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.

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.