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. Building event systems from scratch teaches you the patterns that libraries like lua-ev and copas wrap under the hood. 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. When your Lua program grows beyond a single script, say a game with dozens of UI widgets or a network daemon with per-connection state, you need named events so handlers register for only the signal they care about. Without per-event namespacing, every trigger_click call iterates every registered function, including ones meant for keypresses or network timeouts. The callback list also offers no unsubscribe mechanism, so once a handler is registered it stays active for the lifetime of the process, which leaks both memory and unwanted side effects in long-running Lua applications.
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
The EventEmitter implementation above forms the backbone of most Lua event libraries. Each call to on() returns a closure that scans the handler list in reverse, a deliberate choice, since table.remove shifts array indices when deleting from the front, but iterating backward with for i = #list, 1, -1 keeps the loop indices stable regardless of removals. The emit method passes variadic arguments through with ..., so handlers receive exactly the data the emitter sends, whether a single integer or a table of collision parameters. The metatable pattern with __index = EventEmitter gives every instance shared method access without copying functions, which is the idiomatic Lua approach to lightweight OOP.
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
The pub-sub pattern takes the event emitter one step further by decoupling event names from object instances. Where an EventEmitter ties handlers to a specific emitter object, as in emitter:on("damage", fn), a pub-sub system uses globally accessible topic strings shared across the entire Lua runtime. This means a damage system in one module can publish "entity_destroyed" without ever importing the module that subscribes, which is essential for large Lua codebases where subsystems like audio, particle effects, and UI must all react to the same game event independently. The pub-sub table is a singleton: there is no constructor because all subscribers share the same _subscribers table reference, making it accessible from any require-d module.
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
The subscribe method stores handlers in pubsub._subscribers[topic] using table.insert, and publish broadcasts to every handler in that topic’s list via ipairs. Each subscriber receives the same variadic arguments, so publish("entity_destroyed", entity) passes the full entity table to every listener. Calling the returned closure removes the handler from the list by iterating in reverse, the same table.remove-safe backward scan used by the EventEmitter, ensuring no handler is skipped during the unsubscribe traversal. In a LÖVE2D game, you would call subscribe during entity creation and publish from the update loop’s collision detection pass.
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: calling the returned closure stops the handler from receiving future publishes. The key difference from the EventEmitter approach is a matter of coupling: with pub-sub, the publisher never holds a reference to any subscriber, and the subscriber never knows which module published the topic. This loose coupling makes pub-sub ideal for Lua projects like LÖVE2D games where a physics module, an audio module, and a particle system all respond to collision events without importing each other’s internals. No single module owns the event namespace; any require-d file can subscribe to or publish on any topic string.
Message Queues
For async or deferred processing, use a queue. Messages pile up and get processed on demand. While pub-sub fires handlers synchronously during publish, a queue decouples the timing: events accumulate during one phase of your program and are drained during another, giving you control over when side effects happen.
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
The Queue class merges pub-sub semantics with deferred dispatch. Unlike the earlier patterns where emit or publish runs handlers synchronously in the call stack, Queue.push stores messages in insertion order via table.insert, and Queue.process drains them in FIFO order with table.remove(self._messages, 1). The _subscribers table on each queue instance is populated by Queue.subscribe, which mirrors the pub-sub interface but keeps subscriptions local to that queue. Deferred execution is critical in Lua game loops that use fixed-timestep updates: if an entity is destroyed mid-tick, any handler that tries to reference that entity’s table fields will hit a nil access, so you queue destruction events and process them between update passes.
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
The message queue pattern works well for per-frame batch processing, but many Lua use-cases call for one-shot signalling: initialisation complete, asset loaded, timeout expired. For these, a queue introduces unnecessary overhead because you push a message only to process it immediately from the same call stack. The one-time event pattern discards the list-of-handlers approach entirely and instead stores a single handler per event name in a lightweight table. After emit_once fires, it sets the handler slot to nil with an explicit assignment, so a second call to emit_once with the same event name is a silent no-op. You will see this pattern in Lua module initialisers throughout the LÖVE2D and LÖVR ecosystems, where startup events like love.load complete exactly once.
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
The one-time handler above works when you fire-and-forget, but many Lua programs need the resolved value after the event fires. A promise-like table captures the fulfilled state so that calling then after resolution still receives the value; this is the _state == "fulfilled" check at the top of the then method. The implementation uses setmetatable with __index = Promise so each promise inherits the then method from the prototype table via metamethod dispatch. Unlike JavaScript promises, this Lua version does not chain: each then returns nil rather than a new promise, but it covers the common case of waiting for a single asynchronous result like a coroutine-completed signal or a network response callback from LuaSocket.
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
The promise pattern is useful for single-value resolution, but it does not help when an event handler must pause mid-execution, for example an NPC behaviour tree that emits a wait(seconds) event and expects to resume after the timer expires. Lua coroutines provide exactly this capability through coroutine.yield and coroutine.resume. The scheduler below wraps coroutines in a cooperative multitasking loop: each scheduled function runs until it yields or completes, and suspended coroutines are re-queued for the next tick by checking coroutine.status. This is the same pattern used by the LÖVE2D love.thread module and by CoppeliaSim for Lua scripting within robot simulation loops, where each behaviour script runs in its own coroutine and yields every simulation step.
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
The scheduler code above implements a simple round-robin cooperative scheduler using coroutine.create and coroutine.resume. When coroutine.resume returns with status "suspended", checked via coroutine.status(task.co), the task goes back into the queue for the next call to scheduler.run(). Completed coroutines, whose status is "dead", are simply dropped from the queue. This pattern is the foundation of Lua-based game scripting in engines like Solar2D (formerly Corona SDK), where each scene or entity runs in its own coroutine that yields every frame. Error handling via coroutine.resume with the ok, err return pattern lets you catch exceptions without crashing the entire scheduler loop. The following example shows a handler that yields once and resumes on the next tick.
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
The coroutine scheduler is powerful for long-running Lua processes like game loops, but OpenResty operates in a different model: each HTTP request runs as a short-lived Lua VM instance inside Nginx worker processes, and coroutines that yield across request boundaries are not supported. Instead, OpenResty exposes ngx.ctx, a per-request Lua table that persists across the access, content, and log phases of a single request lifecycle. This per-request table is an event bus where earlier phases emit structured data (authentication results, rate-limit decisions, parsed headers) and later phases read that data without needing shared global state. The table is automatically cleared when the request completes, so there is no risk of memory leaks from stale handler references.
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 per-request event table, then emit events during the content_by_lua_file phase where you generate the response body, and handle them in log_by_lua_file for post-request processing like metrics collection or audit logging. Because ngx.ctx is request-scoped, you never need to worry about unsubscribe; the table evaporates when the request completes.
Gotchas
Every event pattern in Lua carries subtle failure modes that surface in production. Here are the ones that bite hardest in long-running applications, game loops, and web servers.
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.
Choosing the right event pattern for your Lua project comes down to two questions: how long does your process live, and how tightly coupled are the modules that produce and consume events. Short-lived scripts can get away with a callback list; game loops benefit from message queues; OpenResty demands request-scoped tables. Beyond the patterns shown here, libraries like lua-ev (libev bindings) and copas (LuaSocket’s cooperative scheduler) provide production-grade event loops, but understanding the internals helps you debug when things go wrong. See the /guides/lua-debug-library/ guide for techniques to trace handler execution in complex event graphs.
See Also
- /guides/lua-closures/ explains how closures are the foundation of callback handlers in event systems
- /guides/lua-state-machines/ covers combining state machines with event handlers for structured game logic
- /guides/lua-metatables/ shows how to build event-emitting objects with
__indexproxies - /tutorials/lua-coroutines/coroutine-basics/ dives deeper into the cooperative multitasking model behind the scheduler pattern