Lua for Game Development — Chapter 4: Building a Full Lua Game Architecture
Leeting Yan
This chapter teaches you how to design a complete Lua game architecture—the same structure used by real production engines.
We will build:
- A robust game loop
- Scene management (stack-based)
- Systems & update flow
- Input abstraction
- Resource manager
- Save/load system
- Hot reload support
- 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 commandsos.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.