Godot 4: Detect body collision before adding Node to scene in a 2D game (part 2).

Godot 4: Detect body collision before adding Node to scene in a 2D game (part 2).

Learning Godot

·

10 min read

Introduction

In my previous blog post, I explained how we can detect two overlapping objects before making it visible in the scene. I achieved this with an Area2D and a CollisionShape2D. However, this was not the ideal solution I was aiming for, as it still required adding an invisible object to the scene and working with signals and await to wait for the first frame to be rendered in order to detect a collision.

After some Googling, I found a different (and in my opinion, a better) solution by making use of the PhysicsDirectSpaceState2D class and performing a query with the PhysicsShapeQueryParameters2D class.

The PhysicsDirectSpace2D class has the following description:

Provides direct access to a physics space in the PhysicsServer2D. It's used mainly to do queries against objects and areas residing in a given space.

So we can do queries on a 2D space. That's interesting. So how do we actually do queries? The PhysicsDirectSpace2D class has a function intersect_shape (docs), which requires as first parameters an instance of the class PhysicsShapeQueryParameters2D :

By changing various properties of this object, such as the shape, you can configure the parameters for PhysicsDirectSpaceState2D.intersect_shape.

Ok, let's see how this works.

The good solution 2: Making queries on the PhysicsDirectSpace2D

Let's clean up the project first by removing unnecessary code and go back to a state where we programmatically add a planet again. When we run the game, we will once again see two planets, but without the SpawnBoundary nodes from the previous solution:

Let's attempt to use the PhysicsDirectSpaceState2D class in the Game scene script:

  1. First get the Space state by using var space_state = get_world_2d().direct_space_state. This returns an instance of PhysicsDirectSpaceState2D:
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state
  1. Now, we want to perform a query on the space state using the intersect_shape function. Remember that the first parameter is an instance of the PhysicsShapeQueryParameters2D class. So, let's create that first:
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()
  1. Now, we want to define two things: the shape [docs] we want to use to query the space state—in our case, a circle—and also the origin of the shape, indicating where we want to place the circle to determine what is inside it. We can use the transform [docs] property for this. The transform property is a Transform2D object, which contains the origin property [docs]. Let's start by creating the shape. I'm using a radius of 200 pixels::
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()

    # 3. Create the Circle shape
    var shape = CircleShape2D.new()
    shape.radius = 200
  1. Now we are gonna set the properties for the PhysicsShapeQueryParameters2D instance:
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()

    # 3. Create the Circle shape
    var shape = CircleShape2D.new()
    shape.radius = 200

    # 4. Set the shape and origin
    shape_query_params.shape = shape
    shape_query_params.transform.origin = Vector2(500, 300)
  1. Let's now execute the query on the space state and print the results:
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()

    # 3. Create the Circle shape
    var shape = CircleShape2D.new()
    shape.radius = 200

    # 4. Set the shape and origin
    shape_query_params.shape = shape
    shape_query_params.transform.origin = Vector2(500, 300)

    # 5. Perform the query
    var results = space_state.intersect_shape(shape_query_params)
    print(results)
  1. For debugging purposes, let's draw an Area2D node with a CollisionShape2D child node on top of the CircleShape2D:
extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(600, 300)

    add_child(second_planet)

    # Detect other objects in Space

    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()

    # 3. Create the Circle shape
    var shape = CircleShape2D.new()
    shape.radius = 200

    # 4. Set the shape and origin
    shape_query_params.shape = shape
    shape_query_params.transform.origin = Vector2(500, 300)

    # 5. Perform the query
    var results = space_state.intersect_shape(shape_query_params)
    print(results)

    # 6. Debug
    var area_2d = Area2D.new()
    area_2d.position = shape_query_params.transform.origin

    var collision_shape_2d = CollisionShape2D.new()
    var collision_shape = CircleShape2D.new()
    collision_shape.radius = shape.radius
    collision_shape_2d.shape = collision_shape

    area_2d.add_child(collision_shape_2d)
    add_child(area_2d)
  1. Be sure that in the debug menu you have enabled 'Visible Collision Shapes'. Now let's run the game!

We will observe two planets intersecting with the query that we performed on the space state. Remember that the CollisionShape2D is just for demonstration purposes to indicate the part where we execute the query. If we examine the output, we observe the following result (in a more organized format):

[
   {
      "rid":RID(2675764625408),
      "collider_id":26508002450,
      "collider":"Planet":<CharacterBody2D#26508002450>,
      "shape":0
   },
   {
      "rid":RID(2774548873217),
      "collider_id":26675774619,
      "collider":"@CharacterBody2D@2":<CharacterBody2D#26675774619>,
      "shape":0
   }
]

Exactly as expected, we see that the query returned two results. Now let's move the second planet a bit more to the right by changing the Vector2 coordinates to 800, 300:

extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # Programmatically add planet
    var second_planet = planet.instantiate() as CharacterBody2D
    second_planet.position = Vector2(800, 300)

    add_child(second_planet)

# ...other code

Now let's run the game again:

The query is intersecting now only one planet. We also see this in the output:

[
   {
      "rid":RID(2675764625408),
      "collider_id":26508002450,
      "collider":"Planet":<CharacterBody2D#26508002450>,
      "shape":0
   }
]

Great! It seems to work.

Filtering the collisions

For my game, I want to detect if there is a planet nearby. However, with the current script, it will also detect other areas or bodies. Let's demonstrate that by creating a new scene, "Player," which is just a basic setup of a CharacterBody2D, a Sprite2D, and a CollisionShape2D.

Now let's link the new Player scene to the game scene and place it close to the planet:

Let's run the game and see what the output will say:

Output:

[
   {
      "rid":RID(2692944494592),
      "collider_id":26860323986,
      "collider":"Planet":<CharacterBody2D#26860323986>,
      "shape":0
   },
   {
      "rid":RID(2710124363777),
      "collider_id":26910655639,
      "collider":"Player":<CharacterBody2D#26910655639>,
      "shape":0
   }
]

As you can see, it now also collides with the Player node. Therefore, we need to filter this array and only return the planets. The result is, in fact, an array with dictionaries containing a collider key, representing the colliding object. All 2D nodes inherit the Node class, allowing us to utilize all methods within that class. The method we are going to use is is_in_group(), which checks if an object belongs to a group. First, we need to ensure that the planets belong to a group. Afterward, we filter the array based on the group.

  1. Let's open the Planet scene and add the scene to the "Planets" group:

  1. Open the Game script. Change the following code at step #5:
# ...

# 5. Perform the query
var results = space_state.intersect_shape(shape_query_params)
print(results)

var filtered_array = results.filter(
    func(collision_object):
        return collision_object.collider.is_in_group("Planets")
)
print(filtered_array)
# ...
  1. Run the game...

We observe two distinct outputs: one before and one after filtering the array:

# Before filtering...
[
   {
      "rid":RID(2692944494592),
      "collider_id":26860323986,
      "collider":"Planet":<CharacterBody2D#26860323986>,
      "shape":0
   },
   {
      "rid":RID(2710124363777),
      "collider_id":26910655639,
      "collider":"Player":<CharacterBody2D#26910655639>,
      "shape":0
   }
]

# After filtering...
[
   {
      "rid":RID(2692944494592),
      "collider_id":26860323986,
      "collider":"Planet":<CharacterBody2D#26860323986>,
      "shape":0
   }
]

In the event that no planets are within the vicinity, an empty array [] will be returned. To determine the array's length, we can employ the size() method.

Armed with this understanding, let's bring everything together. I've reorganized some elements and introduced the Vector2 variable check_position. In instances where no planets are detected at that position, a new planet will be added to the scene:

extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    # 0. Location of new planet:
    var check_position: Vector2 = Vector2(800, 300)

    # Detect other objects in Space
    # 1. Get the PhysicsDirectSpaceState2D
    var space_state = get_world_2d().direct_space_state

    # 2. Create the PhysicsShapeQueryParameters2D instance
    var shape_query_params = PhysicsShapeQueryParameters2D.new()

    # 3. Create the Circle shape
    var shape = CircleShape2D.new()
    shape.radius = 200

    # 4. Set the shape and origin
    shape_query_params.shape = shape

    # Check space around location of new planet
    shape_query_params.transform.origin = check_position

    # 5. Perform the query
    var results = space_state.intersect_shape(shape_query_params)

    var filtered_array = results.filter(
        func(collision_object):
            return collision_object.collider.is_in_group("Planets")
    )

    # 6. Programmatically add planet if nothing is in range
    if filtered_array.size() == 0:
        var second_planet = planet.instantiate() as CharacterBody2D
        second_planet.position = check_position
        add_child(second_planet)

    # 7. Debug
    var area_2d = Area2D.new()

    # Draw debug circle around location of new planet
    area_2d.position = check_position

    var collision_shape_2d = CollisionShape2D.new()
    var collision_shape = CircleShape2D.new()
    collision_shape.radius = shape.radius
    collision_shape_2d.shape = collision_shape

    area_2d.add_child(collision_shape_2d)
    add_child(area_2d)

And the result:

Great. Now let's change the check_position vector to 400,300:

# ...

func _ready() -> void:
    # 0. Location of new planet:
    var check_position: Vector2 = Vector2(400, 300)

# ...

Result:

No planet! Last test: it is still allowed to spawn near the player. Set the check_position vector to 700, 300:

# ...

func _ready() -> void:
    # 0. Location of new planet:
    var check_position: Vector2 = Vector2(700, 300)

# ...

Result:

Awesome :)

Cleaning up

The code is not really reusable, so let's create a more abstract method that can be used for other groups as well. Let's also make the radius configurable:

func detect_body_in_radius(radius: int, pos: Vector2, group_name: String) -> bool:
    # Get the 2D physics space state
    var space_state = get_world_2d().direct_space_state

    # Create a CircleShape2D and set its radius
    var shape = CircleShape2D.new()
    shape.radius = radius

    # Create PhysicsShapeQueryParameters2D and set its shape and transform
    var shape_query_params = PhysicsShapeQueryParameters2D.new()
    shape_query_params.shape = shape
    shape_query_params.transform.origin = pos

    # Perform a shape intersection query in the physics space
    var results = space_state.intersect_shape(shape_query_params)

    # Filter the results to include only objects in the specified group
    var filtered_array = results.filter(func(collision_object): return collision_object.collider.is_in_group(group_name))

    # Check if there are any objects in the filtered array
    return filtered_array.size() > 0

And finally the complete code:

extends Node2D

var planet: PackedScene = preload("res://scenes/Planet.tscn")

func _ready() -> void:
    var check_position: Vector2 = Vector2(650, 300)
    var planet_nearby = detect_body_in_radius(200, check_position, "Planets")

    if !planet_nearby:
        var second_planet = planet.instantiate() as CharacterBody2D
        second_planet.position = check_position
        add_child(second_planet)


func detect_body_in_radius(radius: int, pos: Vector2, group_name: String) -> bool:
    # Get the 2D physics space state
    var space_state = get_world_2d().direct_space_state

    # Create a CircleShape2D and set its radius
    var shape = CircleShape2D.new()
    shape.radius = radius

    # Create PhysicsShapeQueryParameters2D and set its shape and transform
    var shape_query_params = PhysicsShapeQueryParameters2D.new()
    shape_query_params.shape = shape
    shape_query_params.transform.origin = pos

    # Perform a shape intersection query in the physics space
    var results = space_state.intersect_shape(shape_query_params)

    # Filter the results to include only objects in the specified group
    var filtered_array = results.filter(func(collision_object): return collision_object.collider.is_in_group(group_name))

    # Check if there are any objects in the filtered array
    return filtered_array.size() > 0

Conclusion

The initial solution I attempted turned out to be suboptimal—quite frankly, it was far from ideal. Fortunately, the PhysicsDirectSpaceState2D class provides a convenient method for querying 2D space without the need to create a node beforehand. I found this approach not only more efficient but also considerably beneficial. I hope you find it as helpful as I did!

Happy coding :)