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

January 10, 2025

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

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.

The Foundation: 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
/>
TypeScript

The 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>
TypeScript

The library has grown to include features like:

  • Multiple card designs and sizes
  • Deck management utilities
  • Card state handling

You can find the full source on GitHub or install it via npm.

From Blackjack to Memory

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:

  1. Grid Layout: Instead of a linear layout, we’d need to manage a grid of cards
  2. Card States: Each card could be face-down, face-up, or matched
  3. Screen Real Estate: With potentially dozens of cards on screen, space management would be crucial
  4. AI Opponent: Unlike Blackjack’s dealer rules, we’d need a more sophisticated AI

Building tMemory

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.

The Grid Challenge

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>
TypeScript

The solution involved several key components:

1.) Adaptive Card Sizes:

  • 2×2 and 4×4 grids: Full-sized cards with borders
  • 6×6 grids: Minimal variant without borders
  • 8×8 and 10×10 grids: Mini cards
  • 12×12 grids: Micro cards

2.) Spacing Management:

 <Box 
   gap={1}                // Space between cards
   marginY={1}            // Vertical margins
   alignItems="center"    // Center alignment
   flexGrow={1}          // Fill available space
 >
TypeScript

The AI Opponent: Memory Master

The 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)
}
TypeScript

The AI’s behavior is controlled by several key components:

1.) Memory System:

const hasSeenCard = (index: number): boolean => {
   return (
     flippedIndices.includes(index) ||
     matchedIndices.includes(index)
   )
 }
TypeScript

2.) Strategy Selection:

const 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'
 }
TypeScript

3.) Move Timing:

const executeAIMove = async (moves: number[]) => {
   for (const move of moves) {
     await new Promise(resolve => setTimeout(resolve, 800))
     flipCard(move)
   }
 }
TypeScript

The 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
}
TypeScript

This made the game more enjoyable while still maintaining the AI’s competitive edge. After all, nobody likes losing to a perfect opponent!

Bugs and Blunders: A Developer’s Tale

The journey wasn’t without its share of interesting bugs. Here are some of the more memorable ones:

1.) The “Infinite Match” Bug

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)
}
TypeScript

2.) The Ghost Card Phenomenon

Cards 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)
TypeScript

High Scores: Time Travel in the Terminal

The high score system was one of those “just one more feature” moments that turned into a fascinating challenge. We needed to:

1.) Store scores per configuration:

interface HighScore {
   time: number
   gridSize: number
   gameMode: 'single' | 'vs-ai'
   date: string
   pairs: number
 }

 type HighScores = Record<string, HighScore>
TypeScript

2.) Handle multiple game modes and grid sizes:

 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
 }
TypeScript

3.) Display high scores in the UI:

const 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>
 )
TypeScript

Lessons Learned: Beyond the Cards

Building tMemory has been an enlightening journey that taught us several valuable lessons:

1. Terminal UI Development is Unique

// 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>
TypeScript

The 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.

2. React Patterns Are Universal

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
}
TypeScript

3. TypeScript is Essential

The 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)
  }
}
TypeScript

4. Performance in the Terminal

Terminal 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
})
TypeScript

Try It Yourself!

Play instantly via npx

Bash
npx tmemory

Or install globally

Bash
npm install -g tmemory

Want to contribute? Check out our GitHub repository!

The Journey Continues

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.

headshot photo

griffen.codes

made with 💖 and

2025 © all rights are reserved | updated 12 seconds ago

Footer Background Image