One of the first board game clones I wanted to create was for Dominion. For clarity, game terms are denoted in italics, whereas Objects as defined in the code are denoted in
highlights. The clone I was making is currently behind a private Github repository, but the source can be made available as an archive if asked for.
If you are familiar with the game, skip to the next section.
Basic Gameplay Summary
- Play Action cards
- Play Treasure cards
- Use Buys to purchase new cards
- Clean up, and Draw Five new cards
Each player has their own Deck of 10 cards, starting with 7 Copper and 3 Estates. Estates are the lowest victory point value cards, and Copper provide 1 money for purchasing. Once a player runs out of cards in their Deck, the Discard is shuffled and becomes the new Deck. This means new cards that were purchased in previous turns have a chance of being drawn.
Where the game gets complex is in the Action cards. Action cards can cause you to Draw more cards, play more Actions, give your opponents Curses, cause them to Discard cards, or almost anything. There are hundreds of different cards in the game, all with different effects. Some of them React, or are variants on Treasure cards, or are Victory cards with a twist. Though, a typical game will only play with a random set of 10 kinds of Action cards. This way new players and pros are forced to evaluate new strategies each game, since its always different.
To the player, the general strategy is to determine what cards are best to buy and when, and then exactly when to start buying Victory cards before the game ends to amass more points than the opponents. Game Theory and player psychology come in handy.
I glossed over a lot of details and simplified a bit, but its enough for the rest of the post to make sense. I highly recommend the game, by the way.
For my clone, I wanted to do something with NodeJS, and kept the client-side really simple with basic CSS/HTML to keep the focus on the game mechanics. My first thought, given the nature of the game, was to use a Stack-based model for tracking an individual’s phases and chained Actions. Figure 1 below is how it worked for really simple cases.
State implemented a common Interface, which had
help(). These four functions enabled a State to manage transitions, verifying inputs, and acting on the game state.
help() is to hint to the player what they can do. All active
States sat on the
StateStack and were
popped() as the game progressed.
This all made intuitive sense, and I decided that the first most intuitive design that came to mind would probably be the most effective. Given that I wanted to start quickly and didn’t quite take the time to fully assess the trade-offs and consequences of the design, work went straight ahead. At the time, refactoring didn’t seem that intractable.
When a player’s turn ended, the
TurnState would “cheat” somewhat to prevent the
Stack from ever empyting. Basically, it would
swap itself on the
Stack with a new
execute(). For various reasons, this was simpler than having a new
TurnState pushed onto the
Stack during its
popped() call. This is where the cracks in the design started to show up first.
StateStack itself was incredibly simple, with only
executeCurrent(). Similarly, keeping all complexity within the
States instead of various other Objects meant that there was no “God” Object that controlled and knew everything, and also meant that everything was concerned with precisely what it was given and its own state.
From the outside, this seemed to indicate high cohesion, simple interactions, and low Object complexity.
SelectionContext were a huge improvement to help alleviate a lot of complicated rules checking for player inputs. All a
State had to do was declare the restrictions on what could be selected, and the
SelectionContext was responsible for abstracting the nature of exactly how the cards were moving about. This also meant that
States were no longer aware of what piles of cards they were operating on or how exactly an input was valid or not.
Another great part about this design was that cards could be defined in a data-driven manner, and then built up out of simple
State definitions. This wasn’t done in the project because of low-priority, but was definitely doable.
Where it All Went Wrong
This actually worked quite well, but suffered from a major flaw: the
StateStack had to be constantly scanned to assess the game state, as it didn’t store any metadata about itself or the
States on the
StateStack. This caused issues with telling the player how many Actions or Buys they had. It also meant that if an Action added a Buy, then
States would have to be interleaved into the
Stack, breaking the core idea of a stack: first-in, first-out. Worse, if a player wanted to skip their turn, it meant having them confirm several
State transitions until the
Figure 2 below demonstrates how the
StateStack gets modified when the Village card (+1 Card, +2 Actions) is played, and then followed up with a Woodcutter (+2 Coin, +1 Buy).
This makes it clear that the
StateStack will be heavily modified through Action cards, and information has to be carried over between states in a really kludgy way.
Reaction cards proved really difficult to fit into this entire design idea. Whenever anything is done (as Reaction cards can react to almost any kind of ability) all cards currently in play and in each player’s hand would have to be scanned for an appropriately matching Reaction card. Then, given how this could cause further Reaction cards or multiple player choices, would mean
States are being piled on in an apparent recursive fashion.
My first thought was, “o, this is kind of neat”, but this quickly became apparent how the core design was being broken. Now each
State had to be aware of everything in order to properly transition.
Another increasingly worrisome problem was in how
States communicated game state information. The design was focused very heavily on communicating deltas in information in pieces to all clients in a broadcast manner. This was fine, so long as the client never disconnected or never missed a message. For testing on a local machine this was more than sufficient, but would clearly need to be looked at again if it were to move out of the “lab” setting.
For AI, we had to implement a kind of ugly workaround to allow them to interact with the
StateStack as human players do to prevent having them coupled to the interface design of the
State Object. This worked, but meant instead the AI was coupled to the communications object interface.
I was in the middle of making sevearl changes to greatly improve the overall design and feasibility of completing major features, and writing this article helped to illuminate areas for improvement, too.
Firstly, moving towards a PriorityQueue would be much more sane. This would then allow for adding more
BuyStates without worrying about how the
StateStack was never designed for arbitrary insertions. This still has some downsides, namely that a precise ordering of all
States must now be maintained. This can get tricky with many Actions triggering in a row, as order matters, but would seem arbitrary from the point of view of the
ActionState itself. Actually trying out the idea would be necessary to determine the viability.
Rather than each
State be aware of needing to respond to Reaction cards, the
StateStack should instead minimally act as a Broker: its responsible for capturing the game state delta from a
State and passing this to each player’s hands for checking. Each Player will then be responsible for owning a
ReactionStateStack, which would manage handling the interaction much in the same way as if it were a regular turn.
Technically, this could be done with just a single
StateStack, that being the primary one the game itself shares. But a separate
StateStack mostly helps in preventing the
StateStack from getting messy. Its not stricly necessary, and does break away from the core design principles of a very simple
StateStack managing game state interactions. Its a definite trade-off, and one that would need to be examined more closely with other contributors on the project.
States return modification reports, the
StateStack can then more easily return these reports to a
Manager that would pass this along to all players, and store this on a
Stack of its own. This would allow for a highly compact replay system, and AI could couple themselves to a
Report interface for reading changes and planning moves. The
Manager would also be able to consolidate and marshall inputs from an AI or a human player into a single
InputContext. This would then entirely decouple the
State objects from the NodeJS communications protocol (even though this was managed by a wrapper class to abstract away those details, the point still stands).
Granted, these design improvements are to allow for a refactoring on the current code base, and so are very OOP heavy in their imaginings.
This project was significantly more complex than when I originally started it forever ago. Had I instead taken a better sampling of the kinds of cards that would need to be added, and then tried to quickly mock up how they might interact with the
StateStack, I could have much sooner realized a different approach would be necessary.
Hindsight bias can be pretty demoralizing without reminding yourself you can’t know what you don’t know before jumping in. Given that this was a personal project with friends to simply explore how we collaborate together to get a feel with how future game development projects might go, I would say everything went as well as it could have.