| name | ink-playing-cards |
| description | Use when building terminal card games with ink-playing-cards, working with card components, deck management, zone systems, event systems, effect systems, or any card game logic using Ink and React for CLI rendering. |
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
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[]
├── players: string[]
├── eventManager / effectManager
└── dispatch(DeckAction)
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
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
}
type CustomCardProps = BaseCardProps & {
size?: 'micro' | 'mini' | 'small' | 'medium' | 'large'
title?: string; cost?: string
asciiArt?: string; artColor?: string
typeLine?: string
description?: string
footerLeft?: string; footerRight?: string
symbols?: CustomCardSymbol[]
content?: ReactNode
back?: CustomCardBack
borderColor?: string; textColor?: string
value?: TCardValue | string; type?: string
onClick?: () => void
}
function isStandardCard(card: TCard): card is CardProps
function isCustomCard(card: TCard): card is CustomCardProps
function generateCardId(suit: TSuit, value: TCardValue): string
Hooks
useDeck — must be inside <DeckProvider>
const {
deck,
hands,
discardPile,
playArea,
players,
backArtwork,
eventManager,
effectManager,
shuffle,
draw,
deal,
reset,
cutDeck,
addPlayer,
removePlayer,
getPlayerHand,
addCustomCard,
removeCustomCard,
setBackArtwork,
} = 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')
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'
<TarotCard id="fool" arcana="major" majorIndex={0} />
<TarotCard id="tower" arcana="major" majorIndex={16} reversed />
<TarotCard id="ace-cups" arcana="minor" suit="cups" value="Ace" />
<TarotCard id="qw" arcana="minor" suit="wands" value="Queen" reversed />
<TarotCard id="hidden" arcana="major" majorIndex={13} faceUp={false} />
<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'
<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>
const { currentPlayerId, players, turn, phase, dispatch } = React.useContext(GameContext)
dispatch({ type: 'NEXT_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) { }
}
eventManager.addEventListener('CARDS_DRAWN', listener)
eventManager.removeEventListener('CARDS_DRAWN', listener)
eventManager.dispatchEvent({ type: 'CUSTOM_EVENT', playerId: 'p1' })
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), 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)
new Effects.DamageEffect(3)
new Effects.ConditionalEffect(condFn, effect)
new Effects.TriggeredEffect('CARD_PLAYED', effect)
new Effects.DelayedEffect(3, effect)
new Effects.TargetedEffect(selectorFn, effect)
new Effects.ContinuousEffect(condFn, applyFn, removeFn)
Effects.attachEffectToCard(card, effect)
effectManager.applyCardEffects(card, gameState, eventData)
Effects mutate gameState/eventData directly — designed for effect pipelines, not reducer use.
Zone Utilities
import { Zones, createStandardDeck, createPairedDeck, createTarotDeck } from 'ink-playing-cards'
Zones.shuffleCards(cards)
Zones.drawCards(cards, count)
Zones.addCard(cards, card)
Zones.addCards(cards, newCards)
Zones.removeCard(cards, cardId)
Zones.findCard(cards, cardId)
Zones.cutDeck(cards, index)
createStandardDeck()
createPairedDeck(shuffle?)
createTarotDeck()
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 GameAction =
| { type: 'SET_CURRENT_PLAYER'; payload: string }
| { type: 'NEXT_TURN' }
| { type: 'SET_PHASE'; payload: string }
Gotchas
draw()/deal() are async via dispatch — cards appear in hands[playerId] on next render, not as return values.
- Every card needs a unique
id. Use generateCardId() or your own scheme.
useHand.playCard(cardId) and discard(cardId) take a string ID, not a card object.
isStandardCard(card) type guard is required before accessing .suit/.value on a TCard.
- The built-in
deckReducer is not exported. Custom reducers receive (DeckContextType, DeckAction) and must handle all cases or delegate to Zones utilities.
DeckProvider and GameProvider are separate contexts — use both for full game state.