initial commit - basic TUI blackjack game

This commit is contained in:
Nick Pegg 2025-07-04 14:15:56 -07:00
commit 0a72da768e
9 changed files with 738 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

173
Cargo.lock generated Normal file
View file

@ -0,0 +1,173 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "blackjack"
version = "0.1.0"
dependencies = [
"itertools",
"rand",
]
[[package]]
name = "cfg-if"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom",
]
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

8
Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "blackjack"
version = "0.1.0"
edition = "2024"
[dependencies]
itertools = "0.14.0"
rand = "0.9.1"

3
README.md Normal file
View file

@ -0,0 +1,3 @@
Blackjack CLI tool and simulator
I built this just for fun, and because I wanted to test some blackjack strategies

70
src/card.rs Normal file
View file

@ -0,0 +1,70 @@
use std::fmt;
#[derive(Clone)]
pub enum Suit {
Club,
Diamond,
Heart,
Spade,
}
impl fmt::Display for Suit {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let c = match self {
Suit::Club => "",
Suit::Diamond => "",
Suit::Heart => "",
Suit::Spade => "",
};
write!(f, "{c}")
}
}
#[derive(Clone)]
pub struct Card {
pub suit: Suit,
pub value: String,
}
impl Card {
pub fn new(suit: Suit, value: &str) -> Self {
Self {
suit,
value: value.to_owned(),
}
}
}
impl From<&Card> for u8 {
fn from(card: &Card) -> u8 {
match card.value.as_ref() {
"A" => 11,
"K" => 10,
"Q" => 10,
"J" => 10,
x => x.parse().unwrap(),
}
}
}
impl fmt::Display for Card {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}{}", self.suit, self.value)
}
}
/// Create a new deck of cards, without jokers
pub(crate) fn deck_without_jokers() -> Vec<Card> {
let mut deck = Vec::new();
for suit in [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade] {
for num_card in 2..=10 {
deck.push(Card::new(suit.clone(), &(num_card.to_string())))
}
for face_card in ["J", "Q", "K", "A"] {
deck.push(Card::new(suit.clone(), face_card))
}
}
deck
}

208
src/game.rs Normal file
View file

@ -0,0 +1,208 @@
use crate::card::{deck_without_jokers, Card};
use crate::hand::Hand;
use rand::prelude::*;
use std::sync::{Arc, RwLock};
pub enum PlayerChoice {
Hit,
Stand,
// TODO
// DoubleDown,
// TODO
// Split,
// TODO
// Surrender,
}
pub enum PlayResult {
Win, // Player didn't bust, and either has higher value or dealer busted
Blackjack, // Player won with a face card and ace
Lose, // Player busts or dealer had higher without busting
Push, // Dealer and player have same value
DealerBlackjack, // Player lost because dealer had blackjack
Bust, // Player lost because they busted
}
/// The state at the end of a round, useful for printing out
pub struct EndState {
pub result: PlayResult,
pub player_winnings: u64,
pub dealer_cards: Vec<Card>,
pub player_cards: Vec<Card>,
}
impl EndState {
fn new(
result: PlayResult,
player_winnings: u64,
dealer_cards: Vec<Card>,
player_cards: Vec<Card>,
) -> Self {
Self {
result,
player_winnings,
dealer_cards,
player_cards,
}
}
}
pub struct Game {
rng: rand::rngs::ThreadRng,
shoe: Vec<Card>,
/// Discard pile. When the shoe runs out, this gets mixed with the shoe and reshuffled.
discard: Arc<RwLock<Vec<Card>>>,
hit_on_soft_17: bool,
/// Returns when hitting blackjack
blackjack_returns: f32,
}
impl Game {
pub fn new() -> Self {
Self {
// Game settings
hit_on_soft_17: false,
blackjack_returns: 3.0 / 2.0,
// Game state
rng: rand::rng(),
shoe: Vec::new(),
discard: Arc::new(RwLock::new(Vec::new())),
}
}
/// Add some number of decks to the shoe, and shuffle it
pub fn with_decks(mut self, count: u8) -> Self {
for _ in 0..count {
self.shoe.append(&mut deck_without_jokers())
}
self.shoe.shuffle(&mut self.rng);
self
}
/// Sets the dealer to hit on a soft 17 (one involving an Ace)
pub fn with_hit_on_soft_17(mut self) -> Self {
self.hit_on_soft_17 = true;
self
}
/// Sets the return when getting a blackjack
pub fn with_blackjack_returns(mut self, returns: f32) -> Self {
self.blackjack_returns = returns;
self
}
/// Play one round of Blackjack
///
/// Takes a bet and a function, which is what the player will do during their turn. This
/// function must take the player's hand (Vec<Card>) and the card the dealer is showing (Card),
/// and returns a choice (hit, stand, etc.).
///
/// Returns the winnings of the round
pub fn play<F>(self: &mut Self, bet: u32, player_decision: F) -> EndState
where
F: Fn(&Hand, &Card) -> PlayerChoice,
{
// Shuffle the deck if we've played over 75% of cards
let discard_size = self.discard.read().unwrap().len();
if self.shoe.len() < (self.shoe.len() + discard_size) * 1 / 4 {
self.shuffle();
}
// Deal cards
let mut dealer_hand =
Hand::from([self.deal(), self.deal()].to_vec()).with_discard(self.discard.clone());
let mut player_hand =
Hand::from([self.deal(), self.deal()].to_vec()).with_discard(self.discard.clone());
// If dealer has blackjack, immediately lose. If player has blackjack, they win
if dealer_hand.is_blackjack() {
return EndState::new(
PlayResult::DealerBlackjack,
0,
dealer_hand.cards(),
player_hand.cards(),
);
} else if player_hand.is_blackjack() {
let returns = bet as f32 * self.blackjack_returns;
return EndState::new(
PlayResult::Blackjack,
returns as u64,
dealer_hand.cards(),
player_hand.cards(),
);
}
// Player turn
loop {
let decision = player_decision(&player_hand, &dealer_hand.cards()[0]);
match decision {
PlayerChoice::Stand => break,
PlayerChoice::Hit => {
player_hand.push(self.deal());
}
}
// if player busts, immediately lose bet
if player_hand.value() > 21 {
return EndState::new(
PlayResult::Bust,
0,
dealer_hand.cards(),
player_hand.cards(),
);
}
}
// Dealer turn
while dealer_hand.value() < 17 {
dealer_hand.push(self.deal());
}
if dealer_hand.value() > 21 {
EndState::new(
PlayResult::Win,
(bet * 2).into(),
dealer_hand.cards(),
player_hand.cards(),
)
} else if dealer_hand.value() < player_hand.value() {
EndState::new(
PlayResult::Win,
(bet * 2).into(),
dealer_hand.cards(),
player_hand.cards(),
)
} else if dealer_hand.value() == player_hand.value() {
EndState::new(
PlayResult::Push,
bet.into(),
dealer_hand.cards(),
player_hand.cards(),
)
} else {
EndState::new(
PlayResult::Lose,
0,
dealer_hand.cards(),
player_hand.cards(),
)
}
}
/// Deal a single card from the shoe
fn deal(self: &mut Self) -> Card {
self.shoe.pop().unwrap()
}
/// Reset the shoe - combine shoe and discard and shuffle
fn shuffle(self: &mut Self) {
let mut discard = self.discard.write().unwrap();
self.shoe.append(&mut discard);
assert_eq!(discard.len(), 0);
self.shoe.shuffle(&mut self.rng);
}
}

179
src/hand.rs Normal file
View file

@ -0,0 +1,179 @@
use crate::card::Card;
use itertools::Itertools;
use std::fmt;
use std::sync::{Arc, RwLock};
pub struct Hand {
cards: Vec<Card>,
discard: Option<Arc<RwLock<Vec<Card>>>>,
}
impl Hand {
pub fn new() -> Self {
Self {
cards: Vec::new(),
discard: None,
}
}
pub fn with_discard(mut self, discard: Arc<RwLock<Vec<Card>>>) -> Self {
self.discard = Some(discard);
self
}
/// Returns the value of the hand. If there are any aces, this is the highest value without
/// busting.
pub fn value(&self) -> u8 {
// TODO: Return the highest value without busting? And maybe let the player function
// determine if they have an ace and want to hit instead.
// Basic Strategy actually says what to do in the case of an ace
let mut num_aces = 0;
let mut sum = 0;
// count up the value of everything that's not an ace
for card in &self.cards {
if card.value == "A" {
num_aces += 1;
} else {
sum += u8::from(card);
}
}
if num_aces == 0 {
return sum;
}
// Figure out the possible ace sums based on how many aces we have
let mut values: Vec<u8> = [1, 11]
.into_iter()
.combinations_with_replacement(num_aces)
.map(|a| a.iter().sum())
.collect();
// Find the highest value without busting, adding in the rest of the cards. If they all
// bust, then take the lowest value.
values.sort();
values.reverse();
let mut total = 0;
for v in values.iter() {
total = v + sum;
if total <= 21 {
break;
}
}
total
}
/// Returns true if the hand is a blackjack, meaning we have an ace and a 10-valued card
pub fn is_blackjack(&self) -> bool {
self.cards.len() == 2 && self.value() == 21
}
/// Returns true if hand has an ace
pub fn has_ace(&self) -> bool {
for card in &self.cards {
if card.value == "A" {
return true;
}
}
false
}
/// Returns a copy of the cards in the hand
pub fn cards(&self) -> Vec<Card> {
self.cards.clone()
}
/// Add a card to the hand
pub fn push(&mut self, card: Card) {
self.cards.push(card)
}
}
impl From<Vec<Card>> for Hand {
fn from(cards: Vec<Card>) -> Self {
Self {
cards,
discard: None,
}
}
}
impl fmt::Display for Hand {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for (i, c) in self.cards.iter().enumerate() {
if i != 0 {
write!(f, " ")?;
}
write!(f, "{c}")?;
}
Ok(())
}
}
impl Drop for Hand {
fn drop(&mut self) {
match &mut self.discard {
Some(x) => {
let mut discard = x.write().unwrap();
discard.append(&mut self.cards)
}
None => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Suit};
#[test]
fn no_aces() {
let mut h = Hand::new();
h.cards.push(Card::new(Suit::Club, "K"));
h.cards.push(Card::new(Suit::Club, "7"));
assert_eq!(h.value(), 17);
}
#[test]
fn one_ace() {
let mut h = Hand::new();
h.cards.push(Card::new(Suit::Club, "K"));
h.cards.push(Card::new(Suit::Club, "A"));
assert_eq!(h.value(), 21);
}
#[test]
fn three_aces() {
let mut h = Hand::new();
h.cards.push(Card::new(Suit::Club, "3"));
h.cards.push(Card::new(Suit::Club, "A"));
h.cards.push(Card::new(Suit::Club, "A"));
h.cards.push(Card::new(Suit::Club, "A"));
assert_eq!(h.value(), 16);
}
#[test]
/// If we give the hand a discard pile, then it should throw its cards in there when it gets
/// dropped
fn discard_pile() {
let discard = Arc::new(RwLock::new(Vec::new()));
{
let hand =
Hand::from([Card::new(Suit::Heart, "1"), Card::new(Suit::Heart, "2")].to_vec())
.with_discard(discard.clone());
assert_eq!(hand.value(), 3);
// Hand still in scope, so discard pile is empty
assert_eq!(discard.read().unwrap().len(), 0);
}
let d = discard.read().unwrap();
// Hand went out of scope and got discarded
assert_eq!(d.len(), 2);
}
}

3
src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod card;
pub mod game;
pub mod hand;

93
src/main.rs Normal file
View file

@ -0,0 +1,93 @@
use blackjack::card::Card;
use blackjack::game::{Game, PlayResult, PlayerChoice};
use blackjack::hand::Hand;
use std::io;
use std::io::prelude::*;
fn main() {
interactive_play();
}
fn interactive_play() {
let mut bank: u64 = 1_000;
let mut game = Game::new().with_decks(6);
let mut last_bet: Option<u32> = None;
loop {
println!("\nMoney in the bank: {bank}");
// Bet checking loop
let mut bet: u32;
loop {
if last_bet.is_some() {
print!("Your bet [{}]? ", last_bet.unwrap());
io::stdout().flush().unwrap();
let input = read_input();
if input == "" {
bet = last_bet.unwrap();
} else {
bet = input.parse().unwrap();
}
} else {
print!("Your bet? ");
io::stdout().flush().unwrap();
bet = read_input().parse().unwrap();
}
if bet as u64 <= bank {
break;
} else {
println!("You don't have enough money!\n");
}
}
bank -= bet as u64;
last_bet = Some(bet);
println!();
let result = game.play(bet, interactive_decision);
let dealer_hand = Hand::from(result.dealer_cards);
let player_hand = Hand::from(result.player_cards);
println!("Dealer's hand: {} = {}", dealer_hand, dealer_hand.value());
println!("Your hand: {} = {}", player_hand, player_hand.value());
match result.result {
PlayResult::Win => println!("You won!"),
PlayResult::Blackjack => println!("Blackjack!"),
PlayResult::Lose => println!("You lost"),
PlayResult::Push => println!("Push"),
PlayResult::DealerBlackjack => println!("Dealer got blackjack"),
PlayResult::Bust => println!("You busted"),
}
bank += result.player_winnings;
}
}
fn interactive_decision(hand: &Hand, dealer_showing: &Card) -> PlayerChoice {
println!("\nDealer showing: {dealer_showing}");
println!("Your hand: {hand}\n");
if hand.value() == 21 {
println!("You have 21, standing.");
return PlayerChoice::Stand;
}
let choice: PlayerChoice;
loop {
print!("(h)it or (s)tand? ");
io::stdout().flush().unwrap();
choice = match read_input().to_lowercase().as_ref() {
"h" => PlayerChoice::Hit,
"s" => PlayerChoice::Stand,
_ => continue,
};
break;
}
choice
}
fn read_input() -> String {
let mut buf = String::new();
io::stdin().read_line(&mut buf).unwrap();
buf.trim().to_owned()
}