Building tractor (升级) as an online card game
Introduction⌗
This COVID-19 pandemic has left me with lots of free time spent in front of a computer at home. I’d been spending time with my friends playing various videogames online (the Jackbox Party Pack games work reasonably well over Discord or Zoom), and my fiancée suggested that we try to play tractor online… but I couldn’t find one that was easily available over the internet!
What’s tractor, anyways?⌗
Tractor is a trick-taking card game popular in China, played with four or more players and with multiple decks. It’s commonly known as 升级 (“level-up”), 打百分 (“fighting for 100 points”), 四十分 (“forty points”), 八十分 (“eighty points”), or 拖拉機 (“tractor”). You can even find it on Wikipedia! The rules of the game are pretty complicated, and they vary widely: As far as I know, there isn’t a canonical source of rules for the game, at least in English.
For odd numbers of players, there is a variant of the game called 找朋友 (“finding friends”), which adds a team-building aspect to the game. As part of this project, I wrote up the rules that my friends and I usually play with here.
The core of the game is made up of a few phases:
- Draw: players draw cards one at a time and make bids to determine the trump
- Leader actions: the current round’s leader can take advantage of extra cards to make their hand stronger. In 找朋友, the leader also chooses their friends at this time.
- Trick-taking: where the game is broken up into a sequence of “tricks” in which each player plays the same number of cards. At the end of the trick-taking phase, players' ranks may be updated, and the leader for the next round is chosen.
Why build a new version?⌗
At first, I had no real interest in writing my own version of the game. I did some initial searching on Google, and found that there were once Flash versions, and that a couple of folks had implemented open-source variants of the game. Unfortunately, none of these projects seemed to actually work, and would have required me to set up my own infrastructure as well. I later learned that there is an iPhone app that lets you play online called 多乐升级, but I didn’t know about it at the time – and, it requires you to pay money to play with your friends. Asking people to go download an app written entirely in Chinese, and without Android or non-mobile support seemed like too high a barrier to entry.
Some of my friends suggested trying to play the game via an online deck simulator, e.g. Tabletop Simulator or playingcards.io. Unfortunately, the structure of the game as commonly played (half a deck of cards per person) makes it pretty unwieldy to play online without assistance from the game itself.
Writing a card game simulator is one of those classic introductory programming tasks, though it’s usually in the form of implementing a rudimentary form of blackjack. I figured writing something pretty janky could get me most of the way there.
Writing a simulator for tractor⌗
I worked on this project over a couple of evenings, each with a specific goal. Like many of my personal projects, this bulk of the project is written in Rust.
The whole project is open source, so you can check it out on GitHub. Or, you can just play the game at https://robertying.com/shengji/!
Building the core⌗
Representing cards⌗
There’s a deceptive amount of complexity involved in correctly modeling cards for a game like tractor: not only are there multiple decks, the ordering of the cards and their grouping into suits changes from round to round… and there are jokers to deal with as well! Plus, it’d be nice to not allow any states which shouldn’t be representable in game.
The resulting enums are a little verbose, but having the compiler help out with stuff is pretty nice! Note: derivations and implementations are mostly elided.
pub enum Card {
Unknown,
Suited { suit: Suit, number: Number },
SmallJoker,
BigJoker,
}
pub enum Suit {
Clubs,
Diamonds,
Spades,
Hearts,
}
pub enum Number {
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
Jack,
Queen,
King,
Ace,
}
Trumps add a new complication, though: in tractor, the trump suit is defined by a trump number and an optional suit. If no suit is specified, there are five suits in the game! All jokers and cards of the trump number are also part of the trump suit, and not part of the suit they were originally in.
This means that we have to also include another enum to track the “effective” suit of a card, and the current trump specificaton:
pub enum EffectiveSuit {
Unknown,
Clubs,
Diamonds,
Spades,
Hearts,
Trump,
}
pub enum Trump {
Standard { suit: Suit, number: Number },
NoTrump { number: Number },
}
impl Trump {
pub fn effective_suit(self, card: Card) -> EffectiveSuit {
// ...
}
pub fn successor(self, card: Card) -> Vec<Card> {
// ...
}
pub fn compare(self, card1: Card, card2: Card) -> Ordering {
self.compare_effective(card1, card2)
.then(card1.as_char().cmp(&card2.as_char()))
}
pub fn compare_effective(self, card1: Card, card2: Card) -> Ordering {
// ...
}
}
For example if the trump is Trump::Standard { suit: Spades, number: Four }
,
the order of cards is from highest to lowest is
- big joker
- small joker
- 4♤
- 4♡ = 4♢ = 4♧ (non-♤ 4s are distinct but have equal effective value for comparisons)
- A♤
- K♤
- Q♤
- J♤
- 10♤
- 9♤
- 8♤
- 7♤
- 6♤
- 5♤
- 3♤ (3♤ and 5♤ are adjacent!)
- 2♤.
Basic operations like sorting and comparing cards can only be done by functions
which are trump-aware, either by taking in Trump
as a variable or by
requiring EffectiveSuit
rather than Suit
. That way, the compiler can keep
you from accidentally sorting cards incorrectly.
It might seem a little odd to see successor
and two compare*
functions
defined on Trump
: this is because the ordering of cards in tractor includes
cards of equivalent sort-order, but which are not intended to compare equal.
compare_effective
determines the correct ordering of cards for the purposes
of the game, while compare
is used for sorting the cards for display to the
user.
Game state machine⌗
Modeling state machines via session types is pretty easy in Rust, and the nice thing about card games is that there really isn’t that much state to deal with.
pub enum GameState {
Initialize(InitializePhase),
Draw(DrawPhase),
Exchange(ExchangePhase),
Play(PlayPhase),
}
Each phase exposes a mechanism to transition it to the next phase, or back to initialize if the game is reset.
We also encode game settings (set by the user in Initialize
) and pass them
through all of the phases as PropagatedState
:
pub struct PropagatedState {
game_mode: GameModeSettings,
hide_landlord_points: bool,
kitty_size: Option<usize>,
num_decks: Option<usize>,
max_player_id: usize,
players: Vec<Player>,
observers: Vec<Player>,
landlord: Option<PlayerID>,
chat_link: Option<String>,
kitty_penalty: KittyPenalty,
throw_penalty: ThrowPenalty,
hide_played_cards: bool,
num_games_finished: usize,
}
That way, when the game is reset or completed, we know which settings were set by the game itself, and which ones were explicit overrides by the user.
Drawing cards and bidding⌗
Once the game has “started” (i.e. entered the draw phase), players have hands containing cards, and a sequence of cards in random order to draw from:
pub struct DrawPhase {
deck: Vec<Card>,
hands: Hands,
bids: Vec<Bid>,
kitty: Vec<Card>,
// ...
}
impl DrawPhase {
pub fn draw_card(&mut self, id: PlayerID) -> Result<(), Error> {
// ...
}
pub fn bid(&mut self, id: PlayerID, card: Card, count: usize) -> bool {
// ...
}
pub fn advance(&self, id: PlayerID) -> Result<ExchangePhase, Error> {
// ...
}
}
pub struct Hands {
hands: HashMap<PlayerID, HashMap<Card, usize>>,
level: Number,
trump: Option<Trump>,
}
We represent a player’s hand as a map from card to the number of cards in their hand, since this is a useful representation for computing play-validity.
Cards must be drawn in order – even though the cards that each player gets is fully deterministic, the player doesn’t find out about all of them at the same time, and bids are made with limited information.
There are few enough bids available to each player that we can just enumerate all of the valid ones to check if the proposed bid can be accepted.
We can advance to the exchange phase if the deck is empty and there has been at least one bid made. Note: we’ve already separated out the kitty from the deck, so that the remaining game logic is easier to handle.
There are variants of this game where the exchange phase and the draw phase are commingled, so this state machine might change at some point in the future to support that.
Exchanging cards with the kitty and picking friends⌗
Only the current round leader (determined by the exchange phase, or fixed by the initialize phase) can take actions in the exchange phase.
pub struct ExchangePhase {
game_mode: GameMode,
hands: Hands,
kitty: Vec<Card>,
kitty_size: usize,
landlord: PlayerID,
trump: Trump,
// ...
}
impl ExchangePhase {
pub fn move_card_to_kitty(
&mut self,
id: PlayerID,
card: Card
) -> Result<(), Error> {
// ...
}
pub fn move_card_to_hand(
&mut self,
id: PlayerID,
card: Card
) -> Result<(), Error> {
// ...
}
pub fn set_friends(
&mut self,
id: PlayerID,
iter: impl IntoIterator<Item = Friend>,
) -> Result<(), Error> {
// ...
}
pub fn advance(&self, id: PlayerID) -> Result<PlayPhase, Error> {
// ...
}
}
pub enum GameMode {
Tractor,
FindingFriends {
num_friends: usize,
friends: Vec<Friend>,
},
}
pub struct Friend {
card: Card,
skip: usize,
// ...
}
For tractor, the only thing that they need to do is determine what cards they should keep in the kitty vs. their hand. For finding friends, they’re required to also specify their friends (defined by a specific card, and the number of that card to ignore before assigning the friend).
Playing the game and handling tricks⌗
The bulk of the actual gameplay occurs during the Play
phase, so we end up with a bunch more state:
pub struct PlayPhase {
game_mode: GameMode,
hands: Hands,
points: HashMap<PlayerID, Vec<Card>>,
penalties: HashMap<PlayerID, usize>,
kitty: Vec<Card>,
landlord: PlayerID,
landlords_team: Vec<PlayerID>,
trick: Trick,
// ...
}
impl PlayPhase {
pub fn play_cards(
&mut self,
id: PlayerID,
cards: &[Card],
) -> Result<_, Error> {
// ...
}
pub fn take_back_cards(&mut self, id: PlayerID) -> Result<(), Error> {
// ...
}
pub fn finish_trick(&mut self) -> Result<_, Error> {
// ...
}
pub fn finish_game(&self) -> Result<(InitializePhase, _), Error> {
// ...
}
}
We now need to keep track of points and play penalties for each player, and track the trick’s state as each player plays their cards.
Most of the complexity here comes from determining the trick-format for the trick, and determining whether the cards played are legal within the current game-state.
pub struct Trick {
player_queue: VecDeque<PlayerID>,
played_cards: Vec<PlayedCards>,
trick_format: Option<TrickFormat>,
// ...
}
impl Trick {
pub fn play_cards<'a, 'b>(
&mut self,
id: PlayerID,
hands: &'a mut Hands,
cards: &'b [Card],
) -> Result<_, TrickError> {
// ...
}
pub fn take_back(
&mut self,
id: PlayerID,
hands: &'_ mut Hands
) -> Result<(), TrickError> {
// ...
}
pub fn complete(&self) -> Result<TrickEnded, TrickError> {
// ...
}
}
pub struct PlayedCards {
id: PlayerID,
cards: Vec<Card>,
bad_throw_cards: Vec<Card>,
better_player: Option<PlayerID>,
}
pub struct TrickEnded {
winner: PlayerID,
points: Vec<Card>,
// ...
}
The trick is initialized with the sequence of players who need to play, and is
complete when the player_queue
is empty. As players play cards, they are
removed from the player_queue
and the cards they have played are added to the
played_cards
, updating the current state of the player’s hand.
Finding the trick format⌗
Unfortunately, determining the trick-format given a set of cards is actually an ambiguous problem. There are two types of trick unit-types, and then there’s the meta-type of a “throw” that combines an arbitrary number of units.
enum UnitLike {
Tractor { count: usize, length: usize },
Repeated { count: usize },
}
Repeated units are tuples of identical cards, and tractors are sequences of consecutive tuples of length two or greater. In general, tractors are considered “better” than repeated units, so the parsing problem in the common case of two decks is pretty straightforward: take all of the 1-tuples and 2-tuples, find any 2-tuples that are consecutive, and convert them into tractors.
With more than two decks, though, things get more complicated:
- there is no longer a single parse of “tuple” or “tractor”
- the search is no longer strictly ordered, since a tractor might not include all of the cards in a tuple.
For example, if there are three decks, the interpretation of 2-2-3-3-3 can be either [[2, 2, 3, 3], 3] or [[2, 2], [3, 3, 3]]. To resolve this ambiguity, the implementation does a search through the potential unit-allocations and finds all of the viable possibilities, and then picks the one which has the highest maximum unit size.
This is not ideal, but it keeps the interface simple, and playing ambiguous card-sets is not particularly common.
Matching the trick format⌗
After the trick format is set, all of the other players are required to follow the format as best they can. Validating that they have actually done so is a bit of a tricky problem: in some sense, we want to force the cards which are as close as possible to the desired format to be played.
The current implementation relies on decomposition:
while let Some(mut requirement) = requirements.pop() {
// If it's a match, we're good!
let play_matches = UnitLike::check_play(...);
if play_matches {
return true;
}
// Otherwise, if it could match in the player's hand, it's not OK.
let hand_can_play = UnitLike::check_play(...);
if hand_ca_play {
return false;
}
// Otherwise, downgrade the requirements.
while let Some(unit) = requirement.pop() {
let decomposed = unit.decompose();
if !decomposed.is_empty() {
for subunits in decomposed {
let mut r = requirement.clone();
r.extend(subunits);
requirements.push(r);
}
break;
}
}
}
// Couldn't meet requirements in either hand or proposed play, so the proposed
// play is legal.
Essentially, the criteria for a “legal” play is one which cannot be better matched by some other combination of cards from the player’s hand. If neither the proposed play nor the player’s hand can match the trick format, the trick format’s requirements are reduced until there are none left.
There’s a fast-path optimization for the case where the proposed player doesn’t have enough cards in the right suit to match the format at all, but in general this computation assumes that players are trying to play legitimately most of the time.
Some variants of the game have “protected” units, e.g. not allowing a pair to draw out a triple. This tends to be difficult to make consistent as the number of decks increases, so it’s not implemented yet.
Finishing the trick⌗
At the end of the trick, the winner is determined by comparing all of the players which successfully matched the trick format. Then, the game updates the friends, points, and penalties appropriately, and initializes the next trick with the winner as the new trick leader.
Finishing the game⌗
At the end of the game, all players' hands are empty, and the trick is also
empty. The game can then total up the points and penalties for each team, and
determine the new levels and leader for the next round. It is then set to the
Initialize
state with new continuation settings.
Backend architecture⌗
The actual server is implemented as a single binary which serves both the
static assets (HTML, JS, CSS) and the game API over a websocket, using the
warp
library as its async service implementation.
The client is expected to connect to the websocket and send messages serialized as JSON, and the server responds with full state snapshots for the game that the client is connected to. There’s no direct RPC-like connection between the messages in both directions, which simplifies implementation: the client can effectively just render the current game state at all times.
State management⌗
The server keeps all of its state in a single locked map from room name to the corresponding game state. Each game keeps track of its connected users, and users are identified by their active websocket connection.
type Games = Arc<Mutex<HashMap<String, GameState>>>;
struct GameState {
game: interactive::InteractiveGame,
users: HashMap<usize, UserState>,
// ...
}
The InteractiveGame
wrapper effectively just wraps the GameState
session
type and routes the incoming messages to the appropriate function call using
a big match statement:
match (msg, &mut self.state) {
(Message::ResetGame, _) => {
// ...
}
(Message::SetChatLink(ref link), _) => {
// ...
}
(Message::StartGame, GameState::Initialize(ref mut state)) => {
// ...
}
(Message::ReorderPlayers(ref players), GameState::Initialize(ref mut state)) => {
// ...
}
(Message::MakeObserver(id), GameState::Initialize(ref mut state)) => {
// ...
}
(Message::MakePlayer(id), GameState::Initialize(ref mut state)) => {
// ...
}
// ...
}
As long as at least one user is connected to the game, the game will not be cleaned up. Once all players have left, and some time has passed, games are garbage collected to reduce overall memory usage.
Since the game doesn’t actually use a database, it periodically dumps the current state-map to disk. On startup, it reads in the state dump and loads the corresponding data into the new state-map. This doesn’t preserve player websocket connections, but is simple and reliable enough for the current use cases.
Logging⌗
The server process is set up as a systemd
service with a dedicated user, so
stdout
and stderr
are redirected to the systemd
journal. In the Rust
code, I used slog
to provide key-value structured logging, which was fairly
painless. The logs are serialized as JSON by default, and as colored console
output for debugging if the appropriate feature flag is specified. This helps
balance readability against parseability:
lazy_static::lazy_static! {
static ref ROOT_LOGGER: Logger = {
#[cfg(not(feature = "dynamic"))]
let drain = slog_bunyan::default(std::io::stdout());
#[cfg(feature = "dynamic")]
let drain = slog_term::FullFormat::new(slog_term::TermDecorator::new().build()).build();
Logger::root(
slog_async::Async::new(drain.fuse()).build().fuse(),
o!("commit" => env!("VERGEN_SHA_SHORT"))
)
};
}
The current commit hash of the compiled code is also included in the logs, so that it’s easy to see what version of the code generated any particular log line.
Frontend code⌗
The frontend is pretty simple, and contains only the bare minimum of game logic: it’s effectively a glorified renderer for the current server state.
As a result, it fits pretty well with the React render mechanism… and also
that’s the only frontend framework I remembered how to use at the time. While
the components have been factored out a lot more now, the first version
basically just jammed the entire game state into a global state
variable and
drew elements appropriately.
Each frontend component maps pretty much directly to the backend state. The
entire game state derives Serialize
and Deserialize
, so this was pretty
easy. Unfortunately, I did not find a good way to automatically generate
typescript types from the struct definitions, so they’re currently manually
translated to give a bit of type safety to the frontend code.
Generating room IDs⌗
The backend supports pretty much any 16-codepoint unicode sequence as a room name. For simplicity, the frontend generates a random 64-bit number and uses the hex representation of that number as the room name, if one wasn’t already provided.
Playing cards exist in Unicode?⌗
One of the things that made building the frontend much, much easier is that it turns out there are playing card characters in unicode! The graphical display of each card is effectively just taking the card character and making the font-size really big. They end up looking something like this (at least, on Macs):
Most fonts seem to use card characters of the same relative sizes, so it was pretty easy to overlap the cards by setting a bit of negative margin.
Display Settings⌗
Lots of people have different ideas for what kinds of minor frontend features are helpful to them. For example, auto-draw is pretty universally liked (clicks the draw button for you after a couple hundred milliseconds), but four-color suits and beep-on-your-turn are not everyone’s cup of tea.
Preferences like these are stored in the browser’s Local Storage and available behind the settings pane.
Weird bits⌗
There’s some funky stuff that goes on with setTimeout
and setInterval
in
modern browsers when the tab is backgrounded. This is annoying if you have four
tabs open for debugging, since it limits the speed at which auto-draw actually
runs. I worked around this by proxying setTimeout
and setInterval
to a web
worker:
const timers = {};
function fireTimeout(id) {
this.postMessage({id: id, variant: 'timeout'});
delete timers[id];
}
function fireInterval(id) {
this.postMessage({id: id, variant: 'interval'});
}
this.addEventListener('message', function(evt) {
const data = evt.data;
switch (data.command) {
case 'setTimeout':
const time = parseInt(data.timeout || 0, 10);
const timer = setTimeout(fireTimeout.bind(null, data.id), time);
timers[data.id] = timer;
break;
case 'setInterval':
const interval = parseInt(data.interval || 0, 10);
const handle = setInterval(fireInterval.bind(null, data.id), interval);
timers[data.id] = handle;
break;
case 'clearTimeout':
const clearTimeoutHandle = timers[data.id];
if (clearTimeoutHandle) {
clearTimeout(clearTimeoutHandle);
}
delete timers[data.id];
break;
case 'clearInterval':
const clearIntervalHandle = timers[data.id];
if (clearIntervalHandle) {
clearInterval(clearIntervalHandle);
}
delete timers[data.id];
break;
}
});
Apparently web workers get to run at full speed. Who knew?
There’s also a thing where the websocket’s onclose
method doesn’t always get
called, so the UI appears to just stop working entirely. The internet suggests
this is because Chrome doesn’t always call onclose
when network conditions
change, so for now there’s a workaround where we expect a new message from the
websocket within a few seconds of any communication, and the connection is
presumed dead if it doesn’t appear.
Metrics⌗
It turns out that there’s actually pretty decent demand for an online version of tractor within my extended friend groups! It’s mostly been shared via word of mouth, and I only have data from the last week or so, but it’s fun to see that people are actually playing almost every day:
and that they’re playing for an extended period of time each day:
Each color in the above chart is unique room, and the y-axis is the number of games they’ve finished in that session.
The structured logging from the backend is ingested into Honeycomb, which makes it easy to see how things are going with the service without any client-side cookies or persistent user accounts.
If you thought all this was interesting, consider contributing on GitHub or playing a few games with your friends!