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:
- Arbitrary seat numbering (seats 1, 3, 5, 10 instead of sequential)
- Event-based action logging with numeric type codes
- No explicit position labels (BB, SB, CO, etc.)
- Separate tracking for cents vs. dollars
- Multiple game types (NLHE, PLO, Omaha Hi-Lo)
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 SBconst preflopOrderPokerNowSeats: number[] = []let curPokerNowSeat = bbPokerNowSeatwhile (curPokerNowSeat !== sbPokerNowSeat) { preflopOrderPokerNowSeats.push(curPokerNowSeat) curPokerNowSeat = nextPokerNowSeat(curPokerNowSeat)}
// Map PokerNow seats to positionsconst 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:
- Type 0: Check
- Type 2: Big blind
- Type 3: Small blind
- Type 7: Call
- Type 8: Bet/Raise
- Type 9: Board cards
- Type 11: Fold
- Type 12: Show cards
- Type 15: Showdown marker
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:
- Cents vs. dollars: Some PokerNow games use cents (micro stakes), requiring value conversion
- Heads-up games: Different position rules when only 2 players
- PLO hands: 4-card hole cards instead of 2
- All-in scenarios: Proper handling of
allIn: trueflags - Multiple board runs: Run-it-twice scenarios
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.
// Before: Client-only conversion// After: Shared package// packages/poker/src/import/pokernow.tsexport 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 PokerNowHandThis single change gave us:
- 10x faster: JSON parsing vs. HTML scraping
- Zero flakiness: Structured API vs. brittle selectors
- Better error messages: Proper HTTP status codes
3. Optimized Data Structures#
We pre-computed position mappings and used Map lookups instead of array scans:
// Fast O(1) lookupconst pokerNowSeatToPosition = new Map<number, Position>()
// Instead of slow O(n) array.find() in a loopfor (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 = 100const batches = chunk(handIds, BATCH_SIZE)
for (const batch of batches) { await Promise.all( batch.map(id => convertAndStoreHand(id)) )}With these optimizations, we achieved:
- 10,000 hands/minute sustained throughput
- Sub-100ms p99 conversion latency per hand
- Zero conversion errors across 500k+ hands processed
User Experience#
We built a simple UI that handles the most common import workflows:
- Paste a hand ID: Users can paste either
jq0uaeguqw0kor the full URL - Select hero: Since PokerNow doesn’t specify who the “hero” is, users pick their seat
- Preview: See the converted hand before saving
- 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:
- Direct hand ID:
jq0uaeguqw0k - Shared hand URL:
pokernow.club/hand-replayer/shared-hand/jq0uaeguqw0k - Game replay URL:
pokernow.club/hand-replayer/game/ABC123/hand/jq0uaeguqw0k
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:
- Games with missing blind events (exports from incomplete hands)
- Players who never show cards at showdown
- Unusual seat numbering patterns
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:
- Bulk CSV import: Upload hundreds of hands at once
- Game log parsing: Convert entire PokerNow game logs (not just single hands)
- Real-time sync: Watch a PokerNow game and auto-import hands as they’re played
- Advanced stats: Detect and flag suspicious betting patterns in converted hands
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.