How to develop a “balanced” card game

Nicky Reinert
9 min readJan 28, 2023

Join me on my journey from drawing for fun, to Excel, over Python to a, probably working and good balanced card game.

But why?

Shameless bragging: I own a Remarkable2 and from time to time I’m drawing random stuff on it. Just for the sake of recreation. I’m not a good painter. It’s no serious art, but it’s fun.

Nope, I‘m not

One day I decided to draw a couple of figures based on geometric shapes and I thought: Why don’t make a card game out of it. And that’s where the story begins where I tried to figure out, how to develop a balanced card game.

The game setup

First I equipped every figure, I call them characters, with four different attributes that are somehow related to geometry:

  • amount of edges
  • age
  • symmetry bonus
  • euclidean reciprocal

If you look at the characters I drawed, the “amount of edges” and the “symmetry bonus” should make sense. Those values ought to be fixed. The “age” and the “euclidean reciprocal” are meant to be “fictive” values that could vary.

My set of characters

I also created two simple formulas to calculate attacking and defense values:

Attack Value = (1 + amount of edges) * (1 + symmtry bonus)
Defense Value = age * euclidean reciprocal

Those are the game rules:

  • every player gets a stack of cards, laying in front of the player, hidden
  • the player whose turn it is draws the top card from the own stack
  • this player has to chose an opponent from the other players
  • now both players compare the attack and defense values of their characters, the winner gets both cards and puts them under the own stack
  • now the player to the left of the last player draws a card from the own stack and the game proceeds
  • if one player has all cards, this player wins the game

That was my starting point. My first approach was to find out how each character would perform in a fight against it’s peers.

First stop: Excel

I’m in love with Excel, so I created a sheet with dozens of formulas. It’s not sophisticated, it’s complex and not worth complaining at — as this does not help achieving the goal.

The idea was to iterate through the random values for each charachter to finaly get a “fair chance” for every character to win. The range for the random values is always based on the outcome of the current “fight”. This results in a couple of circle references. That’s the result:

(If you don’t know it yet: Enabling “iterative calculations” in Excel allows you to use self referencing formulas, which sometimes help you to crunch numbers)

My success indicator was the average chance of winning for all characters. Which is 87% in the screenshot. The problem with my setup was: This value does not come from the values you actually see, but from the previous ones. So I need to adapt my strategy and invest a little more time.

Next stop: Python

As I am also in love with Python, I switched to Jupyter. The algorithm was quite easy, I’m not going to post it here. Again: It does not help achieving the gol. Let me just give you some pseudo code:

# n sets of 27 characters, where each set contains different values 
# for 'age' and 'euclidean reciprocal'
character_variations = {...}

for charachters in character_variations:
for character_1 in characters:
for character_2 in characters:
fight(character_1, character_2)

Running this code returns a table containing the win/lose rate for every character. Again I looked for a value to measure the “balance” of the set of characters. I played around using average values, standard deviation and so on and finally came to one conclusion: I am on the wrong track.

Epiphany

I want to create a card game. In a card game you have n players with a random stack of cards to play with. I don’t need to calculate how powerful every character is. I don’t want to balance the power between each character. The playing mechanic of my card game is solely based on chance. If you play a game you need to come to an end, eventually.

That’s why I took my simple algorithm to a new level. I’m just showing you how to call it, the full script is on Github (see bottom of page).

variable_attributes = {
'attribute_2': range(1, 50, 1), # min, max
'attribute_4': range(1, 30, 1), # min, max
}

gameEngine = GameEngine(
debug=False,
bonus_attack_activator=[1, 6],
bonus_defense_activator=[1, 2, 3, 4, 5, 6],
escape_activator=[3],
dice_range=[1, 2, 3, 4, 5, 6],
variable_attributes=variable_attributes)

gameEngine.randomizeCharacterAttributes(variations=10)

gameEngine.simulateRealGames(games=1, round_limit=1000, players=4, characters_per_stack=3, sibblings_per_character=1)

gameEngine.simulateAllvsAll(games=1)

gameEngine.exportGameStatsToCsv()

gameEngine.showSummary()

Changes to the game mechanic

First I added a new rule: Characters should have either an attack or defense bonus capability. This means: Before two players fight against each other, they roll a dice to improve their attack or defense values or even escape from the fight. This adds more dynamic to the game and probably some kind of strategic moment.

I hard-coded those rules into the code and will explain them briefly:

Parameters

Let’s start with the attack bonus that e.g. needs to be activated by throwing a 2 or 6:

bonus_attack_activator=[1, 6]

On success, the attacker may roll the dice two times. The greater value can be used as an attack multiplicator:

    def calculateAttackValue(self, character):

attack_value = (1 + character['attributes']['attribute_1']) * (1 + character['attributes']['attribute_3'])
eleveated_attack_value = attack_value
# roll the attack dice
if 'attack' in character['bonus']:
if random.choice(self.dice_range) in self.bonus_attack_activator:
dice_1 = random.choice(self.dice_range)
dice_2 = random.choice(self.dice_range)

if dice_1 > dice_2:
eleveated_attack_value *= dice_1
else:
eleveated_attack_value *= dice_2

if self.debug : print(f'\tbase attack: {attack_value}, elevated attack {eleveated_attack_value}')

return eleveated_attack_value
bonus_defense_activator=[1, 2, 3, 4, 5, 6],

The same logic applies to the defense bonus:

bonus_defense_activator=[1, 2, 3, 4, 5, 6]

If you define the full dice range the defending player does not need to activate the defense bonus. This player simply throws the dice two times to get the multiplicator.

    def calculateDefenseValue(self, character):

defense_value = character['attributes']['attribute_2'] + character['attributes']['attribute_4']
elevated_defense_value = defense_value

# roll the defense dice
if 'defense' in character['bonus']:
if random.choice(self.dice_range) in self.bonus_defense_activator:

dice_1 = random.choice(self.dice_range)
dice_2 = random.choice(self.dice_range)

if dice_1 > dice_2:
elevated_defense_value += dice_1
else:
elevated_defense_value += dice_2

if self.debug : print(f'\tbase defense: {defense_value}, elevated defense {elevated_defense_value}')

return elevated_defense_value

Finally the escape bonus:

escape_activator=[3]

And the logic behind it:

    def calculateEscapeValue(self, character):
# roll the escape dice
escape_success = False
if 'escape' in character['bonus']:
if random.choice(self.dice_range) in self.bonus_attack_activator:
escape_bonus = random.choice(self.dice_range)

if escape_bonus in self.escape_activator:
escape_success = True
else:
escape_success = False

if self.debug : print(f'\tescape success: {escape_success}')

return escape_success

There’s not much to explain here: The player can pull back from a fight, if the dice shows the required number.

As mentioned above, the “age” (attribute_2) and “euclidean reciprocal” (attribute_4) are the attributes that I want to optimize. I’m just defining a range for those attributes:

variable_attributes = {
'attribute_2': range(1, 50, 1), # min, max
'attribute_4': range(1, 30, 1), # min, max
}

The algorithm will now choose random values between 1 and 50 respectively 1 and 30 for every character variation it creates.

Completion

Now, with every starting parameter set, I initalize the “game engine”:

gameEngine = GameEngine(
debug=False,
bonus_attack_activator=[1, 6],
bonus_defense_activator=[1, 2, 3, 4, 5, 6],
escape_activator=[3],
dice_range=[1, 2, 3, 4, 5, 6],
variable_attributes=variable_attributes)

This will also load a “base set of attributes” for all 27 characters from a JSON-file. It contains attributes I defined from the scratch, as a starting point. It looks like that:

{
"character_1": {
"attributes": {
"attribute_1": 0, # remember, this is the amount of edges, fixed
"attribute_2": 42, # the age, this can be randomized
"attribute_3": 1, # the symmetry bonus, fixed
"attribute_4": 2 # the euclidean reciprocal, can be randomized
},
"bonus": ["escape"]
},
...
}

The first step now is to find n random variations for the base set of all characters to randomize attribute_2 and attribute_4:

gameEngine.randomizeCharacterAttributes(variations=10)

No magic here. The higher the number of variations is, the more iterations the full process will do.

Starting the simulation

Real games

After that I will start the simulation for real games:

gameEngine.simulateRealGames(
games=1,
round_limit=100,
players=4,
characters_per_stack=3,
sibblings_per_character=1)

The amount of games I want to simulate should be clear. The round limit is a threshold to prevent a single game from running endlessly. The limit of 100 is pretty high. Imagine you’re playing a card game with your friends and after 100 rounds you’re still playing. Motivating, right? The amount of players should be clear, too.

The amount of characters per stack simply defines with how many cards every player starts initially. I have 27 different characters, so 4 players allows 6 cards which leaves 3 character cards in the pool.

The sibblings per character parameter allows to increase the pool size. If set to 2, you have 27 * 2 cards to play with and therefore more cards per every player’s stack.

Be warned: The amount of iterations comes from the formular games times variations and 1 base varation for 27 characters and maximum 1.000 rounds:

27 * 27 * 11 * 100 * 100 = 80.190.000 iterations max

That’s quite high. But it should give you a statistical useful result. I kept it simple and only simulated 1 game right now.

All vs all

I also migrated the inital approach which allows me to find out how powerful every character is. This simply initiates a fight of every character against every other character.

gameEngine.simulateAllvsAll(games=100)

There’s not much to explain here. This will loop through every variation created above. Be warned again: 100 games for 10 variations and 1 base variation for 27 characters means:

27 * 27 * 11 * 100 = 801.900 iterations

The statistics

Those two lines, or at least the last one, will finally return the desired results:

gameEngine.exportGameStatsToCsv()

gameEngine.showSummary()

The first method creates three CSV-files. One file called “stats.csv” contains all stats from both simulation for every variation, game and round. For the above mentioned run this file has over 7 MBytes. One line for each “fight”. A lot of data to analyze. Luv it.

The second file “stats_per_character.csv” contains stats for each charachter. This will help to understand, how the “power” is being distributed amongst all characters.

The third file “stats_summary.csv” contains a summery of all simulated “real games.” The method showSummary() will also return this information, which is right now the most important one:

The first column contains all variations. Keep in mind: variation 0 is the one I defined in advance and 1 to 10 are the ones containing random values for attribute_2 (age) and attribut_4 (euclidean reciprocal) for each character.

As mentioned above, I only simulated 1 game per variation for the real game simulator. The third column shows how long it took until the game ended and a player wins. And finally the last column showing what player wins.

Conclusion

Let’s look at the statistics for varation no. 10, where it only takes 15 rounds to finish the game. Which seems a good limit for a casual card game — but remember that I only “played” 1 game. Remember, when I first tried to find a good balance for the characters, to give them a fair chance? This is how the stats look now:

As you can see, character_24 is clearly the most powerful character in the set. 49 wins and only 3 loses lead to a win/lose rate of over 16, which outperforms the others with no doubt.

Retrospectively I can say, that it is pretty obvious that a balanced game does not require the same strength for all characters. This could work, but then I must add more strategic options to the game.

Well, I am far away from being a game designer. And this script does not return the perfect definition for my card game, but it helps me finding one and also gives a good understanding of the game mechanic. It allows me to easily fine tune the parameters of the game to find a perfect setup for a…hopefully… satisfying game.

If you are a game designer or have some knowledge in this field, I’d happy to get a comment on how I am performing in this area.

Source

You can find the Jupyter notbook on https://gist.github.com/nickyreinert/05dc269fc5091d9ecc7b03836970cab0

--

--

Nicky Reinert

generalist with many interests, developer, photograph, author, gamer