Global Game Events

In almost every game there are certain events that the game needs to react to. In this article we'll explore how we can handle them in Godot in a clean and simple to maintain way!

by Χάρων

global game events

Share

Global Game Events

There are some examples of what makes an event a global event:

These are what I like to call Global Game Events. They are events that can cause reactions in many different ways in many different places inside your game. These are the events that are impractical to connect via a simple exported variable, or get_node() or by callbacks. For example, Something simple like “Level Started" could be a dependency for a lot of different nodes within your game scene. The Music Handler might want to start playing a tune that matches the level, The Player could play an animation of powering up for a level, the EnemySpawner node could start spawning enemies, the UI could start a timer and so on… You might think that the best way to handle all of these is simply to put the code inside the _ready function, but that might lead to problems. In many games the level doesn't necessarily start the moment it is loaded… You could be waiting for a FadeOut effect to finish, Or a Cutscene to finish first, or for a player to press a button to actually start the level (common in tower defenses). And that's where you might want to be able to have something more granular to control. So instead of putting all your logic into the _ready function, you might want to connect all of the nodes to some sort of level_started signal and then trigger that signal when the level really starts!

These are commonly implemented using a design pattern called an Event Bus or Events Singleton. The idea is simple. Create one Singleton/Autoload purely for declaring and managing signals that you want to be accessible globally. Every signal should usually consist of two things. The signal itself and a trigger method like this:

Simple Example

Let's consider that we created a Singleton named GameSignals.gd and put this code inside of it:

signal level_started

Then it is easy for any entities to trigger and react to the signal. For example if you want to start the level using a button in the UI it could look something like this:

func on_start_level_pressed():
  GameSignals.trigger_level_started()

On the otherside of the signal equation, we can have an EnemySpawner that starts spawning enemies when the actual level starts. The code might look something like this: 

var _spawning_enabled: bool = false

func _ready():
  GameSignals.level_started.connect(_on_level_started)

func _process(delta):
  if _spawning_enabled and spawn_time_elapsed():
    spawn_enemy()

First we need to connect to the signal, this enabled our EnemySpawner to be listening for the signals. Then we simply flip a switch and the level may begin as intended!

An important part when creating your events is to name them correctly, along with every function and variable. The benefit you get is a beautiful self-documenting code. It's a big obvious in this example, but it's something to always keep in mind to maintain clean code. The name of your variable and functions should always just make sense. This allows your code to be read almost like a book. 

A signal lighthouse

This pattern is quite powerful and you should keep it in your mind when making a game. The awesome part is that it decouples your code in a really well designed and reusable way. Take the above example, we don't really care where the level start signal is triggered from, If it's a button node, some timer, maybe an animation player call.. it doesn't matter because all the trigger has to know is that GameSignals exists as a singleton and it doesn't care where it is, who it is or who will react to it. It's job is done by notifying our game that a level start should take place. On the other hand the nodes reacting to the signals also don't have to know anything about who triggered it. We don't need to set up any get_node() or find_node(), any $ paths, any exports. We don't even care who triggered it anyway, all that matters is that the level start has been triggered, and we have a job to do! In this way you can connect as many nodes to this signal as you need.

The same pattern applies very nicely to all the other global game events I mentioned and more. Changes in the UI are my favorite use case for these. For example you can have a signal like “score_changed” which triggers some UI Node, for example a ScoreLabel node to update it's count and play a little animation. It's completely detached and self-sufficient this way. Another great place is the player equipment if you have some sort of visual equipment implemented. The player should listen to equipment_changed events, or weapon_changed events that can come from other sources. Maybe an enemy used a disarm attack, so it should trigger an weapon_changed event, or the player equiped a new weapon from the inventory, in that case a button in the UI would trigger the weapon_changed event, another example is changing the weapon directly on pickup, in which case an Area2D might end up triggering it… a lot of places that can cause a weapon change, but usually only one or two places that react to it, the player changing it's sprite, and the UI updating to reflect the new weapon. This is a great place where to use the Event Bus pattern because ultimately everything in the game revolves around the player. The player is the center piece of the game and events like these can have a lot of triggers and reactions, and you don't want to be connecting cables between the nodes using exports and looking for nodes in the tree, that would get messy really quickly and your player will end up having more exported fields in the UI than lines of code.

Score keeping

The score and weapon change examples actually highlight another really nice feature of signals and that's the ability to pass values by signals. Let's see an example using the score_changed signal.

signal score_changed(score: int)

This is really awesome because we don't even need to worry about retrieving the correct score number from some other singleton (though you definitely can). Instead you can have a ScoreLabel.gd looking like this

func _ready():
  GameSignals.score_changed.connect(_on_score_changed)

If you attach this script to any Label node anywhere in your game, it will accurately reflect the current score as long as the signal is attached, and whatever causes score to increase calls the trigger_score_change() method.

Conclusion

In this article I hopefully introduced you to the power of signals and the Event Bus pattern as well as taught you something about clean code and clean project management. As always that's the number 1 goal of this entire blog. Making games should and can be fun and the Event Bus pattern is one more tool you can use to make it so!