DevLog #2: Signals Part I

Table Of Contents "Send me a Signal" EventManager Class Add a Listener Triggering a Signal Queuing a Signal Remove a Listener The Complete Code What's Next? "Send me a Signal" If you're an old fart like me, you'll probably remember a line from a Chris de Burgh song released in 1982: "Ship to shore, answer my call, Send me a signal, a beacon to bring me home." What does this have to do with game development? Well, the first thing I code in any game I make is a form of the Observer pattern. It allows you to send signals to trigger events in different parts of your game engine without actually calling the code directly. NOTE: This technique is only useful in fire-and-forget type situations. If you need a response from your call, you shouldn't use this method. Don't get trapped in the situation where you try to make signals talk to each other. It can become messy and very difficult to bug hunt. For instance, if you're coding a battle system, there'll be a need to update the health bars of your characters. However, you don't want your battle system to directly update the GUI interface because if the GUI changes or you want to reuse that code for a different game, then your battle system code will break. Sending signals solves this. (Say that 5 times fast) At a high level, different objects in your game will register to listen for certain signals. When a particular signal is transmitted, it goes into a queue where it is picked up by the Event Manager and broadcast to every object that registered for that signal. You can even package up some data to send along with the Signal. Let's implement this in the MiniScript language. EventManager Class First, let's create the EventManager class that will manage listeners and triggered signals. EventManager = { "listeners": {}, "queue": [], } As you can see, it doesn't take much to define the properties for the manager. The listeners are stored in a map data type named listeners. When an object wants to listen for a signal, they are added to that map. As an example, this is what the value of the listeners map might look like after a couple of listeners are added: "listeners" : { "signal_name" : [ { "listener": some_object, "callback": some_function, }, { "listener": another_object, "callback": another_function, } ] } So inside the listeners map, we have a list associated with the signal's name. Each element inside that list, contains a map with data about the listener including the object that is doing the listening and the function to call when the signal is triggered. Add a Listener Let's code the function to add a listener: EventManager.addListener = function(eventType, listener, callback) if not self.listeners.hasIndex(eventType) then self.listeners[eventType] = [] end if if not self.findListener(eventType, listener, callback) then l = { "listener": listener, "callback": callback, } self.listeners[eventType].push l end if end function EventManager.findListener = function(eventType, listener, callback) if self.listeners.hasIndex(eventType) and self.listeners[eventType].len > 0 then // Loop through each Listener and find the one with the Callback place = null for i in range(self.listeners[eventType].len-1) l = self.listeners[eventType][i] if l.listener == listener and l.callback == callback then place = i break end if end for return place end if return null end function The function addListener ensures a list exists for the signal name and that the listener object and its callback exist only once in that signal's list. If an object tries to register the same callback more than once for a signal, it's ignored. The findListener function is included here because it helps to do some heavily lifting that will be required again later. Triggering a Signal / Event Once a listener has been added, the EventManager will be able to process signals. Signals can be triggered to run either immediately such as in the following code: EventManager.triggerEvent = function(eventType, data=null) if self.listeners.hasIndex(eventType) then for l in self.listeners[eventType] listener = l.listener callback = l.callback if data == null then listener[callback] else listener[callback] data end if end for end if end function Or they can be added to a queue where they can be processed at the end of the game loop. In case a signal doesn't have any listeners, then that signal is simply ignored. Queuing a Signal / Event To delay triggering a signal to the end of

May 1, 2025 - 14:16
 0
DevLog #2: Signals Part I

Table Of Contents

  • "Send me a Signal"
  • EventManager Class
  • Add a Listener
  • Triggering a Signal
  • Queuing a Signal
  • Remove a Listener
  • The Complete Code
  • What's Next?

"Send me a Signal"

If you're an old fart like me, you'll probably remember a line from a Chris de Burgh song released in 1982: "Ship to shore, answer my call, Send me a signal, a beacon to bring me home."

What does this have to do with game development? Well, the first thing I code in any game I make is a form of the Observer pattern. It allows you to send signals to trigger events in different parts of your game engine without actually calling the code directly.

NOTE: This technique is only useful in fire-and-forget type situations. If you need a response from your call, you shouldn't use this method. Don't get trapped in the situation where you try to make signals talk to each other. It can become messy and very difficult to bug hunt.

For instance, if you're coding a battle system, there'll be a need to update the health bars of your characters. However, you don't want your battle system to directly update the GUI interface because if the GUI changes or you want to reuse that code for a different game, then your battle system code will break. Sending signals solves this. (Say that 5 times fast)

Image description

At a high level, different objects in your game will register to listen for certain signals. When a particular signal is transmitted, it goes into a queue where it is picked up by the Event Manager and broadcast to every object that registered for that signal. You can even package up some data to send along with the Signal. Let's implement this in the MiniScript language.

EventManager Class

First, let's create the EventManager class that will manage listeners and triggered signals.

EventManager = {
    "listeners": {},
    "queue": [],
}

As you can see, it doesn't take much to define the properties for the manager. The listeners are stored in a map data type named listeners. When an object wants to listen for a signal, they are added to that map.

As an example, this is what the value of the listeners map might look like after a couple of listeners are added:

"listeners" : {
    "signal_name" : [
        {
            "listener": some_object,
            "callback": some_function,
        },
        {
            "listener": another_object,
            "callback": another_function,
        }
  ]
}

So inside the listeners map, we have a list associated with the signal's name. Each element inside that list, contains a map with data about the listener including the object that is doing the listening and the function to call when the signal is triggered.

Add a Listener

Let's code the function to add a listener:

EventManager.addListener = function(eventType, listener, callback)
    if not self.listeners.hasIndex(eventType) then
        self.listeners[eventType] = []
    end if

    if not self.findListener(eventType, listener, callback) then 
        l = {
            "listener": listener,
            "callback": callback,
        }
        self.listeners[eventType].push l
    end if
end function

EventManager.findListener = function(eventType, listener, callback)
    if self.listeners.hasIndex(eventType) and self.listeners[eventType].len > 0 then
        // Loop through each Listener and find the one with the Callback
        place = null
        for i in range(self.listeners[eventType].len-1)
            l = self.listeners[eventType][i]
            if l.listener == listener and l.callback == callback then
                place = i
                break
            end if
        end for
        return place
    end if
    return null
end function

The function addListener ensures a list exists for the signal name and that the listener object and its callback exist only once in that signal's list. If an object tries to register the same callback more than once for a signal, it's ignored.

The findListener function is included here because it helps to do some heavily lifting that will be required again later.

Triggering a Signal / Event

Once a listener has been added, the EventManager will be able to process signals. Signals can be triggered to run either immediately such as in the following code:

EventManager.triggerEvent = function(eventType, data=null)
    if self.listeners.hasIndex(eventType) then
        for l in self.listeners[eventType]
            listener = l.listener
            callback = l.callback

            if data == null then
                listener[callback]
            else
                listener[callback] data
            end if
        end for
    end if
end function

Or they can be added to a queue where they can be processed at the end of the game loop.

In case a signal doesn't have any listeners, then that signal is simply ignored.

Queuing a Signal / Event

To delay triggering a signal to the end of a frame, we can add the signal to a queue and then process that queue as either the first or last thing we do in our game loop. If it is the first thing we do, more than likely any signals queued during your game loop will have to wait until the next loop / frame to process them. This is why I tend to process my event queue at the end of the game loop so that all queued signals are processed in the same loop that they were queued.

Let's take a look at how to enqueue a signal and process the queue:

EventManager.queueEvent = function(eventType, data=null)
    self.queue.push [
        eventType,
        data,
    ]
end function

EventManager.processQueue = function
    if self.queue and self.queue.len > 0 then
        for event in self.queue
            self.triggerEvent event[0], event[1]
        end for
        self.queue = []
    end if
end function

Each time processQueue is called, all signals in the queue list will be triggered and then the queue is emptied.

Remove a Listener

There are times that you will want to remove a listener from a signal's list. The removeListener function will do this for you. Most of the heavy lifting is already done by the findListener function making the removal fairly straightforward.

EventManager.removeListener = function(eventType, listener, callback)
    index = self.findListener(eventType, listener, callback)
    if index != null then
        self.listeners[eventType].remove index
    end if
end function

The Complete Code

Here is the complete code for the event library that I'm using in my game.

import "importUtil"
ensureImport "listUtil"

EventManager = {
    "listeners": {},
    "queue": [],
}

EventManager.findListener = function(eventType, listener, callback)
    if self.listeners.hasIndex(eventType) and self.listeners[eventType].len > 0 then
        // Loop through each Listener and find the one with the Callback
        place = null
        for i in range(self.listeners[eventType].len-1)
            l = self.listeners[eventType][i]
            if l.listener == listener and l.callback == callback then
                place = i
                break
            end if
        end for
        return place
    end if
    return null
end function

EventManager.addListener = function(eventType, listener, callback)
    if not self.listeners.hasIndex(eventType) then
        self.listeners[eventType] = []
    end if

    if not self.findListener(eventType, listener, callback) then 
        l = {
            "listener": listener,
            "callback": callback,
        }
        self.listeners[eventType].push l
    end if
end function    


EventManager.removeListener = function(eventType, listener, callback)
    index = self.findListener(eventType, listener, callback)
    if index != null then
        self.listeners[eventType].remove index
    end if
end function


EventManager.triggerEvent = function(eventType, data=null)
    if self.listeners.hasIndex(eventType) then
        for l in self.listeners[eventType]
            listener = l.listener
            callback = l.callback

            if data == null then
                listener[callback]
            else
                listener[callback] data
            end if
        end for
    end if
end function


EventManager.queueEvent = function(eventType, data=null)
    self.queue.push [
        eventType,
        data,
    ]
end function

EventManager.processQueue = function
    if self.queue and self.queue.len > 0 then
        for event in self.queue
            self.triggerEvent event[0], event[1]
        end for
        self.queue = []
    end if
end function

What's Next?

My game also has the need to delay triggering events longer than one frame and even independent of the frame count. I'll show you how I improved this library to add that feature in my next CO9T DevLog.