Need help understanding custom Signals between objects

Godot Version

4.2.1

Question

Hello everyone !

To put it simply, I’m coming from GM:S for which I’ve learnt how to use a PubSub manager to share informations between Objects (and I suppose does the work of an Observer). I thought it would help with the transition to the Signal functions easier but… not really. I mainly checked GDscript and “Using signals” from the Godot docs but also what I could find on Google/Youtube, I’m getting nowhere and it doesn’t even feel like I’ve taken a step in the right direction.

Here is what I ended up with:

(I commented out the parts to be able to keep on starting the game).

Everything is based on the “Make your first game 2D tutorial”, I have not added any new other node. The “mob” and “main” scenes are also not parented anywhere - I don’t know if it is a necessary step.

If anyone could explain how it works to me like I’m 5, it’d be really appreciated, because I can’t wrap my head around it.

Thanks in advance.

Think of a signal like a list of functions that should all be called when something happens. For example: buttons have a pressed signal. You can connect that signal to any function, and that function will be called when the button is pressed. In that case, the button is in charge of “emitting” the pressed signal. If you write your own signal, you’re in charge of emitting it. In other words, you control when it goes down its list of functions and calls them all one at a time. Which functions are on that list? You’re in charge of that too. You control it by “connecting” that signal to functions.

Let’s say your mob needs to tell your main script when the mob dies.

You might write this code in your mob script:

signal on_death()
# A bunch of code for your mob
func die():
    on_death.emit()

When you call mob.die(), it will “emit” the on_death signal, going down its list of functions (in other words, its list of connections) and executing them all.

But it’s not connected to anything yet.
You can connect signals in the editor, or in script. To do it in script, you might write in main.gd:

func _ready():
    mob.on_death.connect(add_one_to_score)

#  a bunch of code for main.gd

func add_one_to_score():
    # This is called automatically when mob emits the on_death signal
    score += 1

The parentheses when creating a signal can be confusing at first, since it looks like a function, but its not, it’s something else.

You can, however pass information between signals. For example, if you want some mobs to add different amounts to your score, you might change mob.gd to look like this:

signal on_death(score_value)
# A bunch of code for your mob
func die():
    var score_value = randi() % 3 + 1
    on_death.emit(score_value)

Now, when the mob dies, it picks a random number between one and three to be added to the player’s score.this value is “emitted” with its on_death signal, and is therefore passed as a parameter to any function this signal is connected to. Remember that mob.on_death is connected to the function add_one_to_score in main.gd. This function does not take any parameters. When on_death is emitted with a parameter, it will try to call add_one_to_score with that parameter, and this will cause an error. We can fix this by changing main.gd to look like this:

func _ready():
    mob.on_death.connect(add_to_score)

#  a bunch of code for main.gd

func add_to_score(score_val):
    # This is called automatically when mob emits the on_death signal
    score += score_val

Now, the signal passes one parameter, and the function accepts one parameter, so they are compatible.

It is also possible to have main.gd pass additional parameters to a function, like so:

func _ready():
    mob.on_death.connect(add_to_score.bind(mob))

#  a bunch of code for main.gd

func add_to_score(score_val, mob):
    # This is called automatically when mob emits the on_death signal
    score += score_val
    mob.queue_free()

In this example, when mob emits the on_death signal, before the function add_to_score is called, the mob itself is tacked on to the end as another parameter. This way, you can have main.gd control some extra information about each connection. In the code above, the mob decides it should die, so it calls the die function. It chooses a random number, let’s say it picks 2. It emits the on_death signal with the value 2. However, since the signal for this particular mob was connected (in main.gd) with an extra binding, the function add_to_score is called like this: add_to_score(2, mob), which allows the add_to_score function to remove the mob from the scene with the queue_free function.

Here’s another example.

You might have a script which is in charge of creating and destroying all mobs in the scene. This script might be called MobManager.gd. in this script, you might have some code like this:

const mob_scene = preload("path/to/mob.tscn")

var score = 0

func _ready():
    # create 5 mobs
    for x in range(5):
        # A new mob is created each loop, and so new_mob
        # holds something different each loop
        var new_mob = mob_scene.instantiate()
        add_child(new_mob)
        # Connect this loop's mob, and bind this loop's
        # mob in that connection
        new_mob.on_death.connect(kill_mob.bind(new_mob))

func kill_mob(score_value, mob):
    score += score_value
    mob.queue_free()

Here, the script creates 5 mobs, and the kill_mob function will be called when any one of them emits the on_death signal. However, mobmanager.gd still knows which mob to kill, because when its connected, each mob binds itself to the kill_mob function individually. (Each connection is unique) Therefore when the signal is emitted, the kill_mob function will be called with the same mob which emitted that signal. The same is true for all 5 mobs here.

This system can be really useful for structuring code, and getting things done in a clean way. I recommend the call-down, signal-up architecture, where parent nodes can communicate down the tree by calling functions on their children directly, like child.function(), whereas children nodes communicate up the tree via signals.

Under this system, parent nodes have knowledge of their children, but children nodes don’t need any knowledge of their parents, meaning they can be used in more than one place and still work.

Hopefully this helps!

1 Like

Thank you for the long and detailed reply ! It is really informative (especially the bind part) but I still can’t wrap my head around how each instance interact with each other ; I tried to do a few things, and I ended up with

main.gd:

@export var mob_scene: PackedScene # "res://mob.tscn in the inspector

func _on_mob_timer_timeout():
    var new_mob = mob_scene.instantiate()

    add_child(new_mob)
    new_mob.sign_monster_dupe.connect(_on_mob_dupe.bind(new_mob))

func _on_mob_dupe(_instance_id):
	var _string = "Signal received by Node Main"
	print_debug(_string)

And mob.gd:

signal sign_monster_dupe()
var timer

func _process(delta):
	timer += delta

	if timer >= 2:
		timer = -1000
		var _id = get_instance_id()
		sign_monster_dupe.emit(_id)

func _on_sign_monster_dupe(_instance_id):
	var _self_id = get_instance_id()
	var _format_string = "monster %s emitted a signal, received by monster %s"
	var _actual_string = _format_string % [_instance_id, _self_id]
	print_debug(_actual_string)

I got the idea of the last part as a little check, to see if the signal is at least emitted. From there I realized two things:

  • Main is still not connected (and I don’t know why)
  • The other mob instances do not receive the signal sent by one of them (only the one sending it receives it)

Why I was doing it, it also raised a question: if I need to connect Main and Mob when Main create a Mob, how would I go around another Node/Instance creating a mob ? Would I have to instead ask that Node/Instance to send a signal to Main instead and do everything from there ?