Lessons learned making a digital card game - Abbot's Gambol
Abbot's Gambol in action.
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.
I think the green candles add a nice ambience.
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.
Setting Constraints
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:
- I'm bad at art. I'm a programmer by trade, so my art skills are severely lacking. Only needing static 2D art for the cards was very appealing.
- Easy modeling practice. I wanted to try some 3D modeling again; I took a Maya class in college but that was a long time ago. A 3D card game in a 2D plane had very simple modeling requirements.
- New to networking. I wanted to learn some basic networking. A turn-based, low-stakes two-player game seemed like a good learning environment.
- I like card games. I was very into Hearthstone throughout most of college and I also really enjoyed Marvel Snap and Slay the Spire.
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.
"The enemy of art is the absence of limitations." - Orson Welles
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.
Game State Model
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.
What is a card game if not just piles of cards in semantically-important locations?
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:
- Move event: Moves a specified card from one collection to another. E.g. drawing a card in the game triggers a move event which moves the top card of the deck collection to the right-most position of the hand collection.
- Change value event: Increases, decreases, or sets the value of a specified card.
A basic example of multiple draw events triggering for the player's starting hand.
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.
One of the available cards in your deck and its corresponding effect, affectionately named The Swine.
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)
An example of The Swine effect during gameplay.
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.
If the player has a full board the state-based action will trigger to ask them to remove an edge card, which is represented in code with an EventSequence.
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).
Networking model
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.
Opponent AI
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:
- Iterate through every possible move that we can make with the AI's hand of cards.
- Score each of these moves with a very basic board-state heuristic.
- Pick the best move.
- Repeat until someone loses.
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 AI swaps their 10-value card for my 8-value card, messing up my board state and improving their own in the process.
Board-state heuristic
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):
- Tweak the aggressiveness factor. As it stands right now, the AI's board heuristic weights its own board state equivalently with the player's board state. Maybe the AI gets more aggressive towards the end of the game to intentionally avoid stalemates (which are unlikely, but I've witnessed a couple). Other tweaks of the board scoring heuristic are possible as well, but I haven't considered it too much.
- Add a difficulty system. We could have the AI intentionally choosing lower-ranked moves at a lower difficulty. I wanted to keep the experience stream-lined for players and didn't want a new player to be asked to pick a difficulty level for a game they've never played before. I also doubt that most players will play enough matches to start having an opinion on the difficulty level - usually after one or two rounds, you've likely seen all the content the game has to offer. The AI turned out to be reasonable challenge such that most players will lose on their first or second attempts, and then will win most times after that. This was a happy accident but one that I'm extremely pleased with.
- Cull the decision tree. I discussed above that the AI scores every possible move it could make. This is reasonable because the decision space is not that huge (4 cards in hand, 5 possible locations to play the card to, 9 possible other cards to select if the card has an ability, etc.) but if we were to tweak those numbers or add more complicated card abilities then this could explode very quickly. The point I made earlier about hidden information could help us a lot here, as the AI could aggressively cull certain cards in its hand and the player wouldn't notice if we accidentally miss an "optimal" play.
- Offload the AI processing to a background job. Unity's job system would be perfect for handling the decision-tree traversal that the AI does today. This was unavailable to me because I wanted to export the game to browser, but it would be worth considering for a larger-scale desktop release. Right now the single-threaded AI doesn't end up being an issue on any of the lower-end devices that I've tested on.
Saving/Loading Game State
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.
Conclusion
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.
Get Abbot's Gambol
Abbot's Gambol
A competitive card game with a Gothic-GameBoy aesthetic
Status | Released |
Author | mrmeowmurs |
Genre | Card Game |
Tags | Casual, chiptune, Game Boy, Gothic, Mouse only, Multiplayer, Non violent, Pixel Art, Short |
Leave a comment
Log in with itch.io to leave a comment.