Godot 3.0: Visibility with Ray-casting

Tags: godot gamedev tutorial

This tutorial shows how to use the ray-casting feature of Godot’s physics engine (not the RayCast2D node) to make entities that can’t see through walls.

We’ll use a TileMap for walls and create some rotating turrets that look for the player and shoot when they can see it.

Note: This is not a beginner-level tutorial. It assumes you have a general knowledge of Godot. If you’re not there yet, start here.

You can watch a video version of this lesson here:

Introduction

We’re going to skip over the basic setup of the project and just summarize the setup here.

You can download the starting project here: raycasting_demo_start.zip

At the start, we have a Main scene that contains a TileMap and instances of the Player and a Turret. The Player is a KinematicBody2D with basic 8-way movement using move_and_slide(). See the project for the code, and see Using KinematicBody2D for more details on how this type of node works.

We have the Turret node setup to detect the player and fire.

Click to enlarge

We use the turret’s Area2D to detect the player entering and exiting. The code for the Turret looks like this:

extends KinematicBody2D

export (int) var detect_radius  # size of the visibility circle
export (float) var fire_rate  # delay time (s) between bullets
export (PackedScene) var Bullet
var vis_color = Color(.867, .91, .247, 0.1)

var target  # who are we shooting at?
var can_shoot = true

func _ready():
    # dim the sprite when not active
    $Sprite.self_modulate = Color(0.2, 0, 0)
    # set the collision area's radius
    var shape = CircleShape2D.new()
    shape.radius = detect_radius
    $Visibility/CollisionShape2D.shape = shape
    $ShootTimer.wait_time = fire_rate

func _physics_process(delta):
    update()
    # if there's a target, rotate towards it and fire
    if target:
        rotation = (target.position - position).angle()
        if can_shoot:
            shoot(target.position)

func shoot(pos):
    var b = Bullet.instance()
    var a = (pos - global_position).angle()
    b.start(global_position, a + rand_range(-0.05, 0.05))
    get_parent().add_child(b)
    can_shoot = false
    $ShootTimer.start()

func _draw():
    # display the visibility area
    draw_circle(Vector2(), detect_radius, vis_color)

func _on_Visibility_body_entered(body):
    # connect this to the "body_entered" signal
    if target:
        return
    target = body
    $Sprite.self_modulate.r = 1.0

func _on_Visibility_body_exited(body):
    # connect this to the "body_exited" signal
    if body == target:
        target = null
        $Sprite.self_modulate.r = 0.2

func _on_ShootTimer_timeout():
    can_shoot = true

This is fine in an open area, but if the player is behind a wall, the turret can still “see” them. This is what we are going to fix.

Click to enlarge

Ray-casting

Godot has a RayCast2D node that can report collisions. However, there are situations where using the node will not be practical. For example, if you need to cast a large number of rays dynamically, it is very inefficient to instance nodes for each one, especially since the physics engine is going to do the actual collision detection anyway.

Fortunately, we can access the physics server’s state directly and use it for collision checks. We can do this using

get_world_2d().direct_space_state

Once we have access to the space, we can use the intersect_ray() function to cast a ray. If a collision occurs, the function will return useful information about the collision.

Change the code in _physics_process() to the following:

func _physics_process(delta):
    update()
    if target:
        aim()

Instead of just shooting the target, we’re going to try and aim at them first. We also want to be able to visualize what’s going on, so add a var hit_pos at the top - this variable will store the position where the raycast hit so that we can draw it.

In the aim() function we’re going to cast the ray. The function works like this:

intersect_ray(start, end, exceptions, mask)

Note that the start and end coordinates need to be in global coordinates. exceptions lets us list any objects we want the ray to ignore and mask defines the collision layers they ray should scan.

In the turret’s case the ray will be cast from the turret to the player’s position. We need to list the turret itself as an exception, or else the ray won’t travel at all!. Finally, since the Turret node’s collision mask is set to only detect walls (environment) and the player, we can use that same mask for the ray.

Here is what the aim() function looks like:

func aim():
    var space_state = get_world_2d().direct_space_state
    var result = space_state.intersect_ray(position, target.position,
                    [self], collision_mask)
    if result:
        hit_pos = result.position
        if result.collider.name == 'Player':
            $Sprite.self_modulate.r = 1.0
            rotation = (target.position - position).angle()
            if can_shoot:
                shoot(target.position)

If the ray intersects something, it could either be a wall or the player. Only if it’s the player do we shoot as before. Also, move the self_modulate from _on_Visibility_body_entered() and put it here, so that the turret only lights up red when the player can be seen.

We also stored the coordinates of the collision in hit_pos so that we can draw the ray. Add the following to _draw():

func _draw():
    draw_circle(Vector2(), detect_radius, vis_color)
    if target:
        draw_line(Vector2(), (hit_pos - position).rotated(-rotation), laser_color)
        draw_circle((hit_pos - position).rotated(-rotation), 5, laser_color)

Looking good! You can see the red line of the ray, and when it runs into a wall the turret remains inactive. However, we now have a different problem:

Casting Multiple Rays

Since the ray is being cast toward the player’s position, which is the center of the player’s sprite, the turret can’t “see” the player even when it should. To solve this, we need to cast rays to the player’s corners, not its center. Now we will have a list of hits, so we must make hit_pos an array.

To get the player’s four corner positions, we use the extents of its collision shape. We’re also subtracting (5, 5) to give a little leeway for the width of the bullets. The four corners are labeled nw, ne, se, and sw, and a ray is cast towards each:

func aim():
    hit_pos = []
    var space_state = get_world_2d().direct_space_state
    var target_extents = target.get_node('CollisionShape2D').shape.extents - Vector2(5, 5)
    var nw = target.position - target_extents
    var se = target.position + target_extents
    var ne = target.position + Vector2(target_extents.x, -target_extents.y)
    var sw = target.position + Vector2(-target_extents.x, target_extents.y)
    for pos in [nw, ne, se, sw]:
        var result = space_state.intersect_ray(position,
                     pos, [self], collision_mask)
        if result:
            hit_pos.append(result.position)
            if result.collider.name == "Player":
                $Sprite.self_modulate.r = 1.0
                rotation = (target.position - position).angle()
                if can_shoot:
                    shoot(pos)

And we need to change the draw function to use the array of hits:

func _draw():
    draw_circle(Vector2(), detect_radius, vis_color)
    if target:
        for hit in hit_pos:
            draw_circle((hit - position).rotated(-rotation), 5, laser_color)
            draw_line(Vector2(), (hit - position).rotated(-rotation), laser_color)

We’re almost there, but now you will notice that when the turret has a straight shot towards the player, it still shoots at the player’s corner! We should add the center position back in, and check it first. We also can add a break statement to the loop - if one of the rays hits, we don’t need to continue checking the rest of them. The final aim() function looks like this:

func aim():
    hit_pos = []
    var space_state = get_world_2d().direct_space_state
    var target_extents = target.get_node('CollisionShape2D').shape.extents - Vector2(5, 5)
    var nw = target.position - target_extents
    var se = target.position + target_extents
    var ne = target.position + Vector2(target_extents.x, -target_extents.y)
    var sw = target.position + Vector2(-target_extents.x, target_extents.y)
    for pos in [target.position, nw, ne, se, sw]:
        var result = space_state.intersect_ray(position,
                     pos, [self], collision_mask)
        if result:
            hit_pos.append(result.position)
            if result.collider.name == "Player":
                $Sprite.self_modulate.r = 1.0
                rotation = (target.position - position).angle()
                if can_shoot:
                    shoot(pos)
                break

Conclusion

Hopefully, you’ll find this technique useful for your own projects. A similar process can be used for laser shots instead of bullets (draw the line for a brief time, or try using Line2D instead of draw_line() for a different effect). You can implement ricochets by taking the ray collision point and casting another ray from that point at the reflected angle.

If you enjoyed this and found it useful, please let me know in the comments below. I’m always happy to hear requests and suggestions for other gamedev-related material.

Download the finished project

Comments