2020-08-16
For the past month and a half or so, I’ve been working on a new software system for playing card games online. If you’d like to follow along it’s being hosted on sourcehut.
The idea came to me when I was talking with a friend who shares two notable characteristics with me: 1) he is a computer programmer; 2) he’s an enthusiast of obscure traditional card games. He was implementing a new card game on Board Game Arena, which is more or less the best that you can currently do if you want to build a new card game that can be played online and don’t want to implement the entire site from scratch. It’s a generic board games/card games site which has a “studio” feature, where developers can upload their own logic which describes a new game. It’s also, from where I sit, a pretty poor experience: you need to write your game in PHP, and you need to upload it by FTP. It’s a little antiquated, in other words.
Hearing about the developer experience I had the sudden inspiration of a site that worked on the Slack installable apps model instead: one that exposed the generic bones of interaction with a deck of cards, but where you as the developer wrote a simple JSON API to describe the actual game, and installed the game simply by pointing the site at your callback URL. In other words, you could write the game itself in whatever language you liked, and only had to spin up a new web server rather than using anyone’s crufty old library.
I called it Tamerlane, being the first word that popped into my head. The viability and suitability of this model as a product aside1, I thought I’d go into some detail about how I’ve been building it.
In the Tamerlane model, there’s a central game server, which all users interact with, and which maintains the state of all users and ongoing games. It in turn has entries for some number of heterogeneous independent rules servers. In particular, every ongoing game is pointed at a single rules server, which acts as the source of truth for the transformations of game state for that game.
The game, at any point, consists of a blob of state, for example:
{:meta {:high_bid {:player "North"
:bid {...}}
:not_passed @{:North true
:South true
:East true
:West true}}
:phase "bid"}
At any given moment—and I think we can make this assumption for any
card game—the game state is static and waiting for the next player action.
This could be making a bid, discarding a card, playing a card. The
entire function of any rules server is to accept a POST request to a
single endpoint—let’s call it /next
—with a description of the
game state, and the player action, and to return the updated game
state. The only thing left required to drive the game engine is to
also specify any side effects that need to happen as a result of
game state changes. This includes things like updating player
information displays, showing user prompts, and ultimately ending the
game.
This leads to an interaction model like the following:
+---------+ +-------+ +-------+
| Player | | Game | | Rules |
+---------+ +-------+ +-------+
| | |
| Start new game | |
|------------------------>| |
| | |
| | GET /init?players=North,South |
| |------------------------------------>|
| | |
| |{"phase": "draw", |
| | "effects": [{"event": "prompt_draw",|
| | "player": "North"}]} |
| |<------------------------------------|
| | |
| <render draw modal> | |
|<------------------------| |
| | |
| <select a card> | |
|------------------------>| |
| | |
| | POST /next |
| | {"phase": "draw", |
| | "action": {"name": "draw", |
| | "player": "North", |
| | "value": {"rank": 1, |
| | "suit": "hearts"
| | } |
| | } |
| | } |
| |------------------------------------>|
| | |
In other words, the rules server is entirely stateless. It expects
everything it needs in order to calculate game state is included in
the POST
to /next
. Secondarily, the meat of the game interaction
will be in the effects system: it’s up to the game server to expose an
inventory of effects that:
It’s fairly obvious that this system will live or die on the strength of the rules callback API it exposes. We need to design a DSL that rules developers can express both game state and game effects in that is expressive, simple, and elegant.
To that end I started by writing a rules server, and thinking about the most natural way that I, as a rules developer, would want to statelessly implement the rules of an entire card game. That server is part of the Sourcehut project and implements (at time of writing, nearly all of) the game of Bid Whist. Part of the developer proposition of this system is that you can write a rules engine in whatever language you like, so this server is written in the LISP Janet. It’s been an effective way to work out what the most expressive and simple DSL would be for communicating everything that goes on in a card game, and hopefully when it’s all finished will also be a useful reference implementation of a rules server.
I’d like, obviously, to have as few types of things as possible. Thus, here’s my current ontology; all of the entities you can describe and manipulate in order to drive any kind of card game:
trick
, which is where players play to./next
involves a single action, and thus the response always
describes the results of that action.The game state consists of the stacks and the infoboxes; it’s
provided to the rules server in a call to /next
and whatever the
rules server includes at "state"
in its response will be the new
state.
It’s important to note that the total contents of the game state,
however, is completely up to the rules developer. For instance, in the
Bid Whist implementation, the rules server sets and maintains a meta
attribute that it uses to keep track of the current highest bid, or
what contract was selected.
In addition to the game state, the only other content in the rules server response is the game effects. This essentially covers the specialized areas of interaction with the game that would be onerous to maintain in a giant state blob. Therefore, effects include:
In particular, treating player draws and discards as effects means that the rules server doesn’t need to keep track of the contents of the deck, make sure the cards have been shuffled, specify the contents of player hands, and so on. This is making the bet that there is a sufficiently general definition of drawing and discarding that can be relied on by the majority of games.
On the other hand, it doesn’t seem that there is any sufficiently general definition of points that can be relied on in the same way. Bid Whist, for instance, involves two fixed teams, tracking the tricks taken over the course of a single hand, the outcome of which impacts the team scores tracked over the course of multiple hands. Slovenian Tarok, on the other hand, has four individual players, who sometimes (but not always) form teams for the duration of a single hand, and track the number of points captured within that hand which then impact the individual scores tracked over the course of multiple hands. It doesn’t seem that there’s some concept of teams, tricks, card points or game points which is generalizable at all. Thus the specific arrangement of scores is a part of the state—the infoboxes—and is managed directly by the rules engine however the developer sees fit.
The other program which currently exists is the game server. That
is, the program which you will ultimately interact with when you go to
https://toulemonde.cards
; the server which you’ll have an account
on, create tables on, invite your friends to, and so on.
This so far takes the form of an Elixir application which currently does little aside from interact directly with the only rules server it knows about. It can spin up a new GenServer and maintain game state by conveying user input to the rules server and interpreting the response. The key, of course, is that it needs to be completely agnostic of what it’s passing back and forth; it needs to understand the business of card playing well enough to render a compelling user interface to a human player, but simultaneously encode no assumptions whatsoever about how any particular card game works.
To this end I’ve found it surprisingly useful to simply write a simple
Mix.Task
CLI that prompts for user input and passes it back to the
rules server. Even just the task of rendering a compelling text-based
view of the game board2 is a fairly good test of the primitives and
effects system.
Here’s a sample of the system as it stands:
code-src/tamerlane [trunk] ⊕ mix cli
Compiling 2 files (.ex)
=================
North draws 12 cards.
[North] Hand: %{
"clubs" => [#Card<5♣>, #Card<9♣>],
"diamonds" => [#Card<6♦>, #Card<T♦>],
"hearts" => [#Card<3♥>,
#Card<4♥>,
#Card<7♥>,
#Card<T♥>,
#Card<K♥>],
"spades" => [#Card<2♠>,
#Card<9♠>,
#Card<T♠>]
}
South draws 12 cards.
...
West draws 12 cards.
...
East draws 12 cards.
...
Setting Decoration for North: bid_action
[North] Please select 1 of [
"3 Uptown",
"3 Downtown",
"3 No-Trumps",
"4 Uptown",
"4 Downtown",
"4 No-Trumps",
"5 Uptown",
"5 Downtown",
"5 No-Trumps",
"6 Uptown",
"6 Downtown",
"6 No-Trumps",
"7 Uptown",
"7 Downtown",
"7 No-Trumps",
"Pass"
] as bid.
North:
bid_action: bidding
Phase: bid
East/West:
0
North/South:
0
Player [North]: <User input: <ENTER>>
%{
"clubs" => [#Card<5♣>, #Card<9♣>],
"diamonds" => [#Card<6♦>, #Card<T♦>],
"hearts" => [#Card<3♥>,
#Card<4♥>,
#Card<7♥>,
#Card<T♥>,
#Card<K♥>],
"spades" => [#Card<2♠>,
#Card<9♠>,
#Card<T♠>]
}
Action: <User input: "3 Uptown">
=================
Setting Decoration for North: bid_action
Setting Decoration for North: bid
Setting Decoration for South: bid_action
[South] Please select 1 of [
"3 No-Trumps",
"4 Uptown",
"4 Downtown",
"4 No-Trumps",
"5 Uptown",
"5 Downtown",
"5 No-Trumps",
"6 Uptown",
"6 Downtown",
"6 No-Trumps",
"7 Uptown",
"7 Downtown",
"7 No-Trumps",
"Pass"
] as bid.
North:
bid: 3 Uptown
bid_action: declarer
South:
bid_action: bidding
Phase: bid
East/West:
0
North/South:
0
Player [South]: ▌
If you squint, you can imagine how this same information could be rendered by an interface which knows nothing about Bid Whist itself into a fairly generic but snappy user interface, with your hand at the bottom, a pile of cards in the middle, and all the rest.
I’d like to finish both the Whist implementation as well as the
command line interface. At that point there are two dimensions I need
to expand in: on the one hand, I need to build out the whole website,
including the frontend, and there will be all of the challenges and
problems of web development that will need to be solved along the
way. On the other, I need to build engines for a couple other games
that are sufficiently different from bid whist. I need to actually
test, in other words, that the DSL I’m putting together is
sufficiently generic that it can be relied on by different games and
still be understood by the central server, and sufficiently expressive
that those games still resemble themselves by the time they’re
actually rendered on the page. And there are some significant issues, to be sure. As my partner
put it: Isn’t it a problem that your site relies on a bunch of different
other websites to be up and working in order for people to be able to play
any games? Yes, yes it is. ↩ It will, of course, ultimately be a rich Javascript interface on
a website; I’m planning on using Phoenix LiveView since I’m an
awful frontend developer. ↩