Why does using await cause my node references to break?

Godot Version

4.3

Question

I’m fairly new to using the await keyword. I’ve started using it in basic pieces of code to wait for an explosion to finish before doing a queue_free(), for example. I started trying to get fancier, and now I’m running into a problem.

For now, while I’m still building everything, I’ve got some test code in the root Node2D _ready() function. This takes care of starting some music and randomly spawning some enemies. Worked fine until I refactored this:

# This works
$Music.playing = true
$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
await tree.create_timer(20).timeout
	
$Music.stream.set_sync_stream_volume(1,0)
$BaddieSpawner.spawn_X_every_Y_seconds(mine, 10, 1)

into this:

# This does not work
$Music.playing = true
$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
$LevelTimer.start(20)
await $LevelTimer.timeout
	
$Music.stream.set_sync_stream_volume(1,0)  # <-- Errors out here
$BaddieSpawner.spawn_X_every_Y_seconds(mine, 10, 1)

(I made the change because timers I created on the tree would not pause when gameplay was paused.)

Problem is, after the await finishes in this new version, $Music seems to no longer exist. After waiting for 20 seconds, it throws this error: Invalid access to property or key 'stream' on a base object of type 'null instance'. on the next to last line. Commenting that line out just makes it throw a similar error on the next line after being unable to suddenly find $BaddieSpawner. Looking at the Remote tab shows $Music as still being there, so I’m confused.

It feels kinda like when execution resumes it’s happening in a new thread or something; one that doesn’t have access to the original’s scope. But if that’s the case why did it work before?

I feel like there’s something fundamental I’m missing here. I know I could probably change this and get it working again by using lots of functions, but I’ll be needing much more timed events in my game and was planning on abusing awaits. More importantly, I just can’t figure out why this isn’t working and want to know.

I’m decently sure it’s because of the awaits, since commenting them out causes everything to start working again.

Thanks in advance for reading all this and possibly helping explain what I’m doing wrong.

I would try connecting to the timeout signal to see if that changes things. You can just place those delayed lines in an anonymous function if you don’t want to write a separate function.

1 Like

You don’t have a signal already connected to this timer and you’re deleting the nodes there?

1 Like

Probably true. But at this point, I’m more trying to understand how I’m using await incorrectly than I am trying to fix this one specific use-case. And, to be honest, await is far cleaner code-wise (especially after I stuff things into a function that gets awaited), and I’m going to have a lot of these little snippets eventually.

But I thank you for the idea! There may very well come a point where I have to give up understanding this (hopefully) edge-case and just do it a different way just to keep forward momentum, and your idea is one I hadn’t considered.

Or were you suggesting that idea as a way of debugging what is going on here? Just to see if it works at all or if it has the same problems? If that’s the case I can try to do this and report back.

Nope, but thanks for checking. $LevelTimer was only created because I figured having one node I reused would be more performant than deleting and recreating timer nodes constantly. It’s only used here, for these awaits. Nothing connects to that signal except the code above (if you even count that as connecting).

Also, if I place a breakpoint on the line that errors and look at the Remote tab, I still see $Music in the tree, along with the other nodes.

Yeah at the very least if you try it that way and don’t get the same issue, we’ll know that the await keyword is doing something different (I don’t expect that it does, but I don’t know.)

1 Like

Hmmm, well, I’m officially confused now. I did as you suggested, but kept it simple by having it just call a plan function. And to my surprise, it failed in the same way, being unable to find $Music.

func _ready():
	$Music.playing = true
	$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
	$LevelTimer.start(20)
	$LevelTimer.timeout.connect(foo1)

func foo1():
	$Music.stream.set_sync_stream_volume(1,0)    #<--- Errors here still
	$BaddieSpawner.spawn_X_every_Y_seconds(mine, 10, 1)
	$BaddieSpawner.spawn_X_every_Y_seconds(ship, 5, 10)
	$LevelTimer.start(20)
	$LevelTimer.timeout.connect(foo2)

Thinking it was something odd with how I’m doing my connections, I also tried to set the signal connection through the node inspector as well, and got the same results.

Just to verify, I did a git stash to undo all my changes, and yup- still works if I just use await tree.create_timer(20).timeout.

What am I not getting? It seems like maybe this isn’t exactly because of await now.

I guess it must be some difference between SceneTreeTimer and Timer, but off the top of my head, I don’t know.

Maybe a weird question but rather than stashing and undoing all changes, can you verify that your latest version, if replacing the Timer version with the SceneTreeTimer, still works? Just to ensure it’s not something else you did.

Are you sure $Music still exists as a child of the script after 20 seconds? Could you change your code to store the nodes in variables?

@onready var music: AudioStreamPlayer = $Music
@onready var level_timer: Timer = $LevelTimer

music.playing = true
$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
level_timer.start(20)
await level_timer.timeout
	
music.stream.set_sync_stream_volume(1,0) 
$BaddieSpawner.spawn_X_every_Y_seconds(mine, 10, 1)

Check in your Scene’s remote tab to see how the scene tree developed after 20 seconds, when the stack trace is shown.

Could you paste the entire script so we can check for potential free-ings?

1 Like

Yup, I’m sure it still exists. When I debug, it still exists in the Remote tab at the time of the error, and also using your suggestion works just fine. The music variable works normally. Of course, it fails on the next line for the same reasons unless I store that in a variable too.

And yeah, I know I could fix this by doing that for all variables, or other patterns. But I get the feeling something is seriously awry here with either my code or my gdscript knowledge… and sweeping it under the rug would just come back to bite me later.

After making your suggested change, here is the current script:

extends Node2D

var tree:SceneTree
var asteroid = preload("res://Baddies/Asteroid/Asteroid.tscn")
var ship = preload("res://Baddies/RandomMoveAndShoot/RandomMoveAndShoot.tscn")
var mine = preload("res://Baddies/Mine/Mine.tscn")
var missile = preload("res://Baddies/Missile/Missile.tscn")
@onready var music: AudioStreamPlayer = $Music

func _ready():
	tree = get_tree()	
	EventBus.PlayerDied.connect(player_died)
	
	music.playing = true
	$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
	$LevelTimer.start(2)
	$LevelTimer.timeout.connect(foo1)

func foo1():
	music.stream.set_sync_stream_volume(1,0)
	$BaddieSpawner.spawn_X_every_Y_seconds(mine, 10, 1)
	$BaddieSpawner.spawn_X_every_Y_seconds(ship, 5, 10)
	$LevelTimer.start(2)
	$LevelTimer.timeout.connect(foo2)

func foo2():
	$Music.stream.set_sync_stream_volume(2,0)
	while(true):
		$BaddieSpawner.spawn_X_every_Y_seconds(missile, 5, 1)
		$BaddieSpawner.spawn_X_every_Y_seconds(ship, 10, 5)
		$LevelTimer.start(20)

func player_died():
	await tree.create_timer(2).timeout
	tree.change_scene_to_file("res://Gameplay UI/Gameover/game over screen.tscn")

It occurs to me after posting this that I ran into this problem earlier as well, and is the reason I’m storing get_tree() into a variable. When the player died and player_died() was called, the get_tree() func was returning null. I was (and still am) quite new to the changing scenes mechainc, so at the time I figured it was just something to do with that and was content to stuffing it in a variable and moving on. (See previous point about that coming back to bite me later.) I just checked if this is still happening by replacing the variable with get_tree() and yup, it still breaks because get_tree() somehow returns a null value.

I’m not sure that’s a clue or not. It’s almost like this script is getting detached from the node or something. But the node that this script is attached to is the root of the main scene, so I don’t know how that makes any sense. grepping through my code for ‘change_scene_to_file’ shows only two hits for change_scene_to_file, the game over screen, and the script above that changes the scene to that game over screen.

EDIT:

I got to thinking about the above, and I was playing around with the player_died() func, removing the tree variable and using get_tree() instead as normal.

func player_died():
	await get_tree().create_timer(2).timeout
	get_tree().change_scene_to_file("res://Gameplay UI/Gameover/game over screen.tscn")

In this case, the first line works. It creates a timer successfully- so get_tree() returns something. The next next line, after 2 seconds, fails because suddenly get_tree() returns null even though it was fine 2 seconds ago. I’m not doing anything special in those 2 seconds; mostly just wanted to let the explode animation finishing and let the player know they died without a jarring game over screen.

change_scene_to_file does delete every node, outside of globals, and invalidate the current scene tree. If you are only running into issues when the player dies, then that is very likely the culprit

1 Like

That’s not the case. These errors are all happening before the scene change. Heck, the newest one happens when I try to change the scene by way of get_tree().change_scene_to_file().

The original error I started this thread with happens before the player dies. No scene changes, just nodes being added and removed.

EDIT:

OK, I think I figured out my problem in player_died, or I’m at least on my way. The signal was firing more than once, so there were actually three timers getting created. Obviously, after the first timeout though, the scene has changed so the second timeout breaks.

However, this doesn’t fix the original issue of the timers inside _ready causing this. I inserted some debug statements and yup, the _ready func is only getting called once. (That would have been interesting, otherwise.) Sorry for introducing a red herring into this.

Just to get on the same page, this is the part that is still breaking.

func _ready():
	EventBus.PlayerDied.connect(player_died)
	print_debug("Starting game!")
	$Music.playing = true
	$BaddieSpawner.spawn_X_every_Y_seconds(asteroid, 10, 1)
	$LevelTimer.start(.001)
	print_debug(get_tree())  #Still works here
	$LevelTimer.timeout.connect(foo1)

func foo1():
	print_debug(get_tree())  #null
    $Music.stream.set_sync_stream_volume(1,0)  # $Music can't be found

OK, finally figured it out. $LevelTimer had its thread group set to SubThread. Not sure how that happened, as I’ve never messed with threading before. Might have been a misclick at some point or a rogue cat walking across my keyboard.

This makes sense though, since the other thread wouldn’t have access to the main thread’s stuff.

Thanks to everyone who helped me walk through figuring this out!

3 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.