Bid Whist: An Implementation for Tamerlane

1. Introduction

This program implements a rules engine for Tamerlane, the card game server. It provides a working version of the game Bid Whist, as well as an example implementation of a realistically complex rules engine.

2. About This Document

This is a literate program which describes the full implementation of a rules engine for Tamerlane. In it is contained all the source code required to compile and run a web server which implements Tamerlane's API contract.

This code is written using the Literate program, which takes a literate source document and renders the document you are reading, as well as the source files needed to compile the program. The source files are available at sourcehut.

The language that I've implemented this software in is Janet, a lightweight lisp. To thoroughly understand the implementation it might be good to know Janet, or at least a language like Clojure or Scheme. To be able to follow along and understand what you need to implement in order to write your own rules engine (which is the purpose of this document), you don't need to know Janet. You just need to understand some basic Lisp control forms like let and defn, and to understand how to read an s-expression.

You should be able to read this document front-to-back, like a book. I've tried to focus on the concepts that are central to the function of the Tamerlane rules engine concept. Therefore, the bulk of this document is concerned with writing the business logic that defines the rules to the game Bid Whist. Also important is understanding the specifics of the endpoints and HTTP methods that are expected by Tamerlane.

Other implementation details are handled in appendices at the end of the document. I've tried to make the code as readable and effective as possible, but it should be less crucial to this document's purpose as a guide for writing a games engine for Tamerlane.

3. Bid Whist

The game of Bid Whist is, as its name suggests, a descendent of the old English game of Whist, and a cousin of the game Bridge.

A full copy of the rules are available at Pagat. I've tried to reproduce them faithfully and completely; one notable exception is that I've opted to allow the auction to go around until all players have passed instead of having it go exactly once around the table.

The Tamerlane Arhitecture

4. Rules Engine Architecture

Here's a simplified sequence diagram illustrating how a player interacts with the Tamerlane server and a Tamerlane rules engine. Simply put, the server will initialize a game based on the results of an initial state call to the rules engine, and thereafter it will loop on getting player input, using that input to calculate the next game state, and exposing player prompts based on that state.

The rules of the game, therefore, are realized in the next state step.

{Sequence Diagram 4}
+---------+             +---------+              +-------------+
| Player  |             | Server  |              | RulesEngine |
+---------+             +---------+              +-------------+
     |                       |                          |
     | start game            |                          |
     |---------------------->|                          |
     |                       |                          |
     |                       | get initial state        |
     |                       |------------------------->|
     |                       |                          |
     | player action         |                          |
     |---------------------->|                          |
     |                       |                          |
     |                       | current state            |
     |                       |------------------------->|
     |                       |                          | ----------------------\
     |                       |                          |-| (whist/next params) |
     |                       |                          | |---------------------|
     |                       |                          |
     |                       |               next state |
     |                       |<-------------------------|
     |                       |                          |
     |      prompt for input |                          |
     |<----------------------|                          |
     |                       |                          |

Game Logic

5. whist/next

The most important function that we need to implement when building a rules engine is next. Ultimately, we'll expose an API endpoint that accepts a POST request and passes the body to next. The input to our function describes the state of the entire session, and the return value of this function will be to describe what happens next.

{`next` Function Signature 5}
(defn next
  ```
  Rules engine for Bid Whist.
  ```
  [{:state state
    :players players
    :action action}]
  {Return Next State, 7}
)

Used in section 8

The three components of the input are the state, the players, and an action.

Every next state describes a change after some single action.

6. State

The state consists of the state of the game. This includes things like the cards on the table and the scores for the players, but it also will include arbitrary metadata that we read and write.

The players are a list of the players, their metadata, and their current hands.

7.

The most basic and important attribute of the game state is the phase. You can populate this however you like, though it must be present.

Here we'll match on the game phase to call one of the five main game modules. Each one has the same return signature.

As we shall see, the return value of next is always a list with two elements:

  1. The new game state;
  2. A list of events to be evaluated by the server within the game.
{Return Next State 7}
(match (state :phase)
  "deal" (deal/evaluate-phase state players)
  "bid" (bid/evaluate-phase state players action)
  "discard" (discard/evaluate-phase state players action)
  "begin_play" (beginplay/evaluate-phase state players action)
  "play" (play/evaluate-phase state players action))

Used in section 5

8.

We've broken up our Bid Whist game by those phases. Each phase has a single module which exposes a single evaluate-phase function. Our main whist module simply looks at the phase of the incoming game state and delegates to the appropriate module.

{whist.janet 8}
(import game/deal)
(import game/bid)
(import game/discard)
(import game/beginplay)
(import game/play)

{`next` Function Signature, 5}

We'll start with the deal. We see that we only need the game state and the players, as it's the first thing that happens in the game.

9. The Deal

There's very little logic in the deal, so it's a good place to start. We simply pull out the first player and then return.

{Main Deal Function 9}
(defn evaluate-phase
  ```
  Each player starts with 12 cards. 

  The first player is prompted to begin bidding.
  ```
  [state players]
  {Get The First Player, 10}
  {Return Value, 11}

Used in section 17

10.

players is a list of objects representing each player. (player :id) is a string that's guaranteed to be unique among the players. Here we'll simply grab the first player's id.

{Get The First Player 10}
(let [bidder ((in players 0) :id)]

Used in section 9

11. Return: the New State and a List of Events

Finally we see the return value of our functions.

We simply return a tuple of two elements. The first is the new game state; the second is an array of events to be emitted.

{Return Value 11}
[
 {New State, 12}
 {Deal Events, 13}
]))

Used in section 9

12.

First the state.

We begin by merging some values into the state we were passed in. In general this is good practice, because whatever we return will become the new state. So if we clobber something useful, it be lost.

The new state we're merging in has two attributes: phase, which we've already covered, and meta. In fact, meta has no semantics to the Tamerlane system. We'll be using it for our own purposes. The game won't display it; it will simply include it in the next game state it sends over. Thus, it's an easy way for us to keep track of any game variables we like.

In this case, we'll need to keep track of two variables: high_bid, which is empty for now; and not_passed, which is a set containing all the players. We'll use them both in the bidding phase.

{New State 12}
# State: Deal -> Bid 
(merge state {:phase "bid"
              :meta (new-meta players)})

Used in section 11

{Initialize Metadata 12}
(defn- new-meta [players] {:high_bid {}
                           :not_passed (zipcoll (map |($0 :id) players) [true true true true])})

Used in section 17

13.

Next are the events.

{Deal Events 13}
(array/concat
 (all-draw players)
 (events/add-decoration bidder "bid_action" "bidding")
  (events/pick1 "bid" bidder (bids/available-bids)))

Used in section 11

14. Draw

Each element in the events array is, fittingly, a call to the events module. There are three types of event. First, the draw event:

{Each Player Draws 14}
(defn- all-draw [players] (map |(events/draw ($0 :id) 12) players))

Used in section 17

{Events: Draw 14}
(defn draw [player count] {:event "draw" :player player :count count})

Used in section 68

all-draw emits one event for each player. We end up with four structs corresponding to the four players.

As we will see, every event has an event attribute, which identifies a specific side effect understood by the Tamerlane server. Every type of event has its own set of attributes. Together they constitute the Tamerlane API.

The draw event tells the server to draw a certain number of cards into the specified player's hand.

Notice that the hands are not part of the state, and we haven't seen the deck anywhere either; these are controlled by the server. When the server processes one of these events, it will remove the first 12 cards from the deck and append them to the hand of the specified player.

15. Add Decoration

The next type of event is add-decoration.

{Events: Add Decoration 15}
(defn add-decoration [player name value] {:event "add_decoration" :name name :player player :value value})

Used in section 68

decoration is a generic way of displaying information associated with a player. In this case we set the bid_action decoration for the first player to bidding. The name, bid_action, is for our use only; the server will display bidding next to the icon for that player.

16. Pick 1

Finally, we include a prompt.

{Events: Pick 1 16}
(defn pick1 [name player choices] {:event "prompt_select" :name name :player player :count 1 :from choices})

Used in section 68

Most state changes (except for the first, as we've seen!) are in response to actions. And all actions are in response to prompts. This is the first kind of prompt we've seen: a select prompt, where the player is presented with a simple list of choices and they have to choose one.

When they do that, the game server will make a next call to our rules server, and the value of their selection will be the action.

In this case, the choices are a list of possible bids. Thus the bid phase begins.

17. deal.janet

We've now performed everything we need to complete the deal: we've set up our state, populated the players' hands, set a player decoration, and prompted for the first player action.

{game/deal.janet 17}
(import events)
(import bids)

{Each Player Draws, 14}
{Initialize Metadata, 12}
{Main Deal Function, 9}

18. The Auction

The "Bid" in "Bid Whist" is an auction for the right to name trumps. The players, starting to the left of the dealer, take turns bidding on which team will undertake a contract to win the highest number of tricks. Whichever player wins the auction becomes the declarer and announces what the trump suit will be.

Our bid function is structured quite similarly to the deal function. One important difference, however, is that now we actually handle action. Referring to {whist.janet,8}, we always pattern match on action in the incoming request body; however, it doesn't matter in the deal. Intuitively, this makes sense; the deal is the first thing that happens and there's no player input necessary to evaluate it.

The auction, and the rest of the game to boot, are different. In {Deal Events,13} we prompted the first player for their opening bid; therefore, we expect that this incoming bid action is the next input that the server will receive.

[NOTE]: Of course, in production, we should expect that we will be receiving many requests from different ongoing games at once. That doesn't pose any difficulty as our game rules server is stateless. Every request will contain everything needed to evaluate the next state of the game.

{Main Bid Function 18}
(defn evaluate-phase
  ```
  The players bid in an auction to name trump. 

  The highest bid is a number of tricks to take above six with a named
  suit or no-trumps.
  
  Requires:
  - `high_bid`
  - `not_passed`: The players that haven't yet passed.
  ```
  [state players action]
  (if (= action :null) (error {:error "action required"}))
  {Handle the Bid Action, 19}
)

Used in section 26

19.

The first thing we do is check for the existence of the action input. Generally speaking, Tamerlane is designed not to require too much error handling and input validation from rules engines; it should generally try to Do The Right Thing. That said, it might help with development to add some basic error and input validation.

In the bidding phase, as in most other complex game phases that we might want to represent, we need to be able to handle any input and determine whether the phase is over, or whether it should continue by returning a state with the same phase attribute.

In this case, we can reason that two things need to be true for the auction to be over:

  1. There is some high bidder;
  2. Everyone else has passed.

[NOTE]: In a system where we were responsible for the error handling and validation, we might want to validate that the high bidder and the one person left in the set of not-passed players are the same player. In this system, that constraint would only be violated if the Tamerlane input system had a bug in it, and that's not our responsibility.

This gives us the basic structure of the bid phase. We will determine if we've reached that condition, and if so, end the auction. Otherwise we'll continue.

{Handle the Bid Action 19}
(let [new-meta (new-meta state action)
      {:high_bid {:player high-bidder :bid high-bid}} new-meta
      events (initial-events state action high-bid)
      not-passed (update-not-passed state action)]
  # The set of players still in the auction is now up to date. We'll
  # use it to determine whether the action is over.
  (if (and (not (nil? high-bidder)) (= 1 (length not-passed)))
    # If someone has bid and all but one have passed, the auction is over.
    {End the Auction, 25}
    # Otherwise, continue the bidding.
    {Continue the Auction, 24}
))

Used in section 18

20. Bidding: New Meta

The new-meta function takes the existing state and the action and computes the new metadata entry from it.

{New Meta 20}
(defn- new-meta
  [{:meta meta} {:value last-bid :player last-bidder}]
  (let [{:bid previous-high-bid :player previous-high-bidder} (meta :high_bid)
        # Record the high bid (whether it's a new bid or the existing high bid).
        [high-bid high-bidder] (case last-bid
                                 "pass" [previous-high-bid previous-high-bidder]
                                 [last-bid last-bidder])]
    @{:high_bid @{:player high-bidder :bid high-bid}}))

Used in section 26

Conceptually, it's pretty straightforward: we assume the presence of a high_bid attribute in the input state metadata. If the input action was a pass, the new high bid is the same as the old high bid. Otherwise, the new bid is the new high bid. If we had to actually validate that the input was higher, this function would be a little more complex, but we can assume that no illegal inputs were allowed by the server.

21. Bidding: Initial Events

After we determine the new metadata, we generate the initial array of events that we will emit.

{Initial Bidding Events 21}
(defn- initial-events
  [{:meta {:high_bid {:bid previous-high-bid :player previous-high-bidder}}}
   {:value last-bid :player last-bidder}
   current-high-bid]
  (case last-bid
    "pass" @[(events/add-decoration last-bidder "bid_action" "passed")]
    (array/concat 
     (case previous-high-bidder
       nil @[]
       @[(events/clear-decoration previous-high-bidder "bid_action")])
     (events/add-decoration last-bidder "bid_action" "declarer")
     (events/add-decoration last-bidder "bid" (bids/to-text current-high-bid)))))

Used in section 26

As we can see, these all have to do with managing the player decorations that will be displayed by the game interface.

22. Clear Decoration

The only new event type here is add-decoration.

{Events: Clear Decoration 22}
(defn clear-decoration [player name] {:event "clear_decoration" :name name :player player})

Used in section 68

There's very little to this---even less than to {Events: Add Decoration,15}---but one point that is hopefully apparent is the purpose of the name field on the add-decoration struct: it allows us to refer back to this value when specifying what to clear.

23. Updating the Set of Not-Passed Players

The last piece of bookkeeping before we can determine whether to end or continue the action is to manage the set of players still in the auction.

{Update Not Passed 23}
(defn- update-not-passed
  [{:meta {:not_passed not-passed}} {:value last-bid :player last-bidder}]
  (case last-bid
    # Handle a new pass. Mark the player as passed by removing them from the set.
    "pass" (put not-passed (keyword last-bidder) nil))
    not-passed)

Used in section 26

24. Proceeding with the Auction

If (as will necessarily be the case for at least the first trip around the table) the auction isn't over yet, there are two possible states:

  1. Three of the four players have passed and the last is yet to bid; we should prompt them with the bids/force-bid call, which doesn't include the option to pass.
  2. We're in the middle of the auction. In that case, we should prompt them with all the bids that are higher than the current high bid.

In both cases we include a prompt event. In the Tamerlane system there is no concept of turns---any player who has an active prompt can act and move the game state forward. In Bid Whist it's always exactly one player's turn, but that's not a necessary limitation of the system. We can emit prompts for multiple players at once.

{Continue the Auction 24}
(let [{:player last-bidder} action
      next-bidder (players/next-player last-bidder players not-passed)]
  (array/push events (events/add-decoration next-bidder "bid_action" "bidding"))
  # The auction isn't over; include the set of players still bidding in the metadata.
  [(merge state {:meta (put new-meta :not_passed not-passed)
                 # State: Bid -> Bid
                 :phase "bid"})
   (if (= 1 (length not-passed))
     # If no one has bid and all but one have passed, the dealer has to bid.
     (array/push events (events/pick1 "bid" next-bidder (bids/force-bid)))
     # Otherwise, move to the next bidder.
     (array/push events (events/pick1 "bid" next-bidder
                                       (bids/available-bids high-bid))))])

Used in section 19

25. Ending the Auction

If the auction isn't still going, it must be over. In that case we simply return an updated state that advances to the next phase---the discard phase---making sure to include the new metadata with the winning bid in it.

Finally, we prompt the high bidder to complete their bid. If they won with a suit bid, that means they'll call the suit. If they won with a notrumps bid, they'll call Uptown or Downtown.

{End the Auction 25}
[(merge state {:meta new-meta
               # State: Bid -> Discard
               :phase "discard"})
 # Bidder selects suit in a trumps bid or direction in a no-trumps bid.
 (array/push events (events/pick1 "bid" high-bidder (bids/second-bid high-bid)))]

Used in section 19

26. bid.janet

This is all we need to run the entire bidding phase. We've realized it as a recursive function, where the base case is moving to the next phase, and the recursive case is returning a state in the same phase.

{game/bid.janet 26}
(import players)
(import bids)
(import events)

{New Meta, 20}
{Initial Bidding Events, 21}
{Update Not Passed, 23}
{Main Bid Function, 18}

27. Draw and Discard

After the bidder names their full contract, they pick up the undealt kitty and then discard 6 cards.

{game/discard.janet 27}
(import events)
(import bids)

(defn- make-full-bid [high-bid second-bid bidder]
  (merge high-bid second-bid {:player bidder}))

(defn evaluate-phase
  ```
  The bidder has named their full contract. 

  They pick up the kitty, and are then prompted to discard 6 cards.

  Expected metadata:
  - `high_bid`: The winning bid in the auction.
  Provides:
  - `bid`: The full bid for the hand. 
  ```
  [{:meta {:high_bid {:bid high-bid}}} players {:player bidder :value second-bid}]
  (let [full-bid (make-full-bid high-bid second-bid bidder)
        full-bid-text (string (bids/to-text high-bid)
                              ": "
                              (bids/to-text second-bid bids/second-bids))] 
       # State: Discard -> Begin Play
       [{:phase "begin_play" :meta {:bid full-bid}}
        (array/concat
         (map |(events/clear-decoration ($0 :id) "bid") players)
         (map |(events/clear-decoration ($0 :id) "bid_action") players)
         (events/add-decoration bidder "high_bid" full-bid-text)
         (events/draw bidder 6)
         (events/prompt-discard bidder 6))]))

This phase is quite straightforward. The only new element is one new event.

28. Prompt Discard

{Events: Prompt Discard 28}
(defn prompt-discard [player count] {:event "prompt_discard" :player player :count count})

Used in section 68

This is the second kind of prompt we've seen, after prompt_select (available to us in this codebase via events/pick1). The player is prompted to select count cards from their hand, which the server will then discard for them.

The same principles apply here as for drawing cards; the deck and the players' hands are managed by the server. In the next phase, when we receive the game state from the server, whatever cards the player selected will have already been removed from their hand.

29. The Beginning of Play

Once the declarer has discarded their six cards, they score one trick and lead to the first trick.

With the beginning of the play phase, we need to keep track of two new types of data: stacks and info.

30. Stacks

A stack is any collection of one or more cards besides the deck and the players' hands. For any game, the stacks make up the table; any card or pile of cards on the table is part of a stack.

31. Initializing the Stacks

In bid whist, there's only ever one pile of cards on the table: the cards played to the current trick. In the tamerlane system, the stacks state is quite simple: it's an object mapping the name of a stack to a list of card values.

{Initial Stacks 31}
(defn- init-stacks [] {:trick []})

Used in section 33

This is part of the game state, and so whatever we return in the state response will be the content of the stacks in the game. The play phase begins with a single stack, the trick, which is empty.

32. Info

Info is the generic way of recording data that's important to keep track of the game. Unlike the metadata we've been using above, which was specific to our purposes and not designed to be exposed directly to players, there will be some game elements during the play phase that we do expect to be displayed directly (and which we will want to manipulate as state, rather than as side effects): namely, each player will have a count of current tricks and each team will have a score.

{Initial Counters 32}
(defn- tricks-counter [player-name] (keyword player-name "_tricks"))

(defn- init-counters [bidder other-players]
  (var counters (zipcoll (map |(tricks-counter ($0 :id)) other-players)
                         (map (fn [_] 0) other-players)))
  # It's the first trick; bidder gets one trick for the discard.
  (put counters (tricks-counter bidder) 1))

Used in section 33

We structure these counters very similarly to the stacks: as an object mapping the name of the info box to the value it contains.

In this case the names are <player name>_tricks and the values are 0 for every other player, and 1 for the bidder.

33. beginplay.janet

{game/beginplay.janet 33}
(import events)

{Initial Stacks, 31}
{Initial Counters, 32}

(defn evaluate-phase
  ```
  The bidder has discarded six cards. 

  They may lead any card from their hand.

  Provides:
  - `suit`: The led suit of the current trick.
  ```
  [{:meta meta} players {:player bidder :value to-discard}]
  (let [other-players (filter |(not= ($0 :id) bidder) players)]
    # State: Begin -> Play
    [{:phase "play"
      :meta (merge meta {:suit "undefined"})
      :info (init-counters bidder other-players)
      :stacks (init-stacks)}
     (array/concat
      # TODO: when players have names as well as IDs, this will map to names.
      (map |(events/add-info (tricks-counter ($0 :id)) ($0 :id)) players)
      (events/prompt-play bidder))]))

Here we return the state--with two new top-level attributes, info and stacks---and the events.

34. Prompt Play

The first new event type we can cover is prompt_play.

{Events: Prompt Play 34}
(defn prompt-play
  [player &opt from]
  {:event "prompt_play" :player player :to "trick" :count 1 :from from})

Used in section 68

This is the last prompt type that we need. It's structured similarly to prompt_discard, but it has a little more information in it. In addition to count, there's also to and from.

to is required and must be the name of a stack in the game. Unlike in the case of discards, when a player plays a card it has to go somewhere; given a to value, the server will be able to remove the card from the player's hand and put it on the top of the specified stack.

Finally, we sometimes need to specify from. This will be a list of cards in the player's hand that they can select from. In bid whist, players must follow suit if they can; therefore, we will sometimes have to specify which cards in the hand are of the correct suit when prompting the player.

35. Add Info

{Events: Add Info 35}
(defn add-info [id label] {:event "add_info" :id id :label label})

Used in section 68

We would like to be able to display useful labels on our info boxes. For that reason we can make an add_info event, which simply associates an info box ID with a more friendly label (in this case , we'll be associating an info box like North_tricks to a label like North

[NOTE] Update this with Player Names.

36. Play

In the play phase, players take turns playing a single card to a trick. Whoever wins the trick leads to the next one.

This creates a situation similar to the bidding phase: whenever we handle a call in this phase, there are two main cases:

{Main Play Function 36}
(defn- add-to-stack [stack card] (tuple ;stack card))

(defn evaluate-phase
  ```
  Players play to tricks.
  
  In the first trick, the bidder leads; every subsequent trick, the
  winner of the previous trick leads.

  Players must follow suit if possible; if not, they can either
  discard or trump.

  Expected stacks:
  - `trick`: The current trick.
  Expected metadata:
  - `bid`: The current contract.
  - `suit`: The led suit of the current trick.
  ```
  [state players {:player player :value @[card-played]}]
  (let [just-played (with-player card-played player)
        current-trick (-> state
                          (get-in [:stacks :trick])
                          (add-to-stack just-played))]
    (case (length current-trick)
      4 (end-trick players state current-trick)
      (continue-trick players state current-trick just-played))))

Used in section 48

We've structured our program so that the beginning of the play, before someone has led the first card, is the previous phase. That allows us to always assume that someone has played a card in order to enter this function.

Notice that the value of the action is an array of cards, though we assume that only one card was passed in; since the prompt_play prompt is able to specify more than one card, an incoming play action will always refer to a list of cards---whether the player was prompted for one or more.

37. Proceeding with the Trick

Let's first handle the case where the current trick continues.

The main bit of state we need to maintain, in addition to adding the just-played card to the trick stack, is the led suit, if any, of the current trick.

38. Determining the Current Suit

There are a few different possibilities to handle when it comes to determining the current suit.

The simplest is that the suit has already been determined when a previous player played to the trick. If that's the case, it can't be changed. On the other hand, if it's "undefined" (which is what we initialized it to in the previous phase), we probably need to set it for the rest of the trick. This value will determine both what cards can be played by other players, as well as which played card wins the trick.

{New Suit 38}
(defn- new-suit
  ```
  Determine the led suit in a given trick.

  - If the suit has already been determined, use that.
  - If the card isn't a joker, the led suit is the suit of that card.
  - If the card is a joker :
    - If the bid is notrumps, the led suit hasn't been determined yet.
    - Otherwise, the led suit is the trump suit.
  ```
  [current-suit bid card-played]
  # We need to explicitly set the current suit to `"undefined"`. This
  # allows us to check for the edge case where the suit has
  # not been set, even after a card (or two) has been played
  # - if the first card is a Joker, and it's no trumps.
  (if (not= current-suit "undefined")
    current-suit
    (match [(bid :suit) (card-played :suit)]
      ["notrumps" "joker"] "undefined"
      [trumps "joker"] trumps
      [_ led-suit] led-suit)))

Used in section 48

There's one subtle edge case we need to cover: if the bid was a no-trumps bid, and if the led card is a joker, the suit remains undefined. This means we can't assume that the first card is the one that determines the suit; that's why we have to look for the string "undefined" rather than assuming it's always set on the first lead to the trick.

39.

Once we've determined the suit of the trick, we simply need to add the played card to the trick and prompt the next player to play.

{Continue the Trick 39}
(defn- with-player [card player] (merge-into @{:player player} card))

(defn- continue-trick
  ```
  Handle the first, second or third player playing to a trick.

  Set the led suit if necessary, update the stack and prompt the next player in sequence.
  ```
  [players
   {:meta {:bid current-bid :suit current-suit} :info info}
   current-trick
   just-played]
  (let [new-suit (new-suit current-suit current-bid just-played)
        id-to-prompt (players/next-player (just-played :player) players)
        hand-to-prompt (-> |(= ($0 :id) id-to-prompt)
                           (find players)
                           (in :hand))
        play-prompt (->> (cards/of-suit-or-off new-suit current-bid hand-to-prompt)
                         (events/prompt-play id-to-prompt))]

    [{:phase "play"
      :info info
      :meta {:bid current-bid :suit new-suit}
      :stacks {:trick current-trick}}
     [(events/add-decoration (just-played :player)
                             "play_action"
                             (string "played " (cards/to-text just-played)))
      play-prompt]]))

Used in section 48

40. Ending the Trick

Finishing the current trick is more complex, because there are two options again:

Either case begins with determining who wins the trick and updating the trick counters accordingly.

After that, we can decide between the two simply by looking at one of the players' hands and seeing if there are any cards left to play.

{End the Trick 40}
(defn- end-trick
  ```
  Handle the last player playing to a trick.

  Determine which card takes the trick and update: the number of
  tricks taken; if applicable, the team scores.
  ```
  [players state current-trick]
  (let [{:meta {:bid current-bid :suit current-suit}} state
        winning-player (-> current-trick
                           (cards/high-card current-suit current-bid)
                           (in :player))
        events (map |(events/clear-decoration ($0 :id) "play_action") players)
        updated-info (update (state :info) (keyword winning-player "_tricks") inc)]
    (case (length ((players 0) :hand)) 
      # There are no more cards in the players' hands; resolve the
      # current hand. (NB: we expect that the game server will be
      # responsible for updating the hand values on the basis of a
      # `play` prompt.)
      0 (next-hand players events updated-info current-bid)
      (continue-hand events updated-info current-bid winning-player))))

Used in section 48

41. Proceeding with the Hand

The simpler case is that the players have more cards left, and thus we should simply continue the current hand. To do so we just need to clear out any state specific to this trick and prompt the winner to play again.

{Continue the Hand 41}
(defn- continue-hand [events info current-bid winning-player]
  (array/push events (events/prompt-play winning-player))
  # State: Play->Play
  [@{:phase "play"
     :info info
     :meta @{:bid current-bid :suit "undefined"}
     :stacks @{:trick []}}
   events])

Used in section 48

42. Ending the Hand

More complex is if this is the end of the hand, ie, if the players have played all their cards.

There's more bookkeeping in this case. We need to do a few things in order:

43. Getting the Total Number of Tricks

We don't actually keep track of the total number of tricks made by each team (though we certainly could); that means that at the end of the end, we need to figure out which player is on which team, get those players' tricks taken, and add them together.

{Total Tricks 43}
(defn- bidding-team
  [players bid]
  ((find |(= ($0 :id) (bid :player)) players) :team))

(defn- non-bidding-team
  [players bid]
  ((find |(not= ($0 :id) (bid :player)) players) :team))

(defn- total-tricks
  [players bid info]
  (let [bidding-team (bidding-team players bid)] 
    (->> (players/of-team players bidding-team)
         (map |(keyword $0 "_tricks"))
         (map |(info $0))
         (sum))))

Used in section 48

44. Getting the Score Multiplier

One interesting wrinkle in Bid Whist is that if the bid was in no-trumps, the value of the hand---positive or negative---is doubled. We can handle that by either returning a multiplier function which either doubles a value, or one that simply returns it.

If the declaring team fails their bid, the negative value of the hand is whatever they bid. If they make their bid, the positive value of the hand is the number of tricks that they make minus 6.

{Bid Values 44}
(defn- adjustment-for-bid
  ```
  A contract's value is its numerical value, or double its value if it's notrumps.
  ```
  [bid]
  (case (bid :suit)
    "notrumps" |(* $0 2)
    |$0))

(defn- tricks-value
  [total-tricks]
  (- total-tricks 6))

Used in section 48

45. Checking For Win Conditions

It's quite straightforward to tell if the bidding team has won or lost a hand: they simply need to have taken their bid + 6.

Similarly, a team has won the game if they go above 7 game points; they have lost the game if they go below -7 game points.

{Made Bid? 45}
(def- score-threshold 7)

(defn- won? [val] (>= val score-threshold))
(defn- lost? [val] (<= val (- score-threshold)))

(defn- made-bid?
  ```
  A team has made a bid if their combined tricks is greater than or equal to their bid + 6.
  ```
  [total-tricks bid]
  (>= total-tricks (+ 6 (bid :count)))) 

Used in section 48

46.

Now that we know the win conditions for a hand and game, we can adjust the scores accordingly.

In both cases, it's only the declarers' scores that need to be adjusted. We only need to pull out the opponents' score from the game info in order to populate the new state with it.

{End the Hand 46}
(defn- next-hand [players events info current-bid]
  (let [total-tricks (total-tricks players current-bid info)
        score-multiplier (adjustment-for-bid current-bid)
        bidding-team-keyword (-> players (bidding-team current-bid) (keyword))
        bidders-score (in info bidding-team-keyword)
        bidders-score (if (made-bid? total-tricks current-bid)
                        (+ bidders-score (-> total-tricks (tricks-value) (score-multiplier)))
                        (- bidders-score (-> current-bid (in :count)
                        (score-multiplier) )))
        opponent-team-keyword (-> players (non-bidding-team current-bid) (keyword))
        opponents-score (in info opponent-team-keyword)]
            
    (if (or (won? bidders-score) (lost? bidders-score))
      (array/push events (events/end-game players
                                          bidding-team-keyword
                                          bidders-score
                                          opponent-team-keyword
                                          opponents-score)))
    # State: Play->Deal
    [@{:phase "deal"
       :info {opponent-team-keyword opponents-score
              bidding-team-keyword bidders-score}}
     events]))

Used in section 48

47. Ending the Game

If the bidders have won or lost, the game is over. In the Tamerlane system, we simply end a game by emitting an event; it's not part of the game state.

Different card games have different concepts of ending a game; in some games (such as this one), teams win and lose a game together; in some, each player plays for themselves; in yet others, players might be on fixed or shifting teams and still score differently.

To accomodate that degree of flexibility, we end a game in the Tamerlane system by emitting an event that contains a total ranking of all the players by score. In a game like Bid Whist, where players are on fixed teams, both players on a team will end the game with the same score. Thus, out of four players, two will be in first place and two will be in second place.

The specific format of the scores value is a struct mapping each player ID to their final score.

{Events: End Game 47}
(defn end-game
  [players team1 score1 team2 score2]
  (let [players1 (players/of-team players (string team1))
        players2 (players/of-team players (string team2))]
    {:event "end_game" :scores (zipcoll (array/concat players1 players2)
                                        [score1 score1 score2 score2])}))

Used in section 68

48.

Having covered all the possible outcomes of a play state transition, we can wrap up all the components into a single module.

{game/play.janet 48}
(import cards)
(import players)
(import events)
# Play utility logic
{New Suit, 38}
{Total Tricks, 43}
{Bid Values, 44}
{Made Bid?, 45}
# Possible branches of a play state transition
{Continue the Trick, 39}
{Continue the Hand, 41}
{End the Hand, 46}
{End the Trick, 40}
{Main Play Function, 36}

Supplementary Logic

49. Other Endpoints

We have implemented the entirety of our /next API, which contains all of rules for our game.

In order to have a working rules engine, we need to expose two more endpoints: one to return some basic configuration values that will be the case for all Bid Whist sessions, and one to return the initial game state for a single session.

50. /config

Our config function is called by the game server to get the configuration values which are independent from session to session. In particular, that includes:

{Config 50}
(defn
  config
  []
  {:deck "52JJ"
   :player_count 4
   :stacks [{:id "trick"
             :label "trick"
             :orientation :up
             :max-size 4
             :alignment :stagger}]
   :info [{:id "north_south" :label "North/South" :value 0}
          {:id "east_west" :label "East/West" :value 0}]})

Used in section 52

51. /init

Finally, we need to expose an init function that returns the starting state for a specific session. This function takes the list of player IDs in the game and returns an object with players and state in it.

{Init 51}
(defn-
  make-player
  [id team]
  {:id id :team team})

(defn init
  "Create an initial game state."
  [fst snd thd fth]
  {:players [(make-player fst "north_south")
             (make-player snd "east_west")
             (make-player thd "north_south")
             (make-player fth "east_west")]
   :state {:phase "deal" :info {:north_south 0 :east_west 0}}})

Used in section 52

52. init.janet

{init.janet 52}
{Config, 50}
{Init, 51}

The JSON API

In the Tamerlane application, game rules engines are installed by specifying a callback URL for the server to hit. Each call that the server makes to the rules engine takes the form of an JSON-formatted HTTP request, and expects a JSON-formatted response.

[NOTE]: The protocol around specific status codes and port numbers is still very much in development.

In Janet, the standard web framework is Joy; we'll define an extremely simple API using that library.

{main.janet 52}
(import json)
(import init)
(import whist)

(use joy)

(route :get "/api/v1/whist/config" :config)
(route :get "/api/v1/whist/init" :init)
(route :post "/api/v1/whist/next" :next)

{Config Handler, 53}
{Init Handler, 54}
{Next Handler, 55}

(def app (app {:layout layout :csrf-token false}))

(defn main [& args]
  (server app 9001))

53. /api/v1/whist/config

Our config endpoint needs to do very little with the request, as there are no arguments provided by the Tamerlane server.

{Config Handler 53}
(defn config [request]
  (def resp (init/config))
  (application/json resp))

Used in section 52

54. /api/v1/whist/init

Our init endpoint is only slightly more complex; the Tamerlane server has a single argument to /init, which is a list of players. Those are sent under the query parameter players as a comma-separated string. For instance, a sample call might be

{Sample API Call 54}
GET http://localhost:9001/api/v1/whist/init?players=North,East,South,West

We can handle it simply by splitting on the commas.

{Init Handler 54}
(defn init [request]
  (let [players (get-in request [:query-string :players])
        [p1 p2 p3 p4] (string/split "," players)
        resp (init/init p1 p2 p3 p4)]
  
    (application/json resp)))

Used in section 52

55. /api/v1/whist/next

Finally, we need to expose the next business logic itself.

We'd like to be able to do some basic input validation and to return an error response if we get bad input (if only to make it easier to develop our application), so we have some very simple parameter validation associated with this endpoint.

The output of our next function is always a two-tuple of state and events. In fact, the expected response format in the API is an object with a state attribute and an events attribute, so we need to match on the two-tuple that comes from our business logic and return the response that the Tamerlane server expects.

{Next Handler 55}
(def- params
  (params :next
    (validates [:action] :required true)
    (permit [:action :state :players])))

(defn next [request]
  (def handler (fiber/new (fn [req]
                            (whist/next (params req))) :e))
  (match (resume handler request)
    {:error error-msg} @{:status 422
                         :body (json/encode {:error error-msg})
                         :headers @{"Content-Type" "application/json"}}
    [state events]
    (application/json {:state state :events events})))

Used in section 52

56.

We now have a fully working Tamerlane rules engine, which models a complete version of the game of Bid Whist. If we run our web server, then we can point the Tamerlane server at its URL and play a game.

Appendix I

57. Appendix: Bids

We can hardcode the list of possible bids and define a few functions to expose them. They are written in the select format (ie, in the format used to define the choices for a select prompt). It's a simple format, a list of pairs, where the first element is the value that will be sent to the rules engine if that choice is selected, and the second element is the string to display when exposing the prompt to the player.

This kind of general format gives us a maximum of flexibility as game-developers; in this case, the value of a selection is an object containing count and direction attributes, but this value will be passed transparently back to us from the server, so we can make it whatever we like (as long as it can be serialized and deserialized from JSON!).

{All Bids 57}
(def-
  bids
  [[{:count 3 :direction "up"} "3 Uptown"]
   [{:count 3 :direction "down"} "3 Downtown"]
   [{:count 3 :suit "no_trumps"} "3 No-Trumps"]
   [{:count 4 :direction "up"} "4 Uptown"]
   [{:count 4 :direction "down"} "4 Downtown"]
   [{:count 4 :suit "no_trumps"} "4 No-Trumps"]
   [{:count 5 :direction "up"} "5 Uptown"]
   [{:count 5 :direction "down"} "5 Downtown"]
   [{:count 5 :suit "no_trumps"} "5 No-Trumps"]
   [{:count 6 :direction "up"} "6 Uptown"]
   [{:count 6 :direction "down"} "6 Downtown"]
   [{:count 6 :suit "no_trumps"} "6 No-Trumps"]
   [{:count 7 :direction "up"} "7 Uptown"]
   [{:count 7 :direction "down"} "7 Downtown"]
   [{:count 7 :suit "no_trumps"} "7 No-Trumps"]])

Used in section 60

58. Determining the Second Bid

For each of the two types of bids in the auction--suited bids and no-trumps bids---there's a second call that the declarer makes when they have won the auction. For suited bids, the declarer calls the suit. For no-trumps bids, the declarer calls the direction.

{Second Bids 58}
(def- direction [[{:direction "up"} "Uptown"]
                [{:direction "down"} "Downtown"]])

(def- suit [[{:suit "hearts"} "Hearts"]
           [{:suit "spades"} "Spades"]
           [{:suit "diamonds"} "Diamonds"]
           [{:suit "clubs"} "Clubs"]])

(def second-bids (array/concat @[] direction suit))

(defn- no-trumps? [bid] (= (bid :suit) "no_trumps"))

(defn second-bid
  [bid]
  (if (no-trumps? bid) direction suit))

Used in section 60

59. Determining Available Bids

The bulk of the game logic that we need to implement in the bids module is determining what bids are available for the next bidder given what the current high bid in the auction is.

The logic is slightly complex, because Uptown and Downtown bids are equivalent in terms of the auction. That means that if the current high bid is a suited bid, the next available bid is the no-trumps bid of the same number. If the high bid is a no-trumps bid, the next available bid is the Uptown bid of the next number.

Finally, if three out of four players pass, then the fourth player has to bid; so in that case, the Pass option isn't available to them.

{Available Bids 59}
(defn- no-trumps? [bid] (= (bid :suit) "no_trumps"))

(defn available-bids
  [&opt high-bid]
  (case high-bid
    nil (array ;bids ["pass" "Pass"])
    (if-let [minimum-bid (if (no-trumps? high-bid)
                           {:count (inc (high-bid :count)) :direction "up"} 
                           {:count (high-bid :count) :suit "no_trumps"})
             minimum-bid-ind (find-row-index minimum-bid bids)]
      (array/push (array/slice bids minimum-bid-ind) ["pass" "Pass"])
      @[["pass" "Pass"]])))

(defn force-bid
  "The bidder can't pass; they can only select an actual bid."
  []
  (array ;bids))

Used in section 60

60. bids.janet

{bids.janet 60}
{All Bids, 57}
{Second Bids, 58}
(defn- find-row-index [bid source] (find-index |(= ($0 0) bid) source))

(defn to-text
  [bid &opt source]
  (default source bids)
  (if-let [ind (find-row-index (freeze bid) source)
           row (source ind)]
    (row 1)
    (error (string "Not found: " (string/format "%q" bid) " in " (string/format "%q" source)))))
{Available Bids, 59}

Appendix II

61. Appendix: Cards

In Tamerlane, cards are represented in data as simple objects. They have two attributes, suit and rank.

The definition of which cards are contained in any deck type is currently stored on the Tamerlane server, and deck types are specified by name. For instance, Bid Whist uses the 52JJ deck, which is the normal 52-card Anglo-French deck with two Jokers.

Our card logic is a crucial element of the game. This module encodes the logic determining what can be legally played to a given trick, as well as how to determine which card wins a given trick.

62. Representing Cards

The card ranks are encoded as integers in "standard" order, with Aces represented with 1 up to Kings at 13.

{Card to Text 62}
(defn to-text [{:suit suit :rank rank}]
  (case suit
    "joker" (case rank
              2 "Big Joker"
              1 "Little Joker")
    (let [suits {"diamonds" "♦" "spades" "♠" "clubs" "♣" "hearts" "♥"}
          ranks {1 "Ace"
                 2 "2"
                 3 "3"
                 4 "4"
                 5 "5"
                 6 "6"
                 7 "7"
                 8 "8"
                 9 "9"
                 10 "10"
                 11 "J"
                 12 "Q"
                 13 "K"}]
        (string (suits suit) (ranks rank)))))

Used in section 66

63. Determining What Can Be Played

The rules of following suit in Bid Whist are fairly simple:

Our job is only somewhat complicated by the variable role of the Joker. In a suited contract, Jokers behave as though they were members of the trump suit. On the other hand, in a no-trumps contract, Jokers are members of their own suit.

{Of Suit or Off 63}
(defn- of-suit
  "Return any cards in stack that match the led suit."
  [led-suit stack]
  (let [without-jokers |(= led-suit ($0 :suit))]
    (filter without-jokers stack)))

(defn- of-suit-or-jokers
  "Return any cards in stack that match the led suit, or are jokers."
  [led-suit stack]
  (let [with-jokers |(or (= led-suit ($0 :suit))
                         (= "joker" ($0 :suit)))]
    (filter with-jokers stack)))

(defn of-suit-or-off
  "Return any cards in stack that match the led suit, otherwise return all cards."
  [led-suit current-bid stack]
  (let [on-suit (if (= led-suit (current-bid :suit))
                  # If the led suit is trumps, then look for all cards
                  # of that suit or jokers.
                  (of-suit-or-jokers led-suit stack)
                  # Otherwise, look just for cards of that suit.
                  (of-suit led-suit stack))]
    (if (> (length on-suit) 0) on-suit stack)))

Used in section 66

We can be a bit clever here by checking to see if the led suit is the same as the suit of the contract, since "notrumps" is not the suit of any card.

64. Determining the Winning Card

The other crucial element of our card logic is the function which actually determines who takes a trick. In our play phase, we have decorated each card with the player who played it. Thus, in this module, we determine the highest card in the trick, and then back in the play we can pull the winning player off of it.

{High Card 64}
(defn- of-suit-or-trumps
  "Return any cards in stack that match the led suit, or are trumps."
  [led-suit trumps stack]
  (let [with-jokers-and-trumps
        |(or (= led-suit ($0 :suit))
             (= trumps ($0 :suit))
             (= "joker" ($0 :suit)))]
    (filter with-jokers-and-trumps stack)))

(defn high-card
  "Trick resolution for all Bid Whist games."
  [stack led-suit bid]
  (let [f (make-compare-enable-fn bid)]
    (->> stack
         # Get a version of the current-trick where
         # each card has a `:compare` method that's
         # aware of the current bid.
         (map f)
         (of-suit-or-trumps led-suit (bid :suit))
         # Get the highest card according to each
         # one's `:compare`.
         (extreme compare>))))

Used in section 66

65. Compare-Enabled Cards

In this program, possibly the most idiomatic way to find the winning card is to take advantage of some specific features of the Janet language. In particular we're taking advantage of Janet's OO features by defining some prototype cards with custom compare methods, and then setting the right prototype for the cards we are comparing according to the contract they're being evaluated under.

This arguably has more to do with the specifics of object-oriented programming in Janet than anything having to do with the Tamerlane system, so feel free to skip it if it's not of particular interest.

{Enable Card Comparison 65}
(defn- uptown [a b] (match [a b]
            # `1` is the rank of the Ace.
            @[1 1] 0
            @[1 _] 1
            @[_ 1] -1
            @[x y] (compare x y)))

(defn- downtown [a b] (match [a b]
            # `1` is the rank of the Ace.
            @[1 1] 0
            @[1 _] 1
            @[_ 1] -1
            @[x y] (- (compare x y))))

(defn- uptown-card [trumps]
  @{:compare (fn [self other]
               (match [(self :suit) (other :suit)]
                 @["joker" "joker"] (compare (self :rank) (other :rank))
                 @["joker" _] 1
                 @[_ "joker"] -1
                 @[trumps trumps] (uptown (self :rank) (other :rank))
                 @[trumps _] 1
                 @[_ trumps] -1 
                 @[_ _] (uptown (self :rank) (other :rank))))})

(defn- downtown-card [trumps]
  @{:compare (fn [self other]
               (match [(self :suit) (other :suit)]
                 @["joker" "joker"] (compare (self :rank) (other :rank))
                 @["joker" _] 1
                 @[_ "joker"] -1
                 @[trumps trumps] (downtown (self :rank) (other :rank))
                 @[trumps _] 1
                 @[_ trumps] -1 
                 @[_ _] (downtown (self :rank) (other :rank))) )})

(def- notrumps-card
  @{:compare (fn [self other]
               (match [(self :suit) (other :suit)]
                 @["joker" "joker"] (compare (self :rank) (other :rank))
                 @["joker" _] -1
                 @[_ "joker"] 1
                 @[_ _] (uptown (self :rank) (other :rank))))})

(def- notrumps-downtown-card
  @{:compare (fn [self other]
               (match [(self :suit) (other :suit)]
                 @["joker" "joker"] (compare (self :rank) (other :rank))
                 @["joker" _] -1
                 @[_ "joker"] 1
                 @[_ _] (downtown (self :rank) (other :rank))))})

(defn- make-compare-enable-fn [current-bid]
  (let [proto (match [(current-bid :suit) (current-bid :direction)]
                @["notrumps" "up"] notrumps-card
                @["notrumps" "down"] notrumps-downtown-card
                @[trumps "up"] (uptown-card trumps)
                @[trumps "down"] (downtown-card trumps))]
    (fn [card]
      (table/setproto card proto))))

Used in section 66

66. cards.janet

{cards.janet 66}
{Card to Text, 62}
{Of Suit or Off, 63}
{Enable Card Comparison, 65}
{High Card, 64}

Appendix III

67. Appendix: Players

We need a little bit of business logic when it comes to handling players. In particular, we need a way to easily select the "next" player around the table, while optionally filtering out certain ones (in the case of the auction, we want to filter out players who have already passed).

In Janet we can do this using fibers.

{players.janet 67}
(defn next-player
  [id players &opt out-of]
  
  (var ind (find-index |(and (= ($0 :id) id)) players))
  (let [f (fiber/new (fn []
                       (while true
                         (do
                           (set ind (mod (inc ind) (length players)))
                           (yield ind)))))]
    (var found nil)
    (while (not found)
      (let [new-ind (resume f)
            new-id ((players new-ind) :id)]
        (if (or (nil? out-of) (in out-of (keyword new-id)))
          (set found new-id))))
    found))

(defn of-team [players team] (->> players
                                  (filter |(= ($0 :team) team))
                                  (map |($0 :id))))

Appendix IIII

68. Appendix: All Events

{events.janet 68}
(import players)
# Hand side effects
{Events: Draw, 14}
# Game Side Effects
{Events: End Game, 47}
# Decorations
{Events: Add Decoration, 15}
{Events: Clear Decoration, 22}
# Prompts
{Events: Pick 1, 16}
{Events: Prompt Play, 34}
{Events: Prompt Discard, 28}
# State labels
{Events: Add Info, 35}