Posted August 27, 2024 by mrmeowmurs
It's been nearly a year since I demoed Abbot's Gambol at the Seattle Indies Expo so I thought I'd write up this (not-so-short) retrospective on the whole project.
For anyone interested, it'll be a good way to get all the lessons learned making a digital card game out of my head and onto a page. Abbot's Gambol was made in Unity but the general concepts here are applicable to any framework.
Abbot's Gambol was my first solo project from start-to-finish. I had a decent amount of prior experience with game dev in Unity as a programmer, but I knew that I wanted to challenge myself to do a solo game project where I handle all the artwork, music, and design in addition to the programming.
I also knew I wanted to make a digital card game for a few reasons:
The other huge benefit to developing a card game is easy paper prototyping. I used sticky notes on a deck of cards and was able to easily playtest while tweaking the rules and card effects.
Because of this paper prototyping, I already had the core rules in place by the time I started work on the Unity project. There was a solid foundation of "fun" to work from which helped immensely during development.
I also decided on the GameBoy aesthetic that you see in the final product. There are only four colors that are used across the entirety of the game, which meant I needed to get creative with some of the textures and UI design.
All the textures are low-res pixel-art. I considered using a shader effect to actually limit the output resolution and make the screen look properly chunky, but I find the smooth movements of a 3D world combined with the low-res pixel art to be appealing in an anachronistic kind of way.
To finish out the GameBoy vibe, I also opted for a chiptune soundtrack and limited myself to 4 channels of audio. This was the first music track I ever produced and it can get repetitive on multiple playthroughs. But since Abbot's Gambol is such a short game it does the job for most players.
With all these constraints, it's time to actually start making the game.
I knew I needed a robust state model for the cards and a system for easily manipulating them. At the same time I wanted to be careful not to over-engineer the project, since the perfect architecture leaves you with a lot of great tooling and no gameplay.
Very simply, the game state is broken down into a series of card collections. There are four different collections that exist for a player: the deck, the hand, the board, and the discard. Each of these collections consists of zero or more cards. And that's essentially it for the game state. Everything that's relevant for the gameplay is represented with these four different card collections.
For actually manipulating these collections, all the gameplay effects are broken down into a handful of possible events that can mutate collections or cards themselves:
var drawEvent = new MoveCardEvent( fromPlayerId: LOCAL_ID, fromCollection: CardCollection.Deck, toPlayerId: LOCAL_ID, toCollection: CardCollection.Hand); EventHub.SendMoveCardEvent(drawEvent);
We have different resource collections with specific events to mutate them (effectively a CRUD API). There are a few more events to handle things like input (selecting cards from a collection) and other game state mutations (ending your turn), but otherwise that's pretty much it. All the card abilities and game mechanics can be represented by various combinations of the above events, chained together or sequenced in various ways.
I wrote a custom ScriptableObject class named EventSequence that can string together multiple events, like the above card effect. If any of the events involve player input (e.g. choose a dialog option or choose a card on the board), then we'll feed those choices into the next event in the chain.
EventSequence: DialogChooseEvent(+2 to yours OR -1 to any) -> CardChooseEvent(Prev choice) -> ChangeCardValueEvent(Prev choice)
Because they're ScriptableObjects, I can easily plug EventSequences in to various gameplay functions. Each Card object (also represented as a ScriptableObject) has an EventSequence that triggers when it gets played and each EventSequence is wired up via the Unity editor.
Another example is the DestroyEdgeCardSequence that gets triggered if you have five cards on your board at the start of your turn. Using these SerializableObjects as composable gameplay functions made the game much easier to play around with various mechanics via the editor.
DestroyEdgeCardSequence: CardChooseEvent(Select from my cards BUT filter to first/last cards only) -> MoveCardEvent(Selected card moves to discard)
Events have an additional benefit in that they're easily serializable. Abbot's Gambol supports networked play, so I can use the same event class for triggering local state changes that I use to send across the wire to the opponent. When an event is fired, if a network handler is registered that can handle that event we'll just also send it to the opponent. Some events are not networked and are only handled locally (an input event for selecting a card is local-only).
Since I'd never made a networked game before, the networking in Abbot's Gambol is very basic. Whoever wants to host spins up the server and then the other client needs to connect to them directly. The entire game state is communicated through the event diffs described above, with a client-authoritative model. The host and the client both maintain their own local game state and there's no difference between them. When an event is received, the game assumes that the event is valid and will apply it to its game state. It may sequence the event if a current event is still being processed, but there's no authoritative server or cheat detection.
This obviously isn't an ideal networking model... but if someone wants to mess up their game state or cheat in my tiny indie game they are very welcome to. In fact, if you wanted to you could send an "I win" event directly to your opponent at any point during the match and it will probably work.
This was mostly done as learning experience for myself, but making a networked game did have the extra benefit that I could play the game with my out-of-state friends, who had no excuses now to not help me playtest. It also ended up being kind of a neat thing to demo at an expo: I had two computers set up side-by-side at my booth and if you chose the multiplayer option they would connect to each other and let you play against your friend.
But otherwise, a networked game is a gigantic waste of time. Most people don't want to play a tiny indie game and even fewer people than that want to set up port-forwarding to play a tiny indie game with another person.
So, I felt I needed to make a convincing AI for a single-player mode.
There's a great GDC talk by Matthew Davis on the design of Into the Breach (an incredible tactics game) where Davis talks about how the AI works. Put simply, the AI enemies generate a list of possible actions, rank them from best to worst, and then randomly pick one of the best ones. They don't collaborate or work together at all. One of my favorite quotes from the talk is at 57:08:
As a solo programmer [...] I always pick the absolute simplest implementation first. So I do something just stupid simple to put it in the game and then if stupid simple works I say great and move on.
As someone with limited AI experience (having only taken a single AI class back in college and forgotten most of it), this was exactly my approach with the Abbot's Gambol AI. I came up with a stupid simple solution that I knew I could implement quickly and stuck it in the game.
The stupid simple AI does the following:
And this ended up working pretty well. It was perfectly effective for what I was trying to accomplish and many of the compliments that I got when the game launched were about how clever the AI opponent was.
The winning state of the game is to have a perfectly sequential run of five cards from left-to-right (a.k.a a straight). The heuristic I used was a modified Levenshtein distance to do a string-diff between the current board state and the possible winning states - a higher distance means you are further from a winning state. The AI's heuristic will weight the player's distance positively (player is far from winning = good) while the AI's distance is weighted negatively (AI is far from winning = bad).
Another benefit to this stupid simple AI that I didn't realize until later on is the presence of hidden information. The AI only needs to pick a "sensible" move and not necessarily the "best" move because the player can't see what cards the AI has in its hand. In reality the AI usually picks a pretty good choice based on our heuristic, but this was an important lesson for me in making a game with hidden information. Artificial intelligence only needs to appear intelligent to the player, so do what you can to hide its "stupidity".
Here are a handful of other improvements that I considered adding (some easier to implement than others):
For Abbot's Gambol, the games themselves are typically short enough (~15 minutes) that I didn't feel like the game needed any kind of save/load functionality. What I hadn't considered was how useful it would have been for development/debugging to be able to easily load in a specific game state. Especially for debugging the AI, since the AI would often make a strange move that I didn't understand and I'd need to either try to replicate that game state or hardcode in a specific game board (which represents its own risks, as the rest of the game state might not be well-formed).
This is probably the biggest lesson-learned from this project and something I wish I had implemented. Having an easily serializable game state that can read/write from disk would have been a huge timesaver even though it wasn't a feature I was interested in actually implementing for players. For all future projects, I think I'll make it a priority that at the very least I always have a way to consistently and repeatedly place the player back into the same game state.
Games are hard to make. They're even harder if you do everything yourself. And they're even harder than that if you're also working a full-time job that leaves you tired most weekdays. Abbot's Gambol isn't a very impressive game but I sure did make the entire thing myself while working a full-time job and being tired, and that feels pretty impressive to me.
Anyway, thanks for reading and on to the next project.