The core of Lancer Tactics’ engine.
Burn it down, start again
Step #1 of post-Kickstarter production was to start over in a new codebase. If we're going to be spending the next ~2-3 years on this, I want the foundation to be rock-solid. We can borrow a lot of the logic from the demo so it will go a lot faster the second time, but the the new structure 100% needs to be rebuilt from ground up. The demo did its job; it got us over the finish line; it is now time to burn it down and start again.
Behold! A chart!
And it's going great! This chart shows the current state of that foundation. I might be getting ahead of myself but I'm super happy with how clean it is. Godot 4.0, which officially released on the same day our Kickstarter launched, gives several critical tools that makes this architecture possible; most notably custom resources and the "await" keyword.
There are three main layers to this organization.
The top layer is composed of stateless resources with predefined stats and functions. This is where most of the logic for lancer's rules live — stats for weapons, scripts for tech actions, the sequence of an attack. For example, an “assault rifle attack action” up here can be given an attacking unit and a targeted unit and will carry the attack and do the appropriate amount of damage to the given target. But when it's done, it forgets everything; this is what "stateless" means. If it ever needs some kind of decision from the player, such as whether to consume a lock-on, it simply broadcasts a signal via a bus and “awaits” a response, pausing its execution until a signal comes back from somewhere that a decision has been made.
The middle layer is composed of lightweight resources that hold any kind of state that we want to persist between scenes or in a save file. A pilot's name, the gear they have equipped, ammo remaining, or a mech's hit points all live in the "cores". Their task is only to hold and organize data — running logic to change data values (such as doing damage) is the job of the top layer. A core wants to be able to be plugged into anything on the bottom layer and provide everything that node needs to show it off.
The bottom layer is, generally, stuff that you can actually see. It has tons of temporary state information like which pixel to draw a sprite at or whether a dialogue window is open or closed — nothing that we'd care about storing to a save game file. When an action broadcasts a signal asking for a choice to be made, it's some UI element down here that will wake up, show itself, and respond with its own signal broadcasted back up into the sky. The UI down here can also read the available actions from the middle layer and are what kick off events and actions in the first place.
If I may speak poetically for a moment, each of the layers have very different feelings when I work with them. The stateless resources up top layer feel ethereal and heavenly, unmarred by the march of time. The nodes at the bottom feel solid and earthy — they're temporary, but handle all of the dirty business of being a player's interface. The stateful resources in the middle feel like souls or ghosts; unlike the other two layers they have long memories, but by themselves they are inert and unchanging.
Unit + integration tests
Putting the above together, I've had my head in code-land; most of my work the last few weeks have been in the middle and top layers. Without a bottom layer, there's not much to actually see. How can we tell the the whole thing is working as intended? As you might guess from that header text, the answer is by using unit + integration tests.
These are bits of code that are just there to automatically run parts of the engine and check to see if they behaved as expected. I've never written them for a game before now (in favor of just running it and testing stuff manually) ... and even just two weeks into having tests, I take back everything I ever said about them not being needed for games. They rule, and will be especially helpful for such a rules-heavy system like Lancer.
I'm getting ahead of myself. This is what a basic test looks like:
-
set up a dummy target with the properties we're interested in testing (in this case, armor)
check that it starts with full health
make a test attack against it
check that it took some damage
With this in place, from now to the end of the project we can can confidently make changes to the engine and be sure that it won't secretly break the the ability of armor-piercing weapons to ignore armor. When you have enough tests that check other situations (for example, one where a regular weapon does not ignore armor), you can catch bugs before they even really become bugs.
Thinking about how many tests to write and what they're testing is a whole skill in of itself, but even covering the basic actions of the engine mean that I can say with a high degree of confidence that the whole engine up there is working before we've drawn a single mech on the screen.