{}const=>[]async()letfn</>var
DevelopmentLua

Event system in Lua: creating a flexible architecture of events, subscriptions and signals

Learn how to create a powerful Event system in Lua from scratch! Learn how to properly handle events, set up subscriptions, and work with priorities and signals. Practical examples for game development: from the simplest implementation to a full-fledged system with debugging.

К

Kodik

Author

8 min read

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.

🔥 100,000+ students already with us

Tired of reading theory?
Time to code!

Kodik — an app where you learn to code through practice. AI mentor, interactive lessons, real projects.

🤖 AI 24/7
🎓 Certificates
💰 Free
🚀 Start learning
Joined today

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
end

This 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
end

An 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
end

Now 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
end

This 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)
end

Here 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)
end

Now 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()
end

Named 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 = true

Errors 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
end

Now, 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 EventSystem

The 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! 🚀

🎯Stop procrastinating

Liked the article?
Time to practice!

In Kodik, you don't just read — you write code immediately. Theory + practice = real skills.

Instant practice
🧠AI explains code
🏆Certificate

No registration • No card