Lua for Game Development — Chapter 4: Building a Full Lua Game Architecture

A complete, production-ready Lua game architecture: main loop, scene system, resource manager, input abstraction, save/load, hot reload, and debugging utilities.

This chapter teaches you how to design a complete Lua game architecture—the same structure used by real production engines.

We will build:

  1. A robust game loop
  2. Scene management (stack-based)
  3. Systems & update flow
  4. Input abstraction
  5. Resource manager
  6. Save/load system
  7. Hot reload support
  8. Debugging overlays & dev tools

A real game must be modular, testable, and reloadable.

Lua excels here.

1. The Game Loop (Core of Any Engine)

All engines—Unity, Unreal, Godot, Defold, Cocos2dx-lua, Corona, LÖVE—follow the same idea:

while running:
    dt = time since last frame
    process_input()
    update(dt)
    render()

We mimic this in Lua.

1.1 Minimal game loop in pure Lua

local last = os.clock()

while true do
  local now = os.clock()
  local dt = now - last
  last = now

  game:update(dt)
  game:render()

  -- break loop in console demo:
  if dt > 10 then break end
end

Real engines replace:

  • render() with draw commands
  • os.clock() with engine timer
  • Loop with host platform (love.run, Defold internal loop)

2. Scene Management (Stack-Based)

Every game has multiple states:

  • loading
  • main menu
  • gameplay
  • pause
  • battle scene
  • cutscene
  • inventory screen

A good architecture treats each as a scene.

2.1 Scene Manager

local SceneManager = {}
SceneManager.__index = SceneManager

function SceneManager.new()
  return setmetatable({stack = {}}, SceneManager)
end

function SceneManager:push(scene)
  table.insert(self.stack, scene)
  if scene.enter then scene:enter() end
end

function SceneManager:pop()
  local top = table.remove(self.stack)
  if top and top.exit then top:exit() end
end

function SceneManager:switch(scene)
  self:pop()
  self:push(scene)
end

function SceneManager:update(dt)
  local top = self.stack[#self.stack]
  if top and top.update then top:update(dt) end
end

function SceneManager:render()
  local top = self.stack[#self.stack]
  if top and top.render then top:render() end
end

return SceneManager

2.2 A simple scene

local Scene = {}
Scene.__index = Scene

function Scene.new(name)
  return setmetatable({name=name, t=0}, Scene)
end

function Scene:enter()
  print("Entered:", self.name)
end

function Scene:exit()
  print("Exited:", self.name)
end

function Scene:update(dt)
  self.t = self.t + dt
  print(self.name .. " time:", self.t)
end

function Scene:render()
  -- draw stuff
end

return Scene

2.3 Usage

local manager = SceneManager.new()
local menu = Scene.new("menu")
local game = Scene.new("gameplay")

manager:push(menu)
manager:update(0.1)

manager:switch(game)
manager:update(0.2)

Stack-based scenes let you implement:

  • Pause menus (push pause scene)
  • Pop-up windows
  • Modal screens
  • Cutscenes layered over gameplay

3. Systems Architecture

A robust game divides logic into systems rather than giant “god scripts”.

Examples:

  • MovementSystem
  • RenderSystem
  • AISystem
  • CombatSystem
  • TweenSystem
  • PhysicsSystem
  • UIInteractions

3.1 Simple system manager

local Systems = {}
Systems.__index = Systems

function Systems.new()
  return setmetatable({list = {}}, Systems)
end

function Systems:add(system)
  table.insert(self.list, system)
end

function Systems:update(dt)
  for _, sys in ipairs(self.list) do
    if sys.update then sys.update(sys, dt) end
  end
end

return Systems

3.2 Example system

local MoveSystem = {}
MoveSystem.__index = MoveSystem

function MoveSystem.update(self, dt)
  for _, e in ipairs(self.entities) do
    local move = e.components.move
    if move then
      move.x = move.x + move.speed * dt
    end
  end
end

This connects perfectly to the Entity/Component model from Chapter 3.

4. Input Abstraction Layer

Different engines have different input systems.
Good Lua architecture hides this behind an abstraction.

4.1 Input module

local Input = {
  keys_down = {},
  keys_pressed = {}
}

function Input:update()
  self.keys_pressed = {}
end

function Input:key_down(k)
  return self.keys_down[k] == true
end

function Input:key_pressed(k)
  return self.keys_pressed[k] == true
end

return Input

In LÖVE you’d map:

function love.keypressed(k)
  Input.keys_down[k] = true
  Input.keys_pressed[k] = true
end

In Defold, you’d handle on_input(self, action_id, action).

This abstraction lets the rest of the game be engine-agnostic Lua.

5. Resource Manager (Caching Assets)

Games use thousands of resources:

  • textures
  • atlases
  • animations
  • audio
  • JSON configs
  • Lua modules
  • shaders

5.1 Simple resource loader

local Resources = {}
Resources.__index = Resources

function Resources.new()
  return setmetatable({cache = {}}, Resources)
end

function Resources:load(path, loader)
  if not self.cache[path] then
    self.cache[path] = loader(path)
  end
  return self.cache[path]
end

return Resources

5.2 Example loader usage

local images = Resources.new()

local img = images:load("enemy.png", function(path)
  return load_image_from_engine(path)
end)

Lua easily supports custom loaders for:

  • map files
  • enemy stats
  • item definitions

6. Save / Load System

Lua tables serialize well.

6.1 Convert table to string (simple serializer)

local function serialize(o)
  if type(o) == "number" then
    return tostring(o)
  elseif type(o) == "string" then
    return string.format("%q", o)
  elseif type(o) == "table" then
    local s = "{"
    for k, v in pairs(o) do
      s = s .. "[" .. serialize(k) .. "]=" .. serialize(v) .. ","
    end
    return s .. "}"
  else
    return "nil"
  end
end

6.2 Save game

local save_data = {hp=80, gold=150}
local file = io.open("save.lua", "w")
file:write("return " .. serialize(save_data))
file:close()

6.3 Load game

local loaded = dofile("save.lua")
print(loaded.gold)

dofile is the simplest persistence trick.

7. Hot Reloading (Essential for Lua)

Lua is famous for instant iteration.

7.1 Reload module

local function reload(name)
  package.loaded[name] = nil
  return require(name)
end

7.2 Use case

player = reload("player")

Excellent for:

  • Updating AI while game running
  • Adjusting stats
  • Tuning game balance
  • Updating scene scripts

AAA engines rely heavily on this.

8. Debugging Tools and Overlays

Every real engine ships with:

  • On-screen debug info
  • FPS monitor
  • Entity inspector
  • Collision debug draw
  • Log console

Lua makes this trivial.

8.1 Simple debug overlay

local Debug = {enabled = true}

function Debug:draw()
  if not self.enabled then return end
  draw_text("FPS: " .. tostring(fps()), 10, 10)
  draw_text("Entities: " .. tostring(#entities), 10, 30)
end

return Debug

8.2 Toggle debug with a key

if Input:key_pressed("f3") then
  Debug.enabled = not Debug.enabled
end

9. Putting It All Together: A Mini Engine in Lua

local Game = {}
Game.__index = Game

function Game.new()
  return setmetatable({
    scenes = SceneManager.new(),
    systems = Systems.new(),
    input = Input,
    resources = Resources.new(),
  }, Game)
end

function Game:update(dt)
  self.input:update()
  self.scenes:update(dt)
  self.systems:update(dt)
end

function Game:render()
  self.scenes:render()
  Debug:draw()
end

return Game

This skeleton is extendable into a complete game engine.

10. Summary of Chapter 4

In this chapter you learned how to design a fully operational Lua game architecture:

  • A clean game loop
  • Scene stack with push/pop/switch
  • Systems architecture for large games
  • Input abstraction
  • Resource manager with caching
  • Save/load via Lua-based serialization
  • Hot reload for ultra-fast iteration
  • Debug overlays and dev tools

You now have the backbone of a production-grade game engine in Lua.

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