Building Blackjack for the Terminal with ink-playing-cards library

December 27, 2024

Building Blackjack for the Terminal with ink-playing-cards library featured image

As a developer who loves both classic games and modern development practices, I recently embarked on a project to create a terminal-based Blackjack game. The goal was to combine the nostalgic feel of terminal games with modern React patterns and TypeScript’s type safety.

Want to try it out?

Bash
npx tblackjack

Or install globally with:

Bash
npm install -g tblackjack

Evolution of the Game UI in Terminal

One of the primary motivations for developing this game was to create an initial application for the new ink-playing-cards library that I developed. Initially, I started with a basic user interface using an early version of the MiniCard component. In the current version, the MiniCard component has been further minimized. Additionally, several feedback components have been introduced to enhance the user interface experience, especially on smaller screen resolutions.

first working version of game with early version of ink-playing-cards
current version of game with UI updates with newest release of ink-playing-cards

The Vision

I wanted to create a Blackjack game that would:

  1. Feel responsive and modern despite being terminal-based
  2. Provide clear visual feedback and intuitive controls
  3. Implement proper game rules and basic dealer AI
  4. Use modern development practices and type safety

Tech Stack

The project is built with:

The Journey

1. Initial Setup and Basic Game Loop

The first step was setting up the basic game structure. I started with a simple game loop that could deal cards and handle basic hit/stand actions:

const Game: React.FC = () => {
  const { deck, shuffle } = useDeck()
  const [gameState, setGameState] = useState({
    phase: 'playerTurn',
    playerHand: [],
    dealerHand: [],
    // ...
  })

  useInput((input) => {
    if (input === 'h') hit()
    if (input === 's') stand()
  })
}
TypeScript

2. Hand Evaluation System

One of the first complex systems I built was the hand evaluation logic. Blackjack scoring can be tricky, especially with Aces that can count as 1 or 11:

const calculatePossibleScores = (hand: TCard[]): number[] => {
  let scores = [0]
  let aceCount = 0

  for (const card of hand) {
    if (card.value === 'A') {
      aceCount++
    } else if (['J', 'Q', 'K'].includes(card.value)) {
      scores = scores.map(score => score + 10)
    } else {
      scores = scores.map(score => score + parseInt(card.value))
    }
  }

  // Handle aces
  for (let i = 0; i < aceCount; i++) {
    const newScores: number[] = []
    scores.forEach(score => {
      newScores.push(score + 1)  // Ace as 1
      if (score + 11  {
  return (
    
      Dealer's Hand: {dealerCards}
      Your Hand: {playerCards}
      Press H to hit, S to stand
    
  )
}
TypeScript

Adding Status Messages

First, we added a message system to provide clear feedback:

interface GameStatus {
  message: string
  type: 'success' | 'error' | 'info' | 'warning'
  details?: string
}

const Message: React.FC = ({ status }) => {
  const colorFn = {
    success: chalk.green,
    error: chalk.red,
    info: chalk.blue,
    warning: chalk.yellow
  }[status.type]

  return (
    
      {colorFn(status.message)}
      {status.details &&  • {status.details}}
    
  )
}
TypeScript

Deck Management Feedback

We then added visual feedback for deck management:

const shuffleDeck = async () => {
  // Show shuffle animation
  updateGameState({
    status: {
      message: '🔄 Shuffling deck...',
      type: 'info',
      details: `${deck.cards.length} cards remaining`
    }
  })
  await sleep(1500)
  
  shuffle()
  
  // Confirmation
  updateGameState({
    status: {
      message: '✨ Deck shuffled',
      type: 'info'
    }
  })
}
TypeScript

4. Dealer AI and Personality

The dealer started as a simple rule-based system:

// Initial basic dealer logic
const shouldDealerHit = (hand: TCard[]): boolean => {
  const score = calculateScore(hand)
  return score < 17
}
TypeScript

Adding Personality

We evolved this into a more sophisticated system with personality and decision-making transparency:

export interface DealerDecision {
  action: 'hit' | 'stand'
  reason: string
  confidence: 'certain' | 'likely' | 'unsure'
}

export class DealerAI {
  public shouldHit(): DealerDecision {
    const { score, isSoft } = this.evaluation
    // Must hit on soft 17
    if (score === 17 && isSoft) {
      return {
        action: 'hit',
        reason: 'Dealer must hit on soft 17',
        confidence: 'certain'
      }
    }
    // Strong hand
    if (score >= 19) {
      return {
        action: 'stand',
        reason: `Strong ${isSoft ? 'soft' : 'hard'} hand: ${score}`,
        confidence: 'certain'
      }
    }
    // Risky territory
    if (score === 16) {
      return {
        action: 'hit',
        reason: 'Risky... but must hit on 16',
        confidence: 'likely'
      }
    }
  }
}
TypeScript

Simulating Decision Time

To make the dealer feel more human, we added realistic timing to their actions:

const dealerPlay = async () => {
  // Initial pause to build suspense
  updateGameState({
    status: { message: 'Checking cards...' }
  })
  await sleep(2000)

  while (decision.action === 'hit') {
    // Show dealer thinking
    updateGameState({
      status: { message: 'Considering next move...' }
    })
    await sleep(1500)

    // Draw card with dramatic pause
    updateGameState({
      status: { message: 'Drawing next card...' }
    })
    await sleep(1000)

    // React to new card
    const evaluation = evaluateHand(currentHand)
    const reaction = evaluation.score > 21 
      ? 'Bust! 😩'
      : evaluation.score >= 19
      ? 'Looking good! 😎'
      : 'Hmm... 🤔'
    
    updateGameState({
      status: { message: reaction }
    })
    await sleep(1500)
  }
}
TypeScript

This evolution from a simple rule-based system to an engaging AI with personality and timing made the game more immersive. The dealer’s “thoughts” and deliberate pauses create tension and excitement, making each hand feel more like playing against a real dealer.

export class DealerAI {
  public shouldHit(): DealerDecision {
    const { score, isSoft } = this.evaluation

    if (score === 17 && isSoft) {
      return {
        action: 'hit',
        reason: 'Dealer must hit on soft 17',
        confidence: 'certain'
      }
    }

    // More decision logic...
  }
}
TypeScript

The dealer’s thoughts are displayed in a dedicated component:

const DealerThought: React.FC = ({ 
  hand,
  isThinking,
  thoughtText 
}) => {
  const [dots, setDots] = useState(0)
  
  // Animated thinking dots
  useEffect(() => {
    if (isThinking) {
      const timer = setInterval(() => {
        setDots(d => (d + 1) % 4)
      }, 500)
      return () => clearInterval(timer)
    }
  }, [isThinking])

  // Render dealer's thoughts...
}
TypeScript

5. Evolution of Statistics System

The statistics system evolved from simple win/loss tracking to a comprehensive performance analysis tool:

Initial Version

// Basic tracking
interface GameStats {
  wins: number
  losses: number
}
TypeScript

Adding Detailed Metrics

We expanded this to track more specific outcomes:

interface GameStatistics {
  handsPlayed: number
  wins: number
  losses: number
  pushes: number
  blackjacks: number
  busts: number
}
TypeScript

Performance Metrics

Finally, we added performance analysis:

interface GameStatistics {
  // ... previous stats
  averageHandValue: number
  totalHandValue: number
  bestHand: number
}
TypeScript

Statistics Display

We created a toggleable statistics panel that updates in real time:

Challenges and Solutions

1. Async Operations and UI Updates

One of the biggest challenges was handling async operations while keeping the UI responsive. The easy/simple solution was to use a combination of async/await and careful state management:

const dealerPlay = async () => {
  // Show thinking animation
  updateGameState({
    status: { message: 'Thinking...' }
  })
  await sleep(1000)

  // Make decision
  const decision = dealer.shouldHit()
  updateGameState({
    status: { message: decision.reason }
  })
  await sleep(1000)

  // Execute action
  // ...
}
TypeScript

2. Terminal UI Constraints

Working within terminal constraints required careful consideration of layout and spacing. The solution was to use fixed-height containers and mindful padding/margin rules.

Showcase

Screenshots

Lessons Learned

  • Terminal UI Can Be Modern: Using React and Ink, it’s possible to create terminal applications that feel modern and responsive.

  • Type Safety Pays Off: TypeScript’s type system always catches numerous potential bugs during development and made refactoring much easier.

  • State Management Matters: Even in a relatively simple game, proper state management is crucial for maintainability.

  • User Feedback is Crucial: Providing clear visual cues and intuitive controls significantly enhances the user experience. It’s also essential for users to comprehend the operations and actions executed by the AI dealer. Therefore, implementing strategic timeouts between the various actions of both players was vital.

Future Improvements

The project has several planned improvements:

  1. Betting System:(?) Adding chips and betting mechanics
  2. Split Pairs: Allowing players to split matching cards
  3. Doubling Down:(?) double your bet before the dealer gives you another card
  4. Insurance:(?) Adding insurance bets when dealer shows an Ace
  5. Multiplayer(?): Adding support for multiple players

Conclusion

Building tBlackjack was an exciting journey that combined classic gaming with modern development practices. The project demonstrates that terminal applications can be both functional and engaging when built with the right tools and attention to user experience.

Check out the source code on GitHub and feel free to contribute!

headshot photo

griffen.codes

made with 💖 and

2025 © all rights are reserved | updated 11 seconds ago

Footer Background Image