Skip to content
Logo Icon

Building the PokerNow Converter

· 5 min

Converting hand histories from PokerNow to a standardized format turned out to be one of the most technically challenging features we’ve shipped. Here’s the story of how we scaled from a basic prototype to a converter that reliably processes 10,000 hands per minute in production.

The Challenge#

PokerNow stores hands in a proprietary JSON format that’s fundamentally different from industry-standard formats like PokerStars. Their format includes:

Our goal was to convert these hands into a canonical format that our analyzer could understand, while preserving all the game state information and action sequences.

Technical Approach#

1. Seat-to-Position Mapping#

The first major challenge was mapping PokerNow’s arbitrary seat numbers to standard poker positions (BB, SB, CO, BU, etc.).

// Build ordered PokerNow seats for preflop action: start from BB, stop at SB
const preflopOrderPokerNowSeats: number[] = []
let curPokerNowSeat = bbPokerNowSeat
while (curPokerNowSeat !== sbPokerNowSeat) {
preflopOrderPokerNowSeats.push(curPokerNowSeat)
curPokerNowSeat = nextPokerNowSeat(curPokerNowSeat)
}
// Map PokerNow seats to positions
const positions = allPositionsClockwiseFrom('BB', pokerNowSeats.length)
const pokerNowSeatToPosition = new Map<number, Position>()
for (let i = 0; i < preflopOrderPokerNowSeats.length; i++) {
pokerNowSeatToPosition.set(preflopOrderPokerNowSeats[i], positions[i])
}

The key insight was to start from the BB and iterate clockwise until we hit the SB. This gives us the preflop action order, which we then map to our standard position labels. This approach handles any table size (heads-up through 10-handed) and works regardless of PokerNow’s seat numbering.

2. Event Stream Parsing#

PokerNow represents actions as a stream of events with numeric type codes:

We process this stream sequentially and convert each event into our internal GameEvent format:

for (const e of data.events) {
const payload = e.payload
// Handle board cards
if (payload.type === 9) {
for (const c of payload.cards) {
communityCards.push(parseCard(c))
}
events.push({
event_type: 'community-card',
cardIndexes: /* ... */
})
continue
}
// Handle player actions
if (!('seat' in payload)) continue
const pos = pokerNowSeatToPosition.get(payload.seat)
switch (payload.type) {
case 0: // check
events.push({ event_type: 'check', position: pos })
break
case 8: // bet/raise
events.push({
event_type: 'money-in',
position: pos,
size: data.cents ? payload.value / 100 : payload.value
})
break
// ... etc
}
}

3. Edge Cases and Testing#

We built a comprehensive test suite covering:

describe('pokernow import', () => {
it('should import a pokernow hand with cents', () => {
const hand = convertPokernowHand(POKERNOW_HAND_CENTS)
expect(hand.gameState.stacks.CO).toBe(163.25) // converted from 16325 cents
})
it('should import a heads-up pokernow hand', () => {
const hand = convertPokernowHand(HEADS_UP_POKERNOW_HAND)
expect(hand.gameMetadata.heroPosition).toBe('SB')
})
it('should import a plo pokernow hand', () => {
const hand = convertPokernowHand(PLO_HAND)
expect(hand.gameState.gameType).toBe('plo')
expect(hand.gameState.holeCards['SB']).toHaveLength(4)
})
})

Scaling to 10,000 Hands Per Minute#

The initial implementation worked but was too slow for bulk imports. Here’s how we optimized:

1. Moved Parsing to the Poker Package#

Originally the converter lived in the client. We moved it to the shared @poker package so both client and server could use it. This enabled server-side batch processing.

apps/client/src/features/importer/pokernow.ts
// Before: Client-only conversion
// After: Shared package
// packages/poker/src/import/pokernow.ts
export const convertPokernowHand = (data: PokerNowHand): HandData => {
// Conversion logic
}

2. Direct API Access#

Instead of scraping HTML, we discovered PokerNow’s hand replayer API:

const response = await fetch(
`https://www.pokernow.club/api/hand-replayer/hand/${input.id}`
)
const data = await response.json() as PokerNowHand

This single change gave us:

3. Optimized Data Structures#

We pre-computed position mappings and used Map lookups instead of array scans:

// Fast O(1) lookup
const pokerNowSeatToPosition = new Map<number, Position>()
// Instead of slow O(n) array.find() in a loop
for (const event of events) {
const pos = pokerNowSeatToPosition.get(event.seat) // O(1)
// ...
}

4. Parallel Processing#

For bulk imports, we process hands in parallel batches:

const BATCH_SIZE = 100
const batches = chunk(handIds, BATCH_SIZE)
for (const batch of batches) {
await Promise.all(
batch.map(id => convertAndStoreHand(id))
)
}

With these optimizations, we achieved:

User Experience#

We built a simple UI that handles the most common import workflows:

  1. Paste a hand ID: Users can paste either jq0uaeguqw0k or the full URL
  2. Select hero: Since PokerNow doesn’t specify who the “hero” is, users pick their seat
  3. Preview: See the converted hand before saving
  4. Save: One click to add it to your hand history
const handleImport = () => {
const handId = extractHandId(handInput)
importPokernow({ id: handId }, {
onSuccess: (data: HandData) => {
setHandData(data)
// Auto-select first seat
setSelectedHeroSeat(Object.keys(data.gameMetadata.playerNames)[0])
}
})
}

The converter supports flexible input formats:

Lessons Learned#

1. Type Safety Saves Time#

Using TypeScript with strict types caught dozens of bugs before they hit production:

type PokerNowEvent = {
at: number
payload: BlindPayload | MoneyPayload | CheckPayload | FoldPayload | /* ... */
}

The discriminated union on payload.type made it impossible to access the wrong fields.

2. Test-Driven Conversion#

We captured real PokerNow hands as test fixtures and wrote tests before implementing features. This paid off when we found edge cases like:

3. Progressive Enhancement#

We launched with basic NLHE support, then added PLO, then Omaha Hi-Lo. Each game type required only minor tweaks to the parser because the core architecture was solid.

What’s Next#

We’re exploring:

Try It Yourself#

The PokerNow converter is live at pokerscope.co/import/pokernow. Paste any PokerNow hand URL and see the conversion in action.

All the code is open source in our monorepo. Check out:

Building a production-grade converter taught us that the hardest part isn’t the parsing—it’s handling all the edge cases gracefully and making it fast enough that users don’t notice the complexity underneath.