Lua for Game Development โ€” Chapter 7: UI Systems, HUD, Widgets, Event Binding, and Lua-Driven UI Architecture

A complete guide to designing professional UI systems in Lua: widgets, layouts, HUD, event binding, reactive updates, animations, data models, and UI architecture used in real games.

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.

Keep Reading

Follow the engineering thread

Get the next practical Birdor note, or browse the archive for related systems, tooling, and architecture work.

Join newsletter Browse articles