Rummy 500: Learning gRPC with a simple client-server game
Every family has a game. Growing up, we played a lot of Rummy 500, and over Christmas 2016 we played enough of it to develop standings, grievances, and a general reluctance to let the tournament end when everyone went home.
So I spent the rest of the holiday building a server for it: a gRPC service that manages games of Rummy 500, a terminal client to play against other people remotely, and an interface for writing bots to play in their place. The real motivation was that I wanted an excuse to learn gRPC.
The rule that makes it interesting
Most of Rummy 500 is what you would expect. You draw a card, you may lay down melds — three or more cards of the same rank, or three or more in a sequence of the same suit — you may add cards to melds already on the table, and you discard to end your turn. Cards you have played score points; cards left in your hand at the end score against you.
The interesting rule is the discard pile. Instead of drawing from the stock, you may take cards from the discard pile — but you must take every card down to the one you want, and you must immediately play that bottom card in a meld this turn. That single rule is the whole game. It means the discard pile is a tempting, growing liability: everything anyone has thrown away is visible, in order, and available to whoever can use the card at the bottom and afford to absorb everything on top of it. It also means a discard is a real decision, because you are choosing what to hand your opponents along with everything they would have to take to get it.
A turn is a state machine
The obvious design — one RPC per turn, carrying everything the player intends to do — falls apart immediately. A player picking up from the discard needs to see what they drew before deciding what to meld, and needs to see the result of melding before deciding what to discard. The turn has to be interactive.
So a turn is a small state machine, and it lives in the protocol:
TURN_START
├── PickUpStock ────────┐
└── PickUpDiscard ──────┤
▼
PICKED_UP_CARDS
│ PlayCards (optional, but
▼ required if you took from the discard)
PLAYED_CARDS
│ Discard
▼
next player's TURN_START
Every transition is a separate RPC, and the server refuses any request that does not fit the current state. You cannot discard before drawing. You cannot draw twice. And if you took three cards off the discard pile, the server remembers that you owe it a meld containing the bottom one, and will not accept your discard until you have played it.
Putting the state machine in the server rather than the client enforces the rules. The Game struct maintains the invariants of Rummy 500 and every client — the interactive one, the bots, and anything anyone writes later — is checked against the same rules by the same code. My CLI does try to keep you from making illegal moves, but as a courtesy, and the server assumes it might be lying:
Selection: 2
How many cards would you like to pick up?: 3
Error picking up from discard: rpc error: code = Unknown desc =
can't pick up 3 > 2 cards in discard pile
That error came from the server, not the client that displayed it.
Hidden information over a wire
A card game has a public state and several private ones, and a network makes that distinction load-bearing in a way a local game does not. In a program on one machine, “the player can’t see the other hands” is a matter of not drawing them. Over a network, it is a matter of not sending them.
The service splits accordingly. GetGameState returns the publicly observable state — whose turn it is, the melds on the table, the discard pile, how many cards each player holds and how many points they have scored. GetHandCards returns the cards in one player’s hand, and requires a secret established when that player joined the game.
Streaming, because turn-based does not mean idle
Between your turns you still need to see what everyone else does — the whole skill of the game is tracking which cards have gone into the discard pile and who took them.
Rather than have clients poll, SubscribeGame is a server-streaming RPC: the client opens it once and the server pushes a GameEvent for every action. This is the feature that made gRPC feel worth learning. A streaming subscription is a few lines in the .proto file, and on both ends it is an ordinary loop over a channel of typed messages, with reconnection and framing handled underneath. Building the equivalent over raw sockets or long-polling would have been most of the project instead of an afternoon of it.
One protocol, two transports
One small pleasure worth recording: adding grpc-gateway to the build produced a complete REST/JSON API from the same service definition, with no hand-written handlers.
POST /v1/create/{game_name}
POST /v1/join/{game_name}/{player_name}
GET /v1/subscribe/{game_name}
POST /v1/play_cards
POST /v1/discard
POST /v1/call_rummy
I never got around to the web interface those endpoints were meant to enable. But the ability to curl a running game server while debugging turned out to be worth it on its own, and it cost an annotation per method. Pretty neat!
The terminal is an underrated UI
The CLI client is plain Go with two small indulgences: Unicode suit glyphs and ANSI color for the red ones.
Your turn!
Current hand: [3♥ 5♥ 7♦ 9♦ 7♣ 9♠ J♠]
Current discard pile: [5♣ 7♠]
All played melds:
[10♥ 10♣ 10♠]
Current player status:
Tim: 7 cards, 0 points
CP0-greedy: 4 cards, 30 points
Bots that see what you see
A game server that supports remote play is also a game server that does not care whether a player is human, so the AI clients connect over exactly the same API. A strategy implements four methods, and the shape should look familiar:
type Strategy interface {
PickUpCards(discardPile []deck.Card) int
PlayCards(hand rummy.Hand) []deck.Card
Discard(hand rummy.Hand) deck.Card
OnGameEvent(event *rummy.GameEvent)
}
The first three mirror the turn state machine. OnGameEvent delivers the same event stream a human client subscribes to. Using this feed, a strategy can “count cards”: watch what goes into the discard pile, watch who takes from it and how deep they dug, and infer what they are collecting.
The bundled greedy strategy is a simple baseline. It plays every meld it can, and it takes from the discard pile whenever doing so would let it play something — trying each depth from the top down and stopping at the first that produces a meld. It never asks whether taking eight cards to play a meld worth 15 points is a good trade.
Since bots play over the network like anyone else, a driver can sit them down against each other:
$ ./battle -strategies nop,greedy -num_games 1000 -seed 123
nop draws from the stock and discards at random. greedy beats it convincingly, which proves only that doing something beats doing nothing.
The play continues
Using this server, we got a few more games in, even after everyone went back to their home states on opposite sides of the US!