Appearance
UI and Input
Written: 2026-10-14
Learning objectives
- You can build a ScreenGui HUD that survives respawn.
- You can lay out frames and buttons with scale, offset, and AnchorPoint.
- You can respond to button presses with Activated.
- You can bind one action to keyboard, touch, and gamepad with ContextActionService.
- You can keep UI on the client and validate everything on the server.
Prerequisites
ScreenGuis live in PlayerGui
StarterGui holds your UI in Edit mode. Each player's copy lands in their PlayerGui when they spawn. You build and update UI in LocalScripts, because only the client knows its screen size and its input device.
A fresh ScreenGui resets on every respawn by default. Set ResetOnSpawn to false for a HUD that must survive death, like a coin counter. Keep it true for round-specific panels that should vanish with the old character.
LocalScript in StarterPlayerScripts
luau
--!strict
local Players = game:GetService("Players")
local player: Player? = Players.LocalPlayer
if player == nil then
return
end
local guiRoot: PlayerGui = player:WaitForChild("PlayerGui") :: PlayerGui
local screen = Instance.new("ScreenGui")
screen.Name = "CoinHud"
screen.ResetOnSpawn = false
screen.Parent = guiRoot
local label = Instance.new("TextLabel")
label.Name = "CoinLabel"
label.Size = UDim2.new(0, 200, 0, 50)
label.Position = UDim2.new(0, 16, 0, 16)
label.Text = "Coins: 0"
label.Parent = screenWait for PlayerGui, set Parent last, and pin one label to the top-left corner. Expected behavior: the counter appears on join and stays through respawn, because ResetOnSpawn is false.
Position with scale, offset, and AnchorPoint
A UDim2 holds four numbers: horizontal scale, horizontal pixels, vertical scale, vertical pixels. Scale stretches with the screen, so 0.5 stays centered on phones and desktops alike. Offset counts raw pixels, so 16 always means sixteen pixels. You mix them: scale for placement, offset for exact sizes and margins.
AnchorPoint picks which point of the frame sits at Position, typically a Vector2 from 0, 0 (top-left) to 1, 1 (bottom-right). A centered panel uses AnchorPoint 0.5, 0.5 with Position scale 0.5, 0.5. UIListLayout stacks children in a row or column without manual math, and UIPadding insets them from the edges.
LocalScript in StarterPlayerScripts
luau
--!strict
local Players = game:GetService("Players")
local player: Player? = Players.LocalPlayer
if player == nil then
return
end
local guiRoot: PlayerGui = player:WaitForChild("PlayerGui") :: PlayerGui
local screen = Instance.new("ScreenGui")
screen.Name = "Menu"
screen.ResetOnSpawn = true
screen.Parent = guiRoot
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(0.5, 0.5)
panel.Size = UDim2.new(0.6, 0, 0.4, 0)
panel.Position = UDim2.new(0.5, 0, 0.5, 0)
panel.Parent = screen
local stack = Instance.new("UIListLayout")
stack.Padding = UDim.new(0, 8)
stack.Parent = panel
local playButton = Instance.new("TextButton")
playButton.Name = "PlayButton"
playButton.Size = UDim2.new(1, 0, 0, 48)
playButton.Text = "Play"
playButton.Parent = panel
local shopButton = Instance.new("TextButton")
shopButton.Name = "ShopButton"
shopButton.Size = UDim2.new(1, 0, 0, 48)
shopButton.Text = "Shop"
shopButton.Parent = panel
playButton.Activated:Connect(function(): ()
print("play pressed")
end)The panel centers with scale, stacks two full-width buttons with eight-pixel gaps, and connects Activated on the Play button. Expected behavior: the panel stays centered on any screen size, and pressing Play prints once. Activated fires for mouse, touch, and gamepad confirmation, so prefer it over lower-level press events.
One action, every device
Raw keys come through UserInputService, but raw keys only cover keyboards. ContextActionService binds one named action to a keyboard key, a gamepad button, and an automatic touch button at the same time. You check the input state so the action runs once per press, and you return Sink when you consumed it.
LocalScript in StarterPlayerScripts
luau
--!strict
local ContextActionService = game:GetService("ContextActionService")
local function sprint(action: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
if state ~= Enum.UserInputState.Begin then
return Enum.ContextActionResult.Pass
end
print(`{action} pressed`)
return Enum.ContextActionResult.Sink
end
ContextActionService:BindAction("Sprint", sprint, true, Enum.KeyCode.LeftShift, Enum.KeyCode.ButtonX)Sprint binds to left shift and the gamepad X button, with a touch button created automatically. Expected behavior: pressing the key, tapping the touch button, or pressing gamepad X prints once each. When you need raw input instead, read UserInputService InputBegan and ignore events the game already consumed, so typing in chat does not sprint your character.
UI never decides
The client builds and shows, and the server validates and owns. A button press sends an intent through a remote, exactly like the shop buys in Client-Server Protocol. The server re-checks distance, funds, and cooldown, then fires the new total back for the label to display. Never read a TextLabel on the server as proof of anything, and never store secrets where the client can read them.
Common mistakes
Mistake 1: offset-only layouts.
Wrong: Position and Size in pure pixels. Why: a 400-pixel panel fits a desktop and overflows a phone. Fix: place with scale, size critical panels with scale, and keep offsets for margins and exact button heights.
Mistake 2: trusting the button.
Wrong: the server grants because the client fired a press. Why: the client fires anything it wants. Fix: treat every press as an untrusted intent and validate it like any other remote call.
Mistake 3: stale HUD after respawn.
Wrong: ResetOnSpawn left false on round panels. Why: last round's panel survives into the next round. Fix: false for persistent HUD, true for per-life panels.
Mistake 4: chatting triggers actions.
Wrong: InputBegan sprints while the player types. Why: the key reaches your handler even when chat consumed it. Fix: ignore consumed input, or bind through ContextActionService, which handles the routing for you.
Exercises
- Move the coin label to the top-center with AnchorPoint
0.5, 0and scale Position0.5, 0. Confirm it stays centered when you resize the Studio viewport. - Add a second button to the menu panel that prints its own name on Activated. Confirm UIListLayout spaces it without manual positions.
- Bind a "Wave" action to
Qand gamepadButtonYwith a touch button. Confirm each device prints once per press.
Solution 1
Anchor the label and center it with scale:
luau
label.AnchorPoint = Vector2.new(0.5, 0)
label.Position = UDim2.new(0.5, 0, 0, 16)Expected behavior: the label hangs from the top-center on wide and narrow viewports.
Solution 2
Parent a third button to the same panel:
luau
local helpButton = Instance.new("TextButton")
helpButton.Name = "HelpButton"
helpButton.Size = UDim2.new(1, 0, 0, 48)
helpButton.Text = "Help"
helpButton.Parent = panel
helpButton.Activated:Connect(function(): ()
print("help pressed")
end)Expected behavior: three buttons stack evenly without manual position math.
Solution 3
Bind one action to all three devices:
luau
local function wave(action: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
if state ~= Enum.UserInputState.Begin then
return Enum.ContextActionResult.Pass
end
print(`{action} pressed`)
return Enum.ContextActionResult.Sink
end
ContextActionService:BindAction("Wave", wave, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonY)Expected behavior: Q, the touch button, and gamepad Y each print once per press.
Motion with TweenService
TweenService interpolates properties over time: Create(instance, tweenInfo, goalTable) builds a Tween, then Play, Pause, or Cancel it. TweenInfo.new sets duration, easing style/direction, repeat count, reversal, and delay, loop forever with repeat -1, never math.huge. Two tweens on one property cancel the older one, so one tween owns one property. Run visual tweens on the client that owns them (UI, character feedback), and run gameplay truth tweens on the server. Never tween authoritative positions on the client and trust them.
luau
--!strict
local TweenService = game:GetService("TweenService")
local function fadeOut(label: TextLabel): ()
local info = TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local tween = TweenService:Create(label, info, { TextTransparency = 1 })
tween:Play()
endSummary
ScreenGuis go in PlayerGui with Parent last. Place with scale and size details with offset. UIListLayout stacks children. Activated answers presses. One action binds across keyboard, touch, and gamepad. Tween visuals with TweenService, one tween per property. The server keeps every decision. Next, structure larger code in Patterns and Boundaries.