Saltmire
Practical Godot 4 tutorials — game feel, saving, and shipping polish fast.

Fixing 'previously freed instance' errors in Godot 4 combat code

Combat is where Godot 4 projects start crashing for the first time. Everything works until two things die in the same frame, or a homing bullet outlives its target, and the debugger stops on one of these:

  • Trying to call a function on a previously freed instance.
  • Invalid access to property or key 'global_position' on a base object of type 'previously freed'.
  • Cannot call method 'take_damage' on a null value.
  • Resumed function 'flash()' after await, but class instance is gone.

(Exact wording shifts a little between 4.x versions — older builds say Invalid get index ... (on base: 'previously freed') — but the causes are the same.)

"Previously freed" means your variable still points at a node that queue_free() already deleted. The reference isn't null — it's a dangling pointer, so if target: and if target != null: can both let it through. Here are the four ways combat code ends up holding one, ordered by how often they show up.

1. A stored reference outlives the enemy

Symptom. A homing projectile, a turret or an enemy AI crashes a few frames after its target dies.

Cause. You saved the target in a variable (var target: Node2D) and kept using it. Something else killed the target and called queue_free(). Your variable never heard about it.

Fix. Check is_instance_valid() before every use — it's the only check that catches a freed node:

var target: Node2D

func _physics_process(delta: float) -> void:
    if not is_instance_valid(target):
        target = null
        _pick_new_target()
        return
    var dir := global_position.direction_to(target.global_position)
    velocity = dir * speed
    move_and_slide()

If many systems track the same enemy, clear the reference at the source instead of checking everywhere. tree_exiting fires right before the node leaves the tree:

func set_target(t: Node2D) -> void:
    target = t
    t.tree_exiting.connect(func(): target = null, CONNECT_ONE_SHOT)

2. The reference is used again after an await

Symptom. Hit-flash or knockback code crashes only sometimes, usually on the killing blow.

Cause. await pauses your function. Anything can happen while it's paused — including the enemy dying. When the function resumes, every node reference captured before the await is suspect.

# crashes when the hit also kills the enemy
func hit_flash(enemy: Node2D) -> void:
    enemy.modulate = Color.RED
    await get_tree().create_timer(0.1).timeout
    enemy.modulate = Color.WHITE   # enemy may be gone by now

Fix. Treat every await as a checkpoint and re-validate after it:

func hit_flash(enemy: Node2D) -> void:
    enemy.modulate = Color.RED
    await get_tree().create_timer(0.1).timeout
    if not is_instance_valid(enemy):
        return
    enemy.modulate = Color.WHITE

Even better: put the flash on the enemy itself, as a tween on its own sprite. A tween created with the node's create_tween() is bound to that node and dies with it, so there is nothing left to resume.

3. Two hits land in the same physics frame — the enemy dies twice

Symptom. No crash, but a shotgun blast drops double XP, plays the death sound twice, or increments the kill counter by two.

Cause. queue_free() doesn't delete immediately — it deletes at the end of the frame. If two bullets overlap the enemy in the same frame, both callbacks run while the node is still perfectly valid, and both see hp <= 0.

Fix. Guard the death path with a flag (or is_queued_for_deletion()):

var dead := false

func take_damage(amount: float) -> void:
    if dead:
        return
    hp -= amount
    if hp <= 0.0:
        dead = true
        died.emit()
        drop_xp()
        queue_free()

Bullets that hit should also stop themselves right away — set_deferred("monitoring", false) then queue_free() — so a single bullet can't register on two enemies in its last frame.

4. get_parent() chains return something you didn't expect

Symptom. Cannot call method 'take_damage' on a null value, or Nonexistent function 'take_damage' in base 'Area2D'.

Cause. The attacker reaches into the victim: area.get_parent().take_damage(10). That works until one enemy has its hurtbox nested one level deeper, or the parent is a pooled node that's already been detached.

Fix. Don't make the attacker know the victim's tree. Check the contract instead of the shape:

func _on_area_entered(area: Area2D) -> void:
    var victim := area.owner
    if is_instance_valid(victim) and victim.has_method("take_damage"):
        victim.take_damage(damage)

The pattern that removes most of these

Notice what causes 1, 2 and 4 have in common: the attacker holds a reference to the victim and calls into it. Flip that around and most of the bugs disappear. The attacker only overlaps an area; the victim's own hurtbox emits a signal; the victim handles its own damage, death and flash. When the victim is freed, its signal connections go with it — nobody is left holding a stale pointer.

# on the enemy — it owns its own death
func _ready() -> void:
    $Hurtbox.hurt.connect(_on_hurt)

func _on_hurt(amount: float, _source: Node, knockback: Vector2) -> void:
    take_damage(amount)
    velocity += knockback

That's exactly the shape Saltmire Hitbox is built around: drop a Hitbox2D on the attack and a Hurtbox2D on anything that can be hurt, and damage arrives as a hurt signal on the victim's side. The hitbox never calls into your enemy's script, one_shot stops a lingering swing from hitting the same target every frame, and i-frames are one inspector field. You still want the dead flag from cause 3 in your own death code — but the dangling-reference class of crashes mostly stops existing.

Built this the long way once too many times. Saltmire Hitbox does it as a drop-in tool: https://saltmire.itch.io/saltmire-hitbox