Homing missile

Problem

You need a “homing missile” - a projectile that will seek a moving target.

Solution

For this example, we’ll use an Area2D node for the projectile. Areas are typically good choices for bullets because we need to detect when they contact something. If you also need a bullet that bounces/ricochets, one of the PhysicsBody type node might be a better choice.

The node setup and behavior of the missile is the same you would use for a “dumb” bullet. If you’re creating many bullet types, you can use inheritance to base all your projectiles on the same core setup.

The nodes we’ll use:

 Area2D: Missile
     Sprite2D
     CollisionShape2D
     Timer: Lifetime

For the texture, you can use any image you like. Here’s an example one:

alt alt

Set up the nodes and configure the sprite’s texture and the collision shape. Make sure to rotate the Sprite2D node by 90° so that it’s pointing to the right, ensuring it matches the parent’s “forward” direction.

Add a script and connect the Area2D’s body_entered signal and the Timer’s timeout signal.

Here’s the starting script:

extends Area2D

export var speed = 350

var velocity = Vector2.ZERO
var acceleration = Vector2.ZERO

func start(_transform):
    global_transform = _transform
    velocity = transform.x * speed

func _physics_process(delta):
    velocity += acceleration * delta
    velocity = velocity.clamped(speed)
    rotation = velocity.angle()
    position += velocity * delta

func _on_Missile_body_entered(body):
    queue_free()

func _on_Lifetime_timeout():
    queue_free()

This creates a “dumb” rocket that travels in a straight line when fired. To use this projectile, instance it and call its start() method with the desired Transform2D to set its position and direction.

See the related recipes section below for more information.

To change the behavior to seek a target, we’ll use the acceleration. However, we don’t want the missile to “turn on a dime”, so we’ll add a variable to control its “steering” force. This will give the missile a turning radius that can be adjusted for different behavior. We also need a target variable so that the missile knows what to chase. We’ll set that in start() as well:

export var steer_force = 50.0

var target = null

func start(_transform, _target):
    target = _target
    ...

To change the missile’s direction to move toward the target, it needs to accelerate in that direction (acceleration is change in velocity). The missile “wants” to move straight towards the target, but its current velocity is pointing in a different direction. Using a little vector math, we can find that difference:

alt alt

The green arrow represents the needed change in velocity (i.e. acceleration). However, if we turn instantly, that will look unnatural, so the “steering” vector’s length needs to be limited. This is the purpose of the steer_force variable.

This is the function to calculate that acceleration. Note that if there’s no target, there will be no steering, so the missile remains traveling in a straight line.

func seek():
    var steer = Vector2.ZERO
    if target:
        var desired = (target.position - position).normalized() * speed
        steer = (desired - velocity).normalized() * steer_force
    return steer

Finally, the resulting steer force must be applied in _physics_process():

func _physics_process(delta):
    acceleration += seek()
    velocity += acceleration * delta
    velocity = velocity.clamped(speed)
    rotation = velocity.angle()
    position += velocity * delta

Here’s an example of the results, with a little extra visual flair such as particle smoke and explosions:

Here’s the full script, including the above effects. See related recipes for details.

extends Area2D

export var speed = 350
export var steer_force = 50.0

var velocity = Vector2.ZERO
var acceleration = Vector2.ZERO
var target = null

func start(_transform, _target):
    global_transform = _transform
    rotation += rand_range(-0.09, 0.09)
    velocity = transform.x * speed
    target = _target

func seek():
    var steer = Vector2.ZERO
    if target:
        var desired = (target.position - position).normalized() * speed
        steer = (desired - velocity).normalized() * steer_force
    return steer

func _physics_process(delta):
    acceleration += seek()
    velocity += acceleration * delta
    velocity = velocity.clamped(speed)
    rotation = velocity.angle()
    position += velocity * delta

func _on_Missile_body_entered(body):
    explode()

func _on_Lifetime_timeout():
    explode()

func explode():
    $Particles2D.emitting = false
    set_physics_process(false)
    $AnimationPlayer.play("explode")
    await $AnimationPlayer.animation_finished
    queue_free()