After recently completing my terminal-based Blackjack game, I found myself wanting to explore more possibilities with terminal-based card games. There’s something uniquely satisfying about building games that live in the same environment where we spend most of our development time.
If you’re anything like me, you probably have a terminal window open right now. Maybe several. And while we love our command line tools, who says they can’t be a bit more… playful? That’s what led me down the rabbit hole of building card games in the terminal, starting with the ink-playing-cards
library and now culminating in this Memory/Concentration game implementation.
ink-playing-cards
The journey started with ink-playing-cards
, a library I created to solve the fundamental challenge of rendering and managing playing cards in the terminal. Built on top of Ink, it provides a React-based approach to creating card games in the terminal.
One of the key challenges was handling different terminal sizes and layouts. Unlike a web browser where you have plenty of screen real estate, terminal space is precious. This led to the development of multiple card variants:
// Different card variants for different space constraints
<Card
suit="hearts"
value="A"
faceUp
variant="simple" // Full-sized card with borders
/>
<MiniCard
suit="spades"
value="K"
faceUp
variant="micro" // or 'mini' for slightly wider card
/>
// Literally a single unicode character, very hard to read though depending on terminal size.
<UnicodeCard
suit="diamonds"
value="Q"
faceUp
/>
TypeScriptThe library also handles deck management, card states, and various rendering options. Here’s a quick example of creating and shuffling a deck:
import { DeckProvider, useDeck } from 'ink-playing-cards'
function Game() {
const { shuffle, draw, hand } = useDeck()
useEffect(() => {
shuffle()
draw(5, 'player') // Draw 5 cards for the player
}, [])
return <YourGameComponent />
}
// Wrap your game with the DeckProvider
<DeckProvider>
<Game />
</DeckProvider>
TypeScriptThe library has grown to include features like:
You can find the full source on GitHub or install it via npm.
After building tBlackjack, which was a great first test of the library’s capabilities, I wanted to push things further. Blackjack primarily dealt with linear card layouts and simple game states. The Memory/Concentration game would present new challenges:
The Memory game presented an interesting challenge because it pushed the limits of what’s possible in a terminal interface. The game needs to display a grid of cards (up to 12×12!), handle user navigation, maintain the game state, and make it all look good in a space-constrained environment.
Creating a responsive grid in the terminal turned out to be one of our biggest challenges. Unlike web browsers with their sophisticated layout engines, terminal UI requires careful consideration of every character’s space. Here’s how we tackled it:
<Box flexDirection="column" alignItems="center" flexGrow={1}>
{Array.from({ length: gridSize }, (_, row) => (
<Box key={row} gap={1}>
{Array.from({ length: gridSize }, (_, col) => {
const index = row * gridSize + col
const card = grid[index]
return (
<Box key={col}>
{card && (
<>
{gridSize >= 8 ? (
<MiniCard
suit={card.suit}
value={card.value}
faceUp={isCardVisible(index)}
selected={selectedIndex === index}
variant={gridSize >= 12 ? 'micro' : 'mini'}
/>
) : (
<Card
suit={card.suit}
value={card.value}
faceUp={isCardVisible(index)}
selected={selectedIndex === index}
variant={
gridSize === 2
? 'simple'
: gridSize === 4
? 'simple'
: 'minimal'
}
/>
)}
</>
)}
</Box>
)
})}
</Box>
))}
</Box>
TypeScriptThe solution involved several key components:
<Box
gap={1} // Space between cards
marginY={1} // Vertical margins
alignItems="center" // Center alignment
flexGrow={1} // Fill available space
>
TypeScriptThe AI implementation was particularly interesting because it needed to mimic human memory patterns. In the real game, players gradually build up knowledge of card positions. I wanted the AI to follow a similar pattern, but with a twist – it would have perfect recall of any cards it had seen.
Here’s how we implemented the AI’s memory and decision-making:
const playAI = () => {
// Get all unmatched cards
const unmatched = grid
.map((card, index) => ({ card, index }))
.filter(({ index }) => !matchedIndices.includes(index))
if (unmatched.length < 2) return
let firstIndex = 0
let secondIndex = 1
// First strategy: Look for known pairs
const knownPair = unmatched.find(({ card, index: idx }) =>
unmatched.some(
(other) => other.index !== idx && other.card.value === card.value
)
)
if (knownPair) {
// AI remembers a matching pair!
firstIndex = knownPair.index
const secondCard = unmatched.find(
({ card, index: idx }) =>
idx !== firstIndex && card.value === knownPair.card.value
)
if (secondCard) {
secondIndex = secondCard.index
}
} else {
// No known pairs - try random cards
const shuffledIndices = unmatched
.map(({ index }) => index)
.sort(() => Math.random() - 0.5)
.slice(0, 2)
firstIndex = shuffledIndices[0] ?? 0
secondIndex = shuffledIndices[1] ?? 1
}
// Add dramatic pause between moves
flipCard(firstIndex)
setTimeout(() => flipCard(secondIndex), 1000)
}
TypeScriptThe AI’s behavior is controlled by several key components:
const hasSeenCard = (index: number): boolean => {
return (
flippedIndices.includes(index) ||
matchedIndices.includes(index)
)
}
TypeScriptconst getAIStrategy = () => {
const knownCards = grid
.map((card, index) => ({ card, index }))
.filter(({ index }) => hasSeenCard(index))
// If we know more than 30% of the cards, try to use memory
if (knownCards.length > grid.length * 0.3) {
return 'memory'
}
return 'explore'
}
TypeScriptconst executeAIMove = async (moves: number[]) => {
for (const move of moves) {
await new Promise(resolve => setTimeout(resolve, 800))
flipCard(move)
}
}
TypeScriptThe AI turned out to be surprisingly effective – sometimes too effective! I had to add some randomization to its memory to make it feel more “human”:
const shouldForgetCard = (index: number): boolean => {
// 20% chance to "forget" a card position
return Math.random() < 0.2
}
TypeScriptThis made the game more enjoyable while still maintaining the AI’s competitive edge. After all, nobody likes losing to a perfect opponent!
The journey wasn’t without its share of interesting bugs. Here are some of the more memorable ones:
This was our first major bug – matching one pair would magically flip all cards with the same value:
// The problematic code
const isMatched = (card: GameCard) => {
// 🐛 Bug: Checking value instead of specific cards
return matchedPairs.includes(card.value)
}
// The fix: Track specific card indices
const [matchedIndices, setMatchedIndices] = useState<number[]>([])
const isMatched = (index: number) => {
return matchedIndices.includes(index)
}
TypeScriptCards would sometimes stay face-up when they shouldn’t, leading to a complete rethink of our state management:
interface GameState {
grid: GameCard[]
flippedIndices: number[]
matchedIndices: number[]
scores: GameScores
}
// Before: Multiple sources of truth 🐛
const isCardVisible = (card: GameCard) =>
card.faceUp || card.matched
// After: Single source of truth ✨
const isCardVisible = (index: number) =>
flippedIndices.includes(index) ||
matchedIndices.includes(index)
TypeScriptThe high score system was one of those “just one more feature” moments that turned into a fascinating challenge. We needed to:
interface HighScore {
time: number
gridSize: number
gameMode: 'single' | 'vs-ai'
date: string
pairs: number
}
type HighScores = Record<string, HighScore>
TypeScript const getHighScoreKey = (config: GameConfig): string => {
const { gridSize, gameMode } = config
return `${gridSize}x${gridSize}-${gameMode}`
}
const saveHighScore = (score: HighScore): boolean => {
const key = getHighScoreKey(score)
const scores = getHighScores()
if (!scores[key] || score.time < scores[key].time) {
scores[key] = score
try {
localStorage.setItem(
'tmemory-high-scores',
JSON.stringify(scores)
)
return true // New record!
} catch (error) {
console.error('Failed to save high score:', error)
return false
}
}
return false
}
TypeScriptconst HighScoreDisplay: React.FC<{score: HighScore}> = ({score}) => (
<Box flexDirection="column">
<Text color="#ffd700" bold>
Best Time: {formatTime(score.time)}
</Text>
<Text dimColor>
{new Date(score.date).toLocaleDateString()}
</Text>
</Box>
)
TypeScriptBuilding tMemory
has been an enlightening journey that taught us several valuable lessons:
// Web development: Let CSS handle it
<div className="card-grid">
{cards.map(card => <Card {...card} />)}
</div>
// Terminal: Every character matters
<Box
flexDirection="column"
alignItems="center"
height={24}
padding={1}
>
{/* Careful layout management required */}
</Box>
TypeScriptThe terminal environment forces you to think differently about UI design. Every character of space matters, and you can’t rely on the luxuries of web browsers.
The same patterns we use in web development translate beautifully to terminal apps:
// Custom hooks work great
const useTerminalSize = () => {
const [size, setSize] = useState({
width: process.stdout.columns,
height: process.stdout.rows
})
useEffect(() => {
const handleResize = () => {
setSize({
width: process.stdout.columns,
height: process.stdout.rows
})
}
process.stdout.on('resize', handleResize)
return () => process.stdout.off('resize', handleResize)
}, [])
return size
}
TypeScriptThe type system caught countless potential bugs and made refactoring much easier:
// Before TypeScript
const handleMove = (index) => {
if (isValidMove(index)) {
makeMove(index)
}
}
// With TypeScript
interface Move {
index: number
player: 'player' | 'ai'
timestamp: number
}
const handleMove = (move: Move) => {
if (isValidMove(move)) {
makeMove(move)
}
}
TypeScriptTerminal rendering has unique performance considerations:
// Expensive: Re-renders entire grid
const [grid, setGrid] = useState<GameCard[]>([])
// Better: Update only what changed
const [updates, setUpdates] = useState<Map<number, GameCard>>(new Map())
// Use React.memo for card components
const Card = React.memo<CardProps>(({ suit, value, faceUp }) => {
// Only re-render when props change
})
TypeScriptPlay instantly via npx
npx tmemory
Or install globally
npm install -g tmemory
Want to contribute? Check out our GitHub repository!
This project has been a testament to the versatility of modern web technologies. Who would have thought we’d be using React to build terminal games? The ink-playing-cards library has grown from a simple card rendering utility to a full-featured framework for terminal card games.
After building tBlackjack and now tMemory, I’m excited to see what other games the community might create. Maybe Solitaire? Poker? The possibilities are endless!
Remember, sometimes the best projects start with a simple “wouldn’t it be cool if…” moment. So next time you have a quirky idea for a terminal application, give it a shot! You might just create something amazing.
This post is part of my series on building terminal-based games with React Ink. Check out my other posts about Building Blackjack in the Terminal and the ink-playing-cards library.
Published on
January 10, 2025