Skip to content

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.

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.

  • A ScreenGui is drawn when it’s directly inside a player’s PlayerGui (or StarterGui, while you edit in Studio) and its enabled property 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.
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.

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.

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.

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.

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:

StarterGui/Shop/ShopButton.client.ts
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:

ServerScriptService/Shop.server.ts
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;
});
});

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:

ServerScriptService/Coins.server.ts
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:

StarterPlayer/StarterPlayerScripts/CoinsHud.client.ts
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:

StarterPlayer/StarterPlayerScripts/HealthBar.client.ts
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);
});

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:

StarterPlayer/StarterPlayerScripts/Toast.client.ts
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:

ServerScriptService/Welcome.server.ts
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.

Read what a player types from a TextBox, then send it to the server — which checks it, because a player can send anything:

StarterPlayer/StarterPlayerScripts/Nickname.client.ts
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());
});
});
ServerScriptService/Nickname.server.ts
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 higher displayOrder.
  • Update UI when values change, from change events, rather than rebuilding it every frame.