A Handy Assistant
Sometimes AI is a workhorse, and sometimes it's a competent junior engineer
While I'm no "vibe coder," I have been pretty open since I restarted this project that I've made measured use of AI. I like to have creative control to a pretty granular level, and that means all the way down to the way the code is structured. Which is why I'm not a vibe coder: it's important to me that the codebase is workable whether or not I currently have an internet connection and access to Codex. I want my code to be easy to quickly familiarize yourself with, whether you're a human who's never looked at it before, me in a month from now, or, yes, an AI agent. Because even if I'm using AI to write most of the code (I'm not), the easier it is to grok my code, the faster an AI agent can get to work, and the fewer tokens it'll burn.
At least for the way I work, and the level of code quality I care about achieving, I get a lot more value out of treating AI like a competent junior dev (albeit a savant junior dev with excellent recall) than I do out of a fairy godmother that I'm coming to with wishes. As my junior dev assistant, I entrust it with accomplishing simple tasks, but I always keep an eye on code quality. I remain vigilant on the lookout for anti-patterns, code smells, and architectures that will paint me into a corner later on.
For my whole career as a software dev, I've been a big dork about software architecture. I always like to look at the micro and macro level of a codebase, to get a sense of what we're tripping over, where we spin our wheels, and what might me forcing us to write 20 lines instead of 2. So, just like if I was working on a team of human engineers, when I see an architectural problem that creeping up and it's going in an ugly direction, I call a meeting.
Monoliths and leaky models
So it was that I called a meeting with my AI about something I'd seen creeping into the code in this project. And like any meeting that I ever call in the workplace, I came prepared with facts, figures, and charts. (Ok, no figures or charts, but I came with evidence about what was wrong, and a half-baked start to a fix.)
What I had noticed is two main problems:
- Logic and execution for cards in the game were largely piling up inside the battle engine, making it into a card logic monolith instead of just a battle engine.
- There were flagrant violations of encapsulation and the single responsibility prinicple such that we had some information in the PilotCard type, some in the PilotCardDefinition type, and other information in the BattleEngine. This smear of data and functionality muddied and confused things.
There were a handful of battle engine methods that grew with the addition of each new card:
private func applyPilotCardEffect(
for card: PilotCard,
from actor: UnitID,
to target: UnitID,
in state: inout BattleState
) throwsThis handled setting up effects when a card was played. It created and stored the active effect, chose its duration, invoked its .immediate behavior, and removed anything that immediately expired. The "Recharge Beam" card had a special path creating two queued-energy effects. "Full Reset" also caused this function to end the actor’s turn.
private func dispatchPilotCardEffect(
_ effect: ActiveEffect,
at effectIndex: Array<ActiveEffect>.Index,
context: EffectTriggerContext,
result: inout TriggerDispatchResult,
expiredEffectIDs: inout Set<EffectID>,
in state: inout BattleState
)This routed a trigger according to the effect’s payload type. For .pilotCard, it called dispatchPilotCardEffect. For .turnStartEnergy, it applied the queued energy change itself.
private func dispatch(
_ effect: ActiveEffect,
at effectIndex: Array<ActiveEffect>.Index,
context: EffectTriggerContext,
result: inout TriggerDispatchResult,
expiredEffectIDs: inout Set<EffectID>,
in state: inout BattleState
)This executed the rules for a particular card. It switched on the card ID and checked the trigger. This contained the actual gameplay behavior: counting "Kinetic Sink" hits, reducing damage with "Ablative Shielding", adjusting heat for "Thermal Shunt", applying "Full Reset", and so on. It updated battle/effect state and reported adjustments or expiration through the supplied arguments.
Not only does such an approach leave all of the benefits of object-oriented by the wayside, it doesn't get anything in exchange. Usually, if you stray from fundamental guidelines, it's because you gain simplicity and readability, or because doing so would add cycle time for accomplishing things. There was no such tradeoff. In fact, this design pattern was creating a messy, difficult to follow, difficult to maintain monolith.
And how about those super straightforward method names, right? ;)
Vision makes for better instructions
I knew what I wanted and what would not only make much more sense, but what would make card logic easier to follow, and more consistent to execute.
- Cards needed to own their own execution logic. Rather than mere metadata records, they needed to be behavior objects.
- Runtime cards needed to own their own definitions.
PilotCardandPilotCardDefinitionwere separate types, becauase the former was a record of a specific instance of a card in a specific deck, and the latter was a description of card metadata, like description, energy cost, rarity, and category. But whereas before the PilotCard merely kept an ID for the definition and made the engine look it up, I found it made more sense for each card instance to have a direct reference to its own definition. - Cards needed to own their behavior. This was the big one. Rather than making the battle engine responsible with knowing what effects to apply for each card type, the cards (the definitions, specifically) needed to be able to apply those effects themselves.
- This is more like 3a, but:
PilotCardDefinitionneeded to become a class rather than a struct. This was necessary so it could be subclassed, and all the definitions could exist at the type level rather than the instance level. This could also have been accomplished by creating a protocol rather than a superclass, but my approach is to never reach for the higher abstraction until it's needed.
I had a good enough idea of how I wanted this to look that I figured I would get better results by starting the code changes by hand. I moved PilotCard and PilotCardDefinition out into their own respective code files (they'd previously been inside the BattleEngine file), and started sketching out a new interface.
I started by converting PilotCardDefinition to a base class, and then making all of the properties computed properties that have to be overridden in subclasses. The base class now contains as fatalError call in each of these, so that subclasses are forced to override them. This is a bit of a code smell, I know, and makes a strong argument for converting this to a protocol instead.
Some example properties:
/// Pilot card display name.
var name: String {
fatalError("PilotCardDefinition.name must be overridden in subclasses")
}
/// Pilot card gameplay category.
var category: Category {
fatalError("PilotCardDefinition.category must be overridden in subclasses")
}
/// Rule used to derive legal unit targets.
var targeting: Targeting { .`self` }
/// Content rarity.
var rarity: Rarity {
fatalError("PilotCardDefinition.rarity must be overridden in subclasses")
}
/// Energy cost to play the card.
var cost: Int {
fatalError("PilotCardDefinition.cost must be overridden in subclasses")
}
/// Energy cost after the card has been upgraded.
var upgradedCost: Int {
fatalError("PilotCardDefinition.upgradedCost must be overridden in subclasses")
}I then converted one or two cards over to be subclasses of this. I showed the pattern to Codex and said, "follow this pattern to convert the rest of the cards." It did it easily. Made a few mistsakes but they were quicklky fixed and I had a solid base for the next steps.
I started looking at the monolith methods in BattleEngine and all of their helper methods, and seeing how much of them could just be moved directly into the PilotCardDefinition base class. I managed to move a couple of the helper methods, but things got thorny and confusing pretty quickly. It was difficult to hold everything I was moving in my head all at once.
So, I got a good start on it, and told Codex, "Can you take a look at my current active changes and get a sense of what I'm trying to accomplish?"
It ran for a couple minutes and said:
Yes. You’re moving card-specific rules into each card’s subclass, with PilotCard delegating to its definition and BattleEngine coordinating the shared battle mechanics.
It then proceeded to explain that in detail, including a high-level explanation of what still needed to be done. Pretty good!
I then launched into a back-and-forth planning session with it for about an hour, that culminated in a pretty awesome re-architecture plan. Once I approved it, it took about a half hour to execute it, and only needed a couple tweaks after that!
On the other side
Rather than the giant, monolithic methods on BattleEngine, PilotCardDefinition now has these methods in its base class which can be overridden in subclasses:
/// Applies this card's behavior while its resolving copy is outside every pile. Actions that need to take place before the card is discarded happen here.
func onPlay(_ intent: CardPlayIntent, in state: inout BattleState) throws -> CardPlayOutcome
/// Handles a later event for one application of this card.
func onTrigger(
_ context: EffectTriggerContext,
effect: inout ActiveEffect,
result: inout EffectTriggerResult,
in state: inout BattleState,
catalog: GameCatalog
) -> EffectDispositionAs their (clear and straightforward) names suggest, onPlay is called by the battle engine at the time when the card is played, and onTrigger is called when a given effect trigger occurs. onTrigger also returns an EffectDisposition enum, which determines how long an effect lives after its trigger:
/// Lifetime decision returned by one effect reaction.
enum EffectDisposition: Equatable {
case keep
case expireAfterTrigger
}(I'm still trying to figure out if that last case is needed.)
A simple definition subclass thus ends up something like this:
class ChargeCapacitors: PilotCardDefinition {
override var id: CardID { CardID(rawValue: "charge_capacitors") }
override var name: String { "Charge Capacitors" }
override var category: PilotCardDefinition.Category { .tactic }
override var targeting: PilotCardDefinition.Targeting { .`self` }
override var rarity: Rarity { .adept }
override var cost: Int { 1 }
override var upgradedCost: Int { 0 }
override var description: String {
"At the start of your next turn, gain +1 energy."
}
override func onTrigger(
_ context: EffectTriggerContext,
effect: inout ActiveEffect,
result: inout EffectTriggerResult,
in state: inout BattleState,
catalog: GameCatalog
) -> EffectDisposition {
guard context.trigger == .turnStart,
let index = state.units.firstIndex(where: { $0.id == effect.owner }) else {
return .keep
}
state.units[index].currentStats.energy += 1
return .expireAfterTrigger
}
}Simple, and encapsulated!
Furthermore, card play is now standardized in the battle engine and it doesn't check subclasses for anything. Now, after the card cost is resolved, it runs the same code for every card:
// Calls the definition's onPlay method
let cardPlayOutcome = try card.definition.onPlay(heldIntent, in: &state)
let discardIndex = try self.deckIndex(owner: intent.actor, in: state)
// discards the card
state.decks[discardIndex].discardPile.append(card)
if cardPlayOutcome == .endTurn, state.outcome == .inProgress {
// ends the turn if needed
try endTurn(for: intent.actor, in: &state)
}
// returns the battle state
return stateThere were several other changes and improvement, but those were the major ones. It was a very interesting lesson for me about how I use AI. I need to stay in the driver's seat with regard to architecture, design, and product decisions. Ultimately, I need to be the one with taste, vision, and wisdom. It can be the competent junior dev.