In this post I want to discuss how menu navigation is handled in Pongbat.

For Pongbat I wanted the user to be able to navigate using either mouse, keyboard or gamepad. I make use of the suit library for drawing the user interface and suit itself doesn’t have any capability to handle navigation.

One nice aspect of suit is that it draws layouts using a table-like system. As such, items in the menu are by default horizontally or vertically aligned, which can result in the following menu screens:

Pongbat The main menu only allows vertical navigation between buttons.

Pongbat The new game menu allows both horizontal and vertical navigation between buttons.

To internally keep track of the current highlighted item and items adjacent to the current item, I created a graph-like system. The graph represents the nodes that make up the menu. Each node contains list of connected nodes. The connections can be made either horizontally or vertically.

The menu item graph node implementation is as such:

local menuItemGraphNode = Class {} -- based on hump.class

local function getSize(table)
	local size = 0
	for _, _ in pairs(table) do size = size + 1 end
	return size
end

function menuItemGraphNode:init(id)
	self.id = id

	self._left = {}
	self._right = {}
	self._up = {}
	self._down = {}
end

function menuItemGraphNode:connect(node, direction)
	if direction == 'left' then self._left[node.id] = node 
	elseif direction == 'right' then self._right[node.id] = node
	elseif direction == 'up' then self._up[node.id] = node
	elseif direction == 'down' then self._down[node.id] = node
	else error('direction should be left, right, up or down')
	end
end

function menuItemGraphNode:getLeftNodes()
	return getSize(self._left), self._left
end

function menuItemGraphNode:getRightNodes()
	return getSize(self._right), self._right
end

function menuItemGraphNode:getUpNodes()
	return getSize(self._up), self._up
end

function menuItemGraphNode:getDownNodes()
	return getSize(self._down), self._down
end

return menuItemGraphNode

When retrieving a list of adjacent nodes, I return both the amount of nodes and a node list.

The menu item graph class is a bit more complicated:

local menuItemGraph = Class {} -- based on hump.class

--[[
  	This function is used when navigating to a new node. If 
  	the last highlighted node is part of the nodes list, the 
  	last highlighted node with be highlighted once more.
  	Otherwise just return the first item from the node list.
]]
local function getNextHighlightNode(nodes, lastHighlightItemId)
	local lastHighlightNode = nodes[lastHighlightItemId]
	if lastHighlightNode then return lastHighlightNode
	else
		for id, node in pairs(nodes) do return node end
	end
end

-- Initialize the graph with one or more nodes.
function menuItemGraph:init(itemId, ...)
	self._vertices = {}
	self._highlightItemId = nil
	self._lastHighlightItemId = nil

	if itemId then self:addNodes(itemId, ...) end
end

--[[
	Add one or more nodes to the graph; the first item in the 
	graph will be highlighted by default
]]
function menuItemGraph:addNodes(itemId, ...)
	assert(itemId ~= nil, "at least 1 itemId is required")

	self._highlightItemId = itemId

	for _, itemId in ipairs({ itemId, ... }) do
		self._vertices[itemId] = MenuItemGraphNode(itemId)
	end
end

function menuItemGraph:getHighlightItemId()
	return self._highlightItemId
end

function menuItemGraph:setHighlightItemId(itemId)
	local item = self._vertices[itemId]
	
	if not item then error("item id doesn't exist") end
	
	self._highlightItemId = item.id
end

--[[
	Connect a node with other nodes based on an orientation.
	Orientation should be either horizontal or vertical. The
	Nodes will be connected with each other accordingly. If
	a node is connected with another node horizontally, the 
	nodes will be able to 'find' each other using the nodes' 
	getLeftNodes() or getRightNodes() functions.
]]
function menuItemGraph:addEdges(orientation, itemId1, itemId2, ...)
	local itemIds = {itemId1, itemId2, ...}

	for i = 1, #itemIds - 1, 1 do
		local itemNode1 = self._vertices[itemIds[i]]
		local itemNode2 = self._vertices[itemIds[i + 1]]

		if orientation == 'horizontal' then
			itemNode1:connect(itemNode2, 'right')
			itemNode2:connect(itemNode1, 'left')
		elseif orientation == 'vertical' then
			itemNode1:connect(itemNode2, 'down')
			itemNode2:connect(itemNode1, 'up')
		else
			error("orientation should be horizontal or vertical")
		end	
	end
end

function menuItemGraph:getItemIds()
	local itemIds = {}

	for _, item in pairs(self._vertices) do
		itemIds[#itemIds + 1] = item.id
	end

	return itemIds
end

-- Highlight a node upwards of the current highlighted node
function menuItemGraph:highlightUp()
	local highlightItemNode = self._vertices[self._highlightItemId]
	if not highlightItemNode then return end

	local size, upNodes = highlightItemNode:getUpNodes()
	if size > 0 then 
		local nextNode = getNextHighlightNode(upNodes, self._lastHighlightItemId)
		self._lastHighlightItemId = self._highlightItemId
		self._highlightItemId = nextNode.id
		return true
	end

	return false
end

-- Highlight a node downwards of the current highlighted node
function menuItemGraph:highlightDown()
	local highlightItemNode = self._vertices[self._highlightItemId]
	if not highlightItemNode then return end

	local size, downNodes = highlightItemNode:getDownNodes()
	if size > 0 then 
		local nextNode = getNextHighlightNode(downNodes, self._lastHighlightItemId)
		self._lastHighlightItemId = self._highlightItemId
		self._highlightItemId = nextNode.id
		return true
	end

	return false
end

-- Highlight a node on the left of the current highlighted node
function menuItemGraph:highlightLeft()
	local highlightItemNode = self._vertices[self._highlightItemId]
	if not highlightItemNode then return end

	local size, leftNodes = highlightItemNode:getLeftNodes()
	if size > 0 then 
		local nextNode = getNextHighlightNode(leftNodes, self._lastHighlightItemId)
		self._lastHighlightItemId = self._highlightItemId
		self._highlightItemId = nextNode.id 
		return true
	end

	return false
end

-- Highlight a node on the right of the current highlighted node
function menuItemGraph:highlightRight()
	local highlightItemNode = self._vertices[self._highlightItemId]
	if not highlightItemNode then return end

	local size, rightNodes = highlightItemNode:getRightNodes()
	if size > 0 then 
		local nextNode = getNextHighlightNode(rightNodes, self._lastHighlightItemId)
		self._lastHighlightItemId = self._highlightItemId
		self._highlightItemId = nextNode.id 
		return true
	end

	return false
end

return menuItemGraph

For simple menu screens the navigation using keyboard, gamepad or mouse has become pretty trivial. For example in the main menu screen we setup navigation as follows:

function menu:init()
    -- intialize base scene class with a title and background iamge
	Scene.init(self, 'LETHAL PONGBAT', textures['background-menu'])

	-- create a navigation graph with some nodes
    local graph = MenuItemGraph(ID_BUTTON_PLAY, ID_BUTTON_SETTINGS, ID_BUTTON_CREDITS, ID_BUTTON_QUIT)
    -- connect nodes in graph vertically
    graph:addEdges('vertical', ID_BUTTON_PLAY, ID_BUTTON_SETTINGS, ID_BUTTON_CREDITS, ID_BUTTON_QUIT)
    -- set graph in base scene class
    self:setMenuGraph(graph)
end

A menu item graph is created with a list of menu item id’s. Since the main menu only exists of a single column, we connect each menu item id vertically.

The update loop then finds the current highlighted item (which is retrieved from the menu item graph) or in case of mouse input we ignore the highlighted item.

In suit, set the current highlighted node id to hovered, which (in case of keyboard and gamepad) with allow for sticky highlighting. In case of mouse input, suit is responsible for showing hovered state when the mouse pointer is hovering above a control. So in case of mouse input, by default disable highlighting.

function menu:update(dt)
	Scene.update(self, dt)

    suit.layout:reset((VIRTUAL_WIDTH - UI_CONTROL_WIDTH) / 2, self:getTopY() + UI_CONTROL_HEIGHT + UI_PADDING, UI_PADDING, UI_PADDING)

    local highlightId = self:getHighlightItemId()
    if self:getInputMode() == INPUT_MODE_MOUSE then highlightId = nil end
    suit.setHovered(highlightId)

    local x, y, w, h = suit.layout:row(UI_CONTROL_WIDTH, UI_CONTROL_HEIGHT)
	if suit.Button("New Game", { id = ID_BUTTON_PLAY }, x, y, w, h).hit then
		return Gamestate.push(Play {})
	end

    x, y, w, h = suit.layout:row()
	if suit.Button("Settings", { id = ID_BUTTON_SETTINGS }, x, y, w, h).hit then
		return Gamestate.push(Settings {})
	end

    x, y, w, h = suit.layout:row()
	if suit.Button("Credits", { id = ID_BUTTON_CREDITS }, x, y, w, h).hit then
		return Gamestate.push(Credits {})
	end

    suit.layout:push((VIRTUAL_WIDTH - UI_CONTROL_WIDTH) / 2, self:getBottomY())
    do
        x, y, w, h = suit.layout:row(UI_CONTROL_WIDTH, UI_CONTROL_HEIGHT)
    	if suit.Button("Quit", { id = ID_BUTTON_QUIT }, x, y, w, h).hit then quit() end
    end
    suit.layout:pop()
end

To process keyboard input, the main class forwards all released keys to the current scene. The base scene contains a simple implementation for handling keyboard input:

function scene:keyreleased(key, code)
	-- if no menu item graph was configured, we don't allow keyboard navigation
    if not self._menuGraph then return end 

    -- only allow keyboard navigation if user is not entering text in a suit 
    -- input control
    if not self._isTextInputEnabled then
    	-- make sure keyboard input in suit is disabled
        suit.grabKeyboardFocus(nil) 

        -- switch input mode to keyboard, so highlighting becomes 'sticky'
        if inputMode ~= INPUT_MODE_KEYBOARD then 
            setInputMode(INPUT_MODE_KEYBOARD)
            return 
        end

        -- use the graph to highlight next node, or activate current item
        -- activating a button will raise the hit event
        if key == 'up' then self._menuGraph:highlightUp()
        elseif key == 'left' then self._menuGraph:highlightLeft()
        elseif key == 'right' then self._menuGraph:highlightRight()
        elseif key == 'down' then self._menuGraph:highlightDown()
        elseif key == 'space' then suit.setHit(self._menuGraph:getHighlightItemId()) end
    else
    	-- if we are currently entering text in suit input field, end
    	-- input if user enters return key
        if key == 'return' then self._isTextInputEnabled = false end
    end
end

Mouse input handling is even easier. The default implementation in the base scene class is as follows:

function scene:mousemoved(x, y, dx, dy, istouch)
	-- when the user is entering text in a suit input field, ignore mouse 
	-- movement
    if self._isTextInputEnabled then return false end

    -- on mouse movement, switch to mouse input mode
    if inputMode ~= INPUT_MODE_MOUSE then
        setInputMode(INPUT_MODE_MOUSE)
    end

    -- notify suit of the current mouse position, so suit can handle hovered 
    -- and hit states
    suit.updateMouse(x, y, istouch)
end

For handling game controller input, I added a separate class which has callbacks when a game controller button is released. The base scene makes use of this class as follows:

function scene:init(title, background)
    self._background = background
    self._title = title
    self._menuGraph = nil
    self._isTextInputEnabled = false
    self._inputController = GamepadInputController()
    self._inputController.onRelease = function(id, key)
        if key == 'up' then self._menuGraph:highlightUp()
        elseif key == 'down' then self._menuGraph:highlightDown()
        elseif key == 'left' then self._menuGraph:highlightLeft()
        elseif key == 'right' then self._menuGraph:highlightRight()
        elseif key == 'action' then suit.setHit(self._menuGraph:getHighlightItemId()) end
    end
    self._inputController.onAdded = function(id) setInputMode(INPUT_MODE_GAMEPAD) end
end

function scene:gamepadpressed(joystick, button)
    -- body
end

function scene:gamepadreleased(joystick, button)
    if inputMode ~= INPUT_MODE_GAMEPAD then
        setInputMode(INPUT_MODE_GAMEPAD)
    end
end

function scene:joystickadded(joystick)
    self._inputController:joystickAdded(joystick)
end

function scene:joystickremoved(joystick) 
    self._inputController:joystickRemoved(joystick)
end

function scene.joystickreleased(joystick, button)
    if inputMode ~= INPUT_MODE_GAMEPAD then
        setInputMode(INPUT_MODE_GAMEPAD)
    end
end

The scene’s input mode also switches to INPUT_MODE_GAMEPAD when a gamepad is connected or a button is released on the gamepad. Again, setting the input mode property is required to allow for ‘sticky’ menu highlighting. Also, I make a distinction between gamepad and keyboard input modes, since some menu’s should probably only be accessible when using a keyboard.

To be honest I am not 100% happy with the code required to handle different types of hardware inputs, so in the future I will look into using other libraries to simplify te codebase. I will probably try to add baton for my next LÖVE project, as it seems pretty simple to implement and use.

Conclusion

Using a graph-like system for menu navation work well when combined with an immediate-mode GUI like suit.