Lua for Game Development — Chapter 7: UI Systems, HUD, Widgets, Event Binding, and Lua-Driven UI Architecture
Leeting Yan
User interfaces are a core part of game development:
- HUD (health bars, minimaps, ammo counters)
- Menus (pause, settings, inventory)
- In-game shops
- UI alerts and notifications
- Pop-up windows and dialogs
- Touch UI for mobile
Lua excels here because UI scripting requires:
- Flexibility
- Fast iteration
- Easy updates
- Data binding
- Coroutine-friendly animations
- Modular components
This chapter teaches you how to build a complete UI architecture in Lua.
1. The Goals of a Good UI Architecture
A production UI system must be:
✔ Modular
Each UI element (button, panel, list) should be a reusable widget.
✔ Data-driven
UI should update automatically when game data changes.
✔ Event-based
UI reacts to inputs, events, and model changes.
✔ Animatable
Smooth transitions, popups, fades, tweens.
✔ Hot-reloadable
UI iteration speed is critical in game production.
✔ Clean separation
- Logic in Lua
- Visual assets in engine (atlas, sprites, GUI nodes, fonts)
2. UI Scene Structure
A common folder layout:
ui/
core/
ui_manager.lua
widget.lua
event.lua
layout.lua
widgets/
button.lua
label.lua
progress_bar.lua
list.lua
screens/
hud.lua
inventory.lua
pause_menu.lua
themes/
default.lua
Lua handles:
- Widget logic
- Event dispatch
- Layout rules
- Data binding
- Transitions
The engine handles:
- Draw calls
- Text rendering
- Input dispatch
- Node transforms
3. UI Manager (The Heart of the UI System)
local UIManager = {}
UIManager.__index = UIManager
function UIManager.new()
return setmetatable({
stack = {}, -- screen stack
widgets = {},-- global widgets (HUD etc.)
}, UIManager)
end
function UIManager:push(screen)
table.insert(self.stack, screen)
if screen.enter then screen:enter() end
end
function UIManager:pop()
local s = table.remove(self.stack)
if s and s.exit then s:exit() end
end
function UIManager:update(dt)
for _, w in ipairs(self.widgets) do
if w.update then w:update(dt) end
end
local top = self.stack[#self.stack]
if top and top.update then top:update(dt) end
end
function UIManager:render()
local top = self.stack[#self.stack]
if top and top.render then top:render() end
for _, w in ipairs(self.widgets) do
if w.render then w:render() end
end
end
return UIManager
This structure allows:
- stack-based menus
- always-visible HUD elements
- modal windows
4. Base Widget Class
All UI elements are widgets.
local Widget = {}
Widget.__index = Widget
function Widget.new()
return setmetatable({
x = 0, y = 0,
w = 100, h = 20,
visible = true,
children = {},
}, Widget)
end
function Widget:add(child)
table.insert(self.children, child)
end
function Widget:update(dt)
for _, c in ipairs(self.children) do
if c.update then c:update(dt) end
end
end
function Widget:render()
if not self.visible then return end
for _, c in ipairs(self.children) do
if c.render then c:render() end
end
end
return Widget
Widgets can contain other widgets, making UI hierarchical.
5. Event Binding System
UI events:
- key pressed
- mouse/touch clicked
- hover
- input change
- focus/blur
- custom game events
5.1 Event Bus for UI
local Event = {}
Event.__index = Event
function Event.new()
return setmetatable({listeners={}}, Event)
end
function Event:on(event, fn)
self.listeners[event] = self.listeners[event] or {}
table.insert(self.listeners[event], fn)
end
function Event:emit(event, ...)
for _, fn in ipairs(self.listeners[event] or {}) do
fn(...)
end
end
return Event
UI screens subscribe to events:
event:on("hp_changed", function(new_hp)
health_bar:set_value(new_hp)
end)
6. Example Widgets
6.1 Label Widget
local Widget = require "ui.core.widget"
local Label = setmetatable({}, Widget)
Label.__index = Label
function Label.new(text)
local self = Widget.new()
self.text = text
return setmetatable(self, Label)
end
function Label:render()
if not self.visible then return end
draw_text(self.text, self.x, self.y)
Widget.render(self)
end
return Label
6.2 Button Widget
local Widget = require "ui.core.widget"
local Button = setmetatable({}, Widget)
Button.__index = Button
function Button.new(text, onclick)
local self = Widget.new()
self.text = text
self.onclick = onclick
return setmetatable(self, Button)
end
function Button:update(dt)
if is_mouse_pressed() and mouse_in_rect(self.x, self.y, self.w, self.h) then
if self.onclick then self.onclick() end
end
end
function Button:render()
draw_rect(self.x, self.y, self.w, self.h)
draw_text(self.text, self.x+8, self.y+4)
end
return Button
This single widget supports:
- menus
- pause screens
- UI popups
7. Reactive UI Model (Data-Binding)
Reactive updates keep UI in sync with game state.
7.1 Observable Model
local Observable = {}
Observable.__index = Observable
function Observable.new(value)
return setmetatable({value=value, subs={}}, Observable)
end
function Observable:subscribe(fn)
table.insert(self.subs, fn)
end
function Observable:set(v)
self.value = v
for _, fn in ipairs(self.subs) do fn(v) end
end
return Observable
7.2 Bind a progress bar to HP
local hp = Observable.new(100)
hp:subscribe(function(v)
health_bar:set_value(v)
end)
hp:set(75) -- UI automatically updates
This is how modern UI systems (React style) behave.
8. Tween Animations (Pop-in, Fade, Move)
UI animations improve polish.
8.1 Tween Component
local Tween = {}
Tween.__index = Tween
function Tween.new(obj, prop, to, duration)
return setmetatable({
obj=obj, prop=prop,
start=obj[prop], target=to,
t=0, duration=duration,
}, Tween)
end
function Tween:update(dt)
self.t = self.t + dt
local p = math.min(self.t / self.duration, 1)
self.obj[self.prop] = self.start + (self.target - self.start) * p
end
return Tween
8.2 Use for pop-up menu
local popup = Widget.new()
popup.y = -200
local tween = Tween.new(popup, "y", 40, 0.4)
Animate it inside update(dt).
9. UI Screens (HUD, Menus, Inventory)
Below is a common pattern for UI screens.
9.1 HUD Screen
local Label = require "ui.widgets.label"
local hud = {}
function hud:enter()
self.hp_label = Label.new("HP: 100")
self.hp_label.x = 10
self.hp_label.y = 10
event:on("hp_changed", function(v)
self.hp_label.text = "HP: " .. v
end)
end
function hud:update(dt)
end
function hud:render()
self.hp_label:render()
end
return hud
HUD is persistent; UIManager keeps it always active.
9.2 Pause Menu Screen
local Button = require "ui.widgets.button"
local menu = {}
function menu:enter()
self.resume = Button.new("Resume", function()
ui:pop()
end)
self.resume.x = 200
self.resume.y = 200
self.quit = Button.new("Quit", function()
exit_game()
end)
self.quit.x = 200
self.quit.y = 260
end
function menu:update(dt)
self.resume:update(dt)
self.quit:update(dt)
end
function menu:render()
draw_rect(0,0,800,600,0,0,0,0.5)
self.resume:render()
self.quit:render()
end
return menu
10. UI Transition System
Clean transitions matter.
10.1 Fade In / Fade Out
local fade = {alpha = 1}
local fade_in = Tween.new(fade, "alpha", 0, 0.5)
local fade_out = Tween.new(fade, "alpha", 1, 0.5)
function draw_fade()
draw_rect(0,0,800,600,0,0,0,fade.alpha)
end
Used for:
- scene transitions
- UI overlays
- modal windows
11. Touch & Mobile UI Patterns
For mobile games:
- large hit areas
- draggable widgets
- swipe actions
- virtual joystick
- tap/hold detection
Example: virtual joystick:
local joystick = {x=100, y=400, value={x=0,y=0}}
function joystick:update()
if touch_down() and touch_in_circle(self.x, self.y, 50) then
local tx,ty = touch_pos()
self.value.x = (tx - self.x) / 50
self.value.y = (ty - self.y) / 50
else
self.value.x = 0
self.value.y = 0
end
end
12. Debug UI & Dev Panels
Essential for productivity:
- debug overlays
- FPS
- entity inspector
- hitbox display
- logs
Example debug panel:
local debug_panel = {}
function debug_panel:render()
draw_text("FPS: "..tostring(fps()), 10, 10)
draw_text("Entities: "..tostring(#entities), 10, 30)
end
Debug panels dramatically speed up game iteration.
13. Putting It All Together: A Complete UI Scene
function ui_loop(dt)
UIManager:update(dt)
UIManager:render()
end
UI is updated independently from the game scene, allowing:
- menus over gameplay
- HUD always active
- modals on top
- debug overlays
- animated popups
14. Summary of Chapter 7
In this chapter you learned how to build a complete UI system:
- UI manager (stack + global HUD)
- Widget architecture (hierarchical UI)
- Event bus for UI logic
- Reactive data binding
- Tween animations
- Buttons, labels, progress bars
- HUD architecture
- Menu screens & popups
- Input and touch patterns
- Debug overlays
- Transition systems
Lua is exceptionally strong for UI scripting thanks to its speed, flexibility, and hot-reload workflow.