cardpack 0.11.1

Generic Deck of Cards
Documentation

cardpack.rs

Build and Test codecov Crates.io Version Rustdocs

Generic pack of cards library written in Rust. The goals of the library include:

  • Various types of decks of cards.
  • Internationalization support.
  • Ability to create custom sorts for a specific pack of cards.

UPDATE: This is a complete rewrite of the library taking advantage of generics in order to make the code cleaner, and easier to extend.

Setup

Build and run common tasks with GNU make:

make

Run make help to see all available targets.

Usage

use cardpack::prelude::*;

fn main() {
  let mut pack = Standard52::deck();

  // Deterministic shuffle — works in the pure, `no_std` default build.
  // With the `std` feature you can call `pack.shuffle()` for a thread-RNG shuffle.
  pack.shuffle_with_seed(42);

  // Deal no-limit hold'em hands for two players:
  let small_blind = pack.draw(2).unwrap().sorted_by_rank();
  let big_blind = pack.draw(2).unwrap().sorted_by_rank();

  println!("small blind: {}", small_blind.to_string());
  println!("big blind:   {}", big_blind.to_string());

  let flop = pack.draw(3).unwrap();
  let turn = pack.draw(1).unwrap();
  let river = pack.draw(1).unwrap();

  println!();
  println!("flop : {}", flop.to_string());
  println!("turn : {}", turn.to_string());
  println!("river: {}", river.to_string());

  // Now, let's validate that the cards when collected back together are a valid Standard52
  // deck of cards.
  let reconstituted_pile =
          Pile::<Standard52>::pile_on(&*vec![pack, small_blind, big_blind, flop, turn, river]);
  assert!(Standard52::deck().same(&reconstituted_pile));
}

Details

The goal of this library is to be able to support the creation of card decks of various sizes and suits. Out of the box, the library supports:

The project takes advantage of Project Fluent's Rust support to offer internationalization. Current languages supported are English, German, French, Latin, and Klingon.

Cargo features

cardpack is pure by default: a bare dependency is an alloc-only, no_std, no-I/O domain kernel. Every dependency-bearing or I/O-bearing capability is gated behind a Cargo feature, so consumers opt in to exactly what they need:

Feature Default Pulls in What it turns on
full no everything below Umbrella turning on std + i18n + colored-display + yaml + serde
std no libstd std-only APIs (thread-RNG shuffle, draw_random, etc.)
i18n no fluent-templates FluentName, Named, Card::fluent_name*, localization
colored-display no colored Color, Colorize, Card::color*, Pile::to_color_*
yaml no serde_norway Full deck ↔ YAML round-tripping (pure, in-memory) — see Decks as YAML; plus the Razz deck
serde no serde Serialize/Deserialize derives on Pip/Card/Pile etc.
std-io no BasicCard::cards_from_yaml_file — reads decks from YAML files (std::fs). The crate's one filesystem seam; not in full
funky no std The Balatro-style engine — see Funky below
seal-test-double no PlaintextSeal (no security) and the seal_roundtrip conformance helper for testing a Seal backend; not in full
commit-reveal no sha2 Provably-fair shuffles: ShuffleRound, Commitment/Contribution, CombinedSeed, commit_pile, Pile::shuffled_by_round — see Provably-fair shuffles; not in full
seal-aead no chacha20poly1305, hkdf, sha2, zeroize Holder-key seal: HolderKeySeal, DealKey/CardKey, SealedBytes, Custody — see Sealed cards; not in full
crypto no = both above Umbrella over commit-reveal + seal-aead; not in full

To get the previous "batteries-included" behavior, opt into full:

# Full convenience stack (i18n, colored display, YAML, serde):
cardpack = { version = "0.8", features = ["full"] }

# Or trim to just what you need — e.g. the pure kernel plus serde:
cardpack = { version = "0.8", features = ["serde"] }

# Or the pure, no_std, alloc-only with no extra deps at all:
cardpack = "0.8"

yaml implies serde (it deserializes into the serde-derived structs). std-io implies yaml and adds the filesystem reader on top of it; it is the only feature that lets the crate touch std::fs, and it is intentionally left out of full so the pure kernel and the convenience stack both stay I/O-free.

Sealed Decks (EPIC-04) are always on and dependency-free:

  • Ordinal/Codebook (a canonical card ↔ number bijection per deck)
  • Permutation (a shuffle as data)
  • SlotPile (a shoe of card names that shuffles, cuts and deals with no knowledge)
  • Revealed (the only slot → card map), and the five-item Seal adapter.

No kernel type holds ciphertext or is generic over a scheme. Real crypto backends are planned as opt-in features outside full.

Provably-fair shuffles

The commit-reveal feature (EPIC-04a) adds one dependency, sha2, and lets every participant in a game prove the shuffle was fair. Each participant commits to secret entropy, then everyone reveals; the combined seed fixes the shuffle through a frozen SHA-256 derivation that any verifier, in any language, can reproduce from the public transcript alone:

// needs `--features commit-reveal`; the same example is a compiled doctest in `src/seal/commit/mod.rs`
use cardpack::prelude::*;

let (dealer, player) = (ParticipantId(1), ParticipantId(2));
let a = Contribution::from_bytes([0x11; 32]); // Contribution::random(&mut rng) in real code
let b = Contribution::from_bytes([0x22; 32]);

let mut round = ShuffleRound::new([dealer, player])?;
round.commit(dealer, a.commit())?;
round.commit(player, b.commit())?;         // nobody may reveal before this point
round.reveal(dealer, a)?;
round.reveal(player, b)?;

let shuffled = Standard52::deck().shuffled_by_round(&round)?;
# Ok::<(), CardError>(())

commit_pile / verify_pile let a dealer publish a blind commitment to a concrete deck order before dealing and opening it later. Run cargo ex provably_fair for a two-party round end to end. This hides the shuffle, not the cards. Hiding cards is the next feature.

Sealed cards (holder-key seal)

The seal-aead feature (EPIC-04b) is the first real Seal backend: a trusted dealer seals every card under its own HKDF-derived key (XChaCha20-Poly1305, 42 public bytes per card), and a holder turns one card up by publishing one 32-byte token. A spectator with no secret verifies it through Revealed::reveal_with; the token opens nothing else.

// needs `--features seal-aead`; the same flow is a compiled doctest in `src/seal/aead/mod.rs`
use cardpack::prelude::*;

let dealer = HolderKeySeal::<Standard52>::dealer(DealKey::random(&mut rng), b"table-7/hand-12");
let (mut shoe, custody) = dealer.deal(&Standard52::deck(), &mut rng)?;   // SlotPile + Custody
let hole = shoe.draw(2).unwrap();                                        // slot names, no values
let tokens = dealer.tokens_for(hole.slots().iter().copied())?;

// Holder publishes (slot, token); anyone verifies:
let spectator = HolderKeySeal::<Standard52>::verifier(b"table-7/hand-12");
let mut revealed = Revealed::<Standard52>::new();
let card = revealed.reveal_with(slot, custody.get(slot).unwrap(), &spectator, &token)?;

Three plain values — SlotPile (order), Custody (bytes), Revealed (values) — and a scheme that lives inside none of them. The RNG you pass must be a CSPRNG. Run cargo ex holder_seal for the flow end to end. The crypto feature turns on both backends; none of them is in full.

Decks as YAML

With yaml, every deck round-trips deck → YAML → deck. Documents use a self-describing envelope that carries the deck's identity — version, name, fluent_deck_key, count, cards — rather than a bare card list, so a document can be checked against the deck it claims to be. The reader still accepts the legacy bare sequence, so the new format is a strict superset of what BasicCard::cards_from_yaml_str always took.

// This README is included in the crate docs, so its code blocks are compiled
// as doctests. Ignored because it needs the `yaml` feature, which is off by
// default; the executable versions live on the `YamlDecked` methods.
use cardpack::prelude::*;

// Any DeckedBase implementor — including a deck you wrote — gets this free
// via the blanket `YamlDecked` trait:
let yaml = French::to_yaml().unwrap();
assert_eq!(French::deck_from_yaml(&yaml).unwrap(), French::base_vec());

// A well-formed document describing the wrong deck is still rejected:
assert!(Tarot::validate_yaml(&yaml).is_err());

// `Pile` serialization preserves order, so hands and shuffles survive intact:
let shuffled = Pile::<Standard52>::deck().shuffled_with_seed(42);
let restored = Pile::<Standard52>::from_yaml(&shuffled.to_yaml().unwrap()).unwrap();
assert_eq!(restored, shuffled);

DeckKind::to_yaml / DeckKind::from_yaml cover the non-generic path, for decks known only at runtime. Golden fixtures for all shipped decks live in tests/fixtures/yaml/ and are regenerated with make yaml-fixtures.

Funky — Balatro-style cards

The funky feature is a result of having my mind blown🤯 playing the amazing solitare game Balatro. It honestly changed the way I look at playing cards. Suddenly, suits and ranks are just two of an infinite possible number of pips that can be attached to a "playing card". I started realizing that there is little difference between a French Deck of cards and creating heros in the Evercraft Kata.

The goal of the feature is to see how hard I need to push the architecture of this library to support decks such as those in Balatro. I guess the big idea was the MPip, a sort of functional version of a pip on a card.

TBH, this experiment demonstrates the rational behind designing games in flexible languages such as Lua, over tyrannical ones such as my beloved Rust.

There are a couple of use cases that are in the back of my mind for something like this. One is a Balatro score solver, as a way to teach the math mechanics behind the game. The other is a library that would be able to create modded Balatro decks from simple yaml configuration files, similar to what the library already supports in simpler decks.

It is still very much a work in progress, which is documented here: docs/EPIC-01_Funky.md.

There are two examples to see it in action:

# The four-phase scoring pipeline, phase by phase:
cargo ex buffoon

# A seeded four-act tour — round loop, editions, shop & vouchers, spectrals:
cargo ex funky_tour

WebAssembly

cardpack compiles cleanly to wasm32-unknown-unknown (browser WASM) with every feature combination. See docs/wasm.md for the consumer-side getrandom backend setup, recommended feature combos, and runtime gotchas. A working example lives at examples/wasm.rs.

Responsibilities

  • Represent a specific type of card deck.
  • Validate that a collection of cards is valid for that type of deck.
  • Create a textual representation of a deck that can be serialized and deserialized.
  • Shuffle a deck

Examples

The library has several examples programs, including demo which shows you the different decks available.

Run them with cargo ex <name>. Because cardpack is pure by default (default = [], see Cargo features), most examples need --features to compile; cargo ex is an alias in .cargo/config.toml that supplies them for you, so cargo ex demo beats cargo run --features full,funky --example demo.

For the traditional 54 card French Deck with Jokers:

❯ cargo ex demo -- --french -v
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/demo --french -v`

French Deck:          B🃟 L🃟 A♠ K♠ Q♠ J♠ T♠ 9♠ 8♠ 7♠ 6♠ 5♠ 4♠ 3♠ 2♠ A♥ K♥ Q♥ J♥ T♥ 9♥ 8♥ 7♥ 6♥ 5♥ 4♥ 3♥ 2♥ A♦ K♦ Q♦ J♦ T♦ 9♦ 8♦ 7♦ 6♦ 5♦ 4♦ 3♦ 2♦ A♣ K♣ Q♣ J♣ T♣ 9♣ 8♣ 7♣ 6♣ 5♣ 4♣ 3♣ 2♣
French Deck Index:    BJ LJ AS KS QS JS TS 9S 8S 7S 6S 5S 4S 3S 2S AH KH QH JH TH 9H 8H 7H 6H 5H 4H 3H 2H AD KD QD JD TD 9D 8D 7D 6D 5D 4D 3D 2D AC KC QC JC TC 9C 8C 7C 6C 5C 4C 3C 2C
French Deck Shuffled: K♣ 7♦ 8♣ Q♥ 6♠ J♦ 4♦ J♥ K♠ 9♥ 6♥ T♥ 2♦ 3♦ 3♣ J♣ 3♥ Q♣ 5♥ Q♦ 3♠ T♣ 7♥ 4♥ K♦ 5♦ 2♠ 6♦ T♠ 8♥ T♦ 7♠ 8♠ 2♣ Q♠ 7♣ A♣ 5♠ A♥ 9♣ 2♥ 9♦ 9♠ 4♠ K♥ 8♦ 5♣ A♦ L🃟 B🃟 A♠ 6♣ 4♣ J♠

  English                  | German                   | French                   | Latin                    | Klingon
  ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------
  Joker Full-Color         | Joker Großer             | Joker Grand              | Joker Magnus             | Joker qoH'a'
  Joker One-Color          | Joker Kleiner            | Joker Petit              | Joker Parvus             | Joker qoHHom
  Ace of Spades            | Ass Spaten               | As de Pique              | As Spathae               | wa'DIch yan
  King of Spades           | König Spaten             | Roi de Pique             | Rex Spathae              | ta' yan
  Queen of Spades          | Dame Spaten              | Dame de Pique            | Regina Spathae           | ta'be' yan
  Jack of Spades           | Bube Spaten              | Valet de Pique           | Famulus Spathae          | toy'wI' yan
  Ten of Spades            | Zhen Spaten              | Dix de Pique             | Decem Spathae            | wa'maH yan
  ...

Display a hand of Bridge:

❯ cargo ex bridge                                                          
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/examples/bridge`
First, let's deal out a random bridge hand.

Here it is in Portable Bridge Notation:
    W:KJT.JT63.K8.QJT9 A75.KQ9874.65.AK Q6432.5.AJ74.853 98.A2.QT932.7642

How does it look as a traditional compass?
               NORTH
            ♠ A 7 5
            ♥ K Q 9 8 7 4
            ♦ 6 5
            ♣ A K

       WEST              EAST
    ♠ K J T           ♠ Q 6 4 3 2
    ♥ J T 6 3         ♥ 5
    ♦ K 8             ♦ A J 7 4
    ♣ Q J T 9         ♣ 8 5 3

                SOUTH
             ♠ 9 8
             ♥ A 2
             ♦ Q T 9 3 2
             ♣ 7 6 4 2

Now, let's take a PBN Deal String and convert it into a bridge hand.
Here's the original' Portable Bridge Notation:
    S:Q42.Q52.AQT943.Q 97.AT93.652.T743 AJT85.J76.KJ.A65 K63.K84.87.KJ982

As a bridge compass:

                NORTH
             ♠ A J T 8 5
             ♥ J 7 6
             ♦ K J
             ♣ A 6 5

       WEST              EAST
    ♠ 9 7             ♠ K 6 3
    ♥ A T 9 3         ♥ K 8 4
    ♦ 6 5 2           ♦ 8 7
    ♣ T 7 4 3         ♣ K J 9 8 2

               SOUTH
            ♠ Q 4 2
            ♥ Q 5 2
            ♦ A Q T 9 4 3
            ♣ Q

Other decks in the demo program are canasta, euchre, short, pinochle, skat, spades, standard, tarot, mughal, and dashavatara.

Other examples are:

  • cargo ex handandfoot - Shows how to support more than one decks like in the game Hand and Foot.
  • cargo ex poker - A random heads up no-limit Poker deal.
  • cargo ex poker_eval - Scores a Texas Hold'em board via the ckc-rs evaluator, picking each player's best 5-card hand from their 7.
  • cargo ex range - Prints a 13×13 starting-hand range chart.
  • cargo ex buffoon - The Balatro four-phase scoring pipeline, phase by phase (see Funky).
  • cargo ex funky_tour - A seeded tour of the funky engine: round loop, editions, shop & vouchers, spectral cards.
  • cargo build --target wasm32-unknown-unknown --example wasm - Minimal browser-WASM build showing wasm-friendly API patterns (seeded shuffle, no filesystem). See docs/wasm.md.

References

Other Deck of Cards Libraries

Dependencies

Dev Dependencies

TODO