Radial Menu

A radial menu is a style of menu often found in console games due to its ease of use with analog thumbstick controllers. In a radial menu, the selectable options are arranged in a ring. When the user pushes the analog stick, the option in the direction the user pushed will be selected.

This tutorial will cover how to implement a radial menu, with an emphasis on gamepads, although the same approach can be used for mouse/keyboard or mobile games.

Setting up Menu[edit]

Menus can be created manually or through code. This tutorial uses code so it can easily be put into any game:

-- Define position and size for the menu frame in both opened and closed states
local menuOpenPosition = UDim2.new(0.25, 0, 0.25, 0)
local menuOpenSize = UDim2.new(0.5, 0, 0.5, 0)
local menuClosedPosition = UDim2.new(.5, 0, .5, 0)
local menuClosedSize = UDim2.new(0.005, 0, 0.005, 0)
 
-- Define the element for the menu options
local itemTemplate = Instance.new("TextButton")
itemTemplate.Size = UDim2.new(0.3, 0, 0.2, 0)
itemTemplate.AutoButtonColor = false
itemTemplate.TextScaled = true
 
-- Create a screen and frame for the menu
local menuScreen = Instance.new("ScreenGui", player:WaitForChild("PlayerGui"))
menuScreen.Name = "MenuScreen"
local menuFrame = Instance.new("Frame", menuScreen)
menuFrame.Size = menuClosedSize
menuFrame.Position = menuClosedPosition
menuFrame.Visible = false
menuFrame.BackgroundTransparency = 1
menuFrame.BorderSizePixel = 0
menuFrame.Name = "MenuFrame"

Radial menus typically have two states: opened and closed. This code first defines positions and sizes for the main frame of the menu. With the current values the open state of the menu will be centered on the screen and take up half of the screen’s width and height. You can adjust menuOpenPosition and menuOpenSize to adjust how much of the screen the menu takes up and where it is centered.

Next, the script creates a template for the menu items. In this case, a Script error is used, as it is simple. You should change this to use a more complicated element that fits the style of your game and interface. Keep in mind the code will animate the menu opening by changing it’s size, so Script error makes sure the text dynamically changes size with the button.

Script error is disabled as this tutorial is focused on working with a gamepad. If adapting this code for use with a mouse, you should enable this property.

Finally, the code creates a Script error and Script error. The Frame’s Script error and Script error are set to menuClosedSize and menuClosedPosition respectively, as the menu should be hidden initially.

Creating Buttons[edit]

After the menu screen and frame are set up, the script then automatically creates buttons arranged in a ring. There are several constants you can use to configure how many buttons appear in the ring and how they are oriented.

local RADIUS = .5
local NUM_OPTIONS = 6
local ANGLE_OFFSET = 90
 
local menuItems = {}
 
local function newMenuItem(name, angle, range)
	local newItem = {}
	local label = itemTemplate:Clone()
	label.Text = name
	label.Name = name
	local angleRadians = math.rad(ANGLE_OFFSET + angle)
	label.Position = UDim2.new(.5 + RADIUS * math.cos(angleRadians) - label.Size.X.Scale / 2, 0,
							 .5 - RADIUS * math.sin(angleRadians) - label.Size.Y.Scale / 2, 0)
	label.Parent = menuFrame
	newItem.Label = label
	newItem.Vector = Vector2.new(math.cos(angleRadians), math.sin(angleRadians))
	newItem.Range = range
	table.insert(menuItems, newItem)
end

The newMenuItem function is used to create new menu items and to add these items to a table that will be used later when determining which item to select. The function takes three arguments: name indicates the name of the option (and is also used to label it in the GUI), angle sets where on the ring the option is centered, and range sets how wide the input arc is.

RadialMenu Image01.png

The function first creates a clone of the itemTemplate that was created earlier. It also sets the position of the button based on angle. The function also stores a 2D vector made from the input angle. This will be used later to compare with user input to figure out which item is selected.

for i = 1, NUM_OPTIONS do
	local angle = (360 / NUM_OPTIONS) * (i - 1)
	local name = "Option" .. i
	newMenuItem(name, angle, 360 / NUM_OPTIONS)
end

After the newMenuItem function is declared, the code then executes a loop to create the menu items. The constant NUM_OPTIONS determines how many menu options to create. The angle for each item is determined by dividing 360 (the number of degrees in a circle) by NUM_OPTIONS and then multiplying by the current option. The range is also determined by dividing 360 by NUM_OPTIONS. These values are all passed into newMenuItem to create the button.

Opening the Menu[edit]

In this tutorial the menu is toggled open and closed with the left bumper. Script error can be used to bind this button to a custom function that opens and closes the menu.

-- The menu uses left bumper which the backpack also uses. Disabling backpack UI to prevent conflict
game.StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)
 
local function toggleMenu(actionName, inputState)
	if inputState == Enum.UserInputState.Begin then
		openMenu()
	elseif inputState == Enum.UserInputState.End then
		closeMenu()
	end
end
 
ContextActionService:BindAction("ToggleMenu", toggleMenu, false, Enum.KeyCode.ButtonL1)

The default ROBLOX control script binds the bumpers to toggle between items in the backpack. In order to rebind this input to the radial menu, the backpack UI has to be disabled using Script error. If you use a different input than the bumpers for your radial menu, you should be mindful of the ROBLOX defaults to prevent any conflicts.

The toggleMenu function is bound to the left bumper using Script error. toggleMenu checks inputState to see what state the bumper is in. If it is in the Begin state, then the user just started pressing the bumper and the menu needs to be opened. Otherwise, if it is in the End state, the menu needs to be closed.

local function openMenu()
	-- Store GuiNavigationEnabled and AutoSelectGuiEnabled and then set both to false
	wasGuiNavigationEnabled = GuiService.GuiNavigationEnabled		
	wasAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
	GuiService.GuiNavigationEnabled = false
	GuiService.AutoSelectGuiEnabled = false
 
	-- Bind onThumbstickMoved function
	ContextActionService:BindAction("RadialMenu", onThumbstickMoved, false, Enum.KeyCode.Thumbstick1)		
	-- Make sure frame is visible and play opening animation
	menuFrame.Visible = true
	menuFrame:TweenSizeAndPosition(menuOpenSize, menuOpenPosition, Enum.EasingDirection.Out, Enum.EasingStyle.Quart, .5, true)
	menuOpen = true	
end

The openMenu function needs to do several things. First, it needs to disable Script error and Script error and store the values so that they can be restored later. These settings work well for other menu types, but are not well suited for radial menus.

The function then binds a new function, onThumbstickMoved, to thumbstick input. This new function will be used to check which item the user wants to select.

Finally, openMenu makes the menu visible and plays an opening animation.

local THUMBSTICK_DEADZONE = .4
 
-- Goes through the menuItems table and finds the angle between each item's vector and
-- the passed in vector. If the angle is less than half the item's range, then that item
-- is selected.
local function getButtonFromVector(vector)
	for i = 1, #menuItems do
		local item = menuItems[i]
		local dotProduct = vector.X * item.Vector.X + vector.Y * item.Vector.Y
		local angle = math.acos(dotProduct / vector.magnitude)
		if angle <= math.rad(item.Range) / 2 then
			return item.Button
		end
	end
	return nil
end
 
-- Function bound to left thumbstick movement.
local function onThumbstickMoved(actionName, inputState, inputObject)
	-- Make sure the thumbstick was moved past the deadzone
	if inputObject.Position.magnitude >= THUMBSTICK_DEADZONE then
		-- Calculate the angle based on the position of the thumbstick
		local selectedButton = getButtonFromVector(inputObject.Position)
		GuiService.SelectedObject = selectedButton
	else
		-- Thumbstick inside deadzone, clear selection
		GuiService.SelectedObject = nil
	end
end

The functions onThumbstickMoved and getButtonFromVector are both used to determine which menu item to select based on thumbstick input from the player.

onThumbstickMoved first checks if the thumbstick was pushed past the deadzone. It does this by checking the Script error property of inputObject. The Position is a Vector2D with a magnitude between 0 and 1 (0 meaning idle, 1 meaning fully pressed in a direction). If the thumbstick has been sufficiently pushed, it passes the Position of the thumbstick to getButtonFromVector.

getButtonFromVector goes through all of the menu items stored in menuItems. For each item, it finds the angle between the passed in vector (which was the thumbstick’s Position), and the item’s vector. Recall that in Creating Buttons the item’s vector was constructed based on the angle of the menu item. The angle is calculated based on the following formula:

RadialMenu Image00.png

You may notice that the code only divides by the magnitude of the passed in vector (the Position of the thumbstick) and not by the magnitude of the item’s vector. This is because the item’s vector is guaranteed to be a unit vector, since it was made via Vector2.new(math.cos(angle), math.sin(angle)). A unit vector has a magnitude of 1, so it can be safely omitted from the divisor.

The angle between the vectors is then compared to half the item’s range. If it is less than half the item’s range, that means the thumbstick vector is somewhere in the arc defined for that item. If this is the case, getButtonFromVector returns the item’s button.

If getButtonFromVector returns a button, onThumbstickMoved sets the Script error to that button. This highlights the button so the user knows it has been selected.

At the end of onThumbstickMoved, if the thumbstick was not pushed past the deadzone, the SelectedObject is set to nil to clear the selection.

Closing the Menu[edit]

When the menu closes, the code needs to call a function based on the option that was selected (if any).

-- Function to call when item is selected. This is where you should put your custom code
-- to implement your menu
local function onMenuSelect(option)
	print(option, "selected")
end
 
local function closeMenu()
	-- Restore GuiNavigationEnabled and AutoSelectGuiEnabled
	GuiService.GuiNavigationEnabled	= wasGuiNavigationEnabled
	GuiService.AutoSelectGuiEnabled = wasAutoSelectGuiEnabled	
 
	-- Unbind onThumbstickMoved function		
	ContextActionService:UnbindAction("RadialMenu")
 
	if GuiService.SelectedObject then
		-- If there is a selection when the menu closed, this is the option the user wanted
		onMenuSelect(GuiService.SelectedObject)
	end
 
	-- Clear selected object and play closing animation
	GuiService.SelectedObject = nil
	menuFrame:TweenSizeAndPosition(menuClosedSize, menuClosedPosition, Enum.EasingDirection.Out, Enum.EasingStyle.Quart, .4, true,
		function()
			-- Callback function at end of animation. If the user hasn't reopened the menu hide it
			if not menuOpen then
				menuFrame.Visible = false
			end
		end)
	menuOpen = false
end

The closeMenu function first restores GuiNavigationEnabled and AutoSelectGuiEnabled in case the rest of the game relies on those settings. It also unbinds onThumbstickMoved so that the thumbstick can again be used for other purposes. Then, if there is a SelectedObject, the function calls onMenuSelect, passing in the SelectedObject as an argument.

onMenuSelect is where you will want to add any custom code. Based on the button that was passed in, you can write code for whichever action you need to happen when the button is selected.

After onMenuSelect is called, closeMenu then clears the current selection and plays an animation to hide the menu.

Complete Code[edit]

Below is all of the code to implement a radial menu as seen above. The code should be in a Script error and placed either in Script error or Script error.

local THUMBSTICK_DEADZONE = .4
local RADIUS = .5
local NUM_OPTIONS = 6
local ANGLE_OFFSET = 90
 
-- Services
local ContextActionService = game:GetService("ContextActionService")
local GuiService = game:GetService("GuiService")
 
-- The menu uses left bumper which the backpack also uses. Disabling backpack UI to prevent conflict
game.StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)
 
local player = game.Players.LocalPlayer
local menuItems = {}
 
-- Define position and size for the menu frame in both opened and closed states
local menuOpenPosition = UDim2.new(0.25, 0, 0.25, 0)
local menuOpenSize = UDim2.new(0.5, 0, 0.5, 0)
local menuClosedPosition = UDim2.new(0.5, 0, 0.5, 0)
local menuClosedSize = UDim2.new(0.005, 0, 0.005, 0)
 
-- Define the element for the menu options
local itemTemplate = Instance.new("TextButton")
itemTemplate.Size = UDim2.new(0.3, 0, 0.2, 0)
itemTemplate.AutoButtonColor = false
itemTemplate.TextScaled = true
 
-- Create a screen and frame for the menu
local menuScreen = Instance.new("ScreenGui", player:WaitForChild("PlayerGui"))
menuScreen.Name = "MenuScreen"
local menuFrame = Instance.new("Frame", menuScreen)
menuFrame.Size = menuClosedSize
menuFrame.Position = menuClosedPosition
menuFrame.Visible = false
menuFrame.BackgroundTransparency = 1
menuFrame.BorderSizePixel = 0
menuFrame.Name = "MenuFrame"
 
-- Storage for AutoSelectGuiEnabled and GuiNavigationEnabled
local wasAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
local wasGuiNavigationEnabled = GuiService.GuiNavigationEnabled
 
local function newMenuItem(name, angle, range)
	local newItem = {}
	local button = itemTemplate:Clone()
	button.Text = name
	button.Name = name
	local angleRadians = math.rad(ANGLE_OFFSET + angle)
	button.Position = UDim2.new(.5 + RADIUS * math.cos(angleRadians) - button.Size.X.Scale / 2, 0,
							 .5 - RADIUS * math.sin(angleRadians) - button.Size.Y.Scale / 2, 0)
	button.Parent = menuFrame
	newItem.Label = button
	newItem.Vector = Vector2.new(math.cos(angleRadians), math.sin(angleRadians))
	newItem.Range = range
	table.insert(menuItems, newItem)
end
 
for i = 1, NUM_OPTIONS do
	local angle = (360 / NUM_OPTIONS) * (i - 1)
	local name = "Option" .. i
	newMenuItem(name, angle, 360 / NUM_OPTIONS)
end
 
-- Goes through the menuItems table and finds the angle between each item's vector and
-- the passed in vector. If the angle is less than half the item's range, then that item
-- is selected.
local function getButtonFromVector(vector)
	for i = 1, #menuItems do
		local item = menuItems[i]
		local dotProduct = vector.X * item.Vector.X + vector.Y * item.Vector.Y
		local angle = math.acos(dotProduct / vector.magnitude)
		if angle <= math.rad(item.Range) / 2 then
			return item.Button
		end
	end
	return nil
end
 
-- Function bound to left thumbstick movement.
local function onThumbstickMoved(actionName, inputState, inputObject)
	-- Make sure the thumbstick was moved past the deadzone
	if inputObject.Position.magnitude >= THUMBSTICK_DEADZONE then
		-- Calculate the angle based on the position of the thumbstick
		local selectedButton = getButtonFromVector(inputObject.Position)
		GuiService.SelectedObject = selectedButton
	else
		-- Thumbstick inside deadzone, clear selection
		GuiService.SelectedObject = nil
	end
end
 
-- Function to call when item is selected. This is where you should put your custom code
-- to implement your menu
local function onMenuSelect(option)
	print(option, "selected")
end
 
local function openMenu()
	-- Store GuiNavigationEnabled and AutoSelectGuiEnabled and then set both to false
	wasGuiNavigationEnabled = GuiService.GuiNavigationEnabled		
	wasAutoSelectGuiEnabled = GuiService.AutoSelectGuiEnabled
	GuiService.GuiNavigationEnabled	= false
	GuiService.AutoSelectGuiEnabled = false
 
	-- Bind onThumbstickMoved function
	ContextActionService:BindAction("RadialMenu", onThumbstickMoved, false, Enum.KeyCode.Thumbstick1)		
 
	-- Make sure frame is visible and play opening animation
	menuFrame.Visible = true
	menuFrame:TweenSizeAndPosition(menuOpenSize, menuOpenPosition, Enum.EasingDirection.Out, Enum.EasingStyle.Quart, .5, true)
	menuOpen = true	
end
 
local function closeMenu()
	-- Restore GuiNavigationEnabled and AutoSelectGuiEnabled
	GuiService.GuiNavigationEnabled	= wasGuiNavigationEnabled
	GuiService.AutoSelectGuiEnabled = wasAutoSelectGuiEnabled	
 
	-- Unbind onThumbstickMoved function		
	ContextActionService:UnbindAction("RadialMenu")
 
	if GuiService.SelectedObject then
		-- If there is a selection when the menu closed, this is the option the user wanted
		onMenuSelect(GuiService.SelectedObject)
	end
 
	-- Clear selected object and play closing animation
	GuiService.SelectedObject = nil
	menuFrame:TweenSizeAndPosition(menuClosedSize, menuClosedPosition, Enum.EasingDirection.Out, Enum.EasingStyle.Quart, .4, true,
		function()
			-- Callback function at end of animation. If the user hasn't reopened the menu hide it
			if not menuOpen then
				menuFrame.Visible = false
			end
		end)
	menuOpen = false
end
 
local function toggleMenu(actionName, inputState)
	if inputState == Enum.UserInputState.Begin then
		openMenu()
	elseif inputState == Enum.UserInputState.End then
		closeMenu()
	end
end
 
ContextActionService:BindAction("ToggleMenu", toggleMenu, false, Enum.KeyCode.ButtonL1)