Player dies twice in a row

Godot Version

Godot v4.6 Beta 3

Question

Hello! I hope that you are doing well.

When the player dies, they respawn, and die again. Only twice, not more. Using await twice for physics frame is a workaround to the the issue, but I have read that it’s not ideal and should be avoided wherever possible, especially twice.

My platformer game has a LevelManager who handles levels, a StateMachine, and Killzone for death and hits. There is a world boundary Killzone that triggers when the player falls below it.

My theory is that when the player dies, they fall into the world boundary killzone, then they transition to the idle state, but because they are still in world boundary killzone, it triggers and causes a second death when they are teleported. Means the killzone death check is insufficient.

I can possibly add a boolean to fix this, but I think that’s more of a workaround than a real fix.

Sorry if the code below is a bit too large, I have tried my best to make it concise. It’s in order of functions called, so it should be fairly easier to navigate.

killzone.gd:

extends Area2D

enum Function { HIT, DIE }
@export var current_function: Function = Function.HIT

func _on_body_entered(player: Player) -> void:
	# Don't run logic if player is dead or hit.
	if player.state_machine.current_state in [PlayerHit, PlayerDeath]: #and current_function == Function.HIT:
		print("Skipping Killzone Logic.") # Never runs?
		return

	... # Knockback Logic.

	if current_function == Function.HIT: # Reduce health by 1.
		print("Hurt.")
		player.hurt()
	else: # Kill the player instantly.
		print("Die.")
		player.die()

player.gd (Relevant part):

func hurt() -> void:
	health -= 1 # Uses a clamp of 0 to max_health( which is 3).
	if health == 0:
		die()
		return
	hit.emit()

func die() -> void:
	died.emit()

state_machine.gd:

extends Node

@export var _initial_state: State
var current_state: State
var states: Dictionary = {}
@onready var player: Player = get_parent()

func _ready() -> void: # Add states and connect transition signals.
	player.died.connect(on_player_died)
	player.hit.connect(on_player_hit)
	for child in get_children(): # Add states and connect transition signals. 
		if child is State:
			states[child.name.to_lower()] = child
			child.player = player
			child.Transitioned.connect(on_child_transition)
	if _initial_state: # Initialize initial state.
		_initial_state.enter()
		current_state = _initial_state

func _process(delta: float) -> void: # current_state's _process.
	if current_state:
		current_state.update(delta)

func _physics_process(delta: float) -> void: # current_state's _physics_process.
	if current_state:
		current_state.physics_update(delta)

func _transition_state_to(new_state_name: String) -> void:
	var new_state = states.get(new_state_name.to_lower())
	if not new_state:
		return

	if current_state:
		current_state.exit()

	new_state.enter()

	current_state = new_state

func on_child_transition(state: State, new_state_name: String) -> void:
	# Return if current state doesn't match the state that called the function.
	if state != current_state:
		return
	_transition_state_to(new_state_name)

func on_player_hit() -> void:
	_transition_state_to("Hit")

func _on_hit_timer_timeout() -> void:
	_transition_state_to("IdleNRun")

func on_player_died() -> void:
	_transition_state_to("Death")

func _on_death_timer_timeout() -> void:
	EventBus.death_timer_timeout.emit()

player_death.gd:

extends State
class_name PlayerDeath
var player: Player

func enter() -> void:
	print("Death: ENTER.")
	Engine.time_scale = 0.5
	player.play_animation("death")
	player.play_sound("hurt")
	player.collision_shape_2d.set_deferred("disabled", true)
	player.death_timer.start()

func exit() -> void:
	print("Death: EXIT.")
	Engine.time_scale = 1.0
	player.collision_shape_2d.set_deferred("disabled", false)

level_manager.gd:

extends Node
class_name LevelManager

var _current_level: Level
var _level_counter: int = 1
@onready var player: Player = $Player

func _ready() -> void:
	EventBus.level_changed.connect(_on_level_changed)
	EventBus.death_timer_timeout.connect(_on_death_timer_timeout)
	_load_level()

func _on_level_changed() -> void:
	_level_counter += 1
	_load_level()

func _load_level() -> void:
	if _current_level:
		_current_level.queue_free()

	# Add Level as a child of LevelManager.
	var _level_source: PackedScene = load("res://scenes/levels/level_" + str(_level_counter) + ".tscn")
	_current_level = _level_source.instantiate()
	call_deferred("add_child", _current_level)
	_current_level.call_deferred("set_spawn_point", player) # Set's a Marker2D's global position to player's global position.

func _on_death_timer_timeout() -> void:
	_load_level()
	#await get_tree().physics_frame # Uncommenting these two worksaround the issue.
	#await get_tree().physics_frame
	player.health = player.max_health
	player.state_machine.call_deferred("_transition_state_to", "IdleNRun")

player_idle.gd:

extends State
class_name PlayerIdle
var player: Player

func enter() -> void:
	print("Idle.")

func physics_update(_delta: float) -> void:

	... # Some Jump Handling

	... # Idle and Run animation handling.

Output:

Idle.
Die.
Death: ENTER.
Death: EXIT.
Idle.
Die.
Death: ENTER.
Death: EXIT.
Idle.

Thanks in advance!

LL

I’m not sure, what the problem is, but maybe the player is colliding with the old level before it’s freed. Does it fix the problem if you do this on _load_level()?

if _current_level:
    _current_level.queue_free()
    remove_child(_current_level)

If it doesn’t fix the issue, the player must be colliding with the kill zone in the new level. You should be able to fix it by updating the player’s position with force_update_transform() after moving the player and before adding the new level as a child, but that function is glitchy and the bug report hasn’t been closed yet. force_update_transform() doesn't work for CharacterBody2D and AnimatableBody2D · Issue #76256 · godotengine/godot · GitHub

Try calling die() deferred:

func hurt() -> void:
	health -= 1 # Uses a clamp of 0 to max_health( which is 3).
	if health == 0:
		die.call_deferred()  # <-- changed to call_deferred()
		return
	hit.emit()

And then remove the set_deferred() calls in player_death.gd and immediately disable/enable the collision shapes.

1 Like

Is your LevelManager perhaps added to the main scene AND registered as an Autoload in the Project Settings?
If so, it will be loaded twice. You need to remove it from the main scene.

3 Likes

The remove_child(_current_level) function didn’t help.

I had tried calling the force_update_transform() inside the set_spawn_point() function inside level.gd:

func set_spawn_point(player: Player) -> void:
	player.global_position = _spawn_point.global_position
	player.force_update_transform()

Also tried calling it inside level_manager.gd:

	_current_level.call_deferred("set_spawn_point", player)
	player.force_update_transform()

Thanks for the response!

I tried this but it also didn’t help. The terminal and in-game output is still the same.

Though I had also added it inside the killzone.gd:

	else: # Kill the player instantly.
		print("Die.")
		player.die.call_deferred()

Thanks for your response!

The LevelManager is not an autoload and is the child of Game who is changed to by a MainMenu scene via change_scene_to_packed(). The tree looks something like this:

root
  Music # Autoload.
  EventBus # Autoload.
  Game # There was MainMenu before.
    LevelManager
      Player
      Level1

Thanks for your suggestion! Though I was wondering if it would be better to use an autoload? Though I highly doubt it.

What happens if you don’t re-enable collision? How many times the player dies?

Insert a break point on the first line of the die() method, then when it is called you can see in the debugging stack where it was called from. Then hit F12 to continue the simulation and see where it is called from a second time by studying the stack. You insert breakpoints by clicking on the left side of the line number in the code editor.

If I don’t re-enable collision, the player respawns but immediately falls through the floor. Here is the terminal output:

Idle.
Die.
Death: ENTER.
Death: EXIT.
Idle.

Thanks!

I tried but I couldn’t find where it says where it shows from where it was called from. It’s because of inexperience, I couldn’t really find anything in the documentation either (ended up creating a PR to clarify something, lol).

What I noticed was, at the second call, the player is already on the platform.

Thanks!

The problem is likely in how you sequenced/timed various state changes. Perhaps make a MRP and post it. Hard to debug without being able to run the code.

I just went through the same thing with my player respawn. I suggest you get it fix with a bool like you said first. If you could do that then you should be able to pinpoint exact location of error. Right now there are just so many checks that makes it hard to see the problem even with print statement.

You could also try disabling all death/respawn functions after health = 0, then slowly re-enabling one by one to see which is the problematic one.

Call print_stack to check the call stack. If the problem persists, add a member to check if the system is dead, as a temporary fix. Try resolving the issue later with more experience.