Gamepad Grid Menu

Special care has to be taken when creating a menu system with gamepad controls in mind. With conventional interfaces, the user can simply click or tap where they want to select. For simple and quick menus, a Radial Menu works well with a gamepad, but with more complicated and conventional menus the user has to navigate a menu by moving a selector from element to element. GuiService automatically tries to determine which element a user wants to select with a gamepad, but sometimes extra configuration is needed for the menu to work correctly.

This tutorial uses GuiService and ContextActionService to implement a simple equipment management menu system.

Overview[edit]

The code in this tutorial creates a framework for an equipment management system. It creates a button on the screen; if the user presses A with that button selected, the game pops up a model menu showing a simple character model and a grid of items.

GamepadGrid Image00.png

GamepadGrid Image04.png

GamepadGrid Image03.png

Set-up[edit]

The first thing the code does is create the menu. The menu in your game can of course be created and stored before hand, but in this case the menu elements are explicitly created in the script.

GamepadGrid Image01.png

The important thing to note about the menu is the hierarchy, as this will be used when defining selection groups. All of the buttons for the character slots are children of CharacterFrame, and all of the grid button are children of ScrollingFrame

GamepadGrid Image02.png

Opening the menu[edit]

After the menu layout has been defined, the code then binds input to open and close the menu. Gui buttons (such as ImageButtons and TextButtons can accept gamepad input. The event MouseButton1Click will fire if a player presses the A button with the button selected.

-- Closes the equipment menu
local function closeEquipmentMenu()
	-- Play closing animation
	menuFrame:TweenPosition(MENU_CLOSED_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
		-- When animation finished, unbind the close function and reset Gui selection		
		ContextActionService:UnbindAction(CLOSE_EQUIPMENT_MENU_BINDING)
		GuiService.SelectedObject = nil
		GuiService.AutoSelectGuiEnabled = oldAutoSelectGuiEnabled
	end)
end
 
-- Opens the equipment menu
local function openEquipmentMenu()
	-- Store the old value of AutoSelectEnabled and then set to false
	oldAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
	GuiService.AutoSelectGuiEnabled = false
 
	-- Play opening animation
	menuFrame:TweenPosition(MENU_OPEN_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
		-- When animation finished, bind closeEquipmentMenu to the B button
		ContextActionService:BindAction(CLOSE_EQUIPMENT_MENU_BINDING, function(actionName, inputState, inputObject)
			if inputState == Enum.UserInputState.Begin then
				closeEquipmentMenu()
			end
		end, false, Enum.KeyCode.ButtonB)
		-- Select torso by default
		GuiService.SelectedObject = torsoFrame
	end)
end
 
-- Input binding
equipmentButton.MouseButton1Click:connect(openEquipmentMenu)

The code defines two functions: openEquipmentMenu and closeEquipmentMenu. openEquipmentMenu is bound to the equipmentButton which is in the upper left corner of the screen. When that function is called, AutoSelectGuiEnabled is disabled and the value stored for later use. When this property is enabled, the game will automatically choose an element when the select button is pressed. That behavior is needed before the menu is opened so the equipment button can be activated, but while the player is in the menu it should be disabled.

TweenPosition is used to animate the menu opening and closing. The last parameter of this function is a custom callback function which is called when the animation is completed. When the opening animation is finished, this function is used to bind the B button to closeEquipmentMenu and to move the selection to the torso button. In closeEquipmentMenu, after the closing animation is played, the B button is unbound, the selection is cleared and the original value of AutoSelectGuiEnabled is restored.

Navigating Character Slots[edit]

ROBLOX has automatic behavior to help a user with a gamepad navigate Gui elements. When the user presses the Select button on their gamepad, the game will create a selection around a visible GUI element that has Selectable enabled. When the user presses the left thumbstick or the dpad, the game will try to find another GUI element in the direction that was pushed and move the selection there. If there is no element in the direction, then the selection will not change.

The grid in the right of the menu works well with this system (as the elements are always in a cardinal direction from one another), but the elements in the character frame will not work as well with the default system. GuiObjects have several properties you can use to specify which element to switch to (e.g. NextSelectionDown. Using these properties, the directions from each character slot can be set like so:

GamepadGrid Image05.png

headFrame.NextSelectionDown = torsoFrame
headFrame.NextSelectionLeft = rightArmFrame
headFrame.NextSelectionRight = leftArmFrame
 
torsoFrame.NextSelectionUp = headFrame
torsoFrame.NextSelectionLeft = rightArmFrame
torsoFrame.NextSelectionRight = leftArmFrame
torsoFrame.NextSelectionDown = rightLegFrame
 
rightArmFrame.NextSelectionUp = headFrame
rightArmFrame.NextSelectionRight = torsoFrame
rightArmFrame.NextSelectionDown = rightLegFrame
 
leftArmFrame.NextSelectionUp = headFrame
leftArmFrame.NextSelectionLeft = torsoFrame
leftArmFrame.NextSelectionDown = leftLegFrame
 
rightLegFrame.NextSelectionUp = torsoFrame
rightLegFrame.NextSelectionRight = leftLegFrame
rightLegFrame.NextSelectionLeft = rightArmFrame
 
leftLegFrame.NextSelectionUp = torsoFrame
leftLegFrame.NextSelectionRight = leftArmFrame
leftLegFrame.NextSelectionLeft = rightLegFrame

Notice that in the above code there are several edges that are not defined. For example, the code does not define the behavior for moving right from the left arm. If a user has the left arm selected and presses right, since that behavior was not explicitly defined with NextSelectionRight, the game will attempt to find a selectable GUI element to the right. This is undesirable as the user should be confined to the character slots while in that portion of the menu. While this could be defined by setting the value to nil, a much simpler way is to use a selection group.

In GuiService, a selection group is a set of GUI elements that can be navigated between. There are two ways to define selection groups: AddSelectionParent and AddSelectionTuple. AddSelectionParent takes two arguments, a name for the selection and a GuiObject. In such a selection group, only the children of the passed in GuiObject can be navigated between. For the other function, AddSelectionTuple, you simply pass in all of the GuiObjects you want to be in the group. In this case, since all of the character slots are children of characterFrame, the simpler function to use is AddSelectionParent.

GuiService:AddSelectionParent("CharacterMenu", characterFrame)

Now, when a user enters the menu via openEquipmentMenu, they will only be able to navigate between the children of characterFrame.

Next, the character slot buttons have to be bound to move the selection to the inventory grid:

-- Moves selection from the inventory menu back to the character menu
local function exitInventoryMenu()
	ContextActionService:UnbindAction(EXIT_INVENTORY_MENU_BINDING)
	GuiService.SelectedObject = currentEquipmentSlot
end
 
-- Player "clicked" on a character slot. Moves selection to the inventory menu
local function onCharacterSlotClicked()
	-- Store current character slot
	currentEquipmentSlot = GuiService.SelectedObject
	-- Bind exitInventoryMenu to B button
	ContextActionService:BindAction(EXIT_INVENTORY_MENU_BINDING, function(actionName, inputState, inputObject)
		if inputState == Enum.UserInputState.Begin then
			exitInventoryMenu()
		end
	end , false, Enum.KeyCode.ButtonB)
	-- Select first cell in the inventory grid by default
	GuiService.SelectedObject = firstCell
end
 
for _, child in pairs(characterFrame:GetChildren()) do
	child.MouseButton1Click:connect(onCharacterSlotClicked)
end

Again MouseButton1Click is used to detect when the user presses the A button with one of the character slots selected. When this event fires, onCharacterSlotClicked is called. This function first stores the character slot that was selected for later use, then binds the B button to call exitInventoryMenu, and finally selects the first grid cell in the inventory menu. exitInventoryMenu simply unbinds the B binding that onCharacterSlotClicked sets up and moves the selection back to the inventory slot that was selected before.

Navigating Inventory Grid[edit]

The inventory grid is much simpler to navigate than the character frame as the default gamepad selection code works very well with grids. The only things that need to be set up for the inventory grid are the selection group and the event when the user presses A with one of the cells selected.

GuiService:AddSelectionParent("InventoryMenu", inventoryScroll)
 
-- Player "clicked" on an inventory slot. This is where you would put code to take action
-- with the currentEquipmentSlot and the SelectedObject
local function onInventorySlotClicked()
	print("Character slot:", currentEquipmentSlot)
	print("Inventory cell:", GuiService.SelectedObject)
	-- TODO: Your code here!
	exitInventoryMenu()
end
 
for _, child in pairs(inventoryScroll:GetChildren()) do
	child.MouseButton1Click:connect(onInventorySlotClicked)
end

Again, a selection group is set up with AddSelectionParent. Even though the default gamepad selection code will facilitate moving the selection among the grid, the function still needs to make sure the selection does not move outside the grid.

For every cell in the grid, onInventorySlotClicked is bound to MouseButton1Click. This function is what you would modify to do any custom code such as equipping the item the user selected. At the end of the function exitInventoryMenu is called to move the selection back to the character frame.

Once that function has been bound, the framework for the menu system is complete.

Source[edit]

Below is the complete source for the menu outlined in this article. To work properly, it must be inserted in a LocalScript located in StarterPlayerScripts.

-- Constants
local INVENTORY_CELL_WIDTH = 0.2
local INVENTORY_CELL_X_MARGIN = 0.04
local INVENTORY_CELL_HEIGHT = 0.1
local INVENTORY_CELL_Y_MARGIN = 0.02
 
local INVENTORY_COLUMNS = 3
local INVENTORY_ROWS = 7
 
local CHARACTERFRAME_Y_SCALE = 1/8
local CHARACTERFRAME_X_SCALE = 1/8
 
local MENU_OPEN_POSITION = UDim2.new(0.05, 0, 0.05, 0)
local MENU_CLOSED_POSITION = UDim2.new(0.05, 0, -.9, -36)
 
local CLOSE_EQUIPMENT_MENU_BINDING = "CloseEquipmentMenu"
local EXIT_INVENTORY_MENU_BINDING = "ExitInventoryMenu"
 
-- Services
local GuiService = game:GetService("GuiService")
local ContextActionService = game:GetService("ContextActionService")
 
-- Variables
local player = game.Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
 
local firstCell = nil
local currentEquipmentSlot = nil
 
local oldAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
 
-- Create Menu
-- Main screenGui
local screenGui = Instance.new("ScreenGui", playerGui)
 
-- Onscreen equipment button
local equipmentButton = Instance.new("TextButton", screenGui)
equipmentButton.Name = "EquipmentButton"
equipmentButton.Text = "Equipment"
equipmentButton.Size = UDim2.new(0, 300, 0, 75)
equipmentButton.Font = Enum.Font.SourceSans
equipmentButton.FontSize = Enum.FontSize.Size60
 
-- Frame for the menu
local menuFrame = Instance.new("Frame", screenGui)
menuFrame.Size = UDim2.new(0.9, 0, 0.9, 0)
menuFrame.Position = MENU_CLOSED_POSITION
menuFrame.BackgroundTransparency = 1
 
-- Frames for the character slots
local characterFrame = Instance.new("Frame", menuFrame)
characterFrame.Name = "CharacterFrame"
characterFrame.Size = UDim2.new(0.5, 0, 1, 0)
 
local headFrame = Instance.new("ImageButton", characterFrame)
headFrame.Name = "Head"
headFrame.Size = UDim2.new(CHARACTERFRAME_X_SCALE, 0, CHARACTERFRAME_Y_SCALE, 0)
headFrame.Position = UDim2.new(0.5 - CHARACTERFRAME_X_SCALE/2, 0, CHARACTERFRAME_Y_SCALE, 0)
 
local torsoFrame = Instance.new("ImageButton", characterFrame)
torsoFrame.Name = "Torso"
torsoFrame.Size = UDim2.new(2 * CHARACTERFRAME_X_SCALE, 0, 2 * CHARACTERFRAME_Y_SCALE, 0)
torsoFrame.Position = UDim2.new(0.5 - CHARACTERFRAME_X_SCALE, 0, 2.5 * CHARACTERFRAME_Y_SCALE, 0)
 
local leftArmFrame = Instance.new("ImageButton", characterFrame)
leftArmFrame.Name = "LeftArm"
leftArmFrame.Size = UDim2.new(CHARACTERFRAME_X_SCALE, 0, 2 * CHARACTERFRAME_Y_SCALE, 0)
leftArmFrame.Position = UDim2.new(0.5 + 1.5 * CHARACTERFRAME_X_SCALE, 0, 2.5 * CHARACTERFRAME_Y_SCALE, 0)
 
local rightArmFrame = Instance.new("ImageButton", characterFrame)
rightArmFrame.Name = "RightArm"
rightArmFrame.Size = UDim2.new(CHARACTERFRAME_X_SCALE, 0, 2 * CHARACTERFRAME_Y_SCALE, 0)
rightArmFrame.Position = UDim2.new(0.5 - 2.5 * CHARACTERFRAME_X_SCALE, 0, 2.5 * CHARACTERFRAME_Y_SCALE, 0)
 
local leftLegFrame = Instance.new("ImageButton", characterFrame)
leftLegFrame.Name = "LeftLeg"
leftLegFrame.Size = UDim2.new(CHARACTERFRAME_X_SCALE, 0, 2 * CHARACTERFRAME_Y_SCALE, 0)
leftLegFrame.Position = UDim2.new(0.5 + .5 * CHARACTERFRAME_X_SCALE, 0, 5 * CHARACTERFRAME_Y_SCALE, 0)
 
local rightLegFrame = Instance.new("ImageButton", characterFrame)
rightLegFrame.Name = "RightLeg"
rightLegFrame.Size = UDim2.new(CHARACTERFRAME_X_SCALE, 0, 2 * CHARACTERFRAME_Y_SCALE, 0)
rightLegFrame.Position = UDim2.new(0.5 - 1.5 * CHARACTERFRAME_X_SCALE, 0, 5 * CHARACTERFRAME_Y_SCALE, 0)
 
-- Overwrite selection edges
headFrame.NextSelectionDown = torsoFrame
headFrame.NextSelectionLeft = rightArmFrame
headFrame.NextSelectionRight = leftArmFrame
 
torsoFrame.NextSelectionUp = headFrame
torsoFrame.NextSelectionLeft = rightArmFrame
torsoFrame.NextSelectionRight = leftArmFrame
torsoFrame.NextSelectionDown = rightLegFrame
 
rightArmFrame.NextSelectionUp = headFrame
rightArmFrame.NextSelectionRight = torsoFrame
rightArmFrame.NextSelectionDown = rightLegFrame
 
leftArmFrame.NextSelectionUp = headFrame
leftArmFrame.NextSelectionLeft = torsoFrame
leftArmFrame.NextSelectionDown = leftLegFrame
 
rightLegFrame.NextSelectionUp = torsoFrame
rightLegFrame.NextSelectionRight = leftLegFrame
rightLegFrame.NextSelectionLeft = rightArmFrame
 
leftLegFrame.NextSelectionUp = torsoFrame
leftLegFrame.NextSelectionRight = leftArmFrame
leftLegFrame.NextSelectionLeft = rightLegFrame
 
-- Frames for the inventory grid
local inventoryFrame = Instance.new("Frame", menuFrame)
inventoryFrame.Name = "InventoryFrame"
inventoryFrame.Size = UDim2.new(0.5, 0, 1, 0)
inventoryFrame.Position = UDim2.new(0.5, 0, 0, 0)
 
local inventoryScroll = Instance.new("ScrollingFrame", inventoryFrame)
inventoryScroll.Size = UDim2.new(0.9, 0, 0.9, 0)
inventoryScroll.CanvasSize = UDim2.new(0.9, 0, 1.8, 0)
inventoryScroll.Position = UDim2.new(0.05, 0, 0.05, 0)
inventoryScroll.Selectable = false
inventoryScroll.ScrollBarThickness = 0
 
-- Create cells for inventory grid
for y = 0, INVENTORY_ROWS do
	for x = 0, INVENTORY_COLUMNS do
		local inventoryCell = Instance.new("ImageButton", inventoryScroll)
		inventoryCell.Image = "rbxassetid://133293265"
		inventoryCell.Name = "InventoryCell(" .. x .. "," .. y .. ")"
		if not firstCell then firstCell = inventoryCell end
		inventoryCell.Size = UDim2.new(INVENTORY_CELL_WIDTH, 0, INVENTORY_CELL_HEIGHT, 0)
		inventoryCell.Position = UDim2.new(INVENTORY_CELL_X_MARGIN + INVENTORY_CELL_X_MARGIN * x + INVENTORY_CELL_WIDTH * x, 0,
										 INVENTORY_CELL_Y_MARGIN + INVENTORY_CELL_Y_MARGIN * y + INVENTORY_CELL_HEIGHT * y, 0)
 
	end
end
 
-- Add selection groups for the two sections of the menu
GuiService:AddSelectionParent("CharacterMenu", characterFrame)
GuiService:AddSelectionParent("InventoryMenu", inventoryScroll)
 
-- Closes the equipment menu
local function closeEquipmentMenu()
	-- Play closing animation
	menuFrame:TweenPosition(MENU_CLOSED_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
		-- When animation finished, unbind the close function and reset Gui selection		
		ContextActionService:UnbindAction(CLOSE_EQUIPMENT_MENU_BINDING)
		GuiService.SelectedObject = nil
		GuiService.AutoSelectGuiEnabled = oldAutoSelectGuiEnabled
	end)
end
 
-- Opens the equipment menu
local function openEquipmentMenu()
	-- Store the old value of AutoSelectEnabled and then set to false
	oldAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
	GuiService.AutoSelectGuiEnabled = false
 
	-- Play opening animation
	menuFrame:TweenPosition(MENU_OPEN_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
		-- When animation finished, bind closeEquipmentMenu to the B button
		ContextActionService:BindAction(CLOSE_EQUIPMENT_MENU_BINDING, function(actionName, inputState, inputObject)
			if inputState == Enum.UserInputState.Begin then
				closeEquipmentMenu()
			end
		end, false, Enum.KeyCode.ButtonB)
		-- Select torso by default
		GuiService.SelectedObject = torsoFrame
	end)
end
 
-- Moves selection from the inventory menu back to the character menu
local function exitInventoryMenu()
	ContextActionService:UnbindAction(EXIT_INVENTORY_MENU_BINDING)
	GuiService.SelectedObject = currentEquipmentSlot
end
 
-- Player "clicked" on a character slot. Moves selection to the inventory menu
local function onCharacterSlotClicked()
	-- Store current character slot
	currentEquipmentSlot = GuiService.SelectedObject
	-- Bind exitInventoryMenu to B button
	ContextActionService:BindAction(EXIT_INVENTORY_MENU_BINDING, function(actionName, inputState, inputObject)
		if inputState == Enum.UserInputState.Begin then
			exitInventoryMenu()
		end
	end , false, Enum.KeyCode.ButtonB)
	-- Select first cell in the inventory grid by default
	GuiService.SelectedObject = firstCell
end
 
-- Player "clicked" on an inventory slot. This is where you would put code to take action
-- with the currentEquipmentSlot and the SelectedObject
local function onInventorySlotClicked()
	print("Character slot:", currentEquipmentSlot)
	print("Inventory cell:", GuiService.SelectedObject)
 
	-- TODO: Your code here!
 
	exitInventoryMenu()
end
 
-- Input binding
equipmentButton.MouseButton1Click:connect(openEquipmentMenu)
 
for _, child in pairs(characterFrame:GetChildren()) do
	child.MouseButton1Click:connect(onCharacterSlotClicked)
end
 
for _, child in pairs(inventoryScroll:GetChildren()) do
	child.MouseButton1Click:connect(onInventorySlotClicked)
end