How units work in Unto Deepest Depths
This post is about my strategy game Unto Deepest Depths. Give it a wishlist!
I previously showed how the game loop in Unto Deepest Depths works, but the other big piece of any good strategy game is its units, so today I’ll show you how I’ve structured my game’s code so that I can easily support a variety of units.
Functional requirements
To understand the technical side of units, let’s first briefly look at their features in the gameplay.
During gameplay, units have two things they can do: move and use an ability. For most units in the game, this ability is just an attack of some sort, but there are some units that have a non-attack ability. For example, healers heal their allies and necromancers summon skeletons to join the fight. Unlike more complex tactical games, each unit only has one ability, so there’s no AP considerations or choices that need to be made as to what ability should be used, just the choice of where the ability should be used.
Outside of combat, units again have two functions: promotions and upgrades. Promotions are changes to the unit’s class that are acquired by spending XP. Each class allows for promoting to certain other classes along an upgrade tree type system. As currently implemented in the demo, the promotion tree looks like this, though I have plans for several more units in the full version:
|-- Peasant
|-- Knight
|-- Archer
|-- Lancer
|-- Mage
|-- Healer
The last element of units is upgrades. Upgrades are bonuses and modifications to how units operate. These could be simple things like health increases or damage bonuses against certain types of enemies, or more complex things like death saves or certain bonuses for ending turns next to an ally. The list of upgrades available are the same for every unit, but they are expensive and also follow an upgrade tree structure, so choices have to be made as to where and who to upgrade.
Unit data
That’s everything a unit needs to be able to do, so now let’s look at how I define and manage the data for individual classes and instances of units. At the heart of everything is the UnitDefinition class, which is a custom Resource that defines everything unique about a unit. This includes basic things such as the unit’s name, type, and a reference to its unique animation frames, along with combat-oriented things like health, allowed movement patterns, and rules on how to attack, and information on progression, such as unit upgrades and available promotions.
For each unique class in the game, such as a mage or skeleton, I have pre-made definitions that define what a fresh instance of this unit looks like, such as its starting health, cost to promote, and of course all of the previously mentioned elements of a unit. When a unit is needed in the game, such as when adding one to the player’s party, the base instance of that unit is duplicated and used so that I now have a unique copy per-unit. For things like promotions, the same is done but I also copy the relevant unique properties, such as any purchased upgrades, from the UnitDefinition this new unit was promoted from.
Working with UnitDefinitions
There are multiple places that UnitDefinitions get used in the game, but most uses consist of just reading and manipulating the UnitDefinition directly. For instance, to buy an upgrade, I just need to add that upgrade to the list of upgrades on a specific unit. For battles, though, things are a little more involved, so let’s now look at how these unique gets used on the battlefield.
Functionally, every unit is the same, so during battle, every unit is represented in the game world by a UnitInstance, a relatively straightforward Node2D scene with nodes for the graphics, audio, health bar, and miscellaneous effects, which we’ll talk about later. When a battle loads, UnitInstances are created and placed around the map as appropriate and assigned a UnitDefinition, which they use to load operational information, such as how much health they should have, what SpriteFrames to use for animation, and so on.
Taking action
This is enough to get the basics in place, as each unit now at least looks correct on screen, but units still need to be able to do stuff on their turn, and that’s where two components come into play: AbilitiesDB and AiController. In order to talk about those components, though, we need to first talk about how actions work in Unto Deepest Depths.
A brief look at actions
In the UnitDefinition, actions are defined as custom classes that are mostly just data containers holding a series of relative unit coordinates on what cells will be effected (called the path), an entry for the relative cell that should be clicked for confirming that action (called the end point), and a rule on how to handle objects in the world that overlap with these coordinates (called the BlockMode).
For instance, a unit that can move one cell to the right would have an action with the coordinates [1, 0]
and an end point of [1,0]
while a unit that can move two cells to the left would have an action entry with the list [-1, 0], [-2, 0]
and an end point of [-2, 0]
. As a unit can only move to an open cell, each of these actions also will have a BlockMode of “Cancel”, indicating that if any cell in the list is occupied by another unit or object in the world, this movement option cannot be used.
Attacks work the exact same, though most have different BlockModes in accordance with the attack type. A melee attack, such as on the knight, will typically have a BlockMode of “Ignore” since you should be able to attack regardless of if something is in your way (hopefully an opponent!), while the archer, who has a long range attack that should stop at the first thing it hits, will have a BlockMode of “TruncateOn” so that the shot goes as far as it possibly can and then stops.
Using actions
So with actions defined as relative coordinates and rules on how to handle objects in the world, we can now look at how to use this information to look at what actions are viable and how to execute them.
AbilitiesDB
On a unit’s turn, whether for moving or attacking, it first needs to know what actions it can take according to the rules of all its possible actions. That’s where AbilitiesDB comes into play. AbilitiesDB, the name of which is a holdover from early prototyping days, answers the question “Give the current game state what actions are viable right now?” The primary way of answering this question is of course by looking at all possible actions for moving or attacking, turning relative coordinates into absolute, and filtering them according to their block mode and current map and unit positional data.
AbilitiesDB can also handle edge cases related to planning, of which there’s currently two. The first is teleporters, which appear late in the game and zip you around the map. For teleporters, a move action needs to be modified so the final end point is wherever you’ll end up after teleporting. This let’s the player know where they’ll go, but is also required so that the AI can plan around its potential usage. AbilitiesDB makes this change internally so that units and their AI only know they have a list of move options and don’t have to worry about the details of how they got there.
The other edge case is when there are no viable actions to choose from. It’s rare, but it is possible a unit with limited movement options, such as the mage, gets surrounded by other units on their turns and can’t go anywhere when it’s time to move. AbilitiesDB also checks for this and returns a simple no-op action as a backup so that neither the player nor the AI ever get soft locked due to a lack of options. For those of you hoping you could choose not to have to move or attack every turn, this is the one time in the game you have that option!
AiController
Once the list of actual actions that can be taken has been generated, it’s then time to make a decision about what the do. As covered previously, player units get their actions bubbled up into the UI, whereas the AI just needs to make a decision and act. This decision is made inside the AiController, a RefCounted instance that takes the list of actions provided by AbilitiesDB, grabs any relevant world state, such as the position of allies and enemies, and decides upon the best available action at that moment in time. I’ll talk more about how this decision is made in a future post, but for now just know that it takes the output of AbilitiesDB, makes a decision on how to act, and returns this data to the unit instance where it can then be acted upon as usual.
Type components
And that’s how actions are handled, but there’s one remaining item you may have noticed that we haven’t covered: the unit-specific effects that can be triggered when acting. For instance, the mage shoots a wave of flames around the field, and the healer lights up the unit it’s healing. These are implemented via what I call UnitTypeComponents. These components are custom scenes filled with whatever content that unit needs to support their actions. The key is that each scene inherits from a very minimal class that helps wire events from the parent unit to the component. With these events wired to the component, I can play an animation, sound effect, or even spawn an enemy as part of a unit’s attack.
class_name UnitTypeComponent
extends Node
var unit: UnitInstance:
set(value):
if unit:
unit.attack_beginning.disconnect(_on_attack_beginning)
unit.attack_complete.disconnect(_on_attack_end)
unit = value
unit.attack_beginning.connect(_on_attack_beginning)
unit.attack_complete.connect(_on_attack_end)
func can_attack() -> bool:
return true
func _on_attack_beginning(_ac: ActionInstance) -> void:
pass
func _on_attack_end(_ac: ActionInstance) -> void:
pass
You may also notice I have a can_attack
function in there, which typically returns true. Type components also have the option to override the regular enemy attack flow in case I want to have a cooldown or similar on a unit’s attack. It’s a rare use case, but there is a unit or two that may need it, so I have it in here as a method that can be overridden.
To use these type components, I just create whatever scene I want, have it inherit from this script, override as necessary, and add an entry to the unit’s definition, which takes an array of UnitTypeComponents. These will get generated and added to the unit’s scene tree at runtime and become an extension of that unit.
Wrapup
And that’s a look at how unit’s are coded in Unto Deepest Depths. There’s still a lot I want to cover on this game, so let me know if you have any questions or things you want to see!