RigidBody2D "vanishes" randomly on collision

Godot Version

v4.6.2.stable.official [71f334935]

Context

Hi :slight_smile: ,
So I’ve been working on yet another side project where you play as a mage manipulating furniture to kill enemies. I’ve got very little experience with the Godot physics engine so this is kind of a learning project.

Question

Sometimes (seemingly randomly), while a piece of furniture is colliding with something, it suddenly gains a huge amount of angular velocity and teleports somewhere in the scene, usually off-screen (I assume this is because of high linear velocity). This is pretty rare, I had to record 6 minutes of gameplay to find an occurrence, but I think it’s a sign that I’m incorrectly using the physics system somehow. Here’s an example of the phenomenon:


Does anyone know what might be causing this to happen?

Edit: I took care to never set the RigidBody2D velocity directly and to instead use apply_central_force(), apply_torque(), and apply_impulse()

References

This is the scene of the vanishing object:


The CollisionPolygon2D:

And some of the RigidBody2D settings:

Full scene file
[gd_scene format=3 uid="uid://cadg3ixkgepqv"]

[ext_resource type="PackedScene" uid="uid://csywxag822rb5" path="res://furniture/furniture.tscn" id="1_3sguc"]
[ext_resource type="Texture2D" uid="uid://c26c6d2eyx86e" path="res://assets/sprites/furniture/table.png" id="2_a70le"]
[ext_resource type="Texture2D" uid="uid://ds2cntfkwgi2u" path="res://assets/sprites/furniture/table-tag.png" id="3_uqgoh"]

[sub_resource type="OccluderPolygon2D" id="OccluderPolygon2D_uqgoh"]
polygon = PackedVector2Array(-46, 33, -60, -27, 57, -27, 42, 33, 34, -16, -37, -16)

[node name="Table" unique_id=1119687450 node_paths=PackedStringArray("collision_players") instance=ExtResource("1_3sguc")]
collision_players = {
&"Furniture": NodePath("FurnitureCollision"),
&"Player": NodePath("PlayerCollision"),
&"TileMapLayer": NodePath("MapCollision")
}
killer_name = "Table"

[node name="Furniture" parent="Sprites" parent_id_path=PackedInt32Array(1077350344) index="0" unique_id=996704128]
texture = ExtResource("2_a70le")

[node name="Tag" parent="Sprites" parent_id_path=PackedInt32Array(1077350344) index="1" unique_id=212728429]
texture = ExtResource("3_uqgoh")

[node name="CollisionPolygon2D" parent="." index="1" unique_id=248987615]
polygon = PackedVector2Array(-60, -27, 57, -27, 42, 33, 34, -16, -37, -16, -46, 33)

[node name="LightOccluder2D" parent="." index="2" unique_id=746587377]
occluder = SubResource("OccluderPolygon2D_uqgoh")

[node name="VisibleOnScreenNotifier2D" parent="." index="3" unique_id=2022141694]
rect = Rect2(-63, -37, 126, 74)
The furniture script
class_name Furniture
extends RigidBody2D

signal collided(state: PhysicsDirectBodyState2D, contact_index: int)

@export_group("References")
@export var tag: Sprite2D
@export var visible_on_screen_notifier: VisibleOnScreenNotifier2D
@export var whoosh_player: AudioStreamPlayer2D
@export var collision_players: Dictionary[StringName, AudioStreamPlayer2D]
@export_group("")

@export_group("audio")
@export_range(0.0, 1.0, 0.0001) var whoosh_volume_multiplier: float
@export var whoosh_pitch_offset_multiplier: float
@export var collision_volume_multiplier: float
@export_group("")

@export var kill_velocity: float
@export var max_velocity: float

@export var killer_name: String

@export var actor: Creature

# override

func _ready() -> void:
	whoosh_player.play()

func _process(_delta: float) -> void:
	tag.modulate = actor.color if actor else Color.WHITE
	whoosh_player.volume_linear = whoosh_volume_multiplier * linear_velocity.length()
	whoosh_player.pitch_scale = max(1.0 + whoosh_pitch_offset_multiplier * abs(angular_velocity), 0)

func _integrate_forces(state: PhysicsDirectBodyState2D) -> void:
	var velocity: Vector2 = state.linear_velocity
	if velocity.length() > max_velocity:
		state.linear_velocity = velocity.normalized() * max_velocity

	for i in range(state.get_contact_count()):
		if not state.get_contact_collider_object(i):
			continue

		collided.emit(state, i)
		_collision(state, i)

		var creature: Creature = state.get_contact_collider_object(i) as Creature
		if not creature:
			continue

		if not _should_kill(state, i, creature):
			continue

		creature.kill(killer_name, velocity)

# other

func is_on_screen() -> bool:
	return visible_on_screen_notifier.is_on_screen()

func _collision(state: PhysicsDirectBodyState2D, index: int) -> void:
	var collider: Object = state.get_contact_collider_object(index)

	var script := collider.get_script() as GDScript
	var collider_type: StringName = script.get_global_name() if script else StringName(collider.get_class())

	var player: AudioStreamPlayer2D = null
	for type: StringName in collision_players.keys():
		if collider_type != type:
			continue

		player = collision_players[type]
		break

	if not player:
		return

	var relative_velocity: Vector2 = state.get_contact_local_velocity_at_position(index) - state.get_contact_collider_velocity_at_position(index)
	player.volume_linear = collision_volume_multiplier * relative_velocity.length()
	player.play()

# - virtual

func _should_kill(state: PhysicsDirectBodyState2D, contact_index: int, victim: Creature) -> bool:
	if victim == actor:
		return false

	var actual_kill_velocity: float = 2 * kill_velocity if victim.is_in_group("Player") else kill_velocity

	if state.get_contact_local_velocity_at_position(contact_index).length() < 10.0:
		return false
	var relative_velocity: Vector2 = state.get_contact_local_velocity_at_position(contact_index) - state.get_contact_collider_velocity_at_position(contact_index)
	return relative_velocity.dot(-state.get_contact_local_normal(contact_index)) >= actual_kill_velocity
And the player script
@tool
class_name Player
extends Creature

signal killed(killer_name: String)

enum Action {
	NONE = 0,
	GRAB,
	PUSH,
}

@export_group("References")
@export var hand: Marker2D
@export var wand: Wand
@export var body: Sprite2D
@export var hat: Sprite2D
@export var walk_player: AudioStreamPlayer2D
@export var jump_player: AudioStreamPlayer2D
@export var fall_player: SFXPlayer
@export var land_player: AudioStreamPlayer2D
@export_group("")

@export_group("Stats")
@export var max_speed: float
@export var ground_acceleration: float
@export var ground_deceleration: float
@export var air_multiplier: float

@export var jump_strength: float

@export var action_angular_strength: float
@export var push_strength: float
@export var swing_strength: float
@export_group("")

@export_group("audio")
@export var walk_volume_multiplier: float
@export var fall_volume_multiplier: float
@export var fall_fade_out_duration: float
@export_group("")

@export var input_device := InputDevice.new()
@export var automatic_device_type: bool

@export var invincible: bool
## If false, the player can only control the furniture they controlled last.
@export var allow_furniture_change: bool = true
## If false, only furniture with a null actor can be controlled.
@export var allow_furniture_steal: bool = true

var _jump_cancelled: bool = false

var _dead: bool = false

var _current_action := Action.NONE
var _action_furniture: Furniture = null

var _last_controller_mouse_dir := Vector2.LEFT

var _face_right: bool = false:
	get:
		return _face_right
	set(value):
		_face_right = value
		body.scale.x = -1 if _face_right else 1
		wand.global_position = hand.global_position
var _landed: bool = false

# override

func _ready() -> void:
	if Engine.is_editor_hint():
		return

	input_device.setup_actions()

func _process(_delta: float) -> void:
	hat.modulate = color

func _physics_process(delta: float) -> void:
	if Engine.is_editor_hint():
		return

	if not _dead:
		_actions()
	_move(delta)

	if not is_on_floor():
		velocity += get_gravity() * delta

	if velocity.x > 0:
		_face_right = true
	elif velocity.x < 0:
		_face_right = false

	move_and_slide()

	var on_floor: bool = is_on_floor()
	walk_player.volume_linear = walk_volume_multiplier * abs(velocity.x) * int(on_floor)
	fall_player.volume_linear = fall_volume_multiplier * Vector2(velocity.x, max(velocity.y, 0)).length()
	if on_floor:
		if not _landed:
			fall_player.fade_out(fall_fade_out_duration)
			land_player.play()
	elif not fall_player.playing:
		fall_player.fade_in(0.0)

	_landed = on_floor

func _input(event: InputEvent) -> void:
	if Engine.is_editor_hint():
		return

	if not automatic_device_type:
		return

	input_device.is_joypad = InputDevice.is_event_joypad(event)

func _exit_tree() -> void:
	if input_device:
		input_device.cleanup_actions()

# - custom

func kill(killer_name: String, force: Vector2) -> void:
	if invincible:
		return

	_dead = true
	velocity += force
	killed.emit(killer_name)

func revive() -> void:
	_dead = false

# other

func _actions() -> void:
	if not _action_furniture:
		_current_action = Action.NONE

	var mouse_dir: Vector2 = _get_mouse_dir()
	if _current_action == Action.PUSH:
		mouse_dir = hand.global_position.direction_to(_action_furniture.global_position)

	wand.rotation = mouse_dir.angle()

	var furniture_filter := func(furniture: Node2D) -> bool:
		if not furniture is Furniture:
			return false
		if not allow_furniture_steal and (furniture as Furniture).actor and (furniture as Furniture).actor != self:
			return false
		if not allow_furniture_change and _action_furniture and furniture != _action_furniture:
			return false
		return (furniture as Furniture).is_on_screen()
	var target_furniture: Furniture = null
	if _current_action == Action.NONE:
		var all_furniture: Array[Node2D] = []
		all_furniture.assign(get_tree().get_nodes_in_group("Furniture").filter(func(a: Node) -> bool: return is_instance_valid(a)))
		target_furniture = AimAssist.pick_target(hand.global_position, all_furniture.filter(furniture_filter), mouse_dir)

	var passive: bool = true
	if _should_grab(target_furniture):
		_current_action = Action.GRAB
		passive = false
	if _should_push(target_furniture):
		_current_action = Action.PUSH
		passive = false
	if passive:
		_current_action = Action.NONE

	if not passive and target_furniture:
		if _action_furniture:
			_action_furniture.actor = null
		_action_furniture = target_furniture
		_action_furniture.actor = self

	match _current_action:
		Action.NONE:
			wand.state = Wand.State.PASSIVE if target_furniture else Wand.State.DISABLED
		Action.GRAB:
			wand.state = Wand.State.GRAB
			_grab(mouse_dir)
		Action.PUSH:
			wand.state = Wand.State.PUSH
			_push(mouse_dir)

func _move(delta: float) -> void:
	var dir: float
	if _dead:
		dir = 0.0
	else:
		dir = Input.get_axis(input_device.get_custom_action("move_left"), input_device.get_custom_action("move_right"));

	var acceleration: float = ground_acceleration if sign(dir) == sign(velocity.x) else ground_deceleration
	if not is_on_floor():
		acceleration *= air_multiplier

	var velocity_target: float = 0.0 if abs(dir) < 0.1 else dir * max_speed;
	velocity.x = move_toward(velocity.x, velocity_target, acceleration * delta);

	if is_on_floor() and Input.is_action_just_pressed(input_device.get_custom_action("jump")):
		velocity.y = -jump_strength;
		jump_player.play()
	if velocity.y < 0.0:
		if not _jump_cancelled and not Input.is_action_pressed(input_device.get_custom_action("jump")):
			velocity.y /= 2.0;
			_jump_cancelled = true
	else:
		_jump_cancelled = false

func _should_grab(target_furniture: Furniture) -> bool:
	if not Input.is_action_pressed("grab"):
		return false

	if _current_action == Action.NONE and target_furniture and Input.is_action_pressed(input_device.get_custom_action("grab")):
		return true

	return _current_action == Action.GRAB

func _should_push(target_furniture: Furniture) -> bool:
	if not Input.is_action_pressed("push"):
		return false

	if _current_action == Action.NONE and target_furniture and Input.is_action_pressed(input_device.get_custom_action("push")):
		return true

	return _current_action == Action.PUSH

func _grab(aim: Vector2) -> void:
	_action_furniture.apply_torque(action_angular_strength)

	var angle_to_furniture: float = hand.global_position.direction_to(_action_furniture.global_position).angle()
	var ray_angle: float = aim.angle()
	var swing_dir := Vector2.from_angle(angle_to_furniture).direction_to(Vector2.from_angle(ray_angle))
	_action_furniture.apply_central_force(swing_dir * abs(angle_difference(ray_angle, angle_to_furniture)) * swing_strength)

func _push(aim: Vector2) -> void:
	_action_furniture.apply_central_force(aim * push_strength)
	_action_furniture.apply_torque(action_angular_strength)

func _get_mouse_dir() -> Vector2:
	if input_device.is_joypad:
		var dir: Vector2 = Input.get_vector(
			input_device.get_custom_action("look_left"), input_device.get_custom_action("look_right"),
			input_device.get_custom_action("look_up"), input_device.get_custom_action("look_down")
		)
		if dir == Vector2.ZERO:
			return _last_controller_mouse_dir
		_last_controller_mouse_dir = dir
		return dir

	return hand.global_position.direction_to(get_global_mouse_position())

Here’s what I would do to debug it: Create a label on screen and just update it with the table’s velocity every frame. See if it’s building up over time.

P.S. Very amusing game concept. Looks fun!

Thanks! I did that, as well as printing a “velocity history” when it goes crazy and both angular and linear velocity are completely normal up until that point, they just spike up instantly to some big value… but I suppose now there is a bit more insight into what is happening:

  • It doesn’t matter if the table is moving/rotating, it can vanish either way
  • The linear velocity after vanishing is usually around 500 to 1000 (which makes sense as I’ve set the maximum velocity to 1000)
  • The angular velocity ranges from 100 to over 2000

So next thing I’d try is testing your supposition that it’s high velocity that does it and cap it at a much lower rate - something that may be too slow for your playability and see if you can reproduce the bug. If you can then it’s not the velocity. If you can’t, then it is the velocity and you can start looking at whether there’s a new cap or something else you can do - like adding a Physics Material that absorbs some of the velocity on impact.

You could try to use a separate area to detect colissions. I have found that sometimes when two bodies of various kinds collide and use their inbuilt areas to detect each other and apply behaviour simultaneously, the behaviour can be erratic and velocity shoots straight up into the air.

Above only applies assuming your problems occur in situations like that. Two bodies detecting each other using their main body area, not a separate area node.

The bug is still there after disabling all the manual collision detection, so apparently that wasn’t the issue.

It still happens with max_velocity 300, which is much lower than the usual velocity after the vanishing, I guess the problem isn’t the velocity after all…

In the meantime, I have tried removing the furniture physics material altogether, fixing minor bugs in furniture.gd (using outdated velocity value, passing physics state to functions), commenting all functions in furniture.gd and disabling enemies. None of it has worked.

I’m really sorry for not being more helpful, I’m just really lost :sweat_smile: I will try reproducing the behaviour in a new project to see what causes it

So my next suggestion is comment out your _integrate_forces() function and see if that fixes the problem. Because I’m not sure, but I suspect that trying to limit the velocity might be your problem. If you can get it working without that, then I’d try adding a reverse impulse to slow it down.

My gut also says your code is overcomplicated. I typically rely a lot more on the physics engine with RigidBody2D objects.

You could also refactor a lot of that code out. For example, when you hit something, if it’s got a hit_by() function, pass a reference of the table and let the target calculate if it dies.

I could have sworn I had already checked this, but I tried reproducing the bug with a new scene and all it took was to set the collision layers & mask. So I fixed them in the entire project (they were kind of messed up, I didn’t really put much thought into it before) and now it works…

I will obviously still be applying the last advice from @dragonforge-dev (I always struggle with overcomplication :sweat_smile: ) but at least this bug is fixed..

TL;DR fix your collision layers, the docs are as always a good starting point to understand them: