36 minute read

Unto Deepest Depths, my new strategy game (click the link to wishlist!), and over the next few posts I’ll be discussing various aspects of the game’s development, starting today with the application architecture and some code snippets showing how all of this fits together.

What about the other game?

If you’re a regular reader, you may be wondering why I’m not discussing my tactics game. The short answer is that my partner is doing 90% of the artwork for that game, but she has less time to devote to game development than I do, so while that game is in a good place technically, it’s going to be a little while before it’s ready for a proper public unveiling. So, I decided to put it on the backburner for a bit, fired up Aseprite, and started on this smaller scale concept I’ve been wanting to explore for a little while now. We’ll be back with more info on the Kaiju game at a later date.

Game overview

First up, what is Unto Deepest Depths? It’s a dark fantasy strategy game with a minimal, yet challenging, ruleset: Each turn, every unit must move and must attack. As each unit has unique rules about where it can move and attack, and friendly fire is very much a thing, the player must strategize about not only how to position their units to defeat their opponent, but also how to protect their units from one another and what the impact of moving to a location this turn will be in future turns since they’ll be moving again shortly. Additionally, units have very little health and can typically only take one or two hits before dying, so every turn is important since there’s little room for error.

This is a sort of back-to-basics concept I’ve been wanting to explore for a while now. Something that strips the strategy genre down to strategic positioning and doesn’t get bogged down in too much complexity. It’s not a large scope game by any means, but as someone who enjoys simple strategy games that can be played in a single sitting, I’m hoping others out there will find enjoyment in the concept as well. Plus, I think the dark fantasy atmosphere is fun, especially when the post-processing effects are laid on top of it all and it’s paired with a nice, crunchy, retro soundtrack thanks to my recent discovery of Super Audio Cart (Not sponsored in any way, I just like it).

To round out the game overview, Unto Deepest Depths will offer both fixed challenges and a roguelite mode where you’ll grow and manage a party of adventurers, fight your way through procedurally generated battles, challenge bosses, and respond to random events along the way. And just for fun, you’ll have the choice of playing as the humans fighting off the dark creatures, or as those creatures fighting off the humans.

If this sounds interesting to you, give it a wishlist, but now, let’s talk code!

Game Architecture

If you’ve seen my devlogs for Elemechs or the kaiju game, then you’ll have a good idea of how I’ve decided to structure my game’s code, though I have again made some incremental improvements to it, which I’ll discuss when appropriate. To start with, here’s the node structure of a typical level, slightly abbreviated for clarity:

Level
    |--- Level generator
    |--- Tilemap
    |--- Cell highlights
    |--- Objects
    |--- Map Effects
    |--- Units
        |--- Player group
            |--- Unit A
            |--- Unit B
        |--- Opponent group
            |--- Unit X
            |--- Unit Y
    |--- UI

The top-level node coordinates level initialization, generation, and listens for win-loss conditions, but delegates all major work to its children as appropriate. The vast majority of its code is dedicated to just initializing the level in the correct order and passing in some high-level dependencies (ex: the tilemap for a given level needs to be generated before you can create a navigation grid from it).

The Level Generator node generates the level for procedurally generated battles. I’ll discuss this node more in a later post, but for now I’ll add that it is always present, but is optionally triggered so that I can have one scene structure for both fixed levels, where I’ve placed tiles and objects manually, and for procedurally generated levels.

The various nodes between the level generator and the units are the nodes that take care of anything that isn’t a unit but needs to be displayed within the world space. Objects, in particular, is where any objects that don’t fit elsewhere in the 2D space. Teleporters that can move units around, exploding mushrooms, and so on.

The Units node and its children are where the bulk of the level’s logic occurs. After everything has been initialized, the Units node starts running the main game loop of taking turns on the battlefield and waiting for one side to come out victorious. This node cycles through its children unit groups, informs them that it’s their turn, and then listens for a signal that the turn is complete and that the next group may go. It also takes care of cross-group logic, such as answering the question “Where are all the active units on the map?” and resolving attack outcomes since attacks can hurt a unit in any group and some objects in the world are also affected by attacks. Keep in mind that it doesn’t necessarily perform these tasks, as it delegates where relevant, but it is the place to go for these types of things.

Unit groups are concerned with managing all units on the same side of the battle. This mostly involves managing turn order when it’s that group’s turn, cycling through all active units, and listening for signals from a given unit. It can also answer questions like “Where are the active units in this specific group?”

The various units are the individuals on the battlefield. We’ll discuss them in-depth another time, but just know that in the context of the overall level structure, they emit signals that their unit group listens to informing it of that unit’s actions, such as moving, attacking, being defeated, and so on so that the unit group can respond accordingly.

The last piece, which isn’t shown above, is a number of autoloads that help support everything. These include event buses, mostly for communicating UI events from deep within the scene tree, a few globally accessible functions for looking up data, such as getting a list of the interactive objects in a level, and the navigation system, which is built using Godot’s AStar2D class. I use AStar2D rather than the existing grid-oriented AStarGrid2D class as there are special navigation use cases I need to support that AStarGrid2D isn’t a good fit for, as of this writing, such as teleporters that can take you to a matching tile somewhere in the level. With the basic AStar2D class, I can just connect two arbitrary nodes in the navigation graph to one another, such as the two ends of a teleporter, and now the AI and pathfinding knows how to work with them, but to my knowledge it is not possible to connect arbitrary AStarGrid2D nodes to one another.

Another key thing to note here is that this is all event driven. There is very little code running every frame, and the code that is isn’t related to managing or executing the primary game loop. Pretty much everything runs on signals, which makes it easy to branch or vary code execution for little work. Player and AI units both emit the same signal when they’re moving, for instance, but whereas an AI-controlled unit can just move, a player-controlled unit can kick things to the UI, wait for the player to make a selection, and then rejoin the same path the AI-controlled unit would take. I do have to sometimes be mindful of race conditions if a signal gets fired the same frame its parent logic is called, but that’s a relatively straightforward edge case to handle.

And that’s the high-level structure of the game. While the internals have changed a bit over time, this has become my “standard template” for a strategy game, and I don’t see much need to change it with the games I make. Looking at the critical path of Level > All Units > Unit groups > Specific Units, this structure divides the work into partitions that represent both the game flow and the logical separation of code concerns. The Level can dictate when to begin or end play, the units container can decide whose turn it is, the unit groups can decide what to do on their turn and when their turn is over, and the units themselves can drive their own behavior.

I’ve used this format for turn-based combat in Kaiju Klash, tactical strategy with Elemechs and the kaiju tactics game, and even a roguelike golfing game as I find this to be a fairly flexible system that works well for me in most turn-based scenarios.

Executing the game loop

With the high-level design out of the way, let’s take a deeper look into how all of this is actually implemented by stepping through the code driving game loop. As with the architecture diagram, I will be slightly simplifying things so that we don’t get lost in the weeds (do you really need to see that I start playing music when a level loads?), but what I’m going to show in this section is an accurate reflection of how the game code operates in order to manage and operate turns.

Startup and initialization

So, what happens when the player loads a level, ready to take on the forces of evil? Let’s take a look at the _ready function of my top-level node for the entire scene:

# Slightly abbreviated for clarity, especially for code not relevant to the current discussion
extends Node2D

@export var needs_generation: bool = false

@onready var tiles: TileMap = %tiles
@onready var units: UnitsContainer = %units
@onready var objects: Node2D = %objects
@onready var camera: Camera2D = %camera
@onready var level_generator: Node = %level_generator

func _ready() -> void:
	if needs_generation:
		level_generator.generate_level()
	
	seed(123)
	
	Navigation.init_level(tiles, units.get_active_units)
	
	units.battle_over.connect(_on_battle_over)
	
	camera.init_position()
	units.init()
	objects.init()
	units.start_battle()

func _on_battle_over(player_won: bool) -> void:
    # Show end screen here

The first thing to do is generate the level, if needed, as there are multiple nodes that need to work with the final level output. As I mentioned above, level generation will get its own post, but what’s relevant here is that the level generator is self contained. There are a few exported variables so it can write its output to the correct nodes, and it accesses a few resources so that it knows what its generation goals are (difficulty, level type, etc), but that’s it.

After that, there’s a curious call to seed(123), but the reason is quite simple. There’s a limited number of places in the game code where a number may be generated or an array is shuffled, but since this is a game with little room for error, I want the game to be deterministic. Assuming all else is the same, taking the same action should result in the same outcome every time you play the same level, so I set the global seed to a fixed number to remove any variation in results.

After the level is generated, it’s time to start initializing everything else in the game. The Navigation autoload is where pathfinding is managed, so I grab the level tilemap and pass it to Navigation so it can set up the AStar2D instance. That second parameter, units.get_active_units, is a Callable that Navigation can use to look up active units on the board and help answer line of sight questions that require information on both the tilemap and units.

Once pathfinding is ready, the scene starts listening for a signal informing it that only one team is left and that battle is over, triggering UI and other effects as necessarily. After that, the remaining nodes to get initialized with whatever they require. The camera positions itself to the center of the game map, the units node connects up various signals, the object node initializes its nodes where appropriate, and then the battle begins!

Turn resolution

Now we’re ready to fight, so let’s dive in deeper into the Units node and start looking at what the standard game loop looks like. As mentioned above, this node exists to manage the turn order between groups. In Unto Deepest Depths, there’s only the player and their opponent, but the system supports any number of groups by storing them in an array that it will iterate and loop over:

class_name UnitsContainer
extends Node2D

signal battle_over(player_won)

var all_groups: Array[UnitGroup] = []

var current_group: Node
var current_group_index: int = 0

func init() -> void:
	for child in get_children():
		child.init()
		all_groups.append(child)
		child.attack.connect(_process_attack)
		child.turn_complete.connect(_on_turn_complete)
		child.defeated.connect(_on_group_defeated)
	
	Battle.get_active_units_callable = get_active_units

On initialization, Units grabs all of its UnitGroup children, adds them to a queue, initializes them (not shown above), and listens for the turn_complete signal. This signal connects to the following function, which just checks if there’s more than one group that’s still alive. If so, gameplay continues and the next group takes its turn. If not, the node emits a signal telling the top-level scene node that there’s no more battle to be had and whether or not the player won by checking if the player’s group has any active units remaining.

func _on_turn_complete() -> void:
	var active_groups = all_groups.filter(
		func (group):
			return group.get_active_units().size() > 0
	)

	if active_groups.size() > 1:
		_step_turn()
		return
    
	battle_over.emit(player_group.get_active_units().size() > 0)

When its time to step forward in the turn queue, Units simply increases the value of current_group_index, looping back to 0 if necessary, and sees if the next group in the queue still has active units. If it doesn’t, it goes on to the next group and tries again. Since this function is only triggered when more than one group is active, I know I’ll get a different group than the one I started with as long as I loop far enough. Not exactly necessary for this game with its two groups, but I’m trying to keep some low-cost flexibility to the architecture so I can reuse this in other projects.

func _step_turn() -> void:
	while true:
		current_group_index = wrapi(current_group_index + 1, 0, all_groups.size())
		current_group = all_groups[current_group_index]
		if current_group.get_active_units().size() > 0:
			break
        # Should never be triggered, but a little safety catch to prevent a lockup just in case things get weird
        if current_group_index == prev_group_index:
			push_error('Looped through all unit groups and did not find another valid group!')
			break
	_begin_turn()

func _begin_turn() -> void:
	current_group.take_turn()

And that’s how Units manages turn order. This node also does a few other things, like listening for if a group is defeated mid-turn, in case friendly fire or similar kill the group, and helps pass signals around regarding attack outcomes, but I’m going to ignore those for now to instead stay focused on the main game loop.

Unit groups

Inside of the units container are two unit groups, one for the player and one for the opponent. These nodes house the individual units for each team and coordinate turn logic between them. That coordination, combined with the fact that units rarely, if ever, directly operate on things outside of their scope, means there’s a lot of code here to drive the UI for the player controlled units, such as displaying move options, handling unit selection, and so on. I’ll touch on that code as appropriate, but won’t go too in depth as it mostly consists of getting some data and firing signals through a global event bus so that the UI can respond. So, let’s take a look at what we’re working with here:

class_name UnitGroup
extends Node2D

signal turn_complete
signal attack(ac)
signal defeated

var current_unit: UnitInstance
var current_unit_index: int = 0
var current_actions: Array = []

# Other variable declarations

func init() -> void:
	for child in get_children():
		child.init(name, is_player_controlled)
		child.damaged.connect(_on_unit_damaged)
		child.unit_defeated.connect(_on_unit_defeated)

At the top of the class we can now see where the signals that tell the parent Units node what this group is doing are defined. There’s a signal to let it know when a turn is complete, one to let it know when an attack has been made so that Units can pass that data to the opposing group, and a signal for when there are no more active units. I also have some variables to help me track this group’s state, such as the current unit being operated on, the index of that unit among its children, which is used by the AI as it just iterates through all of its children on its turn, and a collection of the actions under current consideration, current_actions, which is used for UI purposes such as checking if a click on the map is valid and if so, which action should be executed.

Wrapping up this top section, on initialization I loop through at the units that belong to each group, initialize them, and connect to a few signals regarding their health. Other signals, such as what actions are being taken, are connected elsewhere, as we’ll see in a moment.

Let’s now skip to where turns are managed.

func take_turn() -> void:
	current_unit_index = -1
	for unit in get_active_units():
		unit.reset_turn()
	_step_turn()

When it’s a UnitGroup’s turn, I reset the index for looping through units, inform each unit that they should reset any turn-based state that they may have, and then move on to stepping through the units, which is where things get a little more interesting. And in case you’re wondering, I set current_unit_index to -1 just as a convenience so that I can do a simple current_unit_index += 1 when stepping through the units and have the index at 0 for the first turn.

func _step_turn() -> void:
	EventBus.clear_cell_highlights.emit()
	await get_tree().create_timer(TURN_COOLDOWN).timeout
	
	if is_player_controlled:
		_step_turn_player()
	else:
		_step_turn_ai()

I start out a specific unit’s turn by clearing any highlights that may be on the map, again calling a signal through the global event bus, and taking a short break the duration of TURN_COOLDOWN so that there’s a beat to take in the outcome of any previous turns that may have been executed. Afterwards, the player and the AI go through two very different processes.

For the player, everything flows through the UI to drive the turn. As any unit can be chosen in any order, the player group grabs all units that haven’t taken a turn, highlights their cells, and then listens for a click in a valid cell. When a cell has been clicked, it marks that as the current unit and then let’s the player select their movement, again doing a loop through the UI to let the player select their target cell, confirming it’s a valid cell, and then letting them move. After moving, a unit has to attack, and the same loop is done once more: highlight cells, click cell, resolve attack. I recognize that I’m glossing over the code for this section, but that’s because it’s all variations of the same thing. Still, here’s what _step_turn_player looks like to give you an idea of what’s going on:

func _step_turn_player() -> void:
    # Check if end of turn
	var unused_units = get_unused_units()
	if unused_units.size() == 0:
		_end_turn()
		return
    
    # Cleanup from the last turn
	disconnect_current_unit_signals()
	EventBus.clear_cell_highlights.emit()

    # Prepare for next turn
	current_unit = null
	is_awaiting_unit_selection = true
	
    # Kick unit selection to the player
    EventBus.show_selectable_player_units.emit(
		unused_units.map(func (unit): return unit.cell)
	)

For the AI, everything is much simpler. The group selects the next active child, marks it as the current unit, and tells it to take its next action, whether that be moving or attacking. It does this twice to ensure a unit moves and attacks, and then goes on to the next unit. As the unit itself tracks what it has or hasn’t done on a turn, the UnitGroup has very little to do for AI units.

func _step_turn_ai() -> void:
	disconnect_current_unit_signals()
	
    # Iterate to the next units and either end the group's turn or start a unit's turn
	while true:
		current_unit_index += 1
		if current_unit_index >= get_child_count():
			_end_turn()
			return
		current_unit = get_child(current_unit_index)
		if current_unit.can_take_turn:
			break
	
	connect_current_unit_signals()
    _step_unit()

func _step_unit() -> void:
	#
	# A few UI signals here
	#
	
	if current_unit.is_player_controlled:
		# Player logic
	else:
        # Tell the AI unit to do whatever it needs to next
		current_unit.take_next_action()

Units

Let’s now jump very briefly to units themselves. I will discuss them in detail another time, but I will touch on them here for completeness of the game loop and show their signals:

class_name UnitInstance
extends Node2D

signal movement_complete
signal attack_beginning(ac)
signal attack_complete(ac)
signal unit_defeated
signal damaged

As you might could infer, each signal here ties into what we’ve seen in the previous sections. There’s a signal to mark that movement has been completed so that the turn can be stepped, but also signals for starting and ending an attack animation. attack_complete is what drives the turn order so that the full animation is played before moving on, but attack_beginning is also useful to help drive certain effects. unit_defeated ties into the turn order, or even marking that a group has lost the battle, as we saw above, and damaged is used primarily because there are visual effects applied when a unit takes damage.

Wrapup

And that’s a look at how level startup and turn order are managed in Unto Deepest Depths. In future posts, I will show more details about how units themselves work, how levels are generated, and more, but this hopefully gives you an idea of how I’ve structured my code for the core game loop. One last thing I’d like to point out is that, relative to the genre, this is a fairly simple game from a technical perspective, so the code here may not apply for all strategy games, but it’s perfectly appropriate for the scope I have here.

Thanks for reading.