User interface
On-screen UI — scores, timers, buttons, menus — is made of instances, like everything else in a Rally game. This page covers the UI classes, how to lay them out, and how to keep UI up to date with what happens on the server.
How UI works
Section titled “How UI works”UI is drawn over the game view. Each layer of UI is a ScreenGui, which holds GUI objects:
Frame, TextLabel, TextButton, TextBox and ImageLabel.
A player sees the ScreenGuis in their own PlayerGui, which only they receive. UI gets there in two ways:
- From StarterGui. When a player joins, everything in StarterGui is copied into their PlayerGui. Build your HUD here, in Studio. The copy happens once, when they join — not again when they respawn.
- From a script. A LocalScript can build UI in its player’s PlayerGui; the server can build it in any player’s PlayerGui, and only that player receives it.
Every game also has some built-in UI: the player list in the top-right corner and, on touch screens, a joystick in the bottom-left and a Jump button in the bottom-right. Leave room for them.
What gets drawn
Section titled “What gets drawn”- A ScreenGui is drawn when it’s directly inside a player’s PlayerGui (or StarterGui, while you edit
in Studio) and its
enabledproperty is on. - GUI objects are drawn when they’re inside a ScreenGui or inside another GUI object. Put
something else in between — a
Folder, say — and what’s below it isn’t drawn. LocalScripts and value objects can sit inside UI; they just aren’t drawn. - A GUI object is drawn inside its parent, and anything that sticks out past the parent’s edges is cut off.
The UI classes
Section titled “The UI classes”| Class | What it is | Its own properties and events |
|---|---|---|
ScreenGui |
A layer of UI | enabled; displayOrder — layers with a higher number are drawn on top |
Frame |
A rectangle: panels, bars, backgrounds | — |
TextLabel |
Text | text, textColor, textSize (6 to 200), fontWeight ('normal', 'medium', 'bold'), textAlign ('left', 'center', 'right'), textWrapped |
TextButton |
Text you can click or tap | everything a TextLabel has, plus the activated event |
TextBox |
A text field | everything a TextLabel has, plus placeholderText and the focusLost event |
ImageLabel |
An image | image (a URL), scaleType ('fit', 'fill', 'stretch') |
Every GUI object also has these, from the GuiObject class they share:
| Property | What it does | Default |
|---|---|---|
position, size |
Position and size in pixels | (0, 0), (100, 100) |
positionScale, sizeScale |
Position and size as fractions of the parent, 0 to 1 | (0, 0), (0, 0) |
anchorPoint |
Which point of the object sits at its position | (0, 0): the top-left corner |
backgroundColor, backgroundTransparency |
The fill, and how see-through it is (1 = none) | white, 0 |
cornerRadius |
Rounded corners, in pixels | 0 |
visible |
Shows or hides it, and everything inside it | true |
zIndex |
Order among siblings: higher is drawn in front | 1 |
Positions, sizes and the anchor point are Vector2 values. Pixel positions count from the
parent’s top-left corner, and y grows downward.
Layout: position and size
Section titled “Layout: position and size”Each axis combines a fraction of the parent with a number of pixels:
- left edge =
positionScale.x× the parent’s width +position.x - width =
sizeScale.x× the parent’s width +size.x
(and the same for the top edge and height). Then anchorPoint shifts the object by a fraction of
its own size, so that point of the object lands on the position: (0.5, 0.5) centers it, (1, 1)
puts its bottom-right corner there.
Some common placements:
| To place it… | positionScale |
position |
anchorPoint |
|---|---|---|---|
| 16 px in from the top-left corner | (0, 0) | (16, 16) | (0, 0) |
| 16 px in from the top-right corner | (1, 0) | (-16, 16) | (1, 0) |
| centered, 24 px up from the bottom | (0.5, 1) | (0, -24) | (0.5, 1) |
| in the middle of the screen | (0.5, 0.5) | (0, 0) | (0.5, 0.5) |
And sizes:
| To size it… | sizeScale |
size |
|---|---|---|
| 200 × 50 pixels | (0, 0) | (200, 50) |
| full width, 40 pixels tall | (1, 0) | (0, 40) |
| filling its parent | (1, 1) | (0, 0) |
| 30% of the width, 14 px tall | (0.3, 0) | (0, 14) |
Fixed pixel sizes work well for buttons and labels; scale works for things that should stretch with the screen, like bars and backgrounds. Anchoring to the edges and corners keeps UI in place on every screen size.
A new TextLabel shows “Label” in dark 18-pixel text on a white box. For text without a box, set
backgroundTransparency to 1.
Text sits in the middle of its box from top to bottom. textAlign places it left, center or right.
With textWrapped on (the default), long text wraps onto more lines; text that doesn’t fit the box
is cut off.
Buttons and text boxes
Section titled “Buttons and text boxes”A TextButton’s activated event fires when the player clicks or taps it, on their device. Buttons
light up a little when hovered and darken when pressed, without any code. Clicks on buttons still
reach UserInputService, with gameProcessed set to true.
A TextBox is a text field. Its text follows what the player types, and placeholderText shows
while it’s empty. focusLost fires when the player presses Enter or Esc or
clicks away, with true if it was Enter. While the player types, their keys go to the
box: the character doesn’t move, and input events arrive with gameProcessed set.
Images
Section titled “Images”An ImageLabel shows the picture at its image URL — an https:// address or a data: URL.
scaleType decides how it fits the box: 'fit' (the default) shows the whole image without
stretching it, 'fill' covers the whole box and may crop the image, and 'stretch' fills the box
exactly, even if that distorts the image.
Building UI in Studio
Section titled “Building UI in Studio”Insert a ScreenGui into StarterGui, then frames, labels and buttons into it, from Insert › UI. Studio draws StarterGui over the viewport, where you can select, drag and resize UI directly, and Hide UI in the toolbar gets it out of your way. If you insert a GUI object straight into StarterGui, Studio wraps it in a new ScreenGui for you. UI editor covers the tools.
UI built in Studio is a good home for LocalScripts that make it work. Here’s a shop: a ScreenGui
named Shop in StarterGui holds a TextButton named BuySpeed and this LocalScript. When the
player joins, the whole ScreenGui — script included — is copied into their PlayerGui, and the
script finds the button next to it:
import { RemoteEvent, ReplicatedStorage, script, TextButton, task, waitForChild } from '@rally/sdk';
// This LocalScript sits inside the Shop ScreenGui, so script.parent is the player's copy of it.task.spawn(async () => { const gui = script.parent; if (!gui) return; const button = await waitForChild(gui, 'BuySpeed', TextButton); const buySpeed = await waitForChild(ReplicatedStorage, 'BuySpeed', RemoteEvent); button.activated.connect(() => buySpeed.fireServer()); // the server checks the price});The button only asks. The server checks the price against the player’s coins — a Coins value in
their leaderstats, set up in the next section — makes the purchase, and tells the player how it
went with a Toast RemoteEvent:
import { getChild, NumberValue, RemoteEvent, ReplicatedStorage, task } from '@rally/sdk';
const PRICE = 10;const buySpeed = getChild(ReplicatedStorage, 'BuySpeed', RemoteEvent);const toast = getChild(ReplicatedStorage, 'Toast', RemoteEvent);
buySpeed.onServerEvent.connect((player) => { const coins = player.findFirstChild('leaderstats')?.findFirstChild('Coins'); const character = player.character; if (!(coins instanceof NumberValue) || !character || character.health <= 0) return; if (coins.value < PRICE) { toast.fireClient(player, `You need ${PRICE} coins.`); return; } coins.value -= PRICE; // the HUD updates by itself: it watches this value character.walkSpeed = 30; toast.fireClient(player, 'Speed boost for 20 seconds!'); task.delay(20, () => { if (!character.destroyed) character.walkSpeed = 16; });});Building UI from code
Section titled “Building UI from code”A LocalScript can build UI too: create a ScreenGui and its GUI objects, set them up, and parent the
ScreenGui to the player’s PlayerGui. The next example shows each player’s coins. The server keeps
them in leaderstats — for this example, it hands out a coin every five seconds:
import { createLeaderstats, Players, task } from '@rally/sdk';
Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Coins: 20 }); // A coin every 5 seconds, for this example. task.spawn(async () => { while (player.parent) { await task.wait(5); stats.Coins.value += 1; } });});The HUD shows the value, and updates whenever it changes:
import { Color3, Folder, NumberValue, PlayerGui, ScreenGui, task, TextLabel, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const playerGui = await waitForChild(me, 'PlayerGui', PlayerGui);
// A ScreenGui is a layer of UI; build it, then parent it to your PlayerGui. const hud = new ScreenGui(); hud.name = 'CoinsHud';
const label = new TextLabel(); label.name = 'Coins'; label.size = new Vector2(160, 44); // pixels label.position = new Vector2(16, 16); // 16 px from the top-left corner label.backgroundColor = Color3.fromHex('#1d2b53'); label.backgroundTransparency = 0.2; label.cornerRadius = 12; label.textColor = Color3.fromHex('#ffd23f'); label.textSize = 24; label.fontWeight = 'bold'; label.text = 'Coins: …'; label.parent = hud; hud.parent = playerGui;
// The server keeps the score in leaderstats; show it whenever it changes. const stats = await waitForChild(me, 'leaderstats', Folder); const coins = await waitForChild(stats, 'Coins', NumberValue); const show = () => { label.text = `Coins: ${coins.value}`; }; coins.getPropertyChangedSignal('value').connect(show); show();});A health bar, sized by scale and tweened as the character’s health changes:
import { type Character, Color3, Frame, PlayerGui, ScreenGui, task, TweenService, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const playerGui = await waitForChild(me, 'PlayerGui', PlayerGui);
const gui = new ScreenGui(); gui.name = 'Health';
// The bar: 30% of the screen wide, 14 px tall, centered at the bottom. const bar = new Frame(); bar.anchorPoint = new Vector2(0.5, 1); bar.positionScale = new Vector2(0.5, 1); bar.position = new Vector2(0, -24); bar.sizeScale = new Vector2(0.3, 0); bar.size = new Vector2(0, 14); // scale and pixels add up: 30% wide + 0 px, 0% tall + 14 px bar.backgroundColor = Color3.fromHex('#1b1f2a'); bar.cornerRadius = 7; bar.parent = gui;
// The fill: full height, width = health fraction (sizeScale only, so zero the pixel size). const fill = new Frame(); fill.sizeScale = new Vector2(1, 1); fill.size = Vector2.zero; fill.backgroundColor = Color3.fromHex('#4ade80'); fill.cornerRadius = 7; fill.parent = bar; gui.parent = playerGui;
const track = (character: Character) => { const update = () => { const fraction = Math.max(0, character.health / character.maxHealth); TweenService.create(fill, { time: 0.2, easing: 'quadOut' }, { sizeScale: new Vector2(fraction, 1) }).play(); fill.backgroundColor = Color3.fromHex(fraction > 0.3 ? '#4ade80' : '#ef4444'); }; character.getPropertyChangedSignal('health').connect(update); update(); }; if (me.character) track(me.character); me.characterAdded.connect(track);});Keeping UI in step with the server
Section titled “Keeping UI in step with the server”The server knows the game’s real state; UI on each player’s device shows it. There are three good ways to get it there:
| The server… | The UI… | Good for |
|---|---|---|
sets a value object or attribute players can see — leaderstats, or a folder in ReplicatedStorage |
listens with getPropertyChangedSignal('value') or getAttributeChangedSignal |
scores, timers, anything that lasts — players who join later see it too |
fires a RemoteEvent to the player |
reacts in onClientEvent |
one-off moments: a message, a hit marker |
| changes UI in the player’s PlayerGui directly | nothing: the change arrives by itself | simple UI the server controls |
The coins HUD above uses the first way. The shop’s messages use the second — here’s the LocalScript that shows them, centered near the bottom of the screen:
import { Color3, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, task, TextLabel, TweenService, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const playerGui = await waitForChild(me, 'PlayerGui', PlayerGui); const toastEvent = await waitForChild(ReplicatedStorage, 'Toast', RemoteEvent);
const gui = new ScreenGui(); gui.name = 'Toasts'; gui.displayOrder = 10; // above the other UI
// Centered horizontally, 120 px from the bottom: half the parent's width, minus half its own. const toast = new TextLabel(); toast.anchorPoint = new Vector2(0.5, 1); toast.positionScale = new Vector2(0.5, 1); toast.position = new Vector2(0, -120); toast.size = new Vector2(420, 48); toast.backgroundColor = Color3.fromHex('#0b1220'); toast.backgroundTransparency = 0.25; toast.textColor = Color3.white; toast.cornerRadius = 24; toast.visible = false; toast.parent = gui; gui.parent = playerGui;
let hide: (() => void) | null = null; toastEvent.onClientEvent.connect((message) => { toast.text = String(message); toast.visible = true; toast.textSize = 26; TweenService.create(toast, { time: 0.25, easing: 'backOut' }, { textSize: 20 }).play(); hide?.(); // a newer toast replaces the old one hide = task.delay(2.5, () => { toast.visible = false; }); });});And the third way — the server puts a welcome banner straight into a new player’s PlayerGui, and removes it four seconds later:
import { Color3, Players, ScreenGui, task, TextLabel, Vector2 } from '@rally/sdk';
// The server can put UI straight into one player's PlayerGui; only that player receives it.Players.playerAdded.connect((player) => { const playerGui = player.playerGui; if (!playerGui) return; const gui = new ScreenGui(); gui.name = 'Welcome'; const banner = new TextLabel(); banner.text = `Welcome, ${player.displayName}!`; banner.sizeScale = new Vector2(1, 0); banner.size = new Vector2(0, 60); banner.positionScale = new Vector2(0, 0.25); banner.backgroundTransparency = 1; banner.textColor = Color3.white; banner.textSize = 36; banner.fontWeight = 'bold'; banner.parent = gui; gui.parent = playerGui; task.delay(4, () => gui.destroy());});Changes a LocalScript makes to UI stay on that player’s device — that’s what you want for UI. But if the server also changes the same object, the server’s next change replaces the player’s. Let one side own each piece of UI.
Getting text from players
Section titled “Getting text from players”Read what a player types from a TextBox, then send it to the server — which checks it, because a
player can send anything:
import { Color3, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, task, TextBox, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const playerGui = await waitForChild(me, 'PlayerGui', PlayerGui); const setNickname = await waitForChild(ReplicatedStorage, 'SetNickname', RemoteEvent);
const gui = new ScreenGui(); gui.name = 'Nickname'; const box = new TextBox(); box.placeholderText = 'Pick a nickname and press Enter'; box.text = ''; box.textAlign = 'left'; box.size = new Vector2(280, 40); box.anchorPoint = new Vector2(0.5, 0); box.positionScale = new Vector2(0.5, 0); box.position = new Vector2(0, 16); box.backgroundColor = Color3.white; box.cornerRadius = 8; box.parent = gui; gui.parent = playerGui;
// `text` follows what the player types. Send it when they press Enter. box.focusLost.connect((enterPressed) => { if (enterPressed && box.text.trim()) setNickname.fireServer(box.text.trim()); });});import { getChild, RemoteEvent, ReplicatedStorage } from '@rally/sdk';
const setNickname = getChild(ReplicatedStorage, 'SetNickname', RemoteEvent);setNickname.onServerEvent.connect((player, name) => { if (typeof name !== 'string') return; const clean = name.trim().slice(0, 20); if (clean) player.setAttribute('nickname', clean); // replicates to everyone});A new TextBox starts with the text “Label”, like every text object — set text to '' so the
placeholder shows.
- Keep the corners clear where Rally draws its own UI: the player list in the top right and, on touch screens, the joystick and Jump button in the bottom corners.
- Make buttons big enough to tap — 44 pixels or more on each side.
- Show and hide panels with
visible, and put menus above the HUD with a higherdisplayOrder. - Update UI when values change, from change events, rather than rebuilding it every frame.
Next steps
Section titled “Next steps”- Input and camera — keys, mouse and touch.
- Animation and tweens — make UI move and fade.
- UI editor — lay out UI visually in Studio.