Developing a tactics engine in Godot - A devlog


I’ve decided that I want to revisit the tactics genre for my next “big” project, so here’s a look at how I’m designing the engine and game flow for this project. If you’ve read my post about Elemechs then some of this will be familiar to you, though I have made some changes to that implementation. I’ve also been playing around with some different ideas in the early stages of development so you’ll see two different prototypes (and maybe a bit of Elemechs) shown in this post: a sci-fi pixel art game and a Kaiju Klash style game. These are both built on the same conceptual framework for the code so I’ll largely show and discuss them interchangeably, though the actual implementation isn’t one-to-one as the sci-fi game is running in Godot 3 and the Kaiju Klash prototype is running in Godot 4. I’ll talk more about that difference in a bit, but for now let’s talk about the general game structure.

What kind of game is it?

Real quick, let’s first establish what kind of game I’m making. As previously mentioned, this will be a tactics game, and specifically a turn-based one similar to X-COM, Into the Breach, and so on. All units on your team perform actions, all units on one or more AI controlled teams perform actions, and repeat until everyone’s dead or the mission has been completed. These games also usually have an action point (AP) system of some sort, whether that be a fairly straightforward allotment of one movement and one other action per turn, or something more in-depth with a pool of points that that are spent on various actions. Since I don’t really care for number-crunchy games, I’m going with something like the former.

Into the Breach - One of the great tactics games to come out in the last several years.

Level structure

It may not seem like it at first, but that quick overview pretty thoroughly informs the high-level designs of the game’s code. Taking the above information and adding some supporting cast, here’s what the node structure looks like for a level:

Level
  |----- Services
            |----- Navigation Service
            |----- Combat Service
            |----- Etc...
  |----- Environment
            |----- Tilemaps
            |----- Objects
            |----- Etc...
  |----- Combatants
            |----- Player Combatant Group
                        |----- Unit A      
                        |----- Unit B
            |----- Opponent Combatant Group
                        |----- Opponent Unit C
                        |----- Opponent Unit D
  |----- UI
            |----- HUD
            |----- Pause Menu
            |----- Etc...

A typical level

Let’s break down how this works…

The top-level node is very lightweight and acts as a very high-level coordinator of the other nodes. It mostly just injects dependencies and calls initialization functions on its children and then emits a signal that combat is ready to begin. Once a mission is over, regardless of its outcome, which will be determined elsewhere, it’ll do the inverse and emit a signal that combat is over.

Services nodes are where the “messy” questions get answered, i.e., those that require a lot of data from a lot of places. Pathfinding, which I implement using Godot’s built-in AStar implementations (AStar2D for Godot 3 and AStarGrid2D for Godot 4), is one such example as on top of needing knowledge about the level’s tile-map to navigate, navigation could also be blocked by another unit, an object, or an environmental condition of some sort. That’s a lot of places data could come from, and rather than directly letting each entity talk to everything else, the Navigation Service is responsible for keeping track of these things, adjusting navigation data appropriately, and answering questions about pathfinding, lines of sight, etc.

Similarly, the Combat Service answers questions about the combat itself, such as “What enemies do I have line of sight to?” or “What’s the nearest ally?”. Since a lot of these questions require knowledge that the Navigation Service holds, the Combat Service talks to that service a fair bit as well.

The result of this is that the chaos is contained to within each service and I’m able to keep my game objects decoupled from one another. The services just expose functions like get_cell_path or get_closest_unit and let any object that needs such information to call them. I’m not certain if I’ll need another service or not in the future (maybe for handling mission objectives?), but if I do it’ll be for similar reasons - confining the chaos of wide-reaching data requirements to a single location.

Environment nodes are just that. Tilemaps, objects in the level, etc. Any entity that’s not a unit goes here. Eventually, some items will be destructible or interactable, but for now, other than impacting pathfinding or action targeting, there’s really nothing too special about these nodes.

Combatants is where the core game loop occurs, and I’m going to explain this one from the bottom up. Units, which I’ll discuss more in just a moment, are the actual entities you control and fight against. Each unit belongs to a Combatant Group, which is a collection of units that should be controlled together. This, of course, includes the player and one or more groups of enemies, but it could also be for neutral or friendly NPCs. Additionally, there’s an “allegiance” property that allows for groups to specify their alignment in a battle. So while the default state is that each group is the enemy of all others, it’s possible to make multiple groups work together as if in one big group just by giving them matching allegiances.

Other than acting as an organizational entity, Combatant Groups have two primary functions: Manage turn execution at a team level and answer questions about its units. Management involves removing defeated units from the turn order, signalling units when its their turn to take an action, and so on. Questions the Combatant Group can answer are things like “Do you have any units at this location?” or “Where are all your currently active units?”.

And that brings us to the Combatants node, which coordinates turn order between groups. It tells a Group when it’s time to take a turn, listens for when that Group has completed its turn, and then goes on to the next Group.

UI is exactly what it sounds like. As it’s still pretty early in development to have much UI, there’s not a whole to say about this node other than that this is specifically for UI elements that should not appear in the game world - Menus, HUDs, etc. In-world UI, such as highlighting a tile or previewing where an attack will land, goes in the environment node so it can optionally be y-sorted, occluded by objects, etc.

What’s not shown in the above diagram that helps provide the connecting glue to all of this is a couple of event buses. These are Autoloaded scripts (or singletons for you non-Godot-ers) that allow for sending signals from various parts of the application without coupling things too tightly. So when the player needs to select an action for a unit, that unit fires a signal via the UiEventBus that the appropriate UI nodes are subscribed to and can respond to by showing the list of available actions for that unit. There are also signals for things like telling the camera to pan to a location, updating the list of current unit positions based on movement, etc. Basically, if a signal needs to travel more than just a node or two away in the scene tree, I tend to reach for an event bus since connecting distance objects directly adds a lot coupling that I don’t like (Ex: I should be able to change or completely rewrite the UI without having to update the code on units), and bubbling up signals through four or five nodes is a pain and still a bit fragile for my tastes. If the concept of an event bus is unfamiliar to you, I talk about it more in my post on the observer pattern.

Units

That’s how the game operates at a high level, so let’s dive into units now. If we ignore artwork, which obviously varies significantly between these two prototypes but doesn’t really impact the overall implementation, the general structure of a unit looks like:

Unit
  |----- Art implementation
  |----- Controllers
            |----- AI Controller
            |----- Action Controller
            |----- Stats Controller
            |----- Animation Controller
  |----- Action Overrides
            |----- Movement Override  

The artwork for this one had some interesting challenges, but that’s a story for another time.

Similar to a Level, the Unit node itself really doesn’t do a whole lot. It does some dependency injection, runs initialization logic, listens for and fires a few signals, but defers to the other components for any real logic.

Controllers are where the bulk of the unit’s logic resides. These components are responsible for one piece of a unit’s operations. The AI Controller, for instance, decides what action an AI unit should take. At this point it’s extremely basic, but fleshing out the AI can be done later.

The Action Controller is in charge of any logic directly involving actions. This is things like deciding what potential actions a unit can take when any requirements, such as AP cost, are taken into consideration, executing a chosen action, resolving any effects it may have, and deciding whether to kick action selection to the UI for player controlled units or the AI controller for all others.

The Stats Controller manages a unit’s stats, which includes things like health, the action point pool, defense, and so on. As a side note, this is one of the classic gamedev examples of composition over inheritance. Rather than putting health properties in a class that all other objects have to inherit from, regardless of if they’re a unit, an explosive barrel, or whatever, keeping stats in its own component means anything that needs to have a health bar or other stat can just have its own instance of this component and everything will just work.

The Animation Controller is, as the name suggests, responsible for playing animations, but animations in this game are a bit more than just visual. Animations also define when effects should resolve so that when a unit fires a gun, for instance, the target won’t react until the animation of the gun firing is actually at the point that a bullet would have left the weapon.

Not every action that needs to be taken fits nicely within a track with a predefined length and set of animations, though, and that’s where Action Overrides come in. These components are essentially escape hatches for actions that don’t fit nicely into a pre-canned animation. Take movement, for example. The length of the animation, the orientation of the unit during that animation, and the positions to animate through will all vary based on where the unit is and where it’s going. So when movement is selected, rather than following the normal path of playing an animation, the Animation Controller sends the relevant info to the Movement Override along with a callback function it can call when movement is over. The Movement Override can then parse that data and do whatever it needs to do with it, including animating the unit and moving across the level. When everything’s good, the Move Component calls the callback function and the Animation Controller resumes its normal flow for when an animation completes.

Actions

So now let’s talk actions, which are where the real complexity requirements live and the differences in Godot versions comes into play. As mentioned earlier, the sci-fi game is running Godot 3 while the Kaiju Klash prototype is in Godot 4. There’s a few reasons for this difference: 1) The first, and simplest, reason is that the sci-fi game came first. Godot 4 was still in beta when I started the project, but when I started exploring the Kaiju Klash prototype, the engine was in the release candidate stage so I was open to at least checking it out if I had reason to do so, which leads into my next reason. 2) For a data-heavy game made by someone who dislikes dynamic typing, Godot 3 was getting on my nerves a bit trying to manage things and develop the game how I wanted to. Additionally, Godot’s way of handling custom resources and data types felt limiting and I had to come up with a few workarounds, including developing a custom plugin for data management, which, while an interesting experience, largely didn’t do anything significantly time saving over what Godot 4 can do out of the box. I may revisit a plugin for Godot 4 in the future though, as there’s obvious benefits to having an editor that is custom tailored to the game’s unique needs. 3) I also found that Godot 4, by default, did a better job rendering and scaling the higher resolution Kaiju Klash graphics than Godot 3 did. I’m not 100% certain why that is, but this, combined with the workflow issues, ultimately pushed me to 4 for the Kaiju Klash prototype. I expect that by the time this game is done, Godot 4.1 and maybe even 4.2 will be out, so any of the oddities and bugs I’ve ran into shouldn’t be a significant issue. In fact, even just the recent patch versions have already removed any of the major workflow bugs I found with 4.0.0. There have been some reports that Godot 4 may have some pixel art rendering regressions, though, so keep that in mind if you’re considering switching.

The plugin I used for the Godot 3 prototype to help me manage data types and relationships more efficiently.

Despite the workflow differences, the overall structure of actions is the same. Each action exposes several configuration properties, such as what kind of targeting it uses (point in range, automatic, target self, etc) and what stats affect it, along with a list of Effects. An Effect represents a singular “thing” that an action does. So an action that deals damage to a target and boosts the acting unit’s defense would have two effects: one for dealing damage and one for boosting defense.

An example action.

One thing worth noting is that Actions themselves are largely just data containers, and this is further reinforced by the fact that all actions are Godot Resources, which, as a quick refresher, are only loaded into memory once and then shared by all objects that need access to it. There’s a little bit of read-only code within to help answer questions about a given action, but they really just serve to house configuration data for other uses.

Effects are also data containers, and therefore Resources, but they do additionally contain the necessary logic necessary to resolve themselves based on their configuration and the data provided to them. So when damage should be applied to a target, for example, they can calculate how much damage to do and apply it to the target.

An example set of effects on an action.

Effects can also support a bit of complexity in their orchestration as each Effect can optionally belong to an effect group, which defines a set of Effects that should be resolved together. For example, during an action’s animation, a keyframe could exist to resolve Effects in Group A at frame 15 followed by a keyframe at frame 30 to resolve Effects in Group B. This makes it easy to sync effects to their associated animations and to support more complex Effect resolution than just playing an animation and then applying all effects at once.

Wrapping up

And that’s a look at how I’m building out the code base for this game. Let me know what you think (or if you see anything wonky) and next time I’ll have the details dialed in a bit more and will share specifics about the game and its development.