luaguides

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