---
url: /roblox-fundamentals/ui-and-input.md
description: >-
  Build client UI with ScreenGuis, scale-and-offset layout, buttons, and one
  input action that works on keyboard, touch, and gamepad.
---

# UI and Input

*Written: {{ $frontmatter.date }}*

## 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

* [Services and Events](./services-and-events.md)
* [Task and Async](./task-and-async.md)
* [Remotes and Networking](./remotes-and-networking.md)
* [Server Authority](./server-authority.md)

## 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 = screen
```

Wait 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](../projects/client-server-protocol.md). 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

1. Move the coin label to the top-center with AnchorPoint `0.5, 0` and scale Position `0.5, 0`. Confirm it stays centered when you resize the Studio viewport.
2. Add a second button to the menu panel that prints its own name on Activated. Confirm UIListLayout spaces it without manual positions.
3. Bind a "Wave" action to `Q` and gamepad `ButtonY` with a touch button. Confirm each device prints once per press.

::: details 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.

:::

::: details 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.

:::

::: details 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](https://create.roblox.com/docs/reference/engine/classes/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()
end
```

## Summary

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](../intermediate/patterns-and-boundaries.md).
