I recently wrote code to simulate a card game. The details are irrelevant, but it was a great opportunity to put inside-out design into practise, because when I started programming, I didn’t actually know the rules of the game, nor what all the card types were. Thus, I started from a very general core that I did know with certainty:
Card - name : string - valid_play : Card -> bool
A card has a name and it gets to decide on its own whether it’s legal to play this card on top of another card. Naturally, we also have
Player - name : string - hand : List Card - add : Card -> () - remove : Card -> Maybe ()
A player has a name and a few cards on their hand. If we give the player a card, we get a new player with more cards. If we try to remove a card from a player, we either get nothing (if the player didn’t have that card) or a new player with one fewer cards. Simple, but extremely general.
Then we have
Table - pile : List Card - previous : Card - draw : Player -> Maybe () - play : Player -> Card -> Maybe ()
In other words, the table has a pile of cards that are not yet in play, and there’s the previously played card. The table lets a player draw a card from the pile, or play a card from the player’s hand. Again, these do minimal validation.13 The draw method ensures there are still cards in the pile, and the play method obviously fails if the player does not have the card that they are attempting to play.
Then to start to impose some structure on this, we can create
Ply - table : Table - player : Player - draw : () -> Maybe () - play : Card -> Maybe () - end_ply : () -> Maybe ()
A ply is game-speak for the portion of a turn in which one player is allowed to act. In some games, the player may perform multiple actions during their ply, which is why it is a separate action to end_ply . This may only be permitted under certain circumstances, e.g. there may be a requirement to play a card before ending one’s ply.
This abstraction also starts to control the sequences of actions that are allowed within a ply. For example, if a player draws a card they may be forced to play that specific card rather than any other they have in their hand. This can be enforced by the Ply by returning nothing when a player attempts to play any other card.
The Ply module can do whatever bookkeeping it wants internally to produce these rules. We don’t really care, because it presents a highly general interface to the rest of the application. What’s neat is that by swapping out the implementation of Ply for something else, we can create widely different games, using the same basic building blocks.14 However! The way I have designed this, all types of actions permitted by the game need to be exposed as methods by the Ply module’s interface. I was able to make that assumption because I could fairly quickly enumerate all the actions that would ever be supported under any set of rules for the game I was studying, but if that’s not the case for you, you may need to find a way to modularise the set of allowed actions also.
Going further, there is usually a ring of players around a table.
Ring - table : Table - players : List Player - draw : Player -> Maybe () - play : Player -> Card -> Maybe () - end_ply : Player -> Maybe ()
This abstraction has the responsibility of managing the order in which plies are created, i.e. in which order the players act. If a player acts out-of-turn, this is where that would be detected as a mismatch between acting player and the player whose ply it currently is.
Here we have a clear illustration of an abstraction that reduces the power of an underlying abstraction. By re-exporting the actions from the Ply in the Ring , we can force players to go through the Ring to act, which means we can enforce more rules on them, which means we provide even more structure to the game.
At this point, we could practically write code that plays a game by creating a few players, a table, passing it all into the ring and then calling methods on the ring to find what the legal moves are and perform them.
We have hidden the decisions around what the actual rules of the game are inside the Ply and Ring abstractions, so if we learn of a new rule we can stick that in there and get a slightly different game. We don’t have to change anything else, because nothing else can depend on the specific ruleset we hade in mind when making the design.
If we learn of a new card type, we can instantiate that type of card and all the code we’ve seen so far continues to work. Nothing in this design depends on the specific cards we had in mind when writing the code.15 Aside from the caveat from before: if we discover a card type that can do something other than being drawn and possibly played, i.e. a card that has some other effect, we need to expose that effect as a method on the card. Since we haven’t designed with that in mind, that would be a more invasive change.