-
Notifications
You must be signed in to change notification settings - Fork 78
Guide ‐ Joker Calculation
This guide is missing any contexts added by RetriggerAPI
The process of score evaluation in Balatro is quite linear. There are well-defined stages that go one after another, but the actual implementation is somewhat convoluted. Perhaps one of the better ways to understand most of this process is by reading the code of Divvy's Simulation. It's a perfect replication of the game's score evaluation but rewritten in a clearer manner. Nevertheless, the rest of this guide explains the high-level details of this process.
Once you play a hand, the game must first determine what cards are actually scoring.
For instance, if you play A228
, only the pair of 2s
will be scored (unless you have the Splash joker!)
For this, the game uses G.FUNCS.get_poker_hand_info(..)
, which takes an array of Card objects and returns a tuple of five values in this order:
- The name of the play (eg. 'High Card', 'Pair', or 'Straight')
- The localized name of the play
- A table mapping play names (eg. 'High Card') to arrays of Card objects; it contains all possible plays out of the played hand, so a 'Two Pair' play will also have at least two 'Pair' values.
Eg.poker_hands["Pair"] == { {2, 2}, {Q, Q} }
, but instead of numbers and letters, there will be Card objects. - An array of scoring Card objects (eg. only the pair of
2s
out ofA228
) - The special name of the play (eg. 'Royal Flush') or its standard name if no special name exists
-- Example usage:
local hand_name, localized_hand_name, poker_hands, scoring_hand, customised_hand_name = G.FUNCS.get_poker_hand_info(..)
Now, as I alluded, the scoring cards may be different in special circumstances, such as when 'Splash' is present. Therefore, if it is present, the game adds all cards to scoring cards. (It also adds any Stone cards during this process, even if no 'Splash' is present)
The game will now check whether the played hand is actually debuffed by the blind. For instance, if you play fewer than five cards under the Psychic.
If the hand is debuffed, then the game will calculate the effects of all jokers with the special context
argument containing debuffed_hand = true
.
In the vanilla game, only 'Matador' actually does anything with this.
If the hand is not debuffed, then the game will carry out its full score evaluation, explained further below.
First, however, it is important to understand the context
argument that is used throughout the whole process.
The context
is just a special argument that is passed to all objects that influence score evaluation.
All context arguments will always contain the following:
-
cardarea
: which CardArea is being evaluated (eitherG.jokers
,G.hand
, orG.play
) -
full_hand
: an array of all played Card objects (incl. non-scoring) -
scoring_hand
: an array of all scored Card objects -
scoring_name
: the name of the play (eg. 'High Card', or 'Straight') -
poker_hands
: the table mapping play names to Card objects (explained earlier)
-- Example minimal context:
local minimal_context = {
cardarea = G.jokers,
full_hand = G.play.cards,
scoring_hand = scoring_hand, -- from G.FUNCS.get_poker_hand_info(..)
scoring_name = hand_name, -- from G.FUNCS.get_poker_hand_info(..)
poker_hands = poker_hands -- from G.FUNCS.get_poker_hand_info(..)
}
Lastly, the context is further extended with particular flags and/or data, which signify and help during the different stages of score evaluation.
One of these flags was mentioned earlier – debuffed_hand = true
– so the full context would look like this:
-- Example context:
local context = {
cardarea = G.jokers,
full_hand = G.play.cards,
scoring_hand = scoring_hand, -- from G.FUNCS.get_poker_hand_info(..)
scoring_name = hand_name, -- from G.FUNCS.get_poker_hand_info(..)
poker_hands = poker_hands, -- from G.FUNCS.get_poker_hand_info(..)
debuffed_hand = true
}
Context enables the strict order of evaluation that Balatro relies upon. Consider that upgrade jokers like 'Hiker' and 'Green Joker' are evaluated before anything else – it would be confusing otherwise. Similarly, the 'DNA' joker must copy a card up-front. Then, there are also jokers that do something for each scored card like 'Hiker' and 'Odd Todd', as opposed to jokers that do something after all cards were evaluated like 'Green Joker' and 'Hologram'.
Notice how some jokers appear multiple times?
Each time, there is a different context.
This is why context
is vital.
On top of that, consider jokers like 'Blueprint' and 'Brainstorm'.
They must somehow know what joker they are replicating, so the context
will also contain data (in this case, it would be min_context + {other_joker = JOKER_OBJ}
)
Hence, there are multiple stages within score evaluation:
-
Before Stage
For any jokers that need to do something before score evaluation.
Setscontext.cardarea = G.jokers
andcontext.before = true
-
Score Initialisation Stage
Sets chips and mult to those associated with the current hand level. -
Blind Effects Stage
For any blind effects like 'Flint' halving initial chips and mult.
This is handled byBlind:modify_hand(..)
in game. -
Scoring-Cards Evaluation Stage
Evaluates each Card incontext.scoring_hand
, withcontext.cardarea = G.play
.
See 'Card Evaluation' section below. -
Held-Cards Evaluation Stage
Evaluates each Card inG.hand.cards
, withcontext.cardarea = G.hand
.
See 'Card Evaluation' section below. -
Global Joker Effects Stage
For any jokers that do something after all cards have been evaluated.
Setscontext.cardarea = G.jokers
andcontext.joker_main = true
-
Consumable Effects Stage
For any consumable effects (due to 'Observatory' for example).
This is a very short and custom stage. -
Deck Effects Stage
For any deck effects like 'Plasma' merging chips and mult.
This is handled byBack:trigger_effect(..)
in game, withcontext.final_scoring_step = true
- Card Destruction Stage
Self-explanatory. -
After Stage
For any jokers that need to do something after a hand is played like 'Loyalty Card.
Setscontext.cardarea = G.jokers
andcontext.after = true
Stages 4 and 5 go through each scored/held card and do the following:
- If the card is debuffed, do nothing.
- Collect repetitions from seals
Sets
context.other_card = [CARD]
andcontext.repetition = true
andcontext.repetition_only = true
- Collect repetitions from jokers
Sets
context.other_card = [CARD]
andcontext.repetition = true
- For each collected repetition:
- Evaluate the Card object via
eval_card([CARD], context)
- For each joker, evaluate joker effects via
[JOKER]:calculate_joker(context)
Setscontext.other_card = [CARD]
andcontext.individual = true
- The return values of the above evaluations comprise a table that contains some fields described below.
-- Table with possible fields, returned from each card evaluation above:
{
chips = 0, -- Chips to add
mult = 0, -- Mult to add
x_mult = 1, -- Mult multiplier
h_mult = 1, -- TODO
message = nil, -- TODO
-- TODO: Difference between 'dollars' vs 'p_dollars'?
dollars = 0, -- Dollars to add
p_dollars = 0, -- Dollars to add
extra.chip_mod = 0, -- Chips to add
extra.mult_mod = 0, -- Mult to add
extra.swap = nil, -- Should swap chips and mult?
extra.func = nil, -- TODO
-- Effects due to Card's edition:
edition.chip_mod = 0, -- Chips to add
edition.mult_mod = 0, -- Mult to add
edition.x_mult_mod = 0 -- Mult multiplier
}
Stage 6 goes through each joker and applies its global effect, if any. For each joker:
- Evaluate its edition via
eval_card([JOKER], context)
Setscontext.edition = true
- Evaluate its effect via
eval_card([JOKER], context)
Setscontext.joker_main = true
- Evaluate any replications of this joker (by jokers like 'Blueprint'); see below.
Setscontext.other_joker = [JOKER]
-- Joker-on-Joker simplified code
-- Assume we are currently evaluating the effects of 'current_joker'
context.other_joker = current_joker
for _, another_joker in ipairs(G.jokers.cards) do
-- Yes, we pass 'current_joker' as context to all other jokers.
another_joker:calculate_joker(context)
end
Lastly, the game will check if any scored cards or jokers need to be destroyed. For each scored card, the game will first check if any joker destroys it via:
[JOKER]:calculate_joker({destroying_card = [CARD], full_hand = G.play.cards})
Then, the game will check if any Glass cards break.
Any destroyed cards are saved in the array cards_destroyed
.
Then, the game will go through all jokers again, checking if any jokers were triggered due to a card being destroyed:
eval_card([CARD], {cardarea = G.jokers, remove_playing_cards = true, removed = cards_destroyed})
Jokers are also evaluated after other player actions, not just when a hand is played. The two main actions are discarding cards and winning the round, which have dedicated sections below. All other actions are listed in the 'Everything Else' section at the bottom, for quick reference.
Once you discard a hand, the game also does multiple stages of evaluation:
-
Before Stage (Held Cards)
Setscontext.pre_discard = true
andcontext.full_hand = G.hand.highlighted
(ie. which cards will be discarded)
Also, it setscontext.hook = true
if 'The Hook' blind is active.- Evaluates each held card with that context.
- Evaluates each joker with that context.
-
Discard Stage
Setscontext.discard = true
andcontext.full_hand = G.hand.highlighted
(ie. which cards are discarded)- Evaluates each held card with that context.
- Evaluates each joker with that context and also
context.other_card = discarded_card
(ie. it's evaluated for each discarded card)
This checks if any joker returnsremove = true
, like 'Trading Card' in vanilla.
- Card Destruction Stage
Evaluates each joker withcontext.cardarea = G.jokers
Setscontext.remove_playing_cards = true
andcontext.removed = cards_destroyed
Once you win (or lose) a round, the game also does multiple stages of evaluation:
-
Game Over Effects
Setscontext.end_of_round = true
andgame_over = true
if the player just lost the the game
This checks if any joker returnssaved = true
, like 'Mr. Bones' in vanilla. -
Held-Cards Evaluation Stage
Setscontext.cardarea = G.hand
andcontext.end_of_round = true
This follows the same steps described in the 'Card Evaluation' section above.
Note
For End of Round effects that trigger once, your joker should use:
if context.end_of_round and not context.repetition and not context.individual then ...
Whenever any of the events below take place, each joker will be evaluated via [JOKER]:calculate_joker(context)
.
All changes to context are mentioned alongside the event:
- Round-related events
-
New Round: sets
context.setting_blind = true
andcontext.blind = G.GAME.round_resets.blind
(ie. the type of blind)
Used by 'Madness', 'Burglar', and others in vanilla -
Drawing First Hand: sets
context.first_hand_drawn = true
Used by 'DNA', 'Trading Card', and others in vanilla -
Skipping a Blind: sets
context.skip_blind = true
Used only by 'Throwback' in vanilla
-
New Round: sets
- Set-up modifications
-
Adding a Playing Card: sets
context.playing_card_added = true
andcontext.cards = cards
(ie. which cards added)
Used only by 'Hologram' in vanilla -
Using a Consumable: sets
context.using_consumeable = true
andcontext.consumeable = card
(ie. which consumable)
Used by 'Fortune Teller', 'Glass Joker', and others in vanilla -
Selling a Joker/Consumable: sets
context.selling_card = true
andcontext.card = card
(ie. which card)
Also, if selling a joker, that joker is evaluated withcontext.selling_self = true
Used only by 'Campfire' and 'Luchador' in vanilla
-
Adding a Playing Card: sets
- Shop-related events
-
Buying Anything: sets
context.buying_card = true
andcontext.card = card
(ie. a joker/consumable/card/voucher object)
Also, if buying a joker, that joker is evaluated with the same context as above. -
Opening a Booster Pack: sets
context.open_booster = true
andcontext.card = booster
(ie. which booster)
Used only by 'Hallucination' in vanilla -
Skipping a Booster: sets
context.skipping_booster = true
Used only by 'Red Card' in vanilla -
Rerolling Shop: sets
context.reroll_shop = true
Used only by 'Flash Card' in vanilla -
Leaving Shop: sets
context.ending_shop = true
Used only by 'Perkeo' in vanilla
-
Buying Anything: sets
Guide written by Divvy and Eremel
Game Objects
- API Documentation
- SMODS.Achievement
- SMODS.Atlas
- SMODS.Blind
- SMODS.Center
- SMODS.Challenge
- SMODS.DeckSkin
- SMODS.Keybind
- SMODS.Language
- SMODS.ObjectType
- SMODS.PokerHand
- SMODS.Rarity
- SMODS.Seal
- SMODS.Sound
- SMODS.Stake
- SMODS.Sticker
- SMODS.Suit and SMODS.Rank
- SMODS.Tag
Guides
- Your First Mod
- Mod Metadata
- Joker Calculation
- Calculate Functions
- Logging
- Event Manager
- Localization
- Mod functions
- UI Structure
- Utility Functions
Found an issue, or want to add something? Submit a PR to the Wiki repo.