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?
npx tblackjack
Or install globally with:
npm install -g tblackjack
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.
I wanted to create a Blackjack game that would:
The project is built with:
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()
})
}
TypeScriptOne 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
)
}
TypeScriptFirst, 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}}
)
}
TypeScriptWe 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'
}
})
}
TypeScriptThe dealer started as a simple rule-based system:
// Initial basic dealer logic
const shouldDealerHit = (hand: TCard[]): boolean => {
const score = calculateScore(hand)
return score < 17
}
TypeScriptWe 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'
}
}
}
}
TypeScriptTo 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)
}
}
TypeScriptThis 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...
}
}
TypeScriptThe 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...
}
TypeScriptThe statistics system evolved from simple win/loss tracking to a comprehensive performance analysis tool:
// Basic tracking
interface GameStats {
wins: number
losses: number
}
TypeScriptWe expanded this to track more specific outcomes:
interface GameStatistics {
handsPlayed: number
wins: number
losses: number
pushes: number
blackjacks: number
busts: number
}
TypeScriptFinally, we added performance analysis:
interface GameStatistics {
// ... previous stats
averageHandValue: number
totalHandValue: number
bestHand: number
}
TypeScriptWe created a toggleable statistics panel that updates in real time:
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
// ...
}
TypeScriptWorking within terminal constraints required careful consideration of layout and spacing. The solution was to use fixed-height containers and mindful padding/margin rules.
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.
The project has several planned improvements:
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!
Published on
December 27, 2024