Rule Changing Poker Game "Balatro" recreated in JavaScript - Behind the Code

Published 5/6/2024

"Balatro" is a rogue-like poker game that comes with all sorts of modifiers to change the rules mid game and allow you to score higher points. There's significant interactivity among various effects that occur and its developer even describes the game as being held together with hopes and dreams.

Let's put on our poker face and see if, armed with the hindsight of the finished game, we can ace the development of its core mechanics, or if we'd be better off flushing our code down the drain?

Terminology

As always, before we start coding, let's get the terminology straight.

  • Hand ranking refers to the poker hand played (flush, straight, full house, three of a kind, etc.)
  • Played cards are the cards placed on the table (up to 5)
  • Scoring cards are the played cards contributing to the score (e.g. in 5 of spades, 5 of hearts, 7 of spades, the hand ranking is "pair" and only the first two cards count as scoring cards)
  • Jokers refer to special cards acquired during gameplay that don't need to be played but have an effect on either the rules or the scoring of the game

How the Game works

In case you haven't played the game, I'll break down one possible round of poker in Balatro:

  • the user plays the cards: 9 of spades, jack of clubs, queen of clubs, and king of hearts
  • the user previously added the following five jokers to his arsenal:
  1. straights can contain a gap
  2. straights and flushes only require four cards
  3. copies joker ability from the joker to the right
  4. retriggers face cards
  5. Face cards get turned into spades

Let's see how the scoring goes for the above:

  • We only played four cards and have a gap in there (missing the 10), but because we have joker 1 and 2 applied, this still counts as a straight.
  • Face cards are also turned into spades so we have a flush as well. Hence the final hand ranking is a straight flush.
  • We get an initial score for the poker hand played (for example 50 chips + 8x multipler).
  • We then go through each scored card and add its value to the score (e.g. 9 chips for the 9 of spades)
  • Thanks to the "retrigger face cards" joker, face cards are scored an additional time...
  • And thanks to the "copy joker ability", a third time as well!
  • We then calculate the final score: 139 chips * 8 multiplier = 1398

There are even jokers that multiply your multiplier which are often the secret to winning later rounds that require a high score to beat.

Coding

Before we get to the fun part, which is the jokers, we need to set up the basic functionality to score points.

Let's create the PlayCard class which we use to create the poker cards:

let lastPlayCardId = 0

const numberedCards = ['2', '3', '4', '5', '6', '7', '8', '9', '10']
const faceCards = ['jack', 'queen', 'king']
// we use this to sort and identify straights
const cardRanks = [...numberedCards, ...faceCards, 'ace']

class PlayCard {
  constructor(name, suit) {
    lastPlayCardId++
    this.uid = lastPlayCardId
    this.name = name
    this.suit = suit
  }

  isFace() {
    return faceCards.includes(this.name)
  }

  isNumbered() {
    return numberedCards.includes(this.name)
  }

  getRank() {
    return cardRanks.indexOf(this.name)
  }

  points() {
    if (this.isNumbered()) {
      return Number(this.name)
    }
    return 10
  }
}

Determine Hand Rankings

Next, let's determine the hand ranking that was played. We can use this code for it:

// a library of mine to simplify data manipulation
const { given } = require('flooent')

// hand rankings are listed from best to worst
const handRankingOptions = {
  // ...we just show the bottom too for now
  'pair': {
    chips: 10, // how many chips you score with a pair
    mult: 2, // how many multipliers get added when you score with a pair
    level: 1, // the level of hand ranking (can be leveled up)
    minCards: 2, // the number of cards required for this hand ranking to be valid
    matches(playedCards, pairs) {
      if (pairs.length === 1) {
        return { scoringCards: pairs[0] }
      }
    }
  },
  'highest': {
    // ...
    matches(playedCards) {
      return { scoringCards: [playedCards.at(-1)] }
    }
  }
}

function determineHandRanking(playedCards) {
  const playedCardsSorted = given.array(playedCards)
    .sortAsc(card => card.getRank())
    .valueOf()

  // collect all pairs needed for various ranking checks like full house or pairs
  const pairs = given.array(playedCards)
    .groupBy('name')
    .values()
    .filter(pair => pair.length > 1)
    .valueOf()

  for (const handRanking of Object.keys(handRankingOptions)) {
    const option = handRankingOptions[handRanking]
    if (playingCardsSorted.length < round.handRankings[handRanking].minCards) continue
    // if hand ranking matches, return the scoring cards and hand ranking
    const result = option.matches.call(handRankingOptions, playedCardsSorted, pairs)
    if (result) {
      // skip if not enough cards -> prevents flush with less than 5 cards
      if (result.scoringCards.length < round.handRankings[handRanking].minCards) continue
      return { scoringCards: result.scoringCards, handRanking }
    }
  }
  // will never reach here as "highest" will always return the highest card played at last
}

With this, we have a basic implementation to identify the hand rankings. Below are the remaining rankings. We will need to modify the code later again to accomodate for specific jokers. What's really useful here is to write tests as you code along to not run into regressions.

const handRankingOptions = {
  'straightFlush': {
    // ...
    matches(playedCards, pairs) {
      const straightResult = this.straight.matches.call(this, playedCards)
      if (!straightResult) return
      return this.flush.matches.call(this, given.array(straightResult.scoringCards))
    }
  },
  'fourOfAKind': {
    // ...
    matches(playedCards, pairs) {
      const fourOfAKind = pairs.find(pair => pair.length === 4)
      if (fourOfAKind) {
        return { scoringCards: fourOfAKind }
      }
    }
  },
  'fullHouse': {
    // ...
    matches(playedCards, pairs) {
      const threeOfAKind = pairs.find(pair => pair.length === 3)
      if (threeOfAKind && pairs.length === 2) {
        return { scoringCards: playedCards }
      }
    }
  },
  'flush': {
    // ...
    matches(playedCards) {
      if (given.array(playedCards).unique('suit').length === 1) {
        return { scoringCards: playedCards }
      }
    }
  },
  'straight': {
    // ...
    matches(playedCards) {
      if (given.array(playedCards).map((c, idx) => c.getRank() - idx).unique().length === 1) {
        return { scoringCards: playedCards }
      }
    }
  },
  'threeOfAKind': {
    // ...
    matches(playedCards, pairs) {
      const threeOfAKind = pairs.find(pair => pair.length === 3)
      if (threeOfAKind) {
        return { scoringCards: threeOfAKind }
      }
    }
  },
  'twoPair': {
    // ...
    matches(playedCards, pairs) {
      if (pairs.length === 2) {
        return { scoringCards: pairs.flat() }
      }
    }
  },
  // ...pair and highest (see in previous code example)
}

Scoring

Now that that's out of the way we can create a function to handle what happens after the user has played the cards. This will be the injection point for our joker effects later. Let's first create the Score class:

class Score {
  chips = 0
  mult = 0
  scoreBreakdown = []

  constructor(handRankingOption) {
    // initialize score with the determined hand ranking
    this.scoreEffect({ type: 'chips', operation: 'add', value: handRankingOption.chips })
    this.scoreEffect({ type: 'mult', operation: 'add', value: handRankingOption.mult })
  }

  scoreCard(card) {
    this.scoreEffect({ type: 'chips', operation: 'add', value: card.points() })
  }

  scoreEffect(effect) {
    if (effect.operation === 'add') {
      this[effect.type] += effect.value
    } else if (effect.operation === 'multiply') {
      this[effect.type] *= effect.value
    }
    this.scoreBreakdown.push(effect)
  }

  total() {
    return this.chips * this.mult
  }
}

This class takes care of the calculations for us and saves a breakdown of every score change. This breakdown is powerful since it's easy to compare the score like this in tests without manually calculating and comparing the final score.

function calculateScore(playedCards, jokers) {
  const { scoringCards, handRanking } = determineHandRanking(playedCards)

  const score = new Score(handRankingOptions[handRanking])

  // add each card's points to the score
  for (const card of round.scoringCards) {
    score.scoreCard(card)
  }

  return { score }
}

And like this we have our very very basic poker functionality. Now, to the fun part!

Jokers

So turns out, there isn't just one type of joker, in fact I identified 4 different types. This means, we need 4 different injection points to apply the joker's effects.

A restriction I set for this excercise is not littering the code with if conditions for joker effects. The logic for the jokers should be solvable within the joker classes themselves. Ideally, this should make the code more easily extensible and modifyable, but just like boss blinds in the game, such restrictions also make challenges like this more interesting to solve in general.

The injection points are as follows:

function calculateScore(playedCards, jokers) {
  const round = new Round(playedCards, jokers)

  // 👇 Jokers that modify the setup, like changing rules/played cards.
  // E.g.: straights can contain gaps
  round.jokers.forEach(j => j.modifySetup?.(round))

  round.determineHandRanking()

  // 👇 Jokers that modify the scoring cards or ranking after it was determined
  // E.g.: Every played card counts in scoring
  round.jokers.forEach(j => j.modifyScoring?.(round))

  const score = new Score(round.getHandRankingOption())

  for (const card of round.scoringCards) {
    score.scoreCard(card)
    // 👇 Jokers that run for specific cards and are triggered one by one
    // E.g.: Add x4 to Mult for each face card scored
    round.jokers.forEach(j => j.scoreExtraPerCard?.(round, score, card))
  }

  // 👇 Jokers that run per round to add to the score
  // E.g.: Add x4 to Mult if scored using three cards or fewer
  round.jokers.forEach(j => j.scoreExtraPerRound?.(round, score))
  
  return { round, score }
}

Now, it would be possible to condense the last 2 or even 3 joker types into just the "scoreExtraPerRound"-type Joker. But the order in which joker effects are applied matters, so the score would only be calculatable after sorting the score breakdown accordingly which would also require more information. Let's hold off on that for the time being.

Looking at the code, we've also established the "Round" class which is as high as we go in this demo.

There's no need for "Game", "Ante", or "Blind" to play around with jokers. That's why you also see this mix of classes and stand-alone functions. We simply want to put our focus on the core part of the game, the rest's just there to help with that for now.

Back to the "Round" class. This class will hold vital information that jokers can access and mutate!

class Round {
  handRankingOptions = deepcopy(handRankingOptions) // copy to allow mutations by jokers
  handRanking = ''
  scoringCards = []
  
  constructor(playedCards, jokers) {
    this.playedCards = playedCards
    this.jokers = jokers
  }

  getHandRankingOption() {
    return this.handRankingOptions[this.handRanking]
  }

  determineHandRanking() {
    const { scoringCards, handRanking } = determineHandRanking(this) // "determineHandRanking" was changed to take an instance of Round as its argument
    this.handRanking = handRanking
    this.scoringCards = scoringCards
  }
}

Now we have everything set up, and all that's left is to create jokers!

Let's create our very first one, I think it speaks for itself!

class Joker {} // could add base methods down the line

class FibonnacciJoker extends Joker {
  scoreExtraPerCard(round, score, card) {
    if (['2', '3', '5', '8', '10', 'ace'].includes(card.name)) {
      score.scoreEffect({ type: 'mult', operation: 'add', value: 4 })
    }
  }
}

The test lets you see the score break down in which you can observe that after 5 and 8 were added, a 4x multiplier was added thanks to the joker!

describe('FibonnacciJoker', () => {
  it('adds 4x multiplier for each number in the fibonacci sequence', () => {
    const jokers = [new Joker.FibonnacciJoker]

    const cardsPlayed = [
      new PlayCard('5', 'spade'),
      new PlayCard('6', 'diamond'),
      new PlayCard('7', 'heart'),
      new PlayCard('8', 'club'),
      new PlayCard('9', 'club')
    ]

    const {score} = calculateScore(cardsPlayed, jokers)
    expect(score.getEffectsBreakdown()).toEqual([
      { operation: 'add', value: 5, type: 'chips' },
      { operation: 'add', value: 4, type: 'mult' },
      { operation: 'add', value: 6, type: 'chips' },
      { operation: 'add', value: 7, type: 'chips' },
      { operation: 'add', value: 8, type: 'chips' },
      { operation: 'add', value: 4, type: 'mult' },
      { operation: 'add', value: 9, type: 'chips' }
    ])
  })
})

"score.getEffectsBreakdown()" is a utility method that will return the score breakdown without the two initial chip + mult scores.

"scoreExtraPerRound" jokers work similarly in that given a certain condition is met, it will add a specific amount to the final score:

class ContainsThreeOrFewerCardsJoker extends Joker {
  scoreExtraPerRound(round, score) {
    if (round.scoringCards.length <= 3) {
      score.scoreEffect({ type: 'mult', operation: 'add', value: 20 })
    }
  }
}

Hey, we can even implement our very own jokers!

class AnswerToEverythingJoker extends Joker {
  scoreExtraPerRound(round, score) {
    if (given.array(round.playedCards).sum(c => c.points()) === 42) {
      score.scoreEffect({ type: 'mult', operation: 'multiply', value: 42 })
    }
  }
}

These two types of jokers can also contain their own state, like this joker which will add an additional x1 to mult for every time the current hand ranking is played:

class PlusOneForPlayingHand extends Joker {
  gameState = {
    timesPlayed: {}
  }

  scoreExtraPerRound(round, score) {
    this.gameState.timesPlayed[round.handRanking] = this.gameState.timesPlayed[round.handRanking] ?? 0
    this.gameState.timesPlayed[round.handRanking]++

    score.scoreEffect({ type: 'mult', operation: 'add', value: this.gameState.timesPlayed[round.handRanking] })
  }
}

There's also "roundState" which gets reset in the Round's constructor. This way we can implement retrigger jokers without causing an infinite loop if two such jokers are applied at the same time (as we have to retrigger all the other jokers as well):

class RetriggerLowNumbersJoker extends Joker {
  roundState = {
    triggered: {}
  }
  
  scoreExtraPerCard(round, score, card) {
    if (!['2', '3', '4', '5'].includes(card.name)) return
    if (this.roundState.triggered[card.id]) return
    this.roundState.triggered[card.id] = true

    score.scoreCard(card)
    round.jokers.filter(j => j.id !== this.id).forEach(j => {
      j.scoreExtraPerCard?.(round, score, card)
    })
  }
}

Regarding "modifyScoring" jokers, I only have one so far which is the "all played cards count in scoring". It's as simple as this:

class AllCardsCountJoker extends Joker {
  modifyScoring(round) {
    round.scoringCards = round.playedCards
  }
}

Much more interesting is the first type "modifySetup" as it allows for changing the rules of the game!

modifySetup Jokers

The first one is quite simple. It swaps the suit to spade for any face card played (before we determine the hand ranking):

class FaceCardsAreSpadesJoker extends Joker {
  modifySetup(round) {
    round.playedCards = round.playedCards.map(card => {
      if (card.isFace()) {
        return new card.constructor(card.name, 'spade', card.effects)
      }
      return card
    })
  }
}

To show how it works with other jokers, let's add another joker that adds a 4x multiplier for each card of the suit spades:

class MultForSpadeJoker extends Joker {
  scoreExtraPerCard(round, score, card) {
    if (card.suit === 'spade') {
      score.scoreEffect({ type: 'mult', operation: 'add', value: 4 })
    }
  }
}

As you can see in this test, we now get the extra multipliers for achieving a pair with the two played cards:

it('applies effect of MultForSpadeJoker for non-spade cards if FaceCardsAreSpadesJoker is set', () => {
  const jokers = [new Joker.MultForSpadeJoker, new Joker.FaceCardsAreSpadesJoker]

  const cardsPlayed = [
    new PlayCard('jack', 'heart'),
    new PlayCard('jack', 'heart'),
  ]
  const {round, score} = calculateScore(cardsPlayed, jokers)
  expect(round.handRanking).toBe('pair')
  expect(score.getEffectsBreakdown('mult')).toEqual([
    {"operation": "add", "type": "mult", "value": 4},
    {"operation": "add", "type": "mult", "value": 4}
  ])
})

Next, let's look at our final three jokers, they all work in conjunction with each other and we have to change some of the hand ranking logic for that to work out.

The first joker will allow us to create straights and flushes using only four cards. For this, we simply mutate the "minCards" property.

class SmallStraightFlushJoker extends Joker {
  modifySetup(round) {
    round.handRankingOptions.straight.minCards = 4
    round.handRankingOptions.straightFlush.minCards = 4
    round.handRankingOptions.flush.minCards = 4
  }
}

We are straight up mutating objects in the instance of "round" within the joker here. Usually you'd try to avoid mutations if possible but in this very instance I give it a pass as it's literally the joker's job to mutate the round's setup. But it wouldn't be too hard to migrate this to more "pure" code.

Using this joker already breaks our code to identify straights and flushes as it should still count when there's an invalid 5th card in the mix. Time to fix it!

Let's first look at the old "flush" code:

matches(playedCards) {
  if (given.array(playedCards).unique('suit').length === 1) {
    return { scoringCards: playedCards }
  }
}

To fix this, we shouldn't check if there's just one suit available, instead, we check for the suit with the most cards and return those. The "determineHandRanking" function already checks for us if the minimum cards were played.

matches(playedCards) {
  const scoringCards = given.array(playedCards)
    .groupBy('suit')
    .values()
    .sortDesc(cards => cards.length)
    .first()

  return scoringCards ? { scoringCards } : false
}

With this, the next flush-related joker is "Hearts and Diamonds count as the same suit, Spades and Clubs count as the same suit" is trivial to implement.

To do so, we can replace the "matches" method with a custom one which reduces the four suits into just two:

class ReducedSuitsJoker extends Joker {
  modifySetup(round) {
    const redSuits = ['heart', 'diamond']
    round.handRankings.flush.matches = function(playedCards) {
      const scoringCards = given.array(playedCards)
        // This is the only change
        .groupBy(c => redSuits.includes(c.suit) ? 'red' : 'black')
        .values()
        .sortDesc(cards => cards.length)
        .first()

      return scoringCards ? { scoringCards } : false
    }
  }
}

Finally, we have the joker "Allows Straights to be made with gaps of 1 rank".

For memory, this is the old logic for straights:

matches(playedCards) {
  if (given.array(playedCards).map((c, idx) => c.getRank() - idx).unique().length === 1) {
    return { scoringCards: playedCards }
  }
}

This one is indeed a bit tricky having to consider both the "only 4 cards" and "can contain a gap" jokers.

Let's first look at the new code to allow for 4 cards to be played:

isNextValid(previous, next) {
  return previous.getRank() + 1 === next.getRank()
},
matches(playedCards) {
  let scoringCards = []
  for (const card of playedCards) {
    const previous = scoringCards.at(-1)
    if (!previous || this.straight.isNextValid(previous, card)) {
      scoringCards.push(card)
    } else if (scoringCards.length === 1) {
      // reset as first card must have been wrong, second chance with the remaining 4 cards
      scoringCards = [card] 
    }
  }
  return { scoringCards }
}

This new "matches" method is a lot more procedural but allows for a fifth invalid card in the beginning, middle, or end. We also broke out the actual comparison between previous and next into a new method "isNextValid".

Now we can easily implement the "can contain gaps" joker by overriding only the tiny method "isValidNext" accordingly!

class SkipNumberStaightJoker extends Joker {
  modifySetup(round) {
    round.handRankingOptions.straight.isNextValid= function(previous, next) {
      return (!previous || (previous.getRank() + 1 === next.getRank()) ||  (previous.getRank() + 2 === next.getRank()))
    }
  }
}

We could do the same with flush but I thought its code was very straight forward compared to identifying straights.

And here you can see a test with four different jokers combined!

it('can combine various straight and flush jokers together', () => {
  const jokers = [new Joker.SkipNumberStaightJoker, new Joker.SmallStraightFlushJoker, new Joker.FaceCardsAreSpadesJoker, new Joker.ReducedSuitsJoker]

  const cardsPlayed = [
    new PlayCard('7', 'spade'),
    new PlayCard('9', 'spade'),
    new PlayCard('10', 'club'),
    new PlayCard('jack', 'heart'),
  ]
  const {round} = calculateScore(cardsPlayed, jokers)
  expect(round.handRanking).toBe('straightFlush')
})

Conclusion

And with that, we have completed the basic integration of Balatro's Joker system! The real game offers even more features such as card modifiers, blinds, etc. But I hope you got a good look into the game's mechanics and that you will give the game a try!