The event system is one of the fundamental programming patterns that allows different parts of the program to communicate with each other without creating a hard link. Imagine that you have a game where a character can take damage. Instead of directly calling the health, interface, and sound update functions, we simply report: "An event occurred — the character took damage." And all interested systems will react to this themselves.

Why do we need events?
In the classic approach, if we want the interface to be updated, the sound to play and the animation to appear when damage is received, we need to write calls to all these systems in the damage function. This creates a problem: our code becomes coupled, it is difficult to maintain and expand. The event system solves this problem elegantly — it allows the program components to work independently of each other.
Let's say you're developing a game where the player collects coins. In a simple version, the code may look like this: when collecting a coin, we update the counter, play the sound, show the animation and check the achievements. All of this is in one function. But what if in a month you want to add a quest system that also needs to know about the collected coins? You'll have to go back into the coin collection code and add a new call there. With events, it is enough to simply subscribe the quest system to the "coin collected" event, and it will receive notifications automatically.
The simplest implementation of the Event system
Let's start with the basic implementation. The event system is basically a table where the keys are the names of events, and the values are the lists of listener functions that must be executed when an event occurs.
EventSystem = {}
EventSystem.events = {}
function EventSystem:subscribe(eventName, callback)
if not self.events[eventName] then
self.events[eventName] = {}
end
table.insert(self.events[eventName], callback)
end
function EventSystem:emit(eventName, ...)
if self.events[eventName] then
for _, callback in ipairs(self.events[eventName]) do
callback(...)
end
end
endThis simple implementation is already operational. The subscribe method registers a listener function for a specific event. If there is no listener list for this event, we create an empty table. Then we add the function to this list. The emit method calls all registered listeners for a specific event, passing them all the parameters that were passed when calling emit.
Let's see how to use it in practice:
-- Подписываемся на событие "player_damaged"
EventSystem:subscribe("player_damaged", function(damage, source)
print("Player received " .. damage .. " damage from " .. source)
end)
EventSystem:subscribe("player_damaged", function(damage)
-- Обновляем интерфейс
updateHealthBar(damage)
end)
EventSystem:subscribe("player_damaged", function()
-- Проигрываем звук
playSound("hurt.wav")
end)
-- Где-то в коде игры происходит событие
EventSystem:emit("player_damaged", 25, "Fireball")As you can see, we can subscribe several functions to one event, and all of them will be executed in turn. Each function receives event parameters and can use them in its own way. One function outputs a message to the console, another updates the interface, and the third plays a sound. At the same time, the place where the event occurs (emit) knows nothing about these functions and does not depend on them.
Unsubscribe from events
Sometimes you need to stop listening to an event. For example, the temporary effect has ended, or the object has been removed from the game. Let's add the ability to unsubscribe:
function EventSystem:unsubscribe(eventName, callback)
if not self.events[eventName] then
return
end
for i, listener in ipairs(self.events[eventName]) do
if listener == callback then
table.remove(self.events[eventName], i)
return
end
end
endAn important point is that to unsubscribe, we need a link to the same function that we subscribed to. You can't unsubscribe from anonymous functions if you haven't saved a link to them:
-- Неправильно: нельзя отписаться
EventSystem:subscribe("event", function() print("test") end)
EventSystem:unsubscribe("event", function() print("test") end) -- Не сработает!
-- Правильно: сохраняем ссылку
local myCallback = function() print("test") end
EventSystem:subscribe("event", myCallback)
EventSystem:unsubscribe("event", myCallback) -- Работает!Event priorities
In some cases, the order of execution of the handlers is important. For example, we want the check for blocking an attack to occur before damage is dealt. Let's add a priority system:
function EventSystem:subscribe(eventName, callback, priority)
if not self.events[eventName] then
self.events[eventName] = {}
end
priority = priority or 0
local listener = {
callback = callback,
priority = priority
}
table.insert(self.events[eventName], listener)
-- Сортируем по приоритету (больший приоритет выполняется раньше)
table.sort(self.events[eventName], function(a, b)
return a.priority > b.priority
end)
end
function EventSystem:emit(eventName, ...)
if self.events[eventName] then
for _, listener in ipairs(self.events[eventName]) do
listener.callback(...)
end
end
endNow we can specify the priority when subscribing:
EventSystem:subscribe("player_attack", function()
print("Unit check")
end, 10)
EventSystem:subscribe("player_attack", function()
print("Damage")
end, 5)
EventSystem:subscribe("player_attack", function()
print("Impact animation")
end, 1)
Data transfer and modification
Often it is necessary not only to notify about the event, but also to transmit data that listeners can modify. For example, the armor system can reduce the damage received. To do this, you can transfer a table with data:
local eventData = {
damage = 50,
damageType = "fire",
cancelled = false
}
EventSystem:subscribe("before_damage", function(data)
-- Проверяем иммунитет к огню
if player.hasFireImmunity and data.damageType == "fire" then
data.cancelled = true
end
end, 10)
EventSystem:subscribe("before_damage", function(data)
-- Применяем защиту от брони
data.damage = data.damage * (1 - player.armor / 100)
end, 5)
EventSystem:emit("before_damage", eventData)
if not eventData.cancelled then
player.health = player.health - eventData.damage
endThis approach allows listeners to influence the outcome of the event. We can cancel the event completely (cancelled = true) or change its parameters (reduce damage).
One-time subscriptions
Sometimes you need the handler to work only once. For example, the "game loaded" event should be processed once. Let's add the once method:
function EventSystem:once(eventName, callback, priority)
local onceWrapper
onceWrapper = function(...)
callback(...)
self:unsubscribe(eventName, onceWrapper)
end
self:subscribe(eventName, onceWrapper, priority)
endHere we create a wrapper function that calls our callback and then unsubscribes from the event. It's easy to use:
EventSystem:once("game_loaded", function()
print("Game loaded! This message will only appear once")
initializeGame()
end)Case study: achievement system
Let's create a simple achievement system using our Event system:
Achievements = {
list = {
first_kill = { unlocked = false, name = "First Blood" },
coin_collector = { unlocked = false, name = "Collector", coins_needed = 100 }
},
coins_collected = 0
}
function Achievements:init()
EventSystem:subscribe("enemy_killed", function()
if not self.list.first_kill.unlocked then
self.list.first_kill.unlocked = true
self:showAchievement(self.list.first_kill.name)
end
end)
EventSystem:subscribe("coin_collected", function()
self.coins_collected = self.coins_collected + 1
if self.coins_collected >= self.list.coin_collector.coins_needed
and not self.list.coin_collector.unlocked then
self.list.coin_collector.unlocked = true
self:showAchievement(self.list.coin_collector.name)
end
end)
end
function Achievements:showAchievement(name)
print("🏆 Achievement received: " .. name)
EventSystem:emit("achievement_unlocked", name)
endNow anywhere in the game code we can just call events, and the achievement system will track progress automatically:
-- В коде боевой системы
function Enemy:die()
EventSystem:emit("enemy_killed", self)
self:remove()
end
-- В коде сбора предметов
function Coin:collect()
EventSystem:emit("coin_collected")
self:remove()
endNamed signals and typing
For large projects, it is useful to create a centralized list of all events in the game. This helps to avoid typos and makes the code more understandable:
Events = {
PLAYER_DAMAGED = "player_damaged",
PLAYER_HEALED = "player_healed",
ENEMY_KILLED = "enemy_killed",
COIN_COLLECTED = "coin_collected",
LEVEL_COMPLETED = "level_completed",
GAME_PAUSED = "game_paused"
}
-- Использование
EventSystem:subscribe(Events.PLAYER_DAMAGED, function(damage)
print("Damage received: " .. damage)
end)
EventSystem:emit(Events.PLAYER_DAMAGED, 25)Tip: This approach protects against typos — if we write Events.PLAYER_DAMAGD (with a typo), Lua will give an error, and not just not find the event.
Event system debugging
When there are many events in the project, it is useful to add the ability to track their calls:
EventSystem.debug = false
function EventSystem:emit(eventName, ...)
if self.debug then
print("Event fired: " .. eventName)
end
if self.events[eventName] then
for i, listener in ipairs(self.events[eventName]) do
if self.debug then
print(" -> Calling listener #" .. i)
end
listener.callback(...)
end
end
end
-- Включаем отладку
EventSystem.debug = trueErrors in handlers
An important detail — if an error occurs in one of the handlers, the other handlers will not be executed. You can add protection:
function EventSystem:emit(eventName, ...)
if self.events[eventName] then
for _, listener in ipairs(self.events[eventName]) do
local success, error = pcall(listener.callback, ...)
if not success then
print("Error in event handler: " .. tostring(error))
end
end
end
endNow, even if one handler fails with an error, the rest will continue to work.
Full implementation
Here is the final version of our Event system with all the features:
EventSystem = {
events = {},
debug = false
}
function EventSystem:subscribe(eventName, callback, priority)
if not self.events[eventName] then
self.events[eventName] = {}
end
priority = priority or 0
local listener = {
callback = callback,
priority = priority
}
table.insert(self.events[eventName], listener)
table.sort(self.events[eventName], function(a, b)
return a.priority > b.priority
end)
return callback
end
function EventSystem:unsubscribe(eventName, callback)
if not self.events[eventName] then return end
for i, listener in ipairs(self.events[eventName]) do
if listener.callback == callback then
table.remove(self.events[eventName], i)
return true
end
end
return false
end
function EventSystem:once(eventName, callback, priority)
local onceWrapper
onceWrapper = function(...)
callback(...)
self:unsubscribe(eventName, onceWrapper)
end
self:subscribe(eventName, onceWrapper, priority)
return onceWrapper
end
function EventSystem:emit(eventName, ...)
if self.debug then
print("Event: " .. eventName)
end
if self.events[eventName] then
for i, listener in ipairs(self.events[eventName]) do
local success, error = pcall(listener.callback, ...)
if not success then
print("Event handler error in '" .. eventName .. "': " .. tostring(error))
end
end
end
end
function EventSystem:clear(eventName)
if eventName then
self.events[eventName] = nil
else
self.events = {}
end
end
return EventSystemThe event system is a powerful tool that makes your code more flexible and extensible. Instead of a rigid connection between components, you get a loosely coupled architecture where each system can operate independently. This is especially important in games where dozens of different systems interact: graphics, sound, physics, artificial intelligence, interface, and much more.
You can learn Lua and other programming languages from scratch in Codice — our educational platform with practical courses for beginner developers.
And we also have a cool Telegram channel with a friendly community where you can ask questions, share your projects and chat with like-minded people! 🚀
