3D Arcade Space Shooter in Godot

Controls:

Key Action
WASD Movement.
LEFT SHIFT Boost while moving.
MOUSE Move mouse to look around.
LEFT MOUSE Shoot (Click or hold.)
RIGHT MOUSE Hold to lock. Keep held while shooting.
E (Debug) Spawn WAR A ship (Will fight WAR B).
Q (Debug) Spawn WAR B ship (Will fight WAR A).
Hint These ships will only fight you if you attack them!
1 (Debug) Spawn ALLY ship (Will help you fight).

The web version uses GLES2 rather than GLES3 that was used for development. All screenshots from this page are using GLES3. The only major differences are that post-processing filters no longer work (most noticeable in the black outline effect being absent.)

Running the game in-browser transfers only 16MB of data.

Videos

Unmute for audio…

Gameplay

Movement

The World

Planets

The world is made up of a single simple ico-sphere shape created in Blender. This model is good enough to represent stars, planets, and moons. When the model is imported into Godot, we can apply a simple, modifiable material and attach a sphere collider.

We can use the following script pieces to randomise the planet: First, we randomise the movement and appearance of the planet…

# Contains all colour constants
# (Provides better colours than just generation three random RGB numbers.)
const colors = [
	Color.aliceblue,
	...
	Color.yellowgreen
]

...

func _ready():
	rotation_speed = rand_range(-7.5, 7.5) * time_scale
	orbit_speed = rand_range(0.5, 1.0) * time_scale
	size = clamp(rand_range(8.0, 32.0) * physical_scale, 0, max_size)
	orbit_start_offset = rand_range(0.0, 360.0)
	color = colors[randi() % colors.size()]
	color = color.linear_interpolate(color_tint, 0.65)
	elliptical = randf()

Then we can apply these random values to the model:

$Sphere.scale = Vector3(size, size, size)
$StaticBody.scale = Vector3(size, size, size)
get_parent().rotate_y(deg2rad(orbit_start_offset))
get_parent().rotate_x(deg2rad((0.5 - sin(elliptical)) * elliptical_mult))
get_parent().rotate_z(deg2rad((0.5 - cos(elliptical)) * elliptical_mult))
get_node("Sphere/Icosphere").set_surface_material(0, get_node("Sphere/Icosphere").get_surface_material(0).duplicate())
get_node("Sphere/Icosphere").get_surface_material(0).albedo_color = color

We can then randomise and generate moons for the planet. Moons are technically small planets that orbit planets rather than stars, so we can just duplicate the current planet and modify its values to create a moon. This has the added benefit of larger planets having bigger moons, and smaller planets having smaller moons. We can adjust the color_tint value to make the moons have a similar colour to the planet that they orbit.

var moon_count
var moons = []
var is_moon = false

...

moon_count = 0
if not is_moon:
	moon_count = randi() % 2

for moon in range(moon_count):
	var moon_inst = self.duplicate()
	moons.append(moon_inst)
	moon_inst.is_moon = true
	moon_inst.physical_scale = 0.5
	moon_inst.time_scale = 0.15
	moon_inst.max_size = size * 0.5
	moon_inst.color_tint = color
	moon_inst.transform.origin.x = rand_range(size*2.5, size*4)
	var orbit_isnt = Position3D.new()
	add_child(orbit_isnt)
	orbit_isnt.add_child(moon_inst)

Now all that’s left is to make the planet follow its orbit and rotate around a certain point. As all planet objects are children of a star, we can achieve this orbiting effect by simply rotating the parent star and by the orbit speed, and then “un-rotating” the planet to ensure we keep only the position of child planet when rotating the parent.

func _process(delta):
	get_parent().rotate_y(orbit_speed * delta)
	rotate_y((-orbit_speed + rotation_speed) * delta)

Stars

Stars use the same model as planets. They are static and do not rotate, nor can they have different colours and have no scripts attached.

Drawing Orbit Path

Orbit paths are created using Polyliner from the Godot Asset Library. This allows 3D lines to be created in Godot.

To create the orbit, we simply create a circle using a Curve3D and make it the Polyliner curve. The size, scale and rotation of the orbit is determined by its parent and the scale of the orbit as defined by the parent planet.

var curve_new = Curve3D.new()
var size = 20

func _ready():
	curve_new.add_point(Vector3(1,0,0) * size, Vector3.ZERO, Vector3(0,0,0.55) * size)
	curve_new.add_point(Vector3(0,0,1) * size, Vector3(0.55,0,0) * size, Vector3(-0.55, 0, 0) * size)
	curve_new.add_point(Vector3(-1,0,0) * size, Vector3(0, 0, 0.55) * size, Vector3(0,0,-0.55) * size)
	curve_new.add_point(Vector3(0,0,-1) * size, Vector3(-0.55,0,0) * size, Vector3(0.55, 0, 0) * size)
	curve_new.add_point(Vector3(1,0,0) * size, Vector3(0,0,-0.55) * size, Vector3.ZERO)

	$LinePath3D.curve = curve_new

We can then set the colour and material values to what we wish.

$LinePath3D.material.set_shader_param("color", Color.lightblue)
$LinePath3D.material.set_shader_param("specular", 0)
$LinePath3D.material.set_shader_param("roughness", 1)

System Generator

The main level scene includes a World.gd script that handles the random creation of the star system. The level is generated when the scene loads. First, the total number of planets to be generated is decided (at least 1).

const MAX_PLANETS = 10

var planet_count
var rolling_distance

func _ready():
	randomize()
	rolling_distance = 100
	planet_count = (randi() % MAX_PLANETS) + 1
	print("Total Planets: " + str(planet_count))

Then, each planet’s instance and its orbit location are created and the distance of the orbit is set.

const PLANET_SCENE = preload("res://PlanetScene.tscn")

...

for planet in range(planet_count):
	var planet_inst = PLANET_SCENE.instance()
	var orbit_inst = Position3D.new()

	planet_inst.transform.origin.x = rolling_distance

	add_child(orbit_inst)
	orbit_inst.add_child(planet_inst)

Next, we can compute how much we need to add to the rolling_distance variable so the planets and moons don’t collide with each-other.

var max_moon_dist = 0.0
for moon in planet_inst.moons:
	if moon.transform.origin.x > max_moon_dist:
		max_moon_dist = moon.transform.origin.x

planet_inst.transform.origin.x += max_moon_dist

rolling_distance += rand_range(15.0, 50.0) + (planet * 25.0) + (planet_inst.size * 2) + max_moon_dist

Then, we tell the planet to draw its orbit:

planet_inst.draw_orbit()

Within the script attached to each planet instance, there is a draw_orbit() method that can be used to recursively draw the orbit indicators for each planet and its moons. This works because moon is technically just a smaller planet that orbits a planet rather than a star.

const ORBIT_INDICATOR_SCENE = preload("res://OrbitIndicator.tscn")

func draw_orbit():
	var orbit_indicator_inst = ORBIT_INDICATOR_SCENE.instance()
	orbit_indicator_inst.size = transform.origin.x
	get_parent().add_child(orbit_indicator_inst)

	for moon in moons:
		moon.draw_orbit()

The UI

Static

HUD

The static HUD includes health and shield meters at the bottom of the screen, an overlay for the warp UI, and some information regarding weapons and targeting in the centre of the screen. Also shown is an FPS counter in the top-right corner.

A crosshair and a hit-marker image are positioned in the centre of the screen. The hit-marker image will flash when the player damages an enemy. A vertical bar can also be seen to the right of the crosshair. This is the targeting meter, and shows the lock-on progress for the currently targeted enemy.

Scripts

The centre-screen based information is handled by the CenterInfo.gd script. To handle hit-markers, every time an enemy ship is hit, the display_hitmarker() function below is called where the hit-marker is made visible. Every frame, the hit-marker’s alpha component is moved back towards 0 (invisible) to achieve a smooth fadeout effect.

func _ready():
	$Hitmarker.modulate.a = 0

func _process():
	$Hitmarker.modulate.a = lerp(clamp($Hitmarker.modulate.a - 0.025, 0, 1), 0, 1.0 * delta)
	...

func display_hitmarker():
	$Hitmarker.modulate.a = 1

If the player is currently attempting to lock-on to an enemy, the UI will move towards the final lock-on point. This gives the impression that the ship is actually locking-on the the other ship. This is handled via the following code where target_position_screen refers to the lock-on point.

func _process(delta):
	...
	if target_position_screen != null:
		if not force_to_target:
			var distance = rect_position.distance_to(target_position_screen)
			self.rect_position = rect_position.move_toward(target_position_screen, 10.0 / target_move_time_total * delta * distance)
		else:
			self.rect_position = target_position_screen
	else:
		self.rect_position = get_viewport().size / 2

When the lock-on has finished and was successful, the following function is called that ensure the crosshair is now positioned exactly on the lock-on point. See the above code for the effects of setting force_to_target to true.

func force_to_target():
	force_to_target = true

To set the target_position_screen, the following method is called. It translates a given world position (a Vector3) into a position on the screen that can be used for moving the UI (a Vector2.) It also allows a time to be given that effects the speed of the crosshair tracking.

func set_target_screen_position(world_pos, time_to_move):
	force_to_target = false
	if world_pos:
		target_position_screen = get_viewport().get_camera().unproject_position(world_pos)
	else:
		target_position_screen = null
	target_move_time_total = time_to_move

The centre script also provides functions for setting the current targeting values:

func set_targeting_text(text):
	$Crosshair/TargetingMessage.text = text

func set_targeting_bar(val, max_val):
	$TargetingBar.max_value = max_val
	$TargetingBar.value = val

Another script called HealthAndShield.gd controls the values of the static HUD elements…

func _ready():
	$ShieldBar.modulate = Color.cyan

func update_values(player):
	$HealthBar.max_value = player.max_health
	$HealthBar.value = player.current_health
	$ShieldBar.max_value = player.max_shield
	$ShieldBar.value = player.current_shield

Multi-purpose Alerts

I created a multi-purpose alert system for displaying important information to the player. For example, it is used when the player’s shields go down.

Any script can call the alert(...) function below to create and display a new alert on the player’s screen. The function makes use of the yield function to simplify the full fade-in and fade-out animation calls into a single function.

extends Control

const ALERT_SCENE = preload("res://Alert.tscn")

func alert(line1, line2, time_to_display):
	var alert = ALERT_SCENE.instance()
	alert.get_node("VBoxContainer/CenterContainer/Line1").text = line1
	alert.get_node("VBoxContainer/CenterContainer2/Line2").text = line2
	add_child(alert)
	var fadein_length = alert.get_node("AnimationPlayer").get_animation("fadein").length
	var fadeout_length = alert.get_node("AnimationPlayer").get_animation("fadeout").length
	alert.get_node("AnimationPlayer").play("fadein")
	yield(get_tree().create_timer(time_to_display + fadein_length), "timeout")
	alert.get_node("AnimationPlayer").play("fadeout")
	yield(get_tree().create_timer(fadeout_length), "timeout")
	alert.queue_free()

Dynamic

The game has in-world UI, where the name, health, shield, and distance to an AI ship is shown to the player. (For debugging purposes, the current AI FSM state of the ship is also shown.) This is presented in the form of a “nameplate” that follows the ship in the in-game world.

The script attached to each of these nameplates is very simple, it stores a target alpha value and moves its alpha towards to target. The updating of the components of the nameplate is handled by another script we will look at next.

var target_alpha = 1.0

func _process(delta):
	$Fadeout.modulate.a = move_toward($Fadeout.modulate.a, target_alpha, 4.0 * delta)

Handling the Nameplates

First, we set up references to the director and the player…

var director
var player

func _ready():
	director = $"../../../Director"
	player = director.player

Then we process each ship one at a time, ignoring the player. We first check if the ship is visible on the screen. This is done by un-projecting the world position of the ship from the camera to get the position on the screen of the ship. We also check if the world position is behind the camera. If the world position is in front of the camera and the unprojected position is within the limits of the camera, then the ship’s nameplate should be visible.

func _process(delta):
	for ship in director.ships:
		if player == ship:
			continue
		var target_alpha_set = false
		var screen_pos = get_viewport().get_camera().unproject_position(ship.get_node("Model").global_transform.origin)
		var is_front_of_camera = not get_viewport().get_camera().is_position_behind(ship.get_node("Model").global_transform.origin)
		if (is_front_of_camera and
			screen_pos.x >= border_buffer and screen_pos.x <= get_viewport().size.x - border_buffer and
			screen_pos.y >= border_buffer and screen_pos.y <= get_viewport().size.y - border_buffer):
			ship.ui_elem.rect_position = screen_pos
			ship.ui_elem.visible = true
			ship.ui_elem.get_node("Indicators/ShipIndicatorArrow").hide()
			ship.ui_elem.get_node("Indicators/ShipIndicator").show()
		else:
			...

Alternatively, if the ship fails this check (meaning it is offscreen,) we hide the nameplate and instead show an arrow indicating where the ship is relative to the edges of the screen. This code was inspired by the Unity script presented in this video. The following code gets the position of the arrow on the screen (respecting a “safe-zone” around the edges.)

const border_buffer = 50.0

...
			...
		else:
			if not is_front_of_camera:
				screen_pos.x = get_viewport().size.x - screen_pos.x
				screen_pos.y = get_viewport().size.y - screen_pos.y

			var center_screen = get_viewport().size / 2.0
			screen_pos -= center_screen

			var angle = atan2(screen_pos.x, screen_pos.y)

			var angle_cos = cos(angle)
			var angle_sin = sin(angle)

			# Small value otherwise there is a divide by zero.
			if angle_sin == 0:
				angle_sin = 0.001

			screen_pos = center_screen + Vector2(angle_sin * 150, angle_cos * 150)
			var m = angle_cos / angle_sin

			var screen_bounds = (center_screen - (Vector2(border_buffer, border_buffer)))

			if angle_cos > 0:
				screen_pos = Vector2(screen_bounds.y/m, screen_bounds.y)
			else:
				screen_pos = Vector2(-screen_bounds.y/m, -screen_bounds.y)

			if (screen_pos.x > screen_bounds.x):
				screen_pos = Vector2(screen_bounds.x, screen_bounds.x * m)
			elif (screen_pos.x < -screen_bounds.x):
				screen_pos = Vector2(-screen_bounds.x, -screen_bounds.x * m)

			screen_pos += center_screen

Once we have worked out the screen_pos we can apply it to the ship’s UI and show the arrow rather than the indicator:

ship.ui_elem.rect_position = screen_pos
ship.ui_elem.visible = true

ship.ui_elem.get_node("Indicators/ShipIndicatorArrow").show()
ship.ui_elem.get_node("Indicators/ShipIndicatorArrow").rect_rotation = 180 - rad2deg(angle)
ship.ui_elem.get_node("Indicators/ShipIndicator").hide()

if !target_alpha_set:
	ship.ui_elem.target_alpha = 0
	target_alpha_set = true

After the position has been set, we can then update the labels:

var dist_from_player = player.get_node("Model").global_transform.origin.distance_to(ship.get_node("Model").global_transform.origin)
ship.ui_elem.get_node("Fadeout/Distance").text = str(int(dist_from_player)) + " m"
ship.ui_elem.get_node("Fadeout/State").text = str(director.state.keys()[ship.state])

We can then set the target_alpha of the ship based on the distance from the player where closer ships will be more visible. If the ship is the one that is currently being locked-on to, then we set its alpha to 1 so its always visible. We also set the relative to the distance…

if !target_alpha_set:
	ship.ui_elem.target_alpha = clamp((75 / dist_from_player) - 0.25, 0.0, 0.5)
if player.current_lock == ship:
	ship.ui_elem.target_alpha = 1

var aim_dist_scale_mod = 100.0 / pow(dist_from_player, 0.8)
ship.ui_elem.get_node("Indicators").rect_scale = Vector2(aim_dist_scale_mod, aim_dist_scale_mod)
ship.ui_elem.get_node("Fadeout").rect_position.x = ((aim_dist_scale_mod) * 10.0) - 10.0

Then, the colour of the indicator is changed based on the relation of the ship to the player. Hostile ships are in red, neutral or allied ships are white.

ship.ui_elem.get_node("Indicators").modulate = Color.white
if ship.get_faction().aware_of_allies:
	if director.is_faction_hostile_to(ship.faction_str, "ALLY"):
		ship.ui_elem.get_node("Indicators").modulate = Color.red
else:
	if ship.unaware_of_allies_hostile_ships.has(player):
		ship.ui_elem.get_node("Indicators").modulate = Color.red

Show the aim indicator if the playing is locked-on to a ship:

if player.current_lock == ship and player.is_locked:
	var aim_vector = ship.get_node("HitScanBox").global_transform.origin
	var aim_pos = get_viewport().get_camera().unproject_position(aim_vector)
	var is_aim_visible = not get_viewport().get_camera().is_position_behind(aim_vector)
	ship.ui_elem.get_node("AimIndicator").rect_position = aim_pos - screen_pos
	ship.ui_elem.get_node("AimIndicator").visible = is_aim_visible
	ship.ui_elem.get_node("AimIndicator").rect_scale = Vector2(aim_dist_scale_mod, aim_dist_scale_mod)
else:
	ship.ui_elem.get_node("AimIndicator").visible = false

Finally, we can update the name of the ship (in this case, its faction,) and the health bar. The health bar will initially display the shield until it is depleted, when it will then show the health.

ship.ui_elem.get_node("Fadeout/Name").text = ship.faction_str

if ship.current_shield == 0:
	ship.ui_elem.get_node("Fadeout/HealthBar").max_value = ship.max_health
	ship.ui_elem.get_node("Fadeout/HealthBar").value = ship.current_health
	ship.ui_elem.get_node("Fadeout/HealthBar").modulate = Color.white
else:
	ship.ui_elem.get_node("Fadeout/HealthBar").max_value = ship.max_shield
	ship.ui_elem.get_node("Fadeout/HealthBar").value = ship.current_shield
	ship.ui_elem.get_node("Fadeout/HealthBar").modulate = Color.cyan

Putting this all together gets us the following:

The Spaceship

Model

The model for the spaceship was created in Blender using very simple geometry.

Version 1

Version 1 was essentially a placeholder ship for testing the game’s movement with. Making this placeholder also allowed me to correctly configure model export and import setting within Blender and Godot.

I modelled half the ship and used a mirror modifier to duplicate it to the other side.

Version 2

Version 2 was a much more modifiable spaceship. Each part of the ship can now be hidden.

Each part of the spaceship can be hidden. This is useful as in the game, trading spaceships won’t have guns.

In the Engine

All spaceship, whether player or AI controlled, use the same base. The spaceship_2 node is the imported model from Blender.

The ship scene contains the following:

  • A camera (removed for AI.)
  • A collision shape for physical collision (mainly with stars and planets.)
  • A hitbox for emulating bullet speed in a fair way for the player (see more about this in the hitbox adjustment section.)
  • Any number (in this case, 2) firing positions for bullets to spawn at.
  • Several timers for health and shield regeneration, etc…
  • A number of particle effects for various actions.
  • Engine SFX.

Player Movement

For the player movement, I decided on a more accessible, arcade style. In this style, the player uses the mouse to aim their spaceship, and the WASD keys to move. A key difference between this movement and the movement in a more realistic game such as Frontier’s Elite Dangerous is that our movement only effects the XY rotation. In Elite Dangerous, the player is free to rotate their ship in the X, Y, and Z axis, i.e., there is no concept of a fixed “up” direction. In our movement, there is a fixed “up” direction that the player rotates around, meaning that the player can’t go upside-down.

To implement this style of movement, we first need some constants and variables to store relevant information…

const SENSITIVITY = 30.0
const DEAD_ZONE_RADIUS = 25.0
const CAMERA_FOV_NORMAL = 65
const CAMERA_FOV_BOOST = 90
const acceleration_model = 4.5

var max_speed = 50.0
var boost_mult = 2.5
var camera_fov = 65
var movement_axis = Vector3()
var turn_acceleration = 3.5
var thrust_acceleration = 1.5
var input = Vector2.ZERO

Another set of variables to store two types of movement modes and the input for the model’s rotation.

onready var model = $Model
var velocity_model = Vector3()
var rotational_input = Vector2()

enum MOVEMENT_MODES {
	center,
	turn
}
export(MOVEMENT_MODES) var movement_mode = MOVEMENT_MODES.center

This code is called every engine tick that there is a user input. It accumulates all mouse inputs between one frame and the next into a variable called input.

func _input(event):
	if not player:
		return

	if event is InputEventMouseMotion:
		if movement_mode == MOVEMENT_MODES.center:
			input = ((get_viewport().size / 2.0).direction_to(get_viewport().get_mouse_position()))
			var distance = ((get_viewport().size / 2.0).distance_to(get_viewport().get_mouse_position()))

			if distance < DEAD_ZONE_RADIUS:
				distance = 1.0
			else:
				distance -= DEAD_ZONE_RADIUS
				distance = 1.0 - (distance / min(get_viewport().size.x, get_viewport().size.y))

			input = input.linear_interpolate(Vector2.ZERO, distance)
		elif movement_mode == MOVEMENT_MODES.turn:
			input += event.relative / (SENSITIVITY * 3.0)
			input = input.clamped(1.0)

Once all the inputs for a frame have be accumulated, the _physics_process function is called every frame that processes this input variable. This snippet sets the rotational_input and velocity_model variables for later use, as well as rotating the ship to point towards the input using rotate_object_local.

func _physics_process(delta):
	...
	if movement_mode == MOVEMENT_MODES.turn:
		input = lerp(input, Vector2.ZERO, 10.0 * delta)

	if not ($WarpTimer.time_left == 0 and not warp_charged):
		# Make turning more difficult if charging or charged...
		input *= 0.5

	rotational_input = lerp(rotational_input, input, turn_acceleration * delta)
	velocity_model.x = lerp(velocity_model.x, input.x * 50.0, acceleration_model * delta)
	velocity_model.y = lerp(velocity_model.y, -input.y * 50.0, acceleration_model * delta)

	# Make turning more difficult if looking too far up or down.
	var rot_speed_mod_extreme_angles = 1.0 - pow(sin(abs(rotation.x) / deg2rad(90.0)), 1.0)
	if rot_speed_mod_extreme_angles < 0.5:
		rot_speed_mod_extreme_angles *= 1.0 - (0.5 - rot_speed_mod_extreme_angles)

	rotate_object_local(Vector3.RIGHT, -rotational_input.y / SENSITIVITY)
	rotate_object_local(Vector3.UP, -rotational_input.x / SENSITIVITY * rot_speed_mod_extreme_angles)
	...

We can then handle the movement of the ship rather than the rotation. This is achieved by reading the inputs from the keyboard using Input.get_vector and applying that to the global_transform.basis rotation to get the input vector relative to the current rotation.

...
var new_movement_axis
var input_vector_keyboard = Input.get_vector("move_left", "move_right", "move_up", "move_down").normalized()
new_movement_axis = (global_transform.basis.z * input_vector_keyboard.y)
new_movement_axis += (global_transform.basis.x * input_vector_keyboard.x)
new_movement_axis = new_movement_axis.normalized()
...

We can then check the user inputs for boosting and warping and proceed accordingly…

...
if Input.is_action_pressed("boost"):
	should_boost_this_frame = true
	camera_fov = CAMERA_FOV_BOOST
else:
	camera_fov = CAMERA_FOV_NORMAL

$Camera.fov = lerp($Camera.fov, camera_fov, 1.5 * delta)

if Input.is_action_just_pressed("charge_warp"):
	begin_warp_charge()

ui.get_node("Warp").update_warp_ui($WarpTimer.wait_time - $WarpTimer.time_left, $WarpTimer.wait_time)

if Input.is_action_just_pressed("activate_warp"):
	if warp_charged:
		warp()
...

Once all user inputs have been processed, we can actually move the player. A check is also made for collisions and the player is bounced away from the collision object.

...
if should_boost_this_frame:
	new_movement_axis *= boost_mult

movement_axis = lerp(movement_axis, new_movement_axis * max_speed, thrust_acceleration * delta)
move_and_slide(movement_axis)

for collision_index in range(get_slide_count()):
	var collision = get_slide_collision(collision_index)

	if collision:
		hurt(null, int(movement_axis.length() / 45))
		movement_axis = movement_axis.bounce(collision.normal)

rotation_degrees.z = 0
rotation_degrees.x = clamp(rotation_degrees.x, -85, 85)
...

AI Movement

The AI movement and player movement code use many of the same snippet as shown in the section prior. These will not be shown or explained again. The AI is based on a FSM (Finite State Machine) that controls how the AI should act. States are mainly set from the director rather than the AI script itself.

First, variables for the AI to use are defined:

var player = false
var target_marker : Position3D
var time_since_last_attack = 0.0
var min_attack_time = 15.0
var keep_distance_one_time_gen_new_target = true
var faction_str : String
var unaware_of_allies_hostile_ships = []
var state
var state_target
var ui_elem

When the spaceship is first created, all player-centric nodes are removed.

func _ready():
	...
	if not player:
		$Particles.queue_free()
		$Camera.queue_free()
		$Model/Particles.process_material = $Model/Particles.process_material.duplicate()
		$Model/Sprite3D.queue_free()
		$Model/Sprite3D2.queue_free()

The _physics_process method contains the logic for the FSM. When the ship is in the ATTACK state, it should turn toward the enemy (stored in the variable state_target) and move towards them. If it is too far away, it should boost to get closer faster. When it is close to the target ship, it should begin to shoot at it. As there is no limit of how close it should get to the enemy ship, it should transition to the TOO_CLOSE state when extremely close.

func _physics_process(delta):
	...
	time_since_last_attack += delta

	if state == get_parent().state.ATTACK:
		time_since_last_attack = 0

		if is_instance_valid(state_target) and not state_target.is_dead:
			target_marker.global_transform.origin = state_target.global_transform.origin + (state_target.movement_axis * 0.75)

			if global_transform.origin.distance_to(state_target.global_transform.origin) <= 30:
				state = get_parent().state.TOO_CLOSE
			elif global_transform.origin.distance_to(state_target.global_transform.origin) <= 150:
				shoot()

			if global_transform.origin.distance_to(state_target.global_transform.origin) >= 100:
				var rot_vector = -global_transform.basis.z
				if global_transform.origin.direction_to(state_target.global_transform.origin).dot(rot_vector) >= 0.75:
					should_boost_this_frame = true

		else:
			get_parent().set_states_for_faction(faction_str, self)

The TOO_CLOSE state is intended to stop ships from flying directly into each-other during the ATTACK state. It works by selecting a random point nearby and overriding the ATTACK state until the random point is reached…

elif state == get_parent().state.TOO_CLOSE:
	state_target = movement_axis / 5.0 + global_transform.origin + Vector3(
		rand_range(-1, 1),
		rand_range(-1, 1),
		rand_range(-1, 1)
	) * 10.0

	if keep_distance_one_time_gen_new_target:
		keep_distance_move_target()
		keep_distance_one_time_gen_new_target = false

	var dist_to_target = target_marker.global_transform.origin.distance_to(global_transform.origin)

	if dist_to_target <= 150 or target_marker.global_transform.origin == Vector3.ZERO:
		keep_distance_one_time_gen_new_target = true
		state = get_parent().state.ATTACK

The following function is used to move the target_position to a location in an area around the player.

func keep_distance_move_target():
	var player_circle_center

	if typeof(state_target) == TYPE_VECTOR3:
		player_circle_center = state_target
	else:
		player_circle_center = state_target.global_transform.origin

	var player_circle_radius = 125 + rand_range(0, 100)
	var circle_offset = randf() * 2 * PI
	var new_target_pos = Vector3(
		player_circle_center.x + player_circle_radius * cos(circle_offset),
		player_circle_center.y + rand_range(-50, 50),
		player_circle_center.z + player_circle_radius * sin(circle_offset)
	)

	new_target_pos = Vector3(
		clamp(new_target_pos.x, -10000, 10000),
		clamp(new_target_pos.y, -500, 500),
		clamp(new_target_pos.z, -10000, 10000)
	)

	target_marker.global_transform.origin = new_target_pos

The KEEP_DISTANCE state is used when the ship has enemies, but is not currently the ship (or ships) that are attacking (in the ATTACK state). It should hover around the area that the fighting is occurring. It is possible that during this state, an enemy ship may decide to attack it.

elif state == get_parent().state.KEEP_DISTANCE:
	if keep_distance_one_time_gen_new_target:
		keep_distance_move_target()
		keep_distance_one_time_gen_new_target = false

	var dist_to_target = target_marker.global_transform.origin.distance_to(global_transform.origin)

	if dist_to_target <= 50 or target_marker.global_transform.origin == Vector3.ZERO:
		keep_distance_one_time_gen_new_target = true

The IDLE state is used when a ship has no enemies on the current level. The ship should wander around the level using random waypoints.

elif state == get_parent().state.IDLE:
	var dist_to_target = target_marker.global_transform.origin.distance_to(global_transform.origin)

	if dist_to_target <= 75 or target_marker.global_transform.origin == Vector3.ZERO:
		var new_target_pos = Vector3(
			rand_range(-1000, 1000),
			rand_range(-150, 150),
			rand_range(-1000, 1000)
		)

		target_marker.global_transform.origin = new_target_pos

The FLEE state is used for ships that have no weapons and should flee when they get into combat (i.e., traders.) They should flee by facing away from the star of the solar system and charging, then activating, their warp to remove themselves from the level.

elif state == get_parent().state.FLEE:
	var sun_loc = Vector2.ZERO
	var dir_from_sun = sun_loc.direction_to(Vector2(global_transform.origin.x, global_transform.origin.z))
	var sun_extended = dir_from_sun * 99999

	target_marker.global_transform.origin = Vector3(sun_extended.x, global_transform.origin.y, sun_extended.y)

	if ($WarpTimer.time_left == 0 and not warp_charged):
		begin_warp_charge()
	elif warp_charged:
		warp()

After the state has been decided, the input variable is set to be a vector that points in the direction of the target_marker (used to direct the spaceship.) The code snippet for movement as seen in the player movement section then handles actually movement the ship.

var direction = self.global_transform.origin.direction_to(target_marker.global_transform.origin)
direction = direction.rotated(Vector3.UP, -rotation.y)
direction = direction.rotated(Vector3.RIGHT, -rotation.x)
input = Vector2(direction.x, -direction.y)

var dist_to_target = global_transform.origin.distance_to(target_marker.global_transform.origin)
new_movement_axis = (-global_transform.basis.z) * clamp(dist_to_target / 100, 0, 1)

The Combat

Hitbox Adjustment

To make the combat feel fair to player where they have to shoot ships moving in sometimes unpredictable ways from a considerable distance away, we can fake the time it take for bullets to travel through space. This means that the player can lead their shots and hit the target even if the target abruptly changes direction so the shot should not have actually hit. (See “Shooting” section for the shooting works with this adjusted hitbox.)

This is achieved by having ships “carry” their hitbox for bullets (HitScanBox) out in-front of them. The hitbox is placed directly ahead of them in the direction they are moving (movement_axis) and the distance between them and the hitbox is relative to the distance between them and the player (to simulate having to lead your shots more if the enemy is farther away.) The scale is also adjusted to ensure the hitbox size is not ridiculously small if the enemy is far away.

call_deferred is used to call the method that actually moves the hitbox as it ensures that the hitbox is not moved between physics frames which could result in ray-casts not colliding correctly.

func _physics_process(delta):
	...
	var hitbox = $HitScanBox
	var dist_from_player

	if player:
		dist_from_player = 0
	else:
		dist_from_player = get_parent().player.get_node("Model").global_transform.origin.distance_to(get_node("Model").global_transform.origin)
	
	var aim_vector = get_node("Model").global_transform.origin + (movement_axis * dist_from_player / 300.0)

	var aim_dist_scale_mod
	if player:
		aim_dist_scale_mod = 1
	else:
		aim_dist_scale_mod = 7.5 / pow(dist_from_player, 0.125)

	call_deferred("adjust_hitbox_pos", aim_vector, aim_dist_scale_mod)

When we adjust the hitbox position, we also update the position of the collision hitbox (for colliding with planets, etc…) This ensures that the collision of the ship respects the current rotation of the ship when it moves.

func adjust_hitbox_pos(new_vector, new_scale):
	var hitbox = $HitScanBox
	hitbox.global_transform.origin = new_vector
	hitbox.scale = Vector3(new_scale, new_scale, new_scale)
	$CollisionShape.transform = $Model.transform

Please view these screenshots taken with physics areas and collisions visible:

Shooting

Bullets are fired from the shoot() method that is called when the player presses the shoot action. This is located within the same script as the movement shown in the above section.

func _physics_process(delta):
	...
	if player:
		if Input.is_action_pressed("shoot"):
			shoot()
	...

The bullet used is a very simple laser-like mesh with an emissive colour. It is made using Godot’s built-in capsule mesh type. The scene contains a timer for cleaning up bullets that have existed for a long time without colliding. Is it important to note that the bullet’s collision is not used, instead, whether or not the bullet collides with anything is determined separately.

To fire the bullet, we must first reference the bullet scene as well as an impact scene (just an explosion particle effect.)

const BULLET_SCENE = preload("res://Bullet.tscn")
const BULLET_IMPACT_SCENE = preload("res://BulletImpact.tscn")

Then we can create a bullet instance when the shoot method is called. We first need to check if the ship is able to fire by checking if the ShotTimer has finished (is 0.) Please note that the bullet that is created is actually just an effect, the hit detection is handled later.

var last_firing_pos = 0

func shoot():
	if $ShotTimer.time_left == 0:
		$ShotTimer.start()

		var bullet = BULLET_SCENE.instance()
		bullet.firing_transform = $Model/FiringPos.get_child(last_firing_pos).global_transform
		last_firing_pos = wrapi(last_firing_pos + 1, 0, $Model/FiringPos.get_child_count())
		bullet.firing_speed = 50000

		get_parent().add_child(bullet)
		Sound.one_shot(self, "gunshot")
		...

To handle the hit detection, we need to fire a ray-cast from the player’s current position directly forward from the camera (i.e., the crosshair in the centre of the screen.) For AI ships, we can cheat slightly; the AI will only call shoot() when it is facing it’s target, so we can just fire the ray-cast to the target’s hitbox. If the player is locked-on, we use the target hitbox’s exact position.

var from
var to

if player:
	var center = get_viewport().size / 2.0

	from = get_viewport().get_camera().global_transform.origin
	to = get_viewport().get_camera().project_position(center, 5000.0)

	if lock_autoaim and is_locked:
		to = current_lock.get_node("HitScanBox").global_transform.origin
	
else:
	from = get_node("Model").global_transform.origin
	to = state_target.get_node("HitScanBox").global_transform.origin

var raycast = get_world().direct_space_state.intersect_ray(from, to, [get_node("HitScanBox"), self], 2, true, true)

If the ray-cast hits any target, we can then processed with the calculations. First, we define what should happen when an AI attempts to shoot. We can calculate the hit chance randomly based on how directly it faces the target and the distance between the AI and the target as seen below…

if not raycast.empty():
	if not player:
		var chance = clamp(1.0 / from.distance_to(to) * 20, 0, 1)
		var rot_vector = -get_node("Model").global_transform.basis.z
		var dot_product = get_node("Model").global_transform.origin.direction_to(state_target.get_node("HitScanBox").global_transform.origin).dot(rot_vector)
		var relative_speed = abs(movement_axis.length() - state_target.movement_axis.length())

		if dot_product <= 0.75:
			chance = 0
		else:
			chance *= dot_product
			chance *= clamp(15.0 / (relative_speed + 0.01), 0, 1)
		
		if randf() > chance:
			# Random chance failed...
			return

Next, regardless of AI or player, we can simulate bullet travel time from the firing ship to the target by using the yield function to pause processing until a timer has elapsed based on the distance between the source and target. yield allows all other scripts and functions to continue while the current function is paused (coroutines) (More about yield).

yield(get_tree().create_timer(from.distance_to(raycast["position"]) / 1000), "timeout")

After the function as resumed execution from the yield, we then check if what the ray-cast collided with (i.e, the target) is still valid before continuing. We can then create the bullet impact effect and place it in the correct location.

if is_instance_valid(raycast["collider"]):
	var impact = BULLET_IMPACT_SCENE.instance()
	impact.process_material.color_ramp = ResourceLoader.load("res://BulletGradientRed.tres")

	get_tree().get_root().add_child(impact)
	...

Also if the instance is still valid, we can check the type of what the ray-cast collided with to see if it is a spaceship or a planet/star. If it is a ship, we can called the hurt() method on the receiving ship to actually damage it. We also check whether the shot damaged the shield or the health of the target and adjust the colour of the impact effect accordingly to provide information to the player. We also show the hit-marker and play a sound effect if the player fired the shot.

...
if raycast["collider"] is Area:
	if player:
		Sound.one_shot_no_pos("hitmarker")
		ui.get_node("CenterInfo").display_hitmarker()
	
	if raycast["collider"].get_parent().current_shield > 0:
		impact.process_material.color_ramp = ResourceLoader.load("res://BulletGradientBlue.tres")
	
	raycast["collider"].get_parent().hurt(self, 1)

	impact.global_transform.origin = raycast["collider"].get_parent().get_node("Model").global_transform.origin

else:
	impact.global_transform.origin = raycast["position"]

Taking Damage

Every ship instance has the following variables to define its health and shield:

var max_health = 12
var max_shield = 6
var current_health
var current_shield
var shield_seconds_per_amount = 0.25
var shield_recharge_progress = 0.0
var shield_should_regenerate = false

func _ready():
	current_health = max_health
	current_shield = max_shield
	...

Within the ship’s _physics_process(delta) method, use keep track of the shield’s recharging process by adding the frame’s delta (time since last frame) to a variable called shield_recharge_progress. When this variable exceeded a value, we add 1 to the ship’s shield and reset the progress.

if shield_should_regenerate:
	shield_recharge_progress += delta

	if shield_recharge_progress >= shield_seconds_per_amount:
		shield_recharge_progress = 0.0
		current_shield = min(current_shield + 1, max_shield)

		if player:
			ui.get_node("HealthAndShield").update_values(self)

When a ship is hurt through any reason (bullet or collision) the hurt method is called. This method takes an amount of damage, and an optional source (null for collisions.) When the ship is hurt, it should stop regenerating shield and restart the timer…

func hurt(source, amount):
	if source != null:
		print(name + " took damage of " + str(amount) + " from " + source.name)
	else:
		print(name + " took damage of " + str(amount) + " from unknown source")

	shield_should_regenerate = false
	$ShieldTimer.start()

When it comes to modifying the health variables, we first need to check if the ship currently has a shield and if so, we should take the damage from the shield rather than the health. If the player is the one getting hurt, we flash the edges of the screen with an appropriate colour to give feedback to the player that they’ve been hit and what was hit (health or shield.)

if current_shield >= 1:
	current_shield = max(current_shield - amount, 0)

	if player:
		ui.get_node("FlashOverlay").color = Color.cyan
		ui.get_node("FlashOverlay").modulate.a = 1
	
	Sound.one_shot(self, "miss")

else:
	...

If the ship has no shield then we take the damage directly from the health. Again, flash feedback is given to the player and a relevant sound if played.

	...
else:
	current_health -= amount

	if player:
		ui.get_node("FlashOverlay").color = Color.red
		ui.get_node("FlashOverlay").modulate.a = 1
	
	Sound.one_shot(self, "damage_hit")
	...

For the player only, we can add additional effect that get activated upon taking enough damage. For example; here, we disable the player’s targeting, meaning they now have to lead their shot manually. We also update the player’s UI to reflect the new health and shield values.

...
if player:
	if current_health < (max_health / 2) and not lock_is_disabled:
		lock_time_progress = 0.0
		is_locked = false
		lock_is_disabled = true
		ui.alert("Warning!", "Targeting systems offline!", 5)
	
	ui.get_node("HealthAndShield").update_values(self)

If the ship has no health left, it should die… (See “Dying”.)

if current_health <= 0:
	if not is_dead:
		die()

Finally we can handle what happens when the player attacks a neutral ship. This sets the relationship between the two factions involved in the combat to hostile and recalculates the states for the two involved factions.

if source:
	if not player:
		if not get_parent().faction[faction_str].aware_of_allies:
			if not (source in unaware_of_allies_hostile_ships):
				unaware_of_allies_hostile_ships.append(source)
	if source.faction_str != faction_str:
		get_parent().set_faction_relations(source.faction_str, faction_str, -100)
		get_parent().set_states_for_faction(source.faction_str, source)
		get_parent().set_states_for_faction(faction_str, self)

The shield timer that each ship scene contains is set to call the following function when it finished. It functions sets shield_should_regenerate to true so the ship can begin to regenerate its shield.

func _on_ShieldTimer_timeout():
	shield_should_regenerate = true
	shield_recharge_progress = 0

Locking Targets

To help the player shooting enemies, there is a targeting system that allows the crosshair to automatically place itself over the enemy. Of course, it is still possible to aim and shoot without targeting the enemy. First, we define variables to control and assist in locking targets.

var locking_range = 250
var locking_radius_dot_product = 0.5
var current_lock = null
var is_locked = false
var time_to_lock = 1.0
var lock_time_progress = 0.0
var lock_autoaim = true
var lock_is_disabled = false

As the player is the only ship able to lock, we first check if the player is the ship executing this script. Then, we can try to clean up the currently locked object, making sure that it still exists and is not dead. If there is no current lock, we set the current lock progress to 0.0 and say that we are not locked.

if player:
	if current_lock != null:
		if not is_instance_valid(current_lock):
			current_lock = null
		if current_lock.is_dead:
			current_lock = null

	if current_lock == null:
		lock_time_progress = 0.0
		is_locked = false

We can then update the player’s UI based on the current lock status.

ui.get_node("CenterInfo").set_targeting_bar(0, time_to_lock)
ui.get_node("CenterInfo").set_targeting_text("Select target")

if lock_is_disabled:
	ui.get_node("CenterInfo").set_targeting_text("ERROR: Targeting offline!")

Next, we need to determine what target out of all the possible ships in the level should be locked on to. This is achieved by calculating the dot product between all the ships positions relative to the camera, and the camera’s forward direction. By the end of this snippet, current_lock contains the closest ship to the crosshair and the one that should be locked on to.

if lock_time_progress == 0 and not is_locked:
	var biggest_dot_product = -99.0
	is_locked = false

	for ship in director.ships:
		if ship == director.player:
			continue
		
		var rot_vector = -get_node("Camera").global_transform.basis.z
		var dot_product = get_node("Camera").global_transform.origin.direction_to(ship.global_transform.origin).dot(rot_vector)

		if dot_product >= 0.65:
			if dot_product > biggest_dot_product:
				current_lock = ship
				biggest_dot_product = dot_product
		
	if biggest_dot_product == -99.0:
		current_lock = null

Once we have the ship to be locked on to, we can then start locking on to it if the player presses the lock_button action. To successfully lock on to the target, the player must be facing in their general direction, this is implemented via another dot product check. The player must also be within a certain distance of the target to continue locking. Invalidating these conditions at any point (even when fully locked) will reset locking progress back to 0.

if current_lock != null and not lock_is_disabled:
	if Input.is_action_pressed("lock_button"):
		ui.get_node("CenterInfo").set_targeting_text("Locking...")

		var rot_vector = -get_node("Camera").global_transform.basis.z
		var dot_product = get_node("Camera").global_transform.origin.direction_to(current_lock.global_transform.origin).dot(rot_vector)
		var distance = get_node("Model").global_transform.origin.distance_to(current_lock.get_node("Model").global_transform.origin)

		if dot_product < locking_radius_dot_product:
			ui.get_node("CenterInfo").set_targeting_text("ERROR: Turn to face target!")
			lock_time_progress = 0.0
			is_locked = false
		
		if distance > locking_range:
			ui.get_node("CenterInfo").set_targeting_text("ERROR: Target out of range!")
			lock_time_progress = 0.0
			is_locked = false
		...

While we are locking, we can set the position of the player’s crosshair to track the target at a delay, and return to centre when not tracking. We can then also increment the lock time progress by the frame’s delta if we are not currently locked and update the UI targeting bar to reflect this value. We then check if we the new value of lock_time_progress is above the time it takes to lock, if it is, we are locked and we set is_locked to true. We also update the UI accordingly.

...
if lock_autoaim and lock_time_progress != 0.0:
	ui.get_node("CenterInfo").set_target_screen_position(current_lock.get_node("HitScanBox").global_transform.origin, time_to_lock)
else:
	ui.get_node("CenterInfo").set_target_screen_position(null, 0)

if not is_locked:
	lock_time_progress += delta
	ui.get_node("CenterInfo").set_targeting_bar(lock_time_progress, time_to_lock)

if lock_time_progress >= time_to_lock:
	is_locked = true
	ui.get_node("CenterInfo").set_targeting_text("Locked!")
	ui.get_node("CenterInfo").set_targeting_bar(lock_time_progress, time_to_lock)
	ui.get_node("CenterInfo").force_to_target()
...

If, at any point, the player releases the locking button, we should stop all locking and reset progress:

	...
	else:
		lock_time_progress = 0.0
		is_locked = false
		if lock_autoaim:
			ui.get_node("CenterInfo").set_target_screen_position(null, 0.0)

We must also make sure to reset the position of the crosshair when we have no target or the locking system is disabled, otherwise it will remain in the tracking position and not reset to the centre of the screen.

if current_lock == null and not lock_is_disabled:
	if lock_autoaim:
		ui.get_node("CenterInfo").set_target_screen_position(null, 0.0)

if lock_is_disabled:
	ui.get_node("CenterInfo").set_target_screen_position(null, 0.0)

Finally, we can play sounds to help the player ascertain the progress of the lock.

if is_locked:
	Sound.play_locking(false)
elif lock_time_progress > 0.0:
	Sound.play_locking(true)
else:
	Sound.stop_locking()

Dying

When a ship dies, we create an explosion effect and call the clean_up().

func die():
	$Explosion.emitting = true
	clean_up()

The clean_up() method handles removing references to the ship from scripts such as the director, as well as removing parts of the ship that are not needed anyone, such as the model and the associated UI nameplate.

var is_dead = false

func clean_up():
	is_dead = true

	if not player:
		get_parent().ships.erase(self)
	
	if ui_elem:
		ui_elem.queue_free()

We also detach the particles from the ship’s instance so when it is removed, the particle effects remain and can expire naturally.

var trail_particles = $Model/Particles
var smoke_particles = $Model/Particles2
var warp_particles = $Model/WarpEffect

$Model.remove_child(trail_particles)
$Model.remove_child(smoke_particles)
$Model.remove_child(warp_particles)

add_child(trail_particles)
add_child(smoke_particles)
add_child(warp_particles)

trail_particles.emitting = false
smoke_particles.emitting = false
warp_particles.emitting = false

Finally, we remove the unneeded models and hitboxes and tell the director to recompute the states for the current faction, given that one of them has just died. We wait 30 seconds and then remove the ship from the level.

$Model.queue_free()
$HitScanBox.queue_free()
$CollisionShape.queue_free()
get_parent().set_states_for_faction(faction_str, self)

yield(get_tree().create_timer(30), "timeout")
self.queue_free()

The Director

The director is in charge of coordinating enemy states in their FMS (Finite State Machine). Each ship’s AI has a limited set of control of their state, for example, when they get too close to the ship they are targeting they will change themselves to the TOO_CLOSE state. Having a global “director” for the enemy AI means we don’t present the player with unfair situations, for example, all enemy ships deciding to attack all at once.

Factions

To realise this behaviour, we first need to define states and factions that ships can have and belong to:

enum state {
	IDLE, # Non-combat
	KEEP_DISTANCE, # Combat, not attacking
	TOO_CLOSE, # Combat, too close to target
	ATTACK, # Combat, moving and attacking if hit chance high
	RETREAT, # Combat, low health
	FLEE, # Exit level via warp
}

# aware_of_allies
# Aware of what happens to other members of the faction and allies. If attacked, the others may fight or flee.

# combat_start_state
# Initial state when attacked by enemy (not friendly fire.)

var faction = {
	"ALLY": {
		"aware_of_allies": true,
		"combat_start_state": state.KEEP_DISTANCE,
		"faction_relations": {},
	},
	"UNAFFILIATED_TRADER": {
		"aware_of_allies": false,
		"combat_start_state": state.FLEE,
		"faction_relations": {},
	},
	"UNAFFILIATED_HUNTER": {
		"aware_of_allies": false,
		"combat_start_state": state.ATTACK,
		"faction_relations": {},
	},
	"PIRATE": {
		"aware_of_allies": true,
		"combat_start_state": state.KEEP_DISTANCE,
		"faction_relations": {},
	},
	"WAR_A": {
		"aware_of_allies": true,
		"combat_start_state": state.KEEP_DISTANCE,
		"faction_relations": {},
	},
	"WAR_B": {
		"aware_of_allies": true,
		"combat_start_state": state.KEEP_DISTANCE,
		"faction_relations": {},
	},
}

Once the factions have been defined and declared, we can then initialise the relations between the different factions, stored as an integer between -100 and 100. The higher the value, the more the two factions like each-other. initialise_faction_relations() ensures that every faction has every other faction in its relations dictionary, with its relation at a default 0.

func _ready():
	...
	initialise_faction_relations()
	set_faction_relations("PIRATE", "UNAFFILIATED_TRADER", -100)
	set_faction_relations("PIRATE", "UNAFFILIATED_HUNTER", -100)
	set_faction_relations("PIRATE", "ALLY", -100)
	set_faction_relations("WAR_A", "WAR_B", -100)
	...

func initialise_faction_relations():
	for fac1 in faction.keys():
		for fac2 in faction.keys():
			if fac2 == fac1:
				continue
			faction[fac1].faction_relations[fac2] = 0

func set_faction_relations(fac1, fac2, absolute_value):
	var should_update_states = false
	if faction[fac1].faction_relations[fac2] != absolute_value:
		faction[fac1].faction_relations[fac2] = absolute_value
		faction[fac2].faction_relations[fac1] = absolute_value
		should_update_states = true
	if !faction[fac1].aware_of_allies or !faction[fac2].aware_of_allies or should_update_states:
		should_update_states = true
	return should_update_states

States

Once the factions and their relations have been set-up, we can then set the states for each ship. First, we find all the hostile factions to the faction whose states we are processing:

func _ready():
	...
	for faction_str in faction.keys():
		set_states_for_faction(faction_str, null)

func set_states_for_faction(fac, calling_ship):
	var fac_info = faction[fac]
	var hostile_factions = []
	for fac2 in fac_info.faction_relations.keys():
		if is_faction_hostile_to(fac, fac2):
			hostile_factions.append(fac2)
	...

func is_faction_hostile_to(fac1, fac2):
	if fac1 == fac2:
		return false
	return (faction[fac1].faction_relations[fac2] <= -80)

Continuing set_states_for_faction, what we do next depends on whether the ship is acting independently of a faction or not. For example, the factions UNAFFILIATED_TRADER and UNAFFILIATED_HUNTER should not care what happens to other members of their faction, whereas other factions ships should all become hostile if a single member of their factions is attacked.

if fac_info.aware_of_allies:
	var goto_state
	var attacking_ships = []
	var attacking_ships_temp = []
	var number_of_concurrent_attacks = 0
	var average_state_loc = get_average_ship_location_factions(hostile_factions)

	var target_array = get_ships_healthshield_order_high_to_low_from_factions(hostile_factions)
	if not target_array.empty():
		number_of_concurrent_attacks = max(int(target_array.size() / 2.0), 1)

	var attacking_ship_array = get_ships_healthshield_order_high_to_low_from_factions([fac])
	attacking_ship_array.erase(player)

	goto_state = fac_info.combat_start_state
	if goto_state == state.FLEE:
		number_of_concurrent_attacks = 0

	for attacking_ship in attacking_ship_array:
		if attacking_ship.state == state.ATTACK:
			attacking_ships_temp.append([attacking_ship, -attacking_ship.min_attack_time])
		else:
			attacking_ships_temp.append([attacking_ship, -attacking_ship.time_since_last_attack])
	attacking_ships_temp.sort_custom(self, "sort_ships_high_low")

	if not attacking_ships_temp.empty():
		while attacking_ships.size() != number_of_concurrent_attacks and not attacking_ships_temp.empty():
			attacking_ships.append(attacking_ships_temp.pop_front()[0])
	...

After this chunk of code, we have an array called attacking_ships that is an ordered list of ships that can attack, ordered by health high to low. Next we decide what state each ship belonging to the faction should be. (Several methods, such as get_closest_ship_from_factions, have been excluded from the code snippet.)

var exclude = []

if target_array.empty():
	goto_state = state.IDLE
	attacking_ships = []

for ship in get_all_ships_from_faction(fac):
	ship.state = goto_state

	if ship in attacking_ships:
		var ship_index = attacking_ships.find(ship)
		ship.state = state.ATTACK

		if ship_index > target_array.size():
			ship.state = goto_state
			ship.state_target = average_state_loc
		else:
			var target_ship = get_closest_ship_from_factions(hostile_factions, ship, exclude)
			exclude.append(target_ship)
			ship.state_target = target_ship

	else:
		ship.state_target = average_state_loc

For ships that should not react to allies being attacked, we used a different set of conditions. Hostile ships are not read from the faction like before, but are instead read from the ship’s unaware_of_allies_hostile_ships array…

...
else:
	for ship in get_all_ships_from_faction(fac):
		if ship.unaware_of_allies_hostile_ships.empty():
			continue
		for ship2 in ship.unaware_of_allies_hostile_ships:
			if not is_instance_valid(ship2):
				ship.unaware_of_allies_hostile_ships.erase(ship2)
			elif ship2.is_dead:
				ship.unaware_of_allies_hostile_ships.erase(ship2)

		var goto_state = fac_info.combat_start_state
		var targets = ship.unaware_of_allies_hostile_ships
		if not targets.empty():
			calling_ship.state = goto_state
			calling_ship.state_target = targets[randi() % targets.size()]
		else:
			calling_ship.state = state.IDLE

The director script is also in charge of creating new enemy ships, as well as the player, when the level is initially loaded. The following code can be used to instantiate any type of ship within the level…

var ships = []

const SHIP_SCENE = preload("res://Ship.tscn")
const SHIP_UI_SCENE = preload("res://ShipInfo.tscn")

func spawn_ship(is_player, ship_faction):
	var ship_inst = SHIP_SCENE.instance()
	var ship_target = Position3D.new()
	ship_inst.state = state.IDLE
	ship_inst.state_target = null
	ship_inst.player = is_player

	if not is_player:
		ship_inst.ui_elem = SHIP_UI_SCENE.instance()

	ship_inst.max_health = 6
	ship_inst.max_shield = 3
	ship_inst.faction_str = ship_faction
	ship_inst.target_marker = ship_target
	ship_inst.director = self
	ship_inst.ui = get_parent().get_node("CanvasLayer/Control")

	add_child(ship_target)
	add_child(ship_inst)

	if is_player:
		# Player start position
		ship_inst.global_transform.origin = Vector3(0, 0, 256)
	else:
		ship_inst.global_transform.origin = player.global_transform.origin + Vector3(rand_range(-200, 200), rand_range(-200, 200), rand_range(-200, 200))

	ships.append(ship_inst)

	if ship_inst.ui_elem:
		world_ui.add_child(ship_inst.ui_elem)
		ship_inst.ui.get_node("HealthAndShield").update_values(ship_inst)

	return ship_inst

This function is utilised in the _ready() method to create the initial population of the level:

var player

func _ready():
	...
	player = spawn_ship(true, "ALLY")
	spawn_ship(false, "UNAFFILIATED_TRADER")
	spawn_ship(false, "UNAFFILIATED_TRADER")
	spawn_ship(false, "UNAFFILIATED_TRADER")
	spawn_ship(false, "UNAFFILIATED_HUNTER")
	spawn_ship(false, "UNAFFILIATED_HUNTER")
	...

I also added a way to spawn different kinds of ships to help with testing:

func _input(event):
	if event is InputEventKey:
		if event.scancode == KEY_E and event.pressed:
			spawn_ship(false, "WAR_A")
		if event.scancode == KEY_Q and event.pressed:
			spawn_ship(false, "WAR_B")
		if event.scancode == KEY_1 and event.pressed:
			spawn_ship(false, "ALLY")

Sound Effects

External

Explosion, 8-bit, 01 by InspectorJ under the Creative Commons Attribution 4.0 License.

Hitmarker Sound Effect by User391915396 under the Creative Commons 0 License.

Created Myself

Critical

Damage

Engine (Original)

Engine (Seamless Loop)

Gunshot

Space Gunshot

Lock-on (Locked)

Lock-on (Locking)

Low Health

Miss

Menu Confirm

Menu Negative

Ready Weapon

Unready Weapon

Credits

Thank you to Firepal, developer of Polyliner, for helping compress images and ensuring video playback compatibility on multiple browsers.