ink-playing-cards v1.0.0
React component library for terminal-based card games with Ink 6 (React 19 for CLIs). Provides UI components, hooks, contexts, and game systems.
npm install ink-playing-cards
# deps: ink ^6.0.0, react ^19.0.0 — requires Node >= 20
Architecture
All state flows through DeckProvider (React context + useReducer). Zones are immutable TCard[] arrays. The useDeck hook wraps dispatch.
DeckProvider
├── zones.deck: TCard[]
├── zones.hands: Record<string, TCard[]>
├── zones.discardPile: TCard[]
├── zones.playArea: TCard[]
├── zones.custom: Record<string, TCard[]> // arbitrary named zones (tableau, foundation, ...)
├── players: string[]
├── eventManager / effectManager
└── dispatch(DeckAction)
Custom zones are addressed by name via moveCard/setZone/clearZone/getZone — useful for games (solitaire, etc.) that need more structure than deck/hand/discard/play. See MOVE_CARD/SET_ZONE/CLEAR_ZONE below.
Quick Start
import React from 'react'
import { render, Box, Text, useInput } from 'ink'
import { DeckProvider, useDeck, useHand, CardStack } from 'ink-playing-cards'
const Game = () => {
const { deck, shuffle, draw } = useDeck()
const { hand, playCard, discard } = useHand('player1')
React.useEffect(() => { shuffle(); draw(5, 'player1') }, [])
useInput((input) => { if (input === 'd') draw(1, 'player1') })
return (
<Box flexDirection="column">
<Text>Deck: {deck.length}</Text>
<CardStack cards={hand} name="Hand" isFaceUp maxDisplay={5} />
</Box>
)
}
render(<DeckProvider><Game /></DeckProvider>)
Types
type TCard = CardProps | CustomCardProps // every card has a unique `id`
type BaseCardProps = {
id: string; effects?: CardEffect[]; faceUp?: boolean; selected?: boolean; rounded?: boolean
}
type CardProps = BaseCardProps & {
suit: 'hearts' | 'diamonds' | 'clubs' | 'spades'
value: '2' | '3' | ... | 'K' | 'A' | 'JOKER'
theme?: AsciiTheme // 'original' | 'geometric' | 'animal' | 'robot' | 'pixel' | 'medieval'
}
type CustomCardProps = BaseCardProps & {
size?: 'micro' | 'mini' | 'small' | 'medium' | 'large' // or explicit width/height
title?: string; cost?: string // header row
asciiArt?: string; artColor?: string // art region
typeLine?: string // between art and body
description?: string // body (auto-wraps)
footerLeft?: string; footerRight?: string // footer row
symbols?: CustomCardSymbol[] // corner symbols
content?: ReactNode // freeform mode — overrides all regions
back?: CustomCardBack // { art?, symbol?, color?, label? }
borderColor?: string; textColor?: string
value?: TCardValue | string; type?: string // game logic metadata (not rendered)
}
// Type guards
function isStandardCard(card: TCard): card is CardProps
function isCustomCard(card: TCard): card is CustomCardProps
function generateCardId(suit: TSuit, value: TCardValue): string // "hearts-A-<rand6>"
Hooks
useDeck — must be inside <DeckProvider>
const {
// State
deck, // TCard[]
hands, // Record<string, TCard[]>
discardPile, // TCard[]
playArea, // TCard[]
customZones, // Record<string, TCard[]> — named zones, keyed by name
players, // string[]
backArtwork, // { ascii, simple, minimal }
eventManager, // EventManager
effectManager, // EffectManager
// Actions
shuffle, // () => void
draw, // (count, playerId) => void — auto-registers player
deal, // (count, playerIds[]) => void — auto-registers players
reset, // (cards?) => void
cutDeck, // (index) => void
addPlayer, // (id) => void
removePlayer, // (id) => void
getPlayerHand, // (id) => TCard[]
addCustomCard, // (card: CustomCardProps) => void
removeCustomCard, // (cardId) => void
setBackArtwork, // (Partial<BackArtwork>) => void
moveCard, // (cardId, from, to, position?) => void — move between any two zones
setZone, // (name, cards) => void — seed/overwrite a custom zone
clearZone, // (name) => void — remove a custom zone
getZone, // (name) => TCard[] — read any zone (built-in or custom) by name
} = useDeck()
draw() and deal() dispatch actions — they do NOT return cards. Access drawn cards via hands[playerId] after re-render.
useHand(playerId) — convenience wrapper
const { hand, drawCard, playCard, discard } = useHand('player1')
// hand: TCard[]
// drawCard(count = 1) — draws from deck
// playCard(cardId: string) — hand → playArea
// discard(cardId: string) — hand → discardPile
Components
Card — standard playing card
Variants: simple (11×9, default), ascii (15×13, themed face cards), minimal (6×5).
<Card id="h-A-1" suit="hearts" value="A" variant="simple" faceUp selected rounded theme="original" />
MiniCard — compact card
Variants: mini (5×4, default), micro (4×4). Same props as CardProps plus variant.
<MiniCard id="s-K-1" suit="spades" value="K" variant="mini" faceUp />
UnicodeCard — single character
Unicode playing card characters. Auto-colors red/white by suit.
<UnicodeCard suit="hearts" value="A" faceUp bordered rounded size={1} dimmed color="red" />
CustomCard — non-standard cards
Two modes: structured (title/cost/art/typeLine/description/footer regions) or freeform (content ReactNode).
Size presets: micro (5×3), mini (8×5), small (12×7), medium (18×11, default), large (24×15). Override with width/height.
<CustomCard id="c1" size="large" title="Dragon" cost="{4}{R}"
asciiArt={art} typeLine="Creature" description="Flying"
footerLeft="5/5" borderColor="red" />
<CustomCard id="c2" content={<Text>Freeform</Text>} />
<CustomCard id="c3" faceUp={false} back={{ art: '♠♠♠', color: 'cyan' }} />
TarotCard — tarot deck cards
Wraps CustomCard with tarot-specific semantics. Uses a discriminated union on arcana for Major vs Minor Arcana. Renders a 20×13 card with themed defaults.
Standard 78-card tarot deck: 22 Major Arcana + 56 Minor Arcana (14 per suit × 4 suits).
Tarot suits: wands (🜂), cups (☽), swords (⚔), pentacles (⛤).
Minor values: Ace–10 + Page, Knight, Queen, King.
import { TarotCard, createTarotDeck } from 'ink-playing-cards'
// Major Arcana (index 0–21: The Fool through The World)
<TarotCard id="fool" arcana="major" majorIndex={0} />
<TarotCard id="tower" arcana="major" majorIndex={16} reversed />
// Minor Arcana
<TarotCard id="ace-cups" arcana="minor" suit="cups" value="Ace" />
<TarotCard id="qw" arcana="minor" suit="wands" value="Queen" reversed />
// Face down with custom tarot back
<TarotCard id="hidden" arcana="major" majorIndex={13} faceUp={false} />
// Custom styling
<TarotCard id="devil" arcana="major" majorIndex={15}
borderColor="red" textColor="red" artColor="red" />
Props (Major): arcana="major", majorIndex (0–21), reversed?, asciiArt?, borderColor?, textColor?, artColor?, back?, plus all BaseCardProps.
Props (Minor): arcana="minor", suit (TarotSuit), value (TarotMinorValue), reversed?, asciiArt?, borderColor?, textColor?, artColor?, back?, plus all BaseCardProps.
Types: TarotCardProps, TarotMajorProps, TarotMinorProps, TarotSuit, TarotMinorValue, MajorArcanaIndex.
Defaults: Major Arcana uses yellow border / magenta art. Minor Arcana uses cyan border / cyan art. All 22 Major Arcana have built-in ASCII art. Minor pip cards auto-generate suit symbol art. reversed shows "⟳ Reversed" on the type line.
CardStack — overlapping card list
Renders TCard[] with overlap. Handles both standard and custom cards.
<CardStack cards={hand} name="Hand" isFaceUp maxDisplay={5}
variant="simple" stackDirection="horizontal"
spacing={{ overlap: -2, margin: 1 }} alignment="start" />
Defaults: isFaceUp: false, maxDisplay: 3, stackDirection: 'vertical', variant: 'simple'.
CardGrid — grid layout (standard cards only)
import { CardGrid, type GridCard } from 'ink-playing-cards'
// GridCard = { id: string, suit: TSuit, value: TCardValue }
<CardGrid rows={4} cols={4} cards={gridCards} variant="simple"
isFaceUp fillEmpty spacing={{ row: 1, col: 1, margin: 1 }} />
Deck — visual deck display
Must be inside DeckProvider. Shows top card + placeholder.
<Deck variant="simple" showTopCard placeholderCard={{ suit: 'hearts', value: 'A' }} />
Contexts
DeckProvider
<DeckProvider initialCards={myCards} customReducer={myReducer}>
<Game />
</DeckProvider>
Default deck: 52 cards via createStandardDeck(). Pass customReducer to extend — note the built-in deckReducer is NOT exported; replicate needed cases or use Zones utilities.
GameProvider — turn management (separate from DeckProvider)
<GameProvider initialPlayers={['alice', 'bob']} customReducer={myGameReducer}>
<DeckProvider><Game /></DeckProvider>
</GameProvider>
// Access: React.useContext(GameContext)
const { currentPlayerId, players, turn, phase, dispatch } = React.useContext(GameContext)
dispatch({ type: 'NEXT_TURN' }) // next player, turn++
dispatch({ type: 'SET_PHASE', payload: 'playing' })
dispatch({ type: 'SET_CURRENT_PLAYER', payload: 'bob' })
Event System
Events dispatch automatically from the reducer. Subscribe via eventManager from useDeck().
const { eventManager } = useDeck()
const listener = {
handleEvent(event: GameEventData) { /* event.type, .playerId, .card, .cards, .count */ }
}
eventManager.addEventListener('CARDS_DRAWN', listener)
eventManager.removeEventListener('CARDS_DRAWN', listener)
eventManager.dispatchEvent({ type: 'CUSTOM_EVENT', playerId: 'p1' }) // custom events OK
eventManager.removeAllListeners()
Built-in events: DECK_SHUFFLED, CARDS_DRAWN (playerId, cards), CARDS_DEALT (playerId, cards, count — per player), CARD_PLAYED (playerId, card), CARD_DISCARDED (playerId, card), CARD_MOVED (card, from, to), DECK_RESET, DECK_CUT.
Effect System
Attach abilities to cards. All implement CardEffect.apply(gameState, eventData).
import { Effects } from 'ink-playing-cards'
new Effects.DrawCardEffect(2) // deck → hand
new Effects.DamageEffect(3) // target.life or .health
new Effects.ConditionalEffect(condFn, effect) // fires when condition true
new Effects.TriggeredEffect('CARD_PLAYED', effect) // fires on event match
new Effects.DelayedEffect(3, effect) // fires after N turns
new Effects.TargetedEffect(selectorFn, effect) // selects target from gameState
new Effects.ContinuousEffect(condFn, applyFn, removeFn) // toggle on condition
Effects.attachEffectToCard(card, effect) // pushes to card.effects[]
effectManager.applyCardEffects(card, gameState, eventData) // runs all effects on card
Effects mutate gameState/eventData directly — designed for effect pipelines, not reducer use.
Zone Utilities
import { Zones, createStandardDeck, createPairedDeck, createTarotDeck } from 'ink-playing-cards'
// Pure functions (immutable, safe for reducers)
Zones.shuffleCards(cards) // Fisher-Yates, new array
Zones.drawCards(cards, count) // [drawn, remaining] — draws from end
Zones.addCard(cards, card) // append
Zones.addCards(cards, newCards) // append multiple
Zones.removeCard(cards, cardId) // filter by id
Zones.findCard(cards, cardId) // find by id
Zones.cutDeck(cards, index) // split and reorder
// Deck creation
createStandardDeck() // 52 cards with unique IDs
createPairedDeck(shuffle?) // paired deck for Memory games
createTarotDeck() // 78-card tarot deck (22 Major + 56 Minor Arcana)
// Legacy class API (mutable): Zones.Deck, Zones.Hand, Zones.DiscardPile, Zones.PlayArea
// Each has addCard(), removeCard(), shuffle(). Deck also has drawCard(), drawCards(n).
DeckAction / GameAction (for custom reducers)
type DeckAction =
| { type: 'SHUFFLE' }
| { type: 'DRAW'; payload: { count: number; playerId: string } }
| { type: 'DEAL'; payload: { count: number; playerIds: string[] } }
| { type: 'RESET'; payload?: { cards?: TCard[] } }
| { type: 'CUT_DECK'; payload: number }
| { type: 'PLAY_CARD'; payload: { playerId: string; cardId: string } }
| { type: 'DISCARD'; payload: { playerId: string; cardId: string } }
| { type: 'ADD_PLAYER'; payload: string }
| { type: 'REMOVE_PLAYER'; payload: string }
| { type: 'ADD_CUSTOM_CARD'; payload: CustomCardProps }
| { type: 'REMOVE_CUSTOM_CARD'; payload: { cardId: string } }
| { type: 'SET_BACK_ARTWORK'; payload: Partial<BackArtwork> }
| { type: 'MOVE_CARD'; payload: { cardId: string; from: ZoneName; to: ZoneName; position?: 'top' | 'bottom' | number } }
| { type: 'SET_ZONE'; payload: { name: string; cards: TCard[] } }
| { type: 'CLEAR_ZONE'; payload: { name: string } }
type GameAction =
| { type: 'SET_CURRENT_PLAYER'; payload: string }
| { type: 'NEXT_TURN' }
| { type: 'SET_PHASE'; payload: string }
Gotchas
draw()/deal()are async via dispatch — cards appear inhands[playerId]on next render, not as return values.- Every card needs a unique
id. UsegenerateCardId()or your own scheme. useHand.playCard(cardId)anddiscard(cardId)take a string ID, not a card object.isStandardCard(card)type guard is required before accessing.suit/.valueon aTCard.- The built-in
deckReduceris not exported. Custom reducers receive(DeckContextType, DeckAction)and must handle all cases or delegate toZonesutilities. DeckProviderandGameProviderare separate contexts — use both for full game state.