Performance: Cleaning Up Event Listeners

Hey,

So a common challenge I've seen in building bigger and more complex games, is how event listeners can build up and effect performance. It's not necessarily the amount of event listeners, but more about what each of those event listeners are doing. A relatively fresh server instance will perform smoothly at first, but after players have been active in it for a long time, it can start to get sluggish due to the build up of event listeners.

Event listener buildup isn't the only way that this can happen, so I recommend using the Performance Profiler to see if this may be affecting your game.

Listeners

A game can have listeners that listen for specific events that are happening in your game. Listeners don't actually actively listen for anything, they are subscribed to an event, and when the event is fired, the listener function is called.

For example, listening for when a player joins the game, or listening for when a specific event is broadcasted.

local function joined(player)

    -- playerJoinedEvent fired

end

Game.playerJoinedEvent:Connect(joined)

-- Listen for a broadcast event

Events.Connect("myevent", function()

    -- myevent fired

end)

As your game systems get bigger and more complex, new features are added, then listeners can build up, and this is absolutely fine. The problem is listeners will hang around even if the script that setup the listener is destroyed. For the most part, this isn't a big deal, but it can cause issues by introducing undesired side effects, and become a potential performance problem for your game.

For example, in Overrun, a pod can drop down and give buffs to the zombies (damage, health etc). Periodically, the pod script would broadcast to the zombies and give them random buffs that could stack. A problem I discovered, was even when a pod had been destroyed by the players, zombies would continue to receive buffs to the point they could not be killed. This was because of a broadcast listener that was still being called even though the script had been destroyed.

As someone who was new to Core at the time, it confused me, and it wasn't until I placed a print statement inside the broadcast that I figured out the issue and how to solve it.

Example Problem

I have picked this type of problem specifically, because I see it very often, especially with objects that get destroyed at runtime.

In this example, a damageable object has a child script that is listening for when the broadcast event alive is fired. For any crate that is still alive, the player receives 100 money when the player interacts with the console. Only 1 crate should ever be alive. So each time the player uses the console, they should be awarded 100 money.

local crate = script:GetCustomProperty("DamageableCrate")

script.parent.diedEvent:Connect(function()
    local pos = script.parent:GetWorldPosition()
    
    Task.Wait(.5)
    World.SpawnAsset(crate, { position = pos })
end)

Events.Connect("alive", function()
    Events.Broadcast("give_money")
end)

The script above is the child script of the damageable object. The important part is the event connection at the bottom of the script. It's listening for the alive event. When it is fired, it will broadcast to another script and award money to the player.

See the video below of it in action, and watch the amount of money that is added when the console is activated. You may expect 100 to be added each time to the total money.

So What is the Problem?

The problem is when the object (along with the child script) is destroyed, the listener is still subscribed to the alive event. Even though the object and script has been destroyed, the listener will live on and continue being called.

Solving the Problem

The easiest way to solve the problem above, is to disconnect the event when we know it is no longer needed. So when a crate is destroyed, we know that the event should not fire any more, and should be removed.

local crate = script:GetCustomProperty("DamageableCrate")

script.parent.diedEvent:Connect(function()
    local pos = script.parent:GetWorldPosition()

    Task.Wait(.5)
    World.SpawnAsset(crate, { position = pos })
end)

local alive_evt = Events.Connect("alive", function()
    Events.Broadcast("give_money")
end)

script.destroyEvent:Connect(function()
    if(alive_evt.isConnected) then
        alive_evt:Disconnect()
    end
end)

The script has been updated so the EventListener returned from connecting the alive event is stored in the variable alive_evt so we have a reference to it later on.

script.destroyEvent:Connect(function()
    if(alive_evt.isConnected) then
        alive_evt:Disconnect()
    end
end)

When the script is destroyed, we can then check if the alive_evt is connected, if so, disconnect it. In doing so, we clean up the broadcast listener. Now when using the console, it will only award the player 100 money, which is now correct.

Solution

If an object that the event is on is destroyed, then those events will be disconnected for you. However, in other cases, then you will need to handle the disconnect yourself.

It's good practice to get into the habit of cleaning up your event listeners yourself.

Events that can connect a listener, will return an EventListener that can be used for disconnecting later.

local my_event = Events.Connect("some_event", some_func)

if(my_event.isConnected) then
    my_event:Disconnect()
end

There may be cases where you want to disconnect a listener from within the listener function. In this case, you need to handle storing the EventListener a little differently by having a global variable that can be referenced from within the listener function.

local my_event

my_event = Events.Connect("some_event", function()
    if(my_event.isConnected) then
        my_event:Disconnect()
    end
end)

Disconnecting Multiple Events

When you have a lot of event listeners, a good way to store them is in a table, and then loop over them when they need to be disconnected.

local my_events = {}

-- Using table insert to add a new item to the table

table.insert(my_events, Events.Connect("some_event", some_event))
table.insert(my_events, Events.Connect("some_other_event", some_other_event))
table.insert(my_events, Events.Connect("other_event", other_event))

script.destroyEvent:Connect(function()
    for index, evt in ipairs(my_events) do
        if(evt.isConnected) then
            evt:Disconnect()
        end
    end

    my_events = nil
end)

The above example will push all event listeners into the my_events table, and when the script is destroy, loop through the table disconnect them. This is a good way to handle a large amount of events that you may have in a script.

Summary

Get in the habit of disconnecting your events. As your project gets bigger and more complex, strange behavior or bugs may occur when listeners are left hanging around. If your game is actively played, and server instances are up for some time, then performance could be effected.

If you have any questions, feedback, or additional information that other creators would benefit from, please post below.

8 Likes

Alt title:

"Hey, listen!" :slight_smile:

2 Likes

It's a good lesson for beginners.
But I was most worried about the destroyEvent:Connect event, can it delete automatically when the object has terminated its life after the event is triggered?

Or do I have to implement it?

local destroyEvent = nil
destroyEvent = script.destroyEvent:Connect(function()
        if(destroyEvent and destroyEvent.isConnected) then
           destroyEvent:Disconnect()
           destroyEvent = nil
        end
end)

I hope the events do not remain in the memory, although it does not cause it when the object is not. Or is there already at hood level the engine deletes after deletion event?

1 Like

So I purposely left this out, because it's hard to test as the profiler is very inconsistent with displaying the amount of listeners, and for me, reports an incorrect amount even when everything has been disconnected.

I personally disconnect the destroyEvent as well if for example, the parent is being destroyed.

Any object that is destroyed will have its listeners (those that are a direct connection to the object) cleaned up automatically. But if it's a script inside (i.e. a template), then unless otherwise stated by an engineer from Manticore, I would advise disconnecting it for most optimal performance.

At the end of the day, it really depends on your game, what is going on in those listener functions. Because the destroyEvent is usually very minimal low impact code, it shouldn't be an issue. But I know for some of us, squeezing everything out of it is important.

Edit: I guess what you could do, is make sure to bind the destroyEvent to the actual object that is being destroyed so it is automatically cleaned up.

1 Like