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.
- An
events.luamodule that returns a table with anewconstructor. - 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 ofonwith a self-removing wrapper. - Error isolation with
pcall, plus a count of listeners that ran. - A
main.luagame 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,errorandpcall— lesson 11.
Project Structure
event-emitter/├── events.lua -- the reusable module└── main.lua -- a small game that uses itevents.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 endendStep 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 calledendStep 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 unsubscribeend
function Emitter:count(event) local list = self.listeners[event] return list and #list or 0endStep 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 — a small, reusable event emitter modulelocal 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 endend
function Emitter:once(event, fn) local unsubscribe unsubscribe = self:on(event, function(...) unsubscribe() return fn(...) end) return unsubscribeend
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 calledend
function Emitter:count(event) local list = self.listeners[event] return list and #list or 0end
return { new = Emitter.new }-- 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 healingbus: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) endend)
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 listenersbus: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 enemiesbus: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) endend)
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 otherslocal 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 closurelocal 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
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
emitcollect 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.