Game freezes then crashes, can't figure out why

Godot Version

4.7.1 stable Windows 11

Question

I’m playtesting my game, specifically trying to fight a boss, and when attacking the boss, my game always ends up freezing for a few seconds, and then crashing. The profiler shows a spike in frame time, specifically process time, but it doesn’t show any other functions with a particularly high frame time. The log in %AppData%/Roaming/Godot/app_userdata only shows some warnings, the same as the Errors tab in the debugger, but it doesn’t show me what the last functions running were when the game crashed. It happens every time, but at a different time everytime.

Of course, this isn’t enough information to figure out what the problem is, but my question is how do I even go about debugging it so I can get the information? There’s no error message, and I haven’t found anything pointing to where the problematic code is, so I can’t post the code either. Just knowing what the last functions running were when the game crashed would help a lot, but I can’t find it anywhere. Any help is appreciated.

If I had to guess with such little info, it’s either a while loop somewhere you wrote that never actually ends and you get a stack overflow, OR you used await in several places that would somehow block the main thread. I lean towards the former.
As for how to debug, see if you do actually have any while functions and post that here, any anything that calls the function that contains that while loop.
Otherwise, start putting print statements in your code, in any relevant function, and see after which print your game stops.

I looked through every while loop and await in my code (which was a lot) and didn’t find anything suspicious. But I did try individually removing specific nodes from the scene one by one to find out if any of them was causing it, and I believe the problem comes from this function:

	if OWNER.is_attacking :
		shoot_sound.set_pitch_scale(randf_range(0.9, 1.1))
		shoot_sound.play()
		var howmany : int = amount_r
		var mouse_angle : float = get_angle_to(OWNER.aim_position)
		var starting_rotation : float = (howmany*(PI/24))/2
		for i : int in howmany :
			var radius : int = 5
			var theta : float = mouse_angle - starting_rotation + ((PI/10) * i)
			### attack copier array key: [copier node, reorient aim?, copier damage multiplier]
			print(OWNER.attack_copier_list)
			for copier : Array in OWNER.attack_copier_list :
				var needle : Area2D = spawn_projectile(needle_scene, copier[copier_node].global_position+Vector2(radius * cos(theta), radius * sin(theta)), base_damage * damage_multiplier * copier[copier_damage])
				if copier[copier_reorient] :
					needle.direction = copier[copier_node].get_angle_to(copier[copier_node].aim_position) - starting_rotation + ((PI/10) * i)
				else :
					needle.direction = mouse_angle - starting_rotation + ((PI/10) * i)
		
		attack_fired.emit(attack_name)
		
		available = false
		await(get_tree().create_timer((1.0/5.0) * cooldown_r, false).timeout)
		available = true

func _process(delta : float) -> void:
	if available :
		get_input()

The print function returns this:
[[PlayerCharacter:<CharacterBody2D#2756798729836>, false, 1.0], [Copy Doll:<Node2D#2764415586187>, false, 0.6], [@Node2D@41527:<Node2D#2765254447030>, false, 0.6], [@Node2D@41531:<Node2D#2765791317961>, false, 0.6], [@Node2D@41535:<Node2D#2766328188892>, false, 0.6]]

It seems that the more items there are in OWNER.attack_copier_list, the faster, on average, the game freezes and crashes. The printed array also tells me that, besides the player character, the first node in the array has the name “Copy Doll” as intended, but the following ones don’t, even though they’re instances of the same scene.

The code on the Copy Doll scene is this:


var speed : float = 0.0
var is_familiar : bool = true
@onready var sprite : Sprite2D = $Sprite2D
var sinewaving_factor : float = 0.0
var OWNER : Node2D
var aim_position : Vector2

func _process(delta: float) -> void:
	sprite.position.y = 0.0 + 5.0 * sin(sinewaving_factor)
	sinewaving_factor += 2.0 * delta

func _physics_process(delta: float) -> void:
	var owner_local_aimpos : Vector2 = OWNER.aim_position - OWNER.global_position
	aim_position = global_position + owner_local_aimpos

This is all helpful information, but I still can’t see what in any of these code snippets could be causing a freeze or a crash.

Can the OWNER attack faster than this timeout? You might just be stacking more and more awaits if it’s possible for this code to run faster than the timeout.

This is normal, see the docs of add_child about force_readable_name.

OWNER.is_attacking is a bool that checks if a button is held down, it’s not a one-time signal or a function, if that’s what you mean. I also tried moving the if available check from the _process function to the get_input function, right after the if OWNER.is_attacking check, but that didn’t fix it. There’s nothing else that is calling get_input, it’s only the _process function on the same script, which is then gated by an if available check. Tried making the _process function a _physics_process, didn’t do anything.

On the line directly below if OWNER.is_attacking:, try putting if available == false: return

Result is the same.

From my perspective, it doesn’t look like a stuck while / await from what you posted.

What stands out is the spawn rate:
howmany x attack_copier_list.size() Area2Ds every shot.
More copiers → crash sooner matches “too many nodes / collisions,” not a hung timer.

Can you check:

  1. Does spawn_projectile always queue_free / free needles (lifetime timer, off-screen, hit)?
  2. How big is amount_r during the boss fight?
  3. Remote scene tree when it starts hitching - are needle counts climbing without bound?

If you can paste spawn_projectile and whatever cleans up needles, that’ll narrow it fast.

  1. The needles have an autostart timer that queue_free()s them after a few seconds.
  2. One.
  3. I can’t seem to be able to view my remote scene tree while the game is running, it’s just entirely empty no matter when I look at it.

spawn_projectile is this:

	var new_projectile : Area2D = scene_to_use.instantiate()
	new_projectile.OWNER = OWNER
	new_projectile.PLAYEROWNER = PLAYEROWNER
	new_projectile.damage = dmg
	new_projectile.rangestat = rangestat_r
	new_projectile.pierce = pierce_r
	new_projectile.critchance = critchance
	new_projectile.critpower = critpower
	new_projectile.weapon_type = weapon_type
	WaveManager.stage_node.add_child(new_projectile)
	new_projectile.global_position = pos
	return new_projectile

And the script on the needle scenes that cleans them up is:

	falltimer.wait_time = 0.7 * rangestat
	falltimer.start()
	lifespantimer.wait_time = 1.1 * rangestat
	lifespantimer.start()

func _on_fall_timer_timeout() -> void:
	is_flying = false

func _on_lifespan_timer_timeout() -> void:
	queue_free()

Empty Remote like yours usually means the debugger isn’t connected, or you’re checking after the game already died. It only fills while you run from the editor (F5), and you have to open Remote before it freezes.

Every second or so is enough. If that number keeps climbing, needles (or something else parented there) are piling up. If it stays low and you still crash, it’s probably not “too many Area2Ds.”

Cleanup looks fine if those timer start() calls always run. Where do you call that _ready? You set rangestat before add_child, so that part should be OK. How big is rangestat on the boss fight tho? 1.1 * rangestat as lifespan gets long fast if that value is high, and with several copiers firing you can still flood the tree even with amount_r = 1.

Drop the cooldown / rangestat values you use in that fight (and the child_count numbers if you try it).

My remote viewer seems to be half-working now, I open it as soon as the game boots up, while it’s still working, but after just a few seconds, long before it dies, it stops updating. At that point I can see everything as intended.

I tried putting print(WaveManager.stage_node.get_child_count()) and it never goes over 200.

Where do you call that _ready?

In the needle scene’s script.

cooldown_r and rangestat are both exactly 1.0 during testing.

Stage children staying under ~200 pretty much rules out a node leak. Timers at 1.0 don’t look suspicious either.

The Remote tree freezing before the game dies makes me think the main thread is getting hung up somewhere. That’s consistent with the freeze before the crash.

Since adding more copiers makes it happen faster, I’d start looking at the hit path instead of the spawn path. Check whatever runs when a needle lands (area_entered, body_entered, boss damage, etc.).

I’d also try a run with only the player in attack_copier_list. If that’s stable but adding dolls brings the crash back, I’d blame the extra hit processing rather than spawning.

I’ve managed to narrow the problem down to this function in the enemy script:

func play_hit_particles(is_critical : bool) -> void :
	var particles_instance : Node2D
	if is_critical :
		particles_instance = WaveManager.enemy_crit_particles_scene.instantiate()
	else :
		particles_instance = WaveManager.enemy_hit_particles_scene.instantiate()
	
	var particle_lifetime : float = 0.6
	if not particles_instance == null :
		particles_instance.lifetime = particle_lifetime
		add_child(particles_instance)

enemy_hit_particles_scene and enemy_crit_particles_scene are very similar scenes, just a single Node2D with a few GPUParticles2D’s as children. They also use the same script, which is just this:

extends Node2D

var lifetime : float = 1.0

func _ready() -> void:
	for i : GPUParticles2D in get_children() :
		i.emitting = true
		i.lifetime = lifetime
	await(get_tree().create_timer(lifetime, false).timeout)
	queue_free()

Could the problem be because there are too many particles active? One of the GPUParticles2D in each of my particle instances has around 180 particles in the shape of a ring, which I did in place of hand-animating a ring effect to minimise workload, but if this is the cause of the problem I might have to fix that.

GPUParticles2D kinda changed the way,

Every hit is instantiating a new scene under the enemy, so with multiple copiers you’re stacking a lot of particle nodes. Those won’t show up under WaveManager.stage_node, so that count can stay low even if the enemy ends up with hundreds of children.

I’d try disabling play_hit_particles() entirely for one run. If the crash disappears, you’ve narrowed it down pretty quickly.

If that’s the culprit, stop spawning a new scene every hit. Either keep one particle node around and call restart(), or just refuse to spawn another one while the current effect is still playing.

Also try cutting the particle count from 180 down to something like 32 just to see if the crash timing changes.

That was where the problem was, thank you. I managed to fix it by making a few changes.

First, the enemy script now instantiates and keeps just a few particle emitter nodes to be reused:

var hit_particles_instances : Array = []
var last_hit_particle_used : int = 0

func _ready() -> void:
	for i : int in range(5) :
		var new_hit_particle_instance : Node2D = WaveManager.enemy_hit_particles_scene.instantiate()
		add_child(new_hit_particle_instance)
		hit_particles_instances.append(new_hit_particle_instance)

func play_hit_particles(is_critical : bool) -> void :
	var particle_lifetime : float = 0.6
	if is_critical :
		crit_particles_instances[last_crit_particle_used].lifetime = particle_lifetime
		crit_particles_instances[last_crit_particle_used].emit_particle_cycle()
		last_crit_particle_used += 1
		if last_crit_particle_used > crit_particles_instances.size() -1 :
			last_crit_particle_used = 0
	else :
		###same thing

Second, I made the particle emitter script fit to be reusable instead of a one-shot:

extends Node2D

var lifetime : float = 1.0

func emit_particle_cycle() -> void:
	for i : GPUParticles2D in get_children() :
		#i.emitting = true
		i.lifetime = lifetime
		i.restart()

And finally, I reduced the particle count on the heavier GPUParticles2D’s. This is a temporary fix, planned to hand-animate it instead of relying on particles to generate the ring shape.