Created September 2024

Video

What is it?

  • Engine: Godot 4
  • Language: GDScript
  • Goal: To learn how to develop systems for a complex game in Godot.

This is an early prototype of a game I’m planning on developing into a full release someday, featuring customisable submarines with full six degrees freedom movement. This game idea has existed on and off in my head for ages, but only recently has a viable design for a multiplayer shooter involving these mechanics come to mind. Hopefully you’ll see this someday!

Features

  • Support for multiple maps and gamemodes
  • Loadout selection screen
  • Submarines with 6-DOF movement and physical weapon mount locations.
  • Weapons with configurable projectiles and settings
  • Torpedoes that can track vehicles.
  • Decoys that can disrupt a torpedo’s tracking.
  • A turret that can be controlled by both AI and the player.
  • Configurable currents that push objects in their wake.
  • Firegroups that let you select which buttons fire which guns (WIP)
  • Soon it will also have multiplayer, but not right now!

How are submarines structured?

The submarines are scenes that are inherited from a base submarine scene. The base submarine includes everything that is absolutely necessary for a submarine to have. These nodes include:

  • The Health and damage receiver components, which I made to decouple health and damage from the submarine. Any 3D body with these nodes can receive damage.
  • The camera and aimcast.
  • The WeaponHardpoints node for hardpoints to be attached under.
  • VFX and SFX shared between all submarines
  • Spawnpoints for torpedoes and decoys, which all submarines have.
  • Timers for delays and durations.

Base submarine scene tree.

The submarines you see in the game change the positions and settings of these existing nodes, as well as adding new ones to suit the looks and gameplay aspects of that particular submarine. Colliders are added, hardpoints are placed around the submarine, and the mesh is rendered.

Albatros submarine scene tree.

This workflow is designed to fit in with Godot’s design principals, preferring composition over inheritance. While there is a Submarine class, seen on the right side of the above screenshot, it merely extends the RigidBody3D node to allow it to move and carry loadouts, as well as manage nodes beneath it.

The Submarine class doesn’t even handle input. Rather, the game uses a system similar to Unreal’s approach using Controllers that possess and control objects using a standardised set of function calls. In GDScript, interfaces like this are done via duck-typing, or simply checking if a class has a function of a certain name:

PlayerController.gd:

func _input(event) -> void:

[...]
    # Thrust input
    var move_3d_input = Vector3(
        Input.get_axis("thrust_left", "thrust_right"),
        Input.get_axis("thrust_down", "thrust_up"),
        Input.get_axis("thrust_forward", "thrust_backward")
    ).normalized()

    if _current_pawn.has_method("move_3d"):
        _current_pawn.move_3d(move_3d_input)

    # Roll input
    var roll_input = Input.get_axis("roll_right", "roll_left")
    
    if _current_pawn.has_method("roll"):
        _current_pawn.roll(roll_input)

[...]

These functions can also be used by a separate controller with an AI algorithm instead of player input, allowing for computer-controlled opponents - although this isn’t implemented yet.

In future versions of this approach, I might forgo functions in favour of duck-typing variables instead, which can be done like this example using the in keyword in GDScript:

if "move_input" in submarine:
    submarine.move_input = input

How is the level system structured?

In Godot, there is no distinction between a level/map and an entity of some description. Instead, everything is made up of groups of nodes called Scenes. This differs from other engines, where levels are a distinct file type compared to Actors (Unreal) or Prefabs (Unity) - in Godot, everything is a Scene.

So in Godot, you make levels out of nodes the same way the player would be made, or submarines in my case. But this raises a problem for some developers: how do you assign information to a level, and how do you easily switch between them? This is something I ran into, since levels can have names, descriptions, and gamemodes. And moreover, scenes aren’t added to an internal database in the engine by default, so switching scenes with Godot’s own change_scene() call requires the whole file path of the scene which could change further down the line.

Luckily, you don’t have to rely on change_scene(), as the tree-style structure of Godot’s engine means you can manipulate things manually and implement your own way of changing scenes for ease of development - Godot even instructs you how to do this. So in similar vain to how I copied Unreal’s controller and pawn architecture, I used Unreal for inspiration on how to implement levels too.

Godot has another way of storing information called Resources, which are objects that can store data and be saved as files in the project. When they are loaded, they can be interfaced with by the engine and can even have custom functions. They are analogous to Scriptable Objects in Unity, or individual entries in a Datatable in Unreal. Resources are preferred to store information in Godot since information inside of a Node’s setting in a Scene is hard to read without loading the entire scene, which can be bad for performance.

So, I made a custom Level resource that stores a reference to a scene, its name, an image, and its gamemode:

extends Resource
class_name Level

## A resource that holds level metadata, including name, images, and the level
## scene itself.

## The name of the level. Appears in UI. Can be used for comparison.
@export var name: StringName

## The description of the levl. Appaers in UI.
@export_multiline var description: String

## The level scene itself. Loaded by the GameManager.
@export var scene: PackedScene

## The gamemode for the level.
@export_enum("MenuGameMode", "GameplayGameMode") var gamemode: String

## Image for the level in UI.
@export var image: Texture

The level resource, showing the various settings in the inspector.

A list of these levels is maintained by a Resource Group, which is implemented by an addon by derkork. This addon adds another resource type keeps track of files of a specific name pattern (REGEX) and stores a list of them inside. If it weren’t for this addon, any levels would need to be indexed manually in a separate list after creation.

The resource group, showing the search patterns for level resources.

This resource group is then read by the GameManager, a singleton that adds basic functions for the game like switching levels and accessing the root node of a level.

GameManager.gd:

extends Node

## The manager for the game. Has functions for loading levels and stores player
## data inside itself.

const LEVEL_LIST: ResourceGroup = preload("res://levels/level_list.tres")

var _levels: Dictionary

[...]

## THE BEGINNING OF EVERYTHING.
func _ready() -> void:
	var levels = LEVEL_LIST.load_all()
	for level: Level in levels:
		_levels[level.name] = level
	
	if get_tree().current_scene.name == "InitScene":
		load_level("MainMenu")

[...]

## Loads a new level on the next frame. Unloads the current scene, instantiates
## a level, and spawns in the gamemode.
func load_level(level_name: StringName, options: Dictionary = {}):
	await get_tree().process_frame
	var root = get_tree().get_root()
	var new_level: Level = get_level(level_name)
	
	if new_level == null:
		printerr("Level is null!")
		return
	
	if _current_level_root != null:
		_current_level_root.free()
	
	_current_level = new_level
	_current_level_root = _current_level.scene.instantiate()
	root.add_child(_current_level_root)
	
	# Add gamemode
	if _current_gamemode != null:
		_current_gamemode.free()
	
	if GAMEMODE_SCRIPTS.has(_current_level.gamemode):
		_current_gamemode = GAMEMODE_SCRIPTS[_current_level.gamemode].new()
		root.add_child(_current_gamemode)
		_current_gamemode.name = "GameMode"
		_current_gamemode.initialize(_current_level_root, options)

Then finally, to load a level from somewhere else in the game, all it needs is the name of the level!

GameManager.load_level("MainMenu")

As seen, this also manages gamemodes too!

A loaded level inside the scene tree

What about that cool minimap?!

Time for some graphics programming!

A screenshot of the minimap

The minimap’s scene is a part of the HUD scene. In Godot, you can add a SubViewport node to a UI Control node, which allows you to render any 2D or 3D node beneath it to a smaller viewport on the UI.

A screenshot of the HUD’s scene tree, showing the viewport and how it is connected with the minimap.

A screensho showing the camera and minimap visible inside the scene.

The minimap is a simple subdivided quad mesh with a shader applied to it. The shader takes in the heightmap data from the terrain plugin, HeightMap Terrain, and uses it to deform the mesh to the terrain’s height. It has some extra math functions that alter the UV of the mesh to change the zoom, rotation, and position of the map. The rotation and position of the mesh can move the map according to the player’s heading and location!

A screenshot showing the settings for the mesh and shader.

hud_map_terrain.gd:

vec2 rotate_uv_to_player(vec2 uv, vec2 pivot)
{
	mat2 rotation_matrix = mat2(
		vec2(sin(rotation),-cos(rotation)),
		vec2(cos(rotation),sin(rotation))
	);

	uv -= pivot;
	uv = uv * rotation_matrix;
	uv += pivot;

	return uv;
}

void vertex()
{
	vec2 base_uv = (VERTEX.xz / 2.0);

	vec2 texture_size = vec2(textureSize(terrain_heightmap, 0));
	RATIO = 2.0 / texture_size.x;

	vec2 player_pos_norm = player_pos.xz / texture_size;
	vec2 player_pos_uv = base_uv + player_pos_norm;

	vec2 rotated_uv = rotate_uv_to_player(player_pos_uv, player_pos_norm);

	SCALE = map_radius / texture_size.x;
	ZOOM_MUL = texture_size.x / map_radius;

	vec2 zoomed_uv = mix(player_pos_norm, rotated_uv, SCALE);

	float height = texture(terrain_heightmap, zoomed_uv).r;

	VERTEX.y = (height - player_pos.y) * RATIO * ZOOM_MUL;

	VERTEX_WPOS = vec4(VERTEX, 0.0) * MODEL_MATRIX;
}

Credits