Event Systems and Message Passing in Lua
Lua has no built-in event system. Games, GUIs, and network servers need one — when the player clicks, when a connection closes, when data arrives. This guide covers the patterns you need, from the simplest callback list to a full pub-sub system.
The Callback List Pattern
The simplest approach: store functions in a list and call them in order.
local callbacks = {}
local function on_click(handler)
table.insert(callbacks, handler)
end
local function trigger_click(x, y)
for _, handler in ipairs(callbacks) do
handler(x, y)
end
end
on_click(function(x, y)
print("clicked at", x, y)
end)
trigger_click(100, 200)
-- clicked at 100 200
This works for straightforward cases. The downside: no way to remove a specific callback, and all handlers always fire.
A Simple Event Emitter
An event emitter stores handlers per event name. Removing a handler requires returning a function:
local EventEmitter = {}
EventEmitter.__index = EventEmitter
function EventEmitter.new()
return setmetatable({ _handlers = {} }, EventEmitter)
end
function EventEmitter.on(self, event, handler)
if not self._handlers[event] then
self._handlers[event] = {}
end
table.insert(self._handlers[event], handler)
-- return an unsubscribe function
return function()
local list = self._handlers[event]
for i = #list, 1, -1 do
if list[i] == handler then
table.remove(list, i)
break
end
end
end
end
function EventEmitter.emit(self, event, ...)
local list = self._handlers[event] or {}
for _, handler in ipairs(list) do
handler(...)
end
end
Usage:
local emitter = EventEmitter.new()
local unsubscribe = emitter:on("player_damaged", function(player_id, damage)
print("player", player_id, "took", damage, "damage")
end)
emitter:emit("player_damaged", 42, 25)
-- player 42 took 25 damage
unsubscribe() -- stop listening
emitter:emit("player_damaged", 42, 10) -- nothing happens
Publish-Subscribe
Pub-sub decouples senders from receivers. Instead of calling a handler directly, you broadcast to all subscribers of a topic:
local pubsub = {
_subscribers = {}
}
function pubsub.subscribe(topic, handler)
if not pubsub._subscribers[topic] then
pubsub._subscribers[topic] = {}
end
table.insert(pubsub._subscribers[topic], handler)
return function()
local list = pubsub._subscribers[topic]
for i = #list, 1, -1 do
if list[i] == handler then
table.remove(list, i)
break
end
end
end
end
function pubsub.publish(topic, ...)
local list = pubsub._subscribers[topic] or {}
for _, handler in ipairs(list) do
handler(...)
end
end
Game usage:
local unsubs = pubsub.subscribe("entity_destroyed", function(entity)
print("entity destroyed:", entity.id)
end)
-- somewhere in your game loop
pubsub.publish("entity_destroyed", { id = 7, type = "enemy" })
-- entity destroyed: 7
The unsubscribe pattern is the same: unsubscribe() stops the handler.
Message Queues
For async or deferred processing, use a queue. Messages pile up and get processed on demand:
local Queue = {}
Queue.__index = Queue
function Queue.new()
return setmetatable({ _messages = {} }, Queue)
end
function Queue.push(self, topic, ...)
table.insert(self._messages, { topic = topic, args = {...} })
end
function Queue.process(self)
while #self._messages > 0 do
local msg = table.remove(self._messages, 1)
local list = self._subscribers[msg.topic] or {}
for _, handler in ipairs(list) do
handler(unpack(msg.args))
end
end
end
function Queue.subscribe(self, topic, handler)
if not self._subscribers[topic] then
self._subscribers[topic] = {}
end
table.insert(self._subscribers[topic], handler)
end
Use this in game loops where events fire during entity updates but handlers shouldn’t mutate the world mid-update:
local events = Queue.new()
-- called during update
events:push("collision", entity_a, entity_b)
-- called after all updates
events:process()
One-Time Events with Promises
If you only need to fire a handler once, track whether it has already fired:
local once = setmetatable({}, {
__call = function(self, event, handler)
self[event] = handler
end
})
function emit_once(event, ...)
local handler = once[event]
if handler then
once[event] = nil
handler(...)
end
end
once("initialized", function()
print("init done")
end)
emit_once("initialized") -- prints "init done"
emit_once("initialized") -- nothing
For one-time events that resolve to a value (like an async operation), return a promise-like table:
local Promise = {}
Promise.__index = Promise
function Promise.new()
return setmetatable({
_state = "pending",
_value = nil,
_handlers = {}
}, Promise)
end
function Promise.resolve(value)
local p = Promise.new()
p._state = "fulfilled"
p._value = value
for _, h in ipairs(p._handlers) do h(value) end
p._handlers = {}
return p
end
function Promise.then(self, handler)
if self._state == "fulfilled" then
handler(self._value)
else
table.insert(self._handlers, handler)
end
end
Using Events with Coroutines
Coroutines let handlers yield and resume, which is useful when an event handler needs to wait:
local scheduler = {
_queue = {}
}
function scheduler.schedule(coroutine_func, ...)
local co = coroutine.create(coroutine_func)
table.insert(scheduler._queue, { co = co, args = {...} })
end
function scheduler.run()
while #scheduler._queue > 0 do
local task = table.remove(scheduler._queue, 1)
local ok, err = coroutine.resume(task.co, unpack(task.args))
if not ok then
print("error:", err)
elseif coroutine.status(task.co) == "suspended" then
-- re-queue for next tick
table.insert(scheduler._queue, task)
end
end
end
In an event handler, yield to wait:
scheduler.schedule(function(player_id)
print("waiting for player", player_id)
coroutine.yield() -- pause until next frame
print("resumed after yield")
end)
scheduler.run() -- first tick
scheduler.run() -- second tick
OpenResty: Request-Level Events
In OpenResty, each request runs in its own Lua context. You can pass data between phases using ngx.ctx:
local function before_handler()
ngx.ctx.events = {}
end
local function emit(event_name, data)
ngx.ctx.events[event_name] = ngx.ctx.events[event_name] or {}
table.insert(ngx.ctx.events[event_name], data)
end
local function on_event(event_name, handler)
local list = ngx.ctx.events[event_name] or {}
for _, data in ipairs(list) do
handler(data)
end
end
Call before_handler() in the access_by_lua_file phase to initialize the context, then emit events during content generation, and handle them in log_by_lua_file.
Gotchas
Memory leaks from forgotten listeners. Every on() call without a matching unsubscribe leaks memory in long-running applications. Always use the returned unsubscribe function.
Handlers firing after teardown. If you clean up event listeners after destroying an object, make sure no events can fire between cleanup and destruction.
Stack overflow from recursive emits. If handler A emits the same event that triggers handler B, which emits again, you get infinite recursion. Check or guard against re-entrant emits if your handlers call back into the event system.
Order of handlers matters. All handlers fire in registration order. If handler B depends on handler A running first, don’t let external code register B before A.
See Also
- /guides/lua-closures/ — closures are the foundation of callback handlers in event systems
- /guides/lua-state-machines/ — combine state machines with event handlers for structured game logic
- /guides/lua-metatables/ — use metatables to build event-emitting objects with
__indexproxies