LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
LuaIntermediate~1.5 hours

Event Emitter Module

Write a reusable event system as a Lua module with closures, varargs, one-time listeners and error-isolated handlers.

ModulesClosuresVarargspcall

Overview

Event systems are the glue of most games and applications: when the player takes damage, the health bar, the sound system and the achievement tracker all need to react — without the player code knowing any of them exist. This project builds that pattern as a reusable Lua module: other code subscribes to named events with on, and the emitter calls every subscriber when the event fires.

Along the way you will use the most "Lua-flavoured" techniques in the course: a module that returns a table, closures that remember their surroundings (each on call returns its own unsubscribe function), varargs (...) to pass any number of event arguments, and pcall so that one broken listener can never stop the others from running.

What You'll Build
  • An events.lua module that returns a table with a new constructor.
  • An on(event, fn) method that returns a closure to unsubscribe.
  • An emit(event, ...) method that forwards any number of arguments to every listener.
  • A once(event, fn) method built on top of on with a self-removing wrapper.
  • Error isolation with pcall, plus a count of listeners that ran.
  • A main.lua game demo with a health bar, an achievement tracker and a level-up system.

Prerequisites

  • Functions as values and varargs (...) — lesson 7.
  • Closures and upvalues — lesson 8.
  • Metatables for simple classes — lesson 9.
  • Modules with require, error and pcall — lesson 11.

Project Structure

event-emitter/
├── events.lua -- the reusable module
└── main.lua -- a small game that uses it

events.lua knows nothing about games — it could equally power a chat app or a build tool. main.lua loads it with require("events"); Lua finds the file because package.path includes ./?.lua, so the two files only need to sit in the same folder and you run lua main.lua from there.

Step 1: The Module Skeleton

A Lua module is simply a file that returns a value — almost always a table. Everything else in the file is local, so nothing leaks into the global namespace. listeners maps each event name to an array of handler functions.

local Emitter = {}
Emitter.__index = Emitter
function Emitter.new()
return setmetatable({ listeners = {} }, Emitter)
end
-- ... methods go here ...
return { new = Emitter.new }

Step 2: Subscribing with on & Closures

The returned unsubscribe function is a closure: it "remembers" list and fn from the moment on was called, even after on has returned. Searching for fn by identity (rather than storing an index) keeps working even if other listeners were removed in the meantime.

function Emitter:on(event, fn)
assert(type(fn) == "function", "listener must be a function")
local list = self.listeners[event]
if list == nil then
list = {}
self.listeners[event] = list
end
list[#list + 1] = fn
return function() -- unsubscribe: a closure over list and fn
for i = #list, 1, -1 do
if list[i] == fn then
table.remove(list, i)
return true
end
end
return false
end
end

Step 3: Emitting Events with Varargs

... collects whatever arguments the caller passes and forwards them unchanged to each listener. The loop iterates over a copy of the list, so listeners can safely unsubscribe themselves (or others) while the event is being emitted. pcall catches any error a listener raises and reports it, and the remaining listeners still run.

function Emitter:emit(event, ...)
local list = self.listeners[event]
if list == nil then return 0 end
local snapshot = table.move(list, 1, #list, 1, {}) -- copy: listeners may unsubscribe mid-emit
local called = 0
for _, fn in ipairs(snapshot) do
local ok, err = pcall(fn, ...)
if ok then
called = called + 1
else
print(("[events] listener for '%s' failed: %s"):format(event, err))
end
end
return called
end

Step 4: One-Time Listeners with once

once is built entirely from on: it subscribes a small wrapper function that first unsubscribes itself and then calls the real listener. The wrapper refers to unsubscribe before it is assigned, which works because Lua closures capture the variable, not its value at creation time.

function Emitter:once(event, fn)
local unsubscribe
unsubscribe = self:on(event, function(...)
unsubscribe()
return fn(...)
end)
return unsubscribe
end
function Emitter:count(event)
local list = self.listeners[event]
return list and #list or 0
end

Step 5: Use the Module in a Game

The game code only ever calls bus:emit(...). The health bar, achievements and level-up logic each subscribe independently — you could delete any one of them without touching the others. One listener contains a deliberate bug to show that pcall keeps the rest of the system running.

local events = require("events")
local bus = events.new()
bus:on("damage", function(player, amount, source)
player.hp = math.max(0, player.hp - amount)
print(("%s takes %d damage from %s -> HP %d"):format(player.name, amount, source, player.hp))
end)

Complete Code

events.lua
-- events.lua — a small, reusable event emitter module
local Emitter = {}
Emitter.__index = Emitter
function Emitter.new()
return setmetatable({ listeners = {} }, Emitter)
end
function Emitter:on(event, fn)
assert(type(fn) == "function", "listener must be a function")
local list = self.listeners[event]
if list == nil then
list = {}
self.listeners[event] = list
end
list[#list + 1] = fn
return function() -- unsubscribe: a closure over list and fn
for i = #list, 1, -1 do
if list[i] == fn then
table.remove(list, i)
return true
end
end
return false
end
end
function Emitter:once(event, fn)
local unsubscribe
unsubscribe = self:on(event, function(...)
unsubscribe()
return fn(...)
end)
return unsubscribe
end
function Emitter:emit(event, ...)
local list = self.listeners[event]
if list == nil then return 0 end
local snapshot = table.move(list, 1, #list, 1, {}) -- copy: listeners may unsubscribe mid-emit
local called = 0
for _, fn in ipairs(snapshot) do
local ok, err = pcall(fn, ...)
if ok then
called = called + 1
else
print(("[events] listener for '%s' failed: %s"):format(event, err))
end
end
return called
end
function Emitter:count(event)
local list = self.listeners[event]
return list and #list or 0
end
return { new = Emitter.new }
main.lua
-- main.lua — run with: lua main.lua (from the folder containing events.lua)
local events = require("events")
local bus = events.new()
local player = { name = "Hero", hp = 100, xp = 0, level = 1 }
-- Health bar: reacts to damage and healing
bus:on("damage", function(p, amount, source)
p.hp = math.max(0, p.hp - amount)
print(("%s takes %d damage from %s -> HP %d"):format(p.name, amount, source, p.hp))
if p.hp == 0 then
bus:emit("defeated", p)
end
end)
bus:on("heal", function(p, amount)
p.hp = math.min(100, p.hp + amount)
print(("%s heals %d -> HP %d"):format(p.name, amount, p.hp))
end)
-- Achievements: one-time listeners
bus:once("damage", function()
print(" [achievement] First Blood: took damage for the first time")
end)
bus:once("levelup", function(p)
print((" [achievement] Growing Up: reached level %d"):format(p.level))
end)
-- Levelling: XP from defeated enemies
bus:on("enemy_defeated", function(p, enemy, xp)
p.xp = p.xp + xp
print(("%s defeats %s and gains %d XP (total %d)"):format(p.name, enemy, xp, p.xp))
while p.xp >= p.level * 50 do
p.xp = p.xp - p.level * 50
p.level = p.level + 1
bus:emit("levelup", p)
end
end)
bus:on("levelup", function(p)
print(("%s is now level %d!"):format(p.name, p.level))
end)
-- A buggy listener: pcall keeps it from breaking the others
local stop_buggy = bus:on("enemy_defeated", function(_, enemy)
error("loot table missing for " .. enemy)
end)
bus:on("defeated", function(p)
print(p.name .. " has fallen. Game over.")
end)
-- ---- The "game" ----
print(("Listeners for 'damage': %d"):format(bus:count("damage")))
bus:emit("damage", player, 15, "a goblin")
bus:emit("damage", player, 10, "a trap")
print(("Listeners for 'damage' after the once-listener fired: %d"):format(bus:count("damage")))
bus:emit("enemy_defeated", player, "goblin", 40)
stop_buggy() -- unsubscribe the broken listener using its closure
local called = bus:emit("enemy_defeated", player, "orc chief", 90)
print(("('enemy_defeated' ran %d listener(s) cleanly)"):format(called))
bus:emit("heal", player, 30)
bus:emit("damage", player, 120, "a dragon")
print(("Emitting an unknown event calls %d listeners"):format(bus:emit("nothing_listens")))

Sample Run

lua main.lua

Click Run to see what this code prints.

Notice that the first "goblin" defeat printed an error from the buggy listener but still awarded XP, and that after stop_buggy() the same event ran cleanly. The line number in the error message comes from main.lua, exactly where the faulty listener lives.

Extend This Project

  • Add an off(event) method that removes every listener for an event at once.
  • Support a wildcard "*" event whose listeners receive every event name plus its arguments.
  • Add listener priorities so that, for example, damage is applied before the UI updates.
  • Let emit collect return values from listeners (table.pack(pcall(fn, ...))) so an event can be "cancelled".
  • Use the module in the Text Adventure project: emit "enter_room" and "take_item" events.

Summary

You built a genuinely reusable module: a file that returns a table, with all its internals kept local. Closures gave each subscription its own unsubscribe function, varargs forwarded arbitrary event data, once was composed from on without new machinery, and pcall made the system robust against faulty listeners. This exact design underlies event systems in game engines, editors and UI frameworks.