1. GUI Object Hierarchy Intermediate

Roblox's GUI system is an object-oriented hierarchy rooted in Instance subclasses that live under a PlayerGui (client-side), a SurfaceGui (placed on parts in the 3D world), or a BillboardGui (floating overlay attached to a BasePart). Understanding the hierarchy is essential for building maintainable UIs.

Core Container Types

Visual Elements

Layout & Decoration Helpers

-- Creating a UI hierarchy in a LocalScript inside StarterGui
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "HUD"
screenGui.Parent = player.PlayerGui

-- Container frame with rounded corners and stroke
local frame = Instance.new("Frame")
frame.Size = UDim2.new(0, 300, 0, 200)
frame.Position = UDim2.new(0.5, -150, 0.2, 0)
frame.BackgroundColor3 = Color3.fromRGB(20, 20, 40)
frame.BackgroundTransparency = 0.2
frame.Parent = screenGui

local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 8)
corner.Parent = frame

local stroke = Instance.new("UIStroke")
stroke.Color = Color3.fromRGB(0, 212, 255)
stroke.Thickness = 1.5
stroke.Parent = frame

-- Auto-layout list inside frame
local listLayout = Instance.new("UIListLayout")
listLayout.Padding = UDim.new(0, 4)
listLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
listLayout.Parent = frame

-- Padding around the frame's contents
local padding = Instance.new("UIPadding")
padding.PaddingTop = UDim.new(0, 8)
padding.PaddingBottom = UDim.new(0, 8)
padding.PaddingLeft = UDim.new(0, 12)
padding.PaddingRight = UDim.new(0, 12)
padding.Parent = frame

local label = Instance.new("TextLabel")
label.Text = "Hello, Roblox UI!"
label.Size = UDim2.new(1, 0, 0, 30)
label.BackgroundTransparency = 1
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.Font = Enum.Font.GothamBold
label.TextSize = 18
label.Parent = frame
Exercise: Build a settings panel with a Frame containing 5 TextButtons arranged vertically using UIListLayout. Add UIStroke and UICorner to each button. Use LayoutOrder to reorder the buttons at runtime.

2. UDim2 & Responsive Layout Intermediate

UDim2 is Roblox's two-dimensional size/position type. Every GuiObject has Size and Position as UDim2 values. Mastering UDim2 is the key to making UIs that work across phone, tablet, and desktop.

UDim2 Structure

A UDim2 consists of four numbers: UDim2.new(xScale, xOffset, yScale, yOffset).

AnchorPoint

AnchorPoint (Vector2, 0–1) defines which point of the GUI element aligns to Position. AnchorPoint = Vector2.new(0.5, 0.5) centers the element at its Position. This is essential for centering UI on screen.

Responsive Techniques

-- Responsive HUD: health bar centered, scaled to screen width
local screenGui = Instance.new("ScreenGui")
screenGui.Parent = player.PlayerGui

-- Health bar container (top center, 60% width)
local healthBar = Instance.new("Frame")
healthBar.Size = UDim2.new(0.6, 0, 0, 24)
healthBar.Position = UDim2.new(0.5, 0, 0, 12)
healthBar.AnchorPoint = Vector2.new(0.5, 0)
healthBar.BackgroundColor3 = Color3.fromRGB(40, 40, 40)
healthBar.Parent = screenGui

local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 12)
corner.Parent = healthBar

-- Fill bar (initially 100% width of parent)
local fill = Instance.new("Frame")
fill.Size = UDim2.new(1, 0, 1, 0)
fill.BackgroundColor3 = Color3.fromRGB(0, 255, 100)
fill.Parent = healthBar

local fillCorner = Instance.new("UICorner")
fillCorner.CornerRadius = UDim.new(0, 12)
fillCorner.Parent = fill

-- Health label (centered over health bar)
local label = Instance.new("TextLabel")
label.Size = UDim2.new(1, 0, 1, 0)
label.BackgroundTransparency = 1
label.Text = "100 / 100"
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.TextSize = 14
label.Font = Enum.Font.GothamBold
label.Parent = healthBar

-- Update health bar function
local function updateHealth(current, max)
  local ratio = math.clamp(current / max, 0, 1)
  fill.Size = UDim2.new(ratio, 0, 1, 0)
  fill.BackgroundColor3 = Color3.new(1 - ratio, ratio, 0)
  label.Text = string.format("%.0f / %.0f", current, max)
end
Exercise: Create a responsive ammo display. Place it in the bottom-right corner using AnchorPoint = Vector2.new(1, 1) and Position = UDim2.new(1, -16, 1, -16). Include an ImageLabel for the ammo icon and a TextLabel for the count. Test it at various screen resolutions using Studio's Device dropdown.

3. Common UI Patterns Intermediate

Every Roblox game uses the same core UI patterns. Build each as a reusable module and you will ship 10x faster.

Pattern 1: HUD (Heads-Up Display)

Pattern 2: Menu (Full-Screen Overlay)

Pattern 3: Popup / Dialog

Pattern 4: Notification (Toast)

Pattern 5: Progress Bar

-- Complete HUD module (Client/HUD.lua)
local HUD = {}
local player = game.Players.LocalPlayer
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "HUD"
screenGui.Parent = player:WaitForChild("PlayerGui")

-- Health bar
local healthContainer = Instance.new("Frame")
healthContainer.Size = UDim2.new(0.35, 0, 0, 20)
healthContainer.Position = UDim2.new(0.5, 0, 0, 16)
healthContainer.AnchorPoint = Vector2.new(0.5, 0)
healthContainer.BackgroundColor3 = Color3.fromRGB(50, 50, 50)
healthContainer.Parent = screenGui
Instance.new("UICorner").CornerRadius = UDim.new(0, 10)

local healthFill = Instance.new("Frame")
healthFill.Size = UDim2.new(1, 0, 1, 0)
healthFill.BackgroundColor3 = Color3.fromRGB(255, 50, 50)
healthFill.Parent = healthContainer
Instance.new("UICorner").CornerRadius = UDim.new(0, 10)

-- Ammo display (bottom right)
local ammoFrame = Instance.new("Frame")
ammoFrame.Size = UDim2.new(0, 160, 0, 40)
ammoFrame.Position = UDim2.new(1, -16, 1, -16)
ammoFrame.AnchorPoint = Vector2.new(1, 1)
ammoFrame.BackgroundTransparency = 0.3
ammoFrame.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
ammoFrame.Parent = screenGui
Instance.new("UICorner").CornerRadius = UDim.new(0, 6)

local ammoLabel = Instance.new("TextLabel")
ammoLabel.Size = UDim2.new(1, 0, 1, 0)
ammoLabel.BackgroundTransparency = 1
ammoLabel.Text = "30 / 30"
ammoLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
ammoLabel.TextSize = 22
ammoLabel.Font = Enum.Font.GothamBold
ammoLabel.Parent = ammoFrame

function HUD.updateHealth(current, max)
  local ratio = math.clamp(current / max, 0, 1)
  healthFill.Size = UDim2.new(ratio, 0, 1, 0)
end

function HUD.updateAmmo(current, max)
  ammoLabel.Text = string.format("%d / %d", current, max)
end

return HUD
Exercise: Build a notification/toast system. Create a function that spawns a small Frame at the top of the screen with a message, fades it in, waits 3 seconds, then fades it out and destroys it. Support a queue so multiple notifications display sequentially.

4. TweenService for UI Animation Advanced

TweenService interpolates property values over time. It is the standard way to animate Roblox UI — smooth, efficient, and readable.

Core API

Common Animations

-- Animated main menu with TweenService
local TweenService = game:GetService("TweenService")
local player = game.Players.LocalPlayer

local screenGui = Instance.new("ScreenGui")
screenGui.Parent = player:WaitForChild("PlayerGui")

-- Background overlay (fades in)
local overlay = Instance.new("Frame")
overlay.Size = UDim2.new(1, 0, 1, 0)
overlay.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
overlay.BackgroundTransparency = 1
overlay.Parent = screenGui

-- Menu panel (slides up from bottom)
local menu = Instance.new("Frame")
menu.Size = UDim2.new(0, 400, 0, 300)
menu.Position = UDim2.new(0.5, 0, 1, 50)
menu.AnchorPoint = Vector2.new(0.5, 0)
menu.BackgroundColor3 = Color3.fromRGB(20, 20, 40)
menu.BackgroundTransparency = 0.1
menu.Parent = screenGui
Instance.new("UICorner").CornerRadius = UDim.new(0, 12)

-- Title
local title = Instance.new("TextLabel")
title.Size = UDim2.new(1, 0, 0, 50)
title.Position = UDim2.new(0, 0, 0, 20)
title.BackgroundTransparency = 1
title.Text = "MAIN MENU"
title.TextColor3 = Color3.fromRGB(0, 212, 255)
title.TextSize = 28
title.Font = Enum.Font.GothamBold
title.TextTransparency = 1
title.Parent = menu

-- Buttons
local buttonData = { "Play", "Settings", "Quit" }
local buttons = {}

for i, text in ipairs(buttonData) do
  local btn = Instance.new("TextButton")
  btn.Size = UDim2.new(0.6, 0, 0, 40)
  btn.Position = UDim2.new(0.5, 0, 0, 80 + (i - 1) * 50)
  btn.AnchorPoint = Vector2.new(0.5, 0)
  btn.Text = text
  btn.TextColor3 = Color3.fromRGB(255, 255, 255)
  btn.TextSize = 18
  btn.Font = Enum.Font.GothamBold
  btn.BackgroundColor3 = Color3.fromRGB(30, 30, 60)
  btn.BackgroundTransparency = 1
  btn.Parent = menu
  Instance.new("UICorner").CornerRadius = UDim.new(0, 8)
  table.insert(buttons, btn)
end

-- Animate entrance
local tweenInfo = TweenInfo.new(0.6, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)

local overlayTween = TweenService:Create(overlay, tweenInfo, {
  BackgroundTransparency = 0.4
})
overlayTween:Play()

local menuTween = TweenService:Create(menu, TweenInfo.new(0.8, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
  Position = UDim2.new(0.5, 0, 0.5, -150)
})
menuTween:Play()

task.wait(0.3)

for i, btn in ipairs(buttons) do
  task.wait(0.08)
  local btnTween = TweenService:Create(btn, TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
    BackgroundTransparency = 0
  })
  btnTween:Play()
end

-- Stagger title fade
TweenService:Create(title, TweenInfo.new(0.5, Enum.EasingStyle.Sine, Enum.EasingDirection.Out), {
  TextTransparency = 0
}):Play()
Exercise: Create a notification that slides in from the right edge of the screen, stays for 3 seconds, then slides back out to the right and destroys itself. Use EasingStyle.Quad for the slide and EasingStyle.Sine for fade. Chain the tweens with :Completed events.

5. Optimizing UI Performance Advanced

Roblox's UI system runs on the client's render thread. Poorly built UIs tank frame rate, especially on mobile. Every GuiObject incurs a draw call. Minimize them aggressively.

Key Principles

-- Efficient inventory grid using object pooling
local screenGui = Instance.new("ScreenGui")
screenGui.Parent = player.PlayerGui

local frame = Instance.new("Frame")
frame.Size = UDim2.new(0, 400, 0, 300)
frame.Position = UDim2.new(0.5, -200, 0.5, -150)
frame.BackgroundColor3 = Color3.fromRGB(30, 30, 50)
frame.Parent = screenGui
Instance.new("UICorner").CornerRadius = UDim.new(0, 8)

local scrollingFrame = Instance.new("ScrollingFrame")
scrollingFrame.Size = UDim2.new(1, 0, 1, 0)
scrollingFrame.BackgroundTransparency = 1
scrollingFrame.ScrollBarThickness = 4
scrollingFrame.Parent = frame

local gridLayout = Instance.new("UIGridLayout")
gridLayout.CellSize = UDim2.new(0, 60, 0, 60)
gridLayout.CellPadding = UDim2.new(0, 4, 0, 4)
gridLayout.FillDirection = Enum.FillDirection.Horizontal
gridLayout.StartCorner = Enum.StartCorner.TopLeft
gridLayout.Parent = scrollingFrame

-- Pool of reusable item slots
local pool = {}

local function getSlot()
  local slot = table.remove(pool)
  if slot then
    slot.Visible = true
    return slot
  end
  local newSlot = Instance.new("ImageLabel")
  newSlot.Size = UDim2.new(1, 0, 1, 0)
  newSlot.BackgroundColor3 = Color3.fromRGB(50, 50, 70)
  Instance.new("UICorner").CornerRadius = UDim.new(0, 4)
  newSlot.Parent = scrollingFrame
  return newSlot
end

local function returnSlot(slot)
  slot.Image = ""
  slot.Visible = false
  table.insert(pool, slot)
end

-- Populate inventory
local items = { "rbxassetid://123", "rbxassetid://456", "rbxassetid://789" }
for _, assetId in ipairs(items) do
  local slot = getSlot()
  slot.Image = assetId
end

-- Update CanvasSize after layout finishes
local function updateCanvasSize()
  local contentSize = gridLayout.AbsoluteContentSize
  scrollingFrame.CanvasSize = UDim2.new(0, contentSize.X, 0, contentSize.Y)
end
updateCanvasSize()
gridLayout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(updateCanvasSize)
Exercise: Create a ScrollingFrame with 200 TextLabels populated via a loop. Measure the FPS with and without setting CanvasSize correctly. Then optimize it further by only updating text every 3 frames instead of every frame. Use task.spawn() to test.

6. Input Handling & Controls Intermediate

Roblox provides multiple input systems. Choose the right one for your use case.

UserInputService

ContextActionService

GuiService

-- Key binding system using ContextActionService
local ContextActionService = game:GetService("ContextActionService")
local UserInputService = game:GetService("UserInputService")
local player = game.Players.LocalPlayer

-- Define all key bindings
local bindings = {
  jump = { Enum.KeyCode.Space, Enum.KeyCode.ButtonA },
  sprint = { Enum.KeyCode.LeftShift, Enum.KeyCode.ButtonX },
  inventory = { Enum.KeyCode.I, Enum.KeyCode.ButtonY },
  interact = { Enum.KeyCode.E, Enum.KeyCode.ButtonB },
  menu = { Enum.KeyCode.Escape, Enum.KeyCode.ButtonStart },
}

-- Action handler
local function onAction(actionName, inputState, inputObject)
  if inputState == Enum.UserInputState.Begin then
    if actionName == "jump" then
      print("Jump!")
    elseif actionName == "sprint" then
      print("Sprint start")
    elseif actionName == "inventory" then
      print("Toggle inventory")
    elseif actionName == "interact" then
      print("Interact")
    elseif actionName == "menu" then
      print("Toggle menu")
    end
  elseif inputState == Enum.UserInputState.End and actionName == "sprint" then
    print("Sprint stop")
  end
end

-- Register all bindings
for actionName, keys in pairs(bindings) do
  ContextActionService:BindAction(actionName, onAction, false, unpack(keys))
end

-- Alternative: raw UserInputService for mouse-specific actions
UserInputService.InputBegan:Connect(function(input, gameProcessed)
  if gameProcessed then return end

  if input.UserInputType == Enum.UserInputType.MouseButton1 then
    print("Left click at:", input.Position.X, input.Position.Y)
  end

  if input.UserInputType == Enum.UserInputType.Touch then
    print("Touch at:", input.Position.X, input.Position.Y)
  end

  if input.UserInputType == Enum.UserInputType.Keyboard and input.KeyCode == Enum.KeyCode.F then
    print("F key pressed (raw input)")
  end
end)

-- Prevent certain keys from reaching the Roblox engine
UserInputService.InputBegan:Connect(function(input, gameProcessed)
  if gameProcessed then return end
  if input.KeyCode == Enum.KeyCode.I then
    ContextActionService:SetBinding("inventory", true) -- prevent default 'I' behavior
  end
end)
Exercise: Build a rebindable key system. Store key bindings in a table, create a settings UI that lets the user click a button then press a key to rebind. Update the ContextActionService binding on change. Persist bindings using PlayerPrefs (or DataStore, from Part 5).

7. Accessibility & UX Best Practices Intermediate

Roblox runs on devices from high-end gaming PCs to 5-year-old phones. Your UI must work for everyone — including players with visual impairments, color blindness, or motor difficulties.

Readability & Text

Touch Targets

UX Patterns

-- Accessible button creation helper
local function createAccessibleButton(text, parent)
  local btn = Instance.new("TextButton")
  btn.Size = UDim2.new(0, 180, 0, 48) -- minimum 44px height
  btn.Text = text
  btn.TextColor3 = Color3.fromRGB(255, 255, 255)
  btn.TextSize = 16
  btn.Font = Enum.Font.GothamBold
  btn.BackgroundColor3 = Color3.fromRGB(0, 120, 255)
  btn.BorderSizePixel = 0
  btn.AutoButtonColor = false
  btn.Parent = parent

  local corner = Instance.new("UICorner")
  corner.CornerRadius = UDim.new(0, 8)
  corner.Parent = btn

  -- Contrast: white text on blue background = 4.6:1 ratio (passes WCAG AA)

  -- Visual feedback on hover (desktop)
  btn.MouseEnter:Connect(function()
    btn:TweenSize(UDim2.new(0, 184, 0, 50), "Out", 0.1, true)
  end)
  btn.MouseLeave:Connect(function()
    btn:TweenSize(UDim2.new(0, 180, 0, 48), "Out", 0.1, true)
  end)

  return btn
end

-- Confirmation dialog with destructive action safety
local function showConfirmDialog(title, message, callback)
  local screenGui = Instance.new("ScreenGui")
  screenGui.Parent = player.PlayerGui

  local overlay = Instance.new("Frame")
  overlay.Size = UDim2.new(1, 0, 1, 0)
  overlay.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
  overlay.BackgroundTransparency = 0.5
  overlay.Parent = screenGui

  local dialog = Instance.new("Frame")
  dialog.Size = UDim2.new(0, 320, 0, 180)
  dialog.Position = UDim2.new(0.5, -160, 0.5, -90)
  dialog.BackgroundColor3 = Color3.fromRGB(25, 25, 45)
  dialog.Parent = screenGui
  Instance.new("UICorner").CornerRadius = UDim.new(0, 12)

  local titleLabel = Instance.new("TextLabel")
  titleLabel.Size = UDim2.new(1, -24, 0, 30)
  titleLabel.Position = UDim2.new(0, 12, 0, 12)
  titleLabel.BackgroundTransparency = 1
  titleLabel.Text = title
  titleLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
  titleLabel.TextSize = 20
  titleLabel.Font = Enum.Font.GothamBold
  titleLabel.Parent = dialog

  local msgLabel = Instance.new("TextLabel")
  msgLabel.Size = UDim2.new(1, -24, 0, 50)
  msgLabel.Position = UDim2.new(0, 12, 0, 48)
  msgLabel.BackgroundTransparency = 1
  msgLabel.Text = message
  msgLabel.TextColor3 = Color3.fromRGB(200, 200, 200)
  msgLabel.TextSize = 14
  msgLabel.TextWrapped = true
  msgLabel.Parent = dialog

  local confirmBtn = createAccessibleButton("Confirm", dialog)
  confirmBtn.Position = UDim2.new(0, 12, 0, 120)
  confirmBtn.BackgroundColor3 = Color3.fromRGB(220, 50, 50)
  confirmBtn.MouseButton1Click:Connect(function()
    screenGui:Destroy()
    callback(true)
  end)

  local cancelBtn = createAccessibleButton("Cancel", dialog)
  cancelBtn.Position = UDim2.new(0, 196, 0, 120)
  cancelBtn.BackgroundColor3 = Color3.fromRGB(60, 60, 80)
  cancelBtn.MouseButton1Click:Connect(function()
    screenGui:Destroy()
    callback(false)
  end)
end
Exercise: Audit your existing game's UI for accessibility issues. Check every TextLabel for font size (<14px), contrast (use a contrast checker tool), and touch target size (<44px). Fix at least 5 issues and document them.

8. Camera System Advanced

The Camera object, parented to Workspace.CurrentCamera, defines what the player sees. Controlling the camera is essential for third-person games, cutscenes, and custom FPS modes.

CameraType

Key Properties & Methods

Third-Person Camera with Wall Prevention

-- Custom third-person camera with wall prevention and zoom
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local camera = workspace.CurrentCamera
local player = game.Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")

camera.CameraType = Enum.CameraType.Custom

-- Camera settings
local config = {
  distance = 10,
  minDistance = 3,
  maxDistance = 20,
  zoomSpeed = 2,
  smoothSpeed = 6,
  angleX = 0,
  angleY = 0,
  mouseSensitivity = 0.003,
}

-- Mouse control for camera rotation
UserInputService.InputChanged:Connect(function(input)
  if input.UserInputType == Enum.UserInputType.MouseMovement then
    config.angleX = config.angleX - input.Delta.Y * config.mouseSensitivity
    config.angleY = config.angleY - input.Delta.X * config.mouseSensitivity
    -- Clamp vertical angle to prevent flipping
    config.angleX = math.clamp(config.angleX, -1.4, 1.4)
  end
end)

-- Scroll wheel zoom
UserInputService.InputBegan:Connect(function(input, processed)
  if processed then return end
  if input.UserInputType == Enum.UserInputType.MouseWheel then
    config.distance = math.clamp(
      config.distance - input.Position.Z * config.zoomSpeed * 0.3,
      config.minDistance,
      config.maxDistance
    )
  end
end)

-- Camera update loop
RunService.RenderStepped:Connect(function(dt)
  local root = character:FindFirstChild("HumanoidRootPart")
  if not root then return end

  local headOffset = Vector3.new(0, 2, 0)
  local targetPos = root.Position + headOffset

  -- Calculate desired camera position
  local offset = Vector3.new(
    math.sin(config.angleY) * math.cos(config.angleX),
    math.sin(config.angleX),
    math.cos(config.angleY) * math.cos(config.angleX)
  ) * config.distance

  local desiredPos = targetPos + offset

  -- Raycast to prevent wall clipping
  local params = RaycastParams.new()
  params.FilterType = Enum.RaycastFilterType.Blacklist
  params.FilterDescendantsInstances = { character }

  local result = workspace:Raycast(targetPos, desiredPos - targetPos, params)
  if result then
    -- Slide camera to the hit point minus a small margin
    local hitDistance = (result.Position - targetPos).Magnitude
    local adjustedDistance = math.max(hitDistance - 0.5, 0.5)
    desiredPos = targetPos + offset.Unit * adjustedDistance
  end

  -- Smooth interpolation
  camera.CFrame = camera.CFrame:Lerp(
    CFrame.lookAt(desiredPos, targetPos),
    math.min(config.smoothSpeed * dt, 1)
  )
end)
Exercise: Add a zoom animation that interpolates FieldOfView smoothly instead of snapping. When the player scrolls, tween camera.FieldOfView between 40 (zoomed) and 80 (wide) over 0.3 seconds. Use TweenService's :Play() on each scroll event, canceling the previous animation.

9. BillboardGui & Name Tags Intermediate

BillboardGui renders 2D UI elements that always face the camera. Use it for name tags over player heads, health bars above enemies, floating interaction prompts, and world-space labels.

Key Properties

Performance Considerations

-- Player name tag system (LocalScript in StarterCharacterScripts)
local player = game.Players.LocalPlayer
local character = script.Parent
local humanoid = character:WaitForChild("Humanoid")
local root = character:WaitForChild("HumanoidRootPart")

-- Wait for all players to load
game.Players.PlayerAdded:Connect(function(plr)
  plr.CharacterAdded:Connect(function(char)
    task.wait(0.5) -- wait for character to fully load
    addNameTag(plr, char)
  end)
end)

for _, plr in ipairs(game.Players:GetPlayers()) do
  if plr ~= player then
    local char = plr.Character
    if char then
      addNameTag(plr, char)
    end
  end
end

local function addNameTag(plr, char)
  local rootPart = char:WaitForChild("HumanoidRootPart")

  -- Create BillboardGui
  local billboard = Instance.new("BillboardGui")
  billboard.Name = plr.Name .. "_NameTag"
  billboard.Size = UDim2.new(0, 200, 0, 40)
  billboard.StudsOffset = Vector3.new(0, 4, 0)
  billboard.AlwaysOnTop = true
  billboard.MaxDistance = 100
  billboard.Adornee = rootPart
  billboard.Parent = char

  -- Background frame
  local bg = Instance.new("Frame")
  bg.Size = UDim2.new(1, 0, 1, 0)
  bg.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
  bg.BackgroundTransparency = 0.4
  bg.Parent = billboard
  Instance.new("UICorner").CornerRadius = UDim.new(0, 6)

  -- Player name label
  local nameLabel = Instance.new("TextLabel")
  nameLabel.Size = UDim2.new(1, 0, 1, 0)
  nameLabel.BackgroundTransparency = 1
  nameLabel.Text = plr.Name
  nameLabel.TextColor3 = plr.Team and plr.Team.TeamColor.Color or Color3.fromRGB(255, 255, 255)
  nameLabel.TextSize = 16
  nameLabel.Font = Enum.Font.GothamBold
  nameLabel.Parent = bg

  -- Health bar below name
  local healthBar = Instance.new("Frame")
  healthBar.Size = UDim2.new(0.8, 0, 0, 4)
  healthBar.Position = UDim2.new(0.1, 0, 0, 30)
  healthBar.BackgroundColor3 = Color3.fromRGB(60, 60, 60)
  healthBar.Parent = billboard

  local healthFill = Instance.new("Frame")
  healthFill.Size = UDim2.new(1, 0, 1, 0)
  healthFill.BackgroundColor3 = Color3.fromRGB(0, 255, 50)
  healthFill.Parent = healthBar

  -- Track health changes
  local hum = char:WaitForChild("Humanoid")
  hum:GetPropertyChangedSignal("Health"):Connect(function()
    local ratio = hum.Health / hum.MaxHealth
    healthFill.Size = UDim2.new(ratio, 0, 1, 0)
    healthFill.BackgroundColor3 = ratio > 0.5
      and Color3.fromRGB(0, 255, 50)
      or Color3.fromRGB(255, 50, 50)
  end)

  -- Cleanup when character dies
  hum.Died:Connect(function()
    billboard:Destroy()
  end)
end

-- Cleanup for existing players when their characters are removed
game.Players.PlayerRemoving:Connect(function(plr)
  local char = plr.Character
  if char then
    local billboard = char:FindFirstChild(plr.Name .. "_NameTag")
    if billboard then
      billboard:Destroy()
    end
  end
end)
Exercise: Create a floating interaction prompt using BillboardGui. When a player stands within 10 studs of an NPC (a part named "NPC"), show a prompt "Press E to talk" with a subtle pulsing animation (tween Size slightly up and down). When they walk away, hide it. Use Magnitude on RenderStepped or a touched event.

10. Cross-Platform UI Advanced

Roblox runs on PC, Mac, Phone, Tablet, Console, and VR. Each platform has different screen sizes, input methods, and performance characteristics. Your UI must adapt seamlessly.

Detecting Device Type

Mobile-Specific Patterns

Console Controller Support

-- Cross-platform UI adapter module (ReplicatedStorage/UIAdapter.lua)
local UserInputService = game:GetService("UserInputService")
local GuiService = game:GetService("GuiService")

local UIAdapter = {}

function UIAdapter.getPlatform()
  if UserInputService.VREnabled then
    return "VR"
  elseif UserInputService.TouchEnabled and UserInputService.KeyboardEnabled then
    return "Tablet"
  elseif UserInputService.TouchEnabled then
    return "Phone"
  elseif UserInputService.GamepadEnabled then
    return "Console"
  else
    return "Desktop"
  end
end

function UIAdapter.getScaleFactor()
  local platform = UIAdapter.getPlatform()
  if platform == "Phone" then
    return 1.3 -- 30% larger buttons
  elseif platform == "Tablet" then
    return 1.15
  elseif platform == "Console" then
    return 1.2 -- larger for TV viewing distance
  end
  return 1.0
end

function UIAdapter.applyScale(guiObject, baseSize)
  local scale = UIAdapter.getScaleFactor()
  guiObject.Size = UDim2.new(0, baseSize.X * scale, 0, baseSize.Y * scale)
end

function UIAdapter.isLandscape()
  local viewport = workspace.CurrentCamera.ViewportSize
  return viewport.X > viewport.Y
end

function UIAdapter.setupMobileJoystick(thumbstickFrame, thumbstickInner)
  -- Called from a LocalScript; creates a touch-screen joystick
  local joystickActive = false
  local joystickCenter = Vector2.new()
  local joystickValue = Vector2.new()

  local function onTouch(touchPositions)
    for _, touch in ipairs(touchPositions) do
      local guiPos = thumbstickFrame.AbsolutePosition
      local guiSize = thumbstickFrame.AbsoluteSize
      local localPos = touch.Position - guiPos

      if localPos.X >= 0 and localPos.X <= guiSize.X and localPos.Y >= 0 and localPos.Y <= guiSize.Y then
        joystickActive = true
        joystickCenter = guiPos + guiSize / 2
      end
    end
  end

  UserInputService.TouchStarted:Connect(onTouch)
  UserInputService.TouchMoved:Connect(function(touches)
    if not joystickActive then return end
    for _, touch in ipairs(touches) do
      local delta = touch.Position - joystickCenter
      local magnitude = delta.Magnitude
      local maxRadius = thumbstickFrame.AbsoluteSize.X / 2

      if magnitude > maxRadius then
        delta = delta.Unit * maxRadius
      end

      thumbstickInner.Position = UDim2.new(0, delta.X, 0, delta.Y)
      joystickValue = Vector2.new(
        math.clamp(delta.X / maxRadius, -1, 1),
        math.clamp(delta.Y / maxRadius, -1, 1)
      )
    end
  end)

  UserInputService.TouchEnded:Connect(function()
    joystickActive = false
    joystickValue = Vector2.new()
    thumbstickInner.Position = UDim2.new(0.5, 0, 0.5, 0)
  end)

  return function()
    return joystickValue
  end
end

function UIAdapter.setupControllerNavigation(screenGui)
  if not UserInputService.GamepadEnabled then return end

  GuiService.AutoSelectGuiEnabled = true

  -- Map gamepad buttons
  ContextActionService:BindAction("Confirm", function(_, state)
    if state == Enum.UserInputState.Begin then
      local selected = GuiService.SelectedObject
      if selected and selected:IsA("TextButton") then
        selected:Fire()
      end
    end
  end, false, Enum.KeyCode.ButtonA)

  ContextActionService:BindAction("Cancel", function(_, state)
    if state == Enum.UserInputState.Begin then
      print("Cancel action")
    end
  end, false, Enum.KeyCode.ButtonB)
end

return UIAdapter

-- Usage in a LocalScript:
-- local adapter = require(game.ReplicatedStorage.UIAdapter)
-- print("Platform:", adapter.getPlatform())
-- adapter.setupControllerNavigation(screenGui)
Exercise: Create a single main menu ScreenGui that adapts to desktop, phone, and console. Use UIAdapter.getPlatform() to decide layout: desktop shows a horizontal button row, phone shows a vertical list with larger buttons, console adds controller navigation hints at the bottom. Test by enabling/disabling touch in Studio's Test → Devices.