Lua for Game Development โ€” Chapter 15: UI Systems, HUD, Menus & Data Binding

A complete, data-driven UI system with widgets, HUD overlays, menus, windows, reactive data binding, and animated transitions using Lua.

UI is the backbone of:

  • inventories
  • crafting
  • equipment menus
  • quests & story
  • shops
  • minimaps
  • HUD overlays
  • dialogue
  • pause menu
  • game settings

Lua is excellent for UI logic because:

  • UI widgets map naturally to tables
  • easy event dispatch
  • hot-reload speeds iteration
  • data binding = reactive UI
  • timeline-based animations work perfectly

This chapter builds a modern, scalable UI framework in Lua.

1. UI Widget System

UI widgets are components:

  • panels
  • labels
  • buttons
  • images/icons
  • windows
  • scroll lists
  • tooltips
  • HUD overlays

We define a generic widget base class.

1.1 Base Widget

local Widget = {}
Widget.__index = Widget

function Widget.new(props)
  return setmetatable({
    x = props.x or 0,
    y = props.y or 0,
    w = props.w or 100,
    h = props.h or 50,
    visible = props.visible ~= false,
    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:draw()
  if not self.visible then return end
  for _, c in ipairs(self.children) do
    if c.draw then c:draw() end
  end
end

return Widget

Widgets can be nested to create complex UI.

2. UI Specific Widgets

We build common widgets:

  • Label
  • Button
  • Image
  • Panel
  • Window

2.1 Label

local Label = {}
Label.__index = Label

function Label.new(text, props)
  local self = setmetatable(Widget.new(props), Label)
  self.text = text
  return self
end

function Label:draw()
  if not self.visible then return end
  draw_text(self.text, self.x, self.y)
end

return Label

2.2 Button

local Button = {}
Button.__index = Button

function Button.new(text, props)
  local self = setmetatable(Widget.new(props), Button)
  self.text = text
  self.on_click = props.on_click
  return self
end

function Button:draw()
  draw_rect(self.x,self.y,self.w,self.h,0.2,0.2,0.2,1)
  draw_text(self.text, self.x+10, self.y+10)
end

function Button:handle_input(mx,my)
  if mx>self.x and mx<self.x+self.w and my>self.y and my<self.y+self.h then
    if self.on_click then self.on_click() end
  end
end

return Button

3. UI Manager

Manages:

  • input targeting
  • window stacking
  • dragging
  • focus
  • modal dialogs

3.1 UI Manager Base

local UIManager = {windows={}, focused=nil}

function UIManager:add(win)
  table.insert(self.windows, win)
end

function UIManager:update(dt)
  for _, w in ipairs(self.windows) do w:update(dt) end
end

function UIManager:draw()
  for _, w in ipairs(self.windows) do w:draw() end
end

3.2 Mouse Input Handling

function UIManager:mouse_pressed(mx,my)
  for i=#self.windows,1,-1 do
    local w = self.windows[i]
    if w:hit(mx,my) then
      self.focused = w
      if w.mouse_pressed then
        w:mouse_pressed(mx,my)
      end
      return true
    end
  end
  return false
end

Windows stack top-to-bottom.

4. UI Windows (Inventory / Settings / Maps)

Window widget:

local Window = {}
Window.__index = Window

function Window.new(props)
  local self = setmetatable(Widget.new(props), Window)
  self.title = props.title or "Window"
  return self
end

function Window:draw()
  if not self.visible then return end
  draw_rect(self.x,self.y,self.w,self.h, 0.1,0.1,0.1,0.9)
  draw_text(self.title, self.x+10, self.y+10)
  Widget.draw(self)
end

function Window:hit(mx,my)
  return mx>self.x and mx<self.x+self.w and my>self.y and my<self.y+self.h
end

return Window

5. Data Binding (Reactive UI)

Reactive UI updates automatically based on game state.

Lua makes it easy:

5.1 Bindable Value

local Bindable = {}
Bindable.__index = Bindable

function Bindable.new(value)
  return setmetatable({value=value, listeners={}}, Bindable)
end

function Bindable:set(v)
  self.value = v
  for _, f in ipairs(self.listeners) do f(v) end
end

function Bindable:on_change(func)
  table.insert(self.listeners, func)
end

return Bindable

5.2 Example: Health Bar Bound to Player HP

player.hp_bind:on_change(function(value)
  ui_healthbar.value = value / player.max_hp
end)

Now HP updates โ†’ bar automatically animates.

6. HUD Elements

HUD includes:

  • health bar
  • stamina bar
  • XP bar
  • boss HP bar
  • minimap
  • objective UI
  • weapon cooldowns
  • buff/debuff icons

6.1 Health Bar Widget

local HealthBar = {}
HealthBar.__index = HealthBar

function HealthBar.new()
  return setmetatable({value=1}, HealthBar)
end

function HealthBar:draw()
  draw_rect(20,20,200,20, 0.1,0.0,0.0,1)
  draw_rect(20,20,200*self.value,20, 1,0,0,1)
end

return HealthBar

Update from HP binding.

7. Inventory UI (Integrated from Chapter 11)

Example grid inventory:

function InventoryUI:draw()
  for i=1,self.inv.size do
    local slot = self.inv.slots[i]
    local x = self.x + ((i-1)%5)*70
    local y = self.y + math.floor((i-1)/5)*70

    draw_rect(x,y,64,64, 0.2,0.2,0.2,1)

    if slot then
      draw_item_icon(slot.id, x,y)
      draw_text(slot.count, x+48, y+48)
    end
  end
end

8. Dialogue UI (From Chapter 12)

Dialogue interface:

function DialogueUI:draw()
  draw_rect(50,400,700,120,0,0,0,0.7)
  draw_text(self.text, 60,410)
  for i, r in ipairs(self.responses) do
    draw_text(i .. ". " .. r.text, 60,440 + i*20)
  end
end

9. Animated UI Transitions (Tween)

Timeline-based UI animations:

9.1 Tween Utility

function tween(value, target, duration, update)
  local t = 0
  timeline:add(function()
    while t < duration do
      local dt = coroutine.yield()
      t = t + dt
      local p = t/duration
      update(value + (target-value)*p)
    end
  end)
end

9.2 Example: Fade In Window

window.opacity = 0
tween(0,1,0.4,function(v) window.opacity = v end)

9.3 Example: Slide Menu

tween(menu.x, -300, 0.3, function(v)
  menu.x = v
end)

10. UI Input Mapping

Input routed through UI first, then gameplay.

10.1 Input Priority

if UIManager:mouse_pressed(mx,my) then
  return -- UI consumed input
end

player:handle_input()

11. Pause Menu

Pause menu is just another window with logic:

pause_menu = Window.new({x=200,y=100,w=400,h=300,title="Paused"})
pause_menu:add(Button.new("Resume", {x=220,y=160,w=360,h=40, on_click=resume_game}))
pause_menu:add(Button.new("Quit",   {x=220,y=220,w=360,h=40, on_click=quit_game}))

12. Settings Menu (Dynamic Data Binding)

settings.volume_bind:on_change(function(v)
  Audio:set_volume(v)
end)

Slider widget emits changes and updates audio immediately.

13. Putting It All Together โ€” Actual UI Loop

function game:update(dt)
  UIManager:update(dt)      -- update UI
  HUD:update(dt)            -- update HUD
  player:update(dt)         -- gameplay
  world:update(dt)
end

function game:draw()
  world:draw()
  player:draw()
  HUD:draw()
  UIManager:draw()          -- always on top
end

14. Summary of Chapter 15

You now understand:

  • Widget system architecture
  • Windows, panels, labels, buttons
  • UI manager (input, focus, stacks)
  • HUD elements
  • Data binding (reactive UI)
  • Animated UI transitions
  • Inventory & dialogue UI
  • Pause menus, settings, and modal dialogs
  • Input coordination between UI and gameplay

Luaโ€™s table-based structure and coroutine system greatly simplify UI development.

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