01Why UI Design Matters More Than You Think
Your game's UI is the first thing players interact with before they even understand what your game is. A confusing HUD makes a great game feel broken. A clean, responsive UI signals professionalism — players judge game quality in the first 30 seconds based largely on UI presentation.
UI is not decoration. It is the communication layer between your game systems and the player's brain. Every design decision is a communication decision: what information does the player need, when, how prominently? Good UI answers these questions invisibly — the player just feels informed and in control.
The most common mistake: developers add UI features without removing old ones, resulting in cluttered, overwhelming screens. Constraints force good design — commit to a maximum amount of information visible at any time.
02The GUI Hierarchy and ScreenGui Properties
Every GUI element lives in this hierarchy: PlayerGui → ScreenGui → (containers) → elements.
ScreenGui is the root canvas. Key properties:
- ResetOnSpawn = false — persistent HUDs; true for respawn-specific UI
- IgnoreGuiInset = false — set to true for full-screen UIs (loading screens)
- ZIndexBehavior = Sibling — each element's ZIndex is relative to its siblings
- DisplayOrder — higher = renders on top. Use for modals that should overlay everything
Frame is an invisible rectangular container. Use it to group related elements, apply UIListLayout/UIGridLayout for automatic arrangement, and clip child content with ClipsDescendants.
-- Best practice ScreenGui setup
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "GameHUD"
screenGui.ResetOnSpawn = false -- persist across death
screenGui.IgnoreGuiInset = false -- respect Roblox topbar
screenGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
screenGui.DisplayOrder = 10
screenGui.Parent = game.Players.LocalPlayer.PlayerGui
-- Modal layer (always on top)
local modalGui = Instance.new("ScreenGui")
modalGui.Name = "ModalLayer"
modalGui.ResetOnSpawn = false
modalGui.DisplayOrder = 100
modalGui.Parent = game.Players.LocalPlayer.PlayerGui03UDim2: Mastering Scale and Offset
Every Size and Position is a UDim2 — two UDim values (Scale, Offset) for X and Y. Scale is a proportion of the parent's size (0–1). Offset is absolute pixels. Final position = parent_size × Scale + Offset.
AnchorPoint defines which point of the element is pinned to its Position. Default (0,0) is top-left. (0.5, 0.5) is center — lets you center elements without knowing their size. (1, 1) is bottom-right.
The centering formula: AnchorPoint = (0.5, 0.5), Position = (0.5, 0, 0.5, 0). Works regardless of parent or element size.
UISizeConstraint: clamp an element's size to MinSize and MaxSize in absolute pixels. Essential for preventing UI from becoming too small on phones or too large on TVs.
-- Common layout patterns
-- Full width, 60px header bar
local header = Instance.new("Frame", screenGui)
header.Size = UDim2.new(1, 0, 0, 60)
header.Position = UDim2.new(0, 0, 0, 0)
-- Centered 400×300 dialog
local dialog = Instance.new("Frame", screenGui)
dialog.Size = UDim2.new(0, 400, 0, 300)
dialog.AnchorPoint = Vector2.new(0.5, 0.5)
dialog.Position = UDim2.new(0.5, 0, 0.5, 0)
-- Bottom-right HUD, 200×150, 16px from edges
local hud = Instance.new("Frame", screenGui)
hud.Size = UDim2.new(0, 200, 0, 150)
hud.AnchorPoint = Vector2.new(1, 1)
hud.Position = UDim2.new(1, -16, 1, -16)
-- Constrain to min/max size
local constraint = Instance.new("UISizeConstraint", dialog)
constraint.MinSize = Vector2.new(280, 200)
constraint.MaxSize = Vector2.new(600, 500)04Layout Objects: Automatic Arrangement
Layout objects arrange children automatically — you never manually position every element in a list.
UIListLayout: stacks children in a row or column. AbsoluteContentSize property lets you resize the parent dynamically to fit content.
UIGridLayout: arranges in a grid with fixed CellSize and CellPadding. Great for inventory grids, achievement icons.
UICorner: rounds corners. CornerRadius = UDim.new(1, 0) makes a perfect circle for any square element.
UIStroke: adds an outline. Color, Transparency, and Thickness can all be tweened.
UIPadding: adds inner margin to a container. All four sides set independently.
UIAspectRatioConstraint: forces an element to maintain a fixed aspect ratio as it resizes.
local parent = Instance.new("Frame", screenGui)
parent.Size = UDim2.new(0, 200, 0, 400)
-- Auto-stacking vertical list
local layout = Instance.new("UIListLayout", parent)
layout.FillDirection = Enum.FillDirection.Vertical
layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
layout.Padding = UDim.new(0, 8)
layout.SortOrder = Enum.SortOrder.LayoutOrder
-- Auto-resize parent to fit content
layout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function()
parent.Size = UDim2.new(0, 200, 0, layout.AbsoluteContentSize.Y + 16)
end)
-- Rounded corners + inner padding + stroke
Instance.new("UICorner", parent).CornerRadius = UDim.new(0, 12)
local pad = Instance.new("UIPadding", parent)
pad.PaddingLeft=UDim.new(0,12); pad.PaddingRight=UDim.new(0,12)
pad.PaddingTop=UDim.new(0,8); pad.PaddingBottom=UDim.new(0,8)
local stroke = Instance.new("UIStroke", parent)
stroke.Color=Color3.new(1,1,1); stroke.Transparency=0.85; stroke.Thickness=105Animation and Feedback with TweenService
Static UIs feel cheap and unprofessional. Every interaction needs immediate visual feedback — hover state, press depression, panel transitions. These micro-animations take 0.1–0.4 seconds and are the difference between "amateur" and "polished."
TweenService animates any numeric or Color3 properties simultaneously with configurable easing curves.
Key easing styles: - Quad/Cubic — gentle ease. Natural for most UI. - Quint — aggressive ease. Snappy panel slide-in/out. - Back — overshoot and settle. Excellent for popups. - Bounce — bounces at end. Notifications, achievements.
Direction: In = ease into motion (starts slow). Out = ease out (ends slow). InOut = ease both ends.
local TweenService = game:GetService("TweenService")
-- Button hover/press feedback
local function addButtonFeedback(button)
local normalColor = button.BackgroundColor3
local hoverColor = normalColor:Lerp(Color3.new(1,1,1), 0.12)
local pressColor = normalColor:Lerp(Color3.new(0,0,0), 0.15)
local normalSize = button.Size
local function tw(props) TweenService:Create(button, TweenInfo.new(0.08), props):Play() end
button.MouseEnter:Connect(function()
tw({BackgroundColor3=hoverColor, Size=normalSize+UDim2.new(0,2,0,2)})
end)
button.MouseLeave:Connect(function()
tw({BackgroundColor3=normalColor, Size=normalSize})
end)
button.MouseButton1Down:Connect(function()
tw({BackgroundColor3=pressColor, Size=normalSize-UDim2.new(0,2,0,2)})
end)
button.MouseButton1Up:Connect(function()
tw({BackgroundColor3=hoverColor, Size=normalSize})
end)
end
-- Popup with Back easing (satisfying overshoot)
local function popIn(frame, targetSize)
frame.Size = UDim2.new(0,0,0,0)
frame.Visible = true
TweenService:Create(frame,
TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
{Size = targetSize}
):Play()
end
-- Slide in from right
local function slideInFromRight(frame)
local target = frame.Position
frame.Position = target + UDim2.new(0.6, 0, 0, 0)
frame.Visible = true
TweenService:Create(frame,
TweenInfo.new(0.35, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
{Position = target}
):Play()
end06Typography and Readability
Text is most of your UI's information delivery system. Poor typography makes information hard to parse; good typography is invisible — users receive information without effort.
Font hierarchy: - GothamBold/Black — headlines, important values - GothamMedium — subheadings, standard buttons - GothamRegular — body text, descriptions - SourceSansPro — long-form readable text - Code — scripting references, debug info
Size scale (establish and stick to it): 32px headline, 20px subheading, 16px body, 12px caption, 10px micro-label.
Contrast: minimum 4.5:1 ratio for normal text. Never use gray on a slightly different gray background.
RichText: enable RichText = true for inline styling: <b>bold</b>, <i>italic</i>, <font color="rgb(255,100,100)">colored</font>.
local label = Instance.new("TextLabel")
label.Font = Enum.Font.GothamBold
label.TextSize = 18
label.LineHeight = 1.35 -- more readable than default 1.0
label.TextWrapped = true
label.RichText = true
label.Text = '<font color="rgb(255,215,0)"><b>1,500</b></font> <font color="rgb(160,160,160)">coins</font>'
-- Adaptive font sizing
local function setAdaptiveText(label, text, maxWidth)
label.Text = text
label.TextScaled = true -- auto-scales to fit frame
-- Or manually:
local size = 18
label.TextSize = size
while label.TextBounds.X > maxWidth and size > 8 do
size -= 1; label.TextSize = size
end
end
-- Truncate long strings with ellipsis
local function truncate(text, maxChars)
if #text <= maxChars then return text end
return text:sub(1, maxChars-3) .. "..."
end07Responsive Design for Mobile and Desktop
Roblox is played on phones, tablets, PCs, and consoles. The smallest common screen is ~320×568px (small Android). The largest is 3840×2160 (4K TV). Design for both extremes.
Touch targets: any tappable element must be at least 44×44 pixels. The human fingertip is ~44 density-independent pixels wide.
Safe areas: phones have notches, tablets have home bars. GuiService:GetSafeAreaInsets() returns the insets you need to respect.
Layout breakpoints: check workspace.CurrentCamera.ViewportSize. Adapt layout for phone vs tablet vs desktop — mobile might use a bottom sheet instead of a side panel.
local GuiService = game:GetService("GuiService")
local camera = workspace.CurrentCamera
-- Apply safe area insets
local insets = GuiService:GetSafeAreaInsets()
local safePad = Instance.new("UIPadding", screenGui)
safePad.PaddingLeft = UDim.new(0, insets.Left + 8)
safePad.PaddingRight = UDim.new(0, insets.Right + 8)
safePad.PaddingTop = UDim.new(0, insets.Top + 8)
safePad.PaddingBottom = UDim.new(0, insets.Bottom + 8)
-- Screen category
local function getCategory()
local vp = camera.ViewportSize
if vp.X < 640 then return "phone" end
if vp.X < 1080 then return "tablet" end
return "desktop"
end
-- Adaptive layout
local function adaptLayout()
local cat = getCategory()
local vp = camera.ViewportSize
-- Scale font sizes with screen width
local scale = math.clamp(vp.X / 1080, 0.7, 1.3)
if cat == "phone" then
-- Bottom sheet on mobile
sidePanel.AnchorPoint = Vector2.new(0.5, 1)
sidePanel.Size = UDim2.new(1, 0, 0.45, 0)
sidePanel.Position = UDim2.new(0.5, 0, 1, 0)
else
-- Side panel on desktop
sidePanel.AnchorPoint = Vector2.new(0, 0)
sidePanel.Size = UDim2.new(0, 280, 1, 0)
sidePanel.Position = UDim2.new(0, 0, 0, 0)
end
end
adaptLayout()
camera:GetPropertyChangedSignal("ViewportSize"):Connect(adaptLayout)08Data-Driven Inventory and Menu Systems
Complex UI systems — inventories, shops, settings menus — should be data-driven: render from a data table, not from manually placed instances. When data changes, clear and re-render from the new data.
Tab systems: track active tab index. On switch, tween old panel out and new panel in. Same animation for all tab transitions for consistency.
Scroll frames: set CanvasSize to the total content size (from UIListLayout.AbsoluteContentSize + padding). ScrollBarThickness = 4 with a subtle color gives a modern scrollbar.
Search and filter: store all items in a Lua table. Filter by query string and re-render. Never hide/show individual frames for filtering — destroys and recreates from filtered data.
local ITEMS = {
{id="sword", name="Iron Sword", icon="rbxassetid://SWORD_ID", rarity="common"},
{id="staff", name="Magic Staff", icon="rbxassetid://STAFF_ID", rarity="rare"},
{id="shield", name="Tower Shield", icon="rbxassetid://SHIELD_ID", rarity="uncommon"},
}
local RARITY_COLOR = {
common="#aaaaaa", uncommon="#50cc50", rare="#5050ff", epic="#bb44ff", legendary="#ffaa00"
}
local function renderInventory(items, container, slotTemplate, onSelect)
for _, child in ipairs(container:GetChildren()) do
if child:IsA("GuiObject") then child:Destroy() end
end
for _, item in ipairs(items) do
local slot = slotTemplate:Clone()
slot.ImageLabel.Image = item.icon
slot.NameLabel.Text = item.name
slot.Visible = true
slot.Parent = container
slot.Activated:Connect(function() onSelect(item) end)
end
end
-- Search
local searchBox = -- your TextBox reference
searchBox:GetPropertyChangedSignal("Text"):Connect(function()
local q = searchBox.Text:lower()
local filtered = {}
for _, item in ipairs(ITEMS) do
if item.name:lower():find(q, 1, true) then filtered[#filtered+1]=item end
end
renderInventory(filtered, inventoryGrid, slotTemplate, handleSelect)
end)