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.
-- 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
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.
A UDim2 consists of four numbers: UDim2.new(xScale, xOffset, yScale, yOffset).
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 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
Every Roblox game uses the same core UI patterns. Build each as a reusable module and you will ship 10x faster.
-- 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
TweenService interpolates property values over time. It is the standard way to animate Roblox UI — smooth, efficient, and readable.
-- 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()
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.
-- 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)
Roblox provides multiple input systems. Choose the right one for your use case.
-- 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)
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.
-- 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
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.
-- 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)
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.
-- 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)
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.
-- 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)