Skip to content
wannacode

The whole game and the balance

In short: Balance is measured with a simulation: thousands of seeded fights, a win percentage for every pair. Numbers are tuned one at a time with a sweep at the same seed, and a recorded-playthrough test of the whole game catches every change in behaviour.

Problem

Is the game any good?

The game is finished. The map reveals itself, fights run by speed, wounds bleed, enemies chase, a save loads. What remains is a question the code does not answer: is it good? Can the Witch beat the Warden of the Depths, or does the Barbarian walk through him? If you play, you will fight ten times and form an opinion. But ten fights are noise: in this lesson one and the same question (“Barbarian against the Warden”) gives 30% with one seed and 55% with another when there are only 20 fights.

We need a way to play thousands of fights in seconds, the same way every time, without drawing anything. That is a simulation: fights run without a screen, AI against AI, and the result is a table of percentages. It is how you find the numbers the game does not have yet: how much life the Warden needs to be hard but beatable.

The lesson does it twice. First on a small toy, so you can see every step and run it in four languages: the fight, the table, the noise, the tuning and the recorded test. Then on the real game, which already has --simulate. At the end come the whole game, to play and download, and ideas for continuing it.

The idea

A fight without a screen

The rules of a fight in the game do not know about the screen: the screen only calls them and draws the result (the lesson on the screen stack). So the same rules can run without a screen. The toy below is the smallest such thing: three heroes against four enemies, each with four numbers (life, accuracy, minimum and maximum damage). It has no speed, critical hits or effects: those are in the combat system, and here we only need the shape.

First the generator. The game uses mulberry32 from the first lesson; this one is even smaller (Park and Miller) so it fits, but it does the same job: the same seed, the same numbers.

class Rng:
def __init__(self, seed):
self.state = seed
def next(self, n):
self.state = self.state * 48271 % 2147483647
return self.state % n

The biggest product is about 10¹⁴, below 2⁵³, so even JavaScript computes it exactly with nothing extra. The remainder % n gives a number from 0 to n − 1.

The strike and the fight:

# One strike: 0 on a miss, otherwise the damage. The roll is 0 to 99.
def strike(rng, attacker):
if rng.next(100) < attacker.hit:
return attacker.min + rng.next(attacker.max - attacker.min + 1)
return 0
# The fight: the hero strikes first. It ends when someone has no life left.
def fight(rng, hero, enemy):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
enemy_life -= strike(rng, hero)
if enemy_life <= 0:
return True, turns
hero_life -= strike(rng, enemy)
if hero_life <= 0:
return False, turns

A strike rolls a number from 0 to 99. If it is less than the accuracy (hit), it is a hit and the damage is a random number between the minimum and the maximum. Otherwise it is a miss. The fight gives a turn to the hero, then to the enemy, until someone has no life left. It returns win or loss and how many turns the hero took.

# One strike: 0 on a miss, otherwise the damage. The roll is 0 to 99.
def strike(rng, attacker):
    if rng.next(100)  attacker.hit:
        return attacker.min + rng.next(attacker.max - attacker.min + 1)
    return 0

The idea

The balance table

One fight tells nothing. So fights are run many times with a shared generator: the hero’s wins and turns are added up.

def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
# Many fights with one shared generator: the hero’s wins and turns.
def duels(rng, hero, enemy, fights):
wins = 0
turns = 0
for _ in range(fights):
win, hero_turns = fight(rng, hero, enemy)
if win:
wins += 1
turns += hero_turns
return wins, turns

The percentage is integer division (wins × 100 ÷ fights, rounded down), and the average turns are in tenths: turns * 10 / fights, then X.Y. That way there are no fractions and the output is the same in all four languages. The table is simulate: rows are the enemies, columns are the heroes.

# The table: rows are the enemies, columns are the heroes.
def simulate(heroes, enemies, fights, seed):
rng = Rng(seed)
header = "Enemy".ljust(10)
for hero in heroes:
header += hero.name.rjust(12)
print(header)
totals = [0] * len(heroes)
for enemy in enemies:
line = enemy.name.ljust(10)
for h in range(len(heroes)):
wins, turns = duels(rng, heroes[h], enemy, fights)
totals[h] += percent(wins, fights)
line += cell(wins, fights, turns).rjust(12)
print(line)
average = "Average".ljust(10)
for total in totals:
average += (str(total // len(enemies)) + "%").rjust(12)
print(average)
heroes = [
Fighter("Barbarian", 40, 70, 4, 9),
Fighter("Amazon", 40, 80, 3, 7),
Fighter("Witch", 24, 85, 3, 8),
]
enemies = [
Fighter("Rat", 9, 60, 1, 3),
Fighter("Wolf", 18, 70, 2, 5),
Fighter("Golem", 40, 60, 3, 6),
Fighter("Warden", 54, 70, 3, 7),
]
simulate(heroes, enemies, 200, 7)
Output
Enemy Barbarian Amazon Witch
Rat 100% 2.7 100% 2.7 100% 2.5
Wolf 100% 4.6 100% 5.0 98% 4.4
Golem 98% 9.5 93% 10.6 64% 8.0
Warden 54% 11.0 35% 11.3 7% 7.2
Average 88% 82% 67%

The rat and the wolf are beaten almost every time, the golem already hurts the Witch. The Warden is the boss: the Barbarian beats him 54% of the time, the Amazon 35%, the Witch 7%. If we want the Warden to be hard but beatable for every class, one hero is outside the band.

See it

How many fights are enough, and how to tune a number

The scene shows two questions. On top: how many fights are enough for the percentage to settle? Below: how do you get the Witch into the band by changing one number?

# One strike: 0 on a miss, otherwise the damage. The roll is 0 to 99.
def strike(rng, attacker):
if rng.next(100) < attacker.hit:
return attacker.min + rng.next(attacker.max - attacker.min + 1)
return 0
# The fight: the hero strikes first. It ends when someone has no life left.
def fight(rng, hero, enemy):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
enemy_life -= strike(rng, hero)
if enemy_life <= 0:
return True, turns
hero_life -= strike(rng, enemy)
if hero_life <= 0:
return False, turns
def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
# Many fights with one shared generator: the hero’s wins and turns.
def duels(rng, hero, enemy, fights):
wins = 0
turns = 0
for _ in range(fights):
win, hero_turns = fight(rng, hero, enemy)
if win:
wins += 1
turns += hero_turns
return wins, turns
# The win percentage with a new generator from the given seed.
def win_rate(hero, enemy, fights, seed):
wins, _ = duels(Rng(seed), hero, enemy, fights)
return percent(wins, fights)
# The balance band: the Warden is beaten in 30 to 60 of 100 fights.
def in_band(rate):
return 30 <= rate <= 60
# The witch’s life from 24 to 40: every row uses the same seed, only the life differs.
def sweep(warden, fights, seed):
for life in range(24, 41, 4):
witch = Fighter("Witch", life, 85, 3, 8)
rate = win_rate(witch, warden, fights, seed)
print(witch.name + ", life " + str(life) + ": " + str(rate) + "%" + (" ✓" if in_band(rate) else ""))
Step by step, in words
  1. One fight tells nothing. The Barbarian against the Warden wins once and loses once: after the first fight the percentage is 0%, after the second 50%. Each dot is the win percentage after that many fights (the scale is logarithmic).
  2. The loop adds up the wins: 60% after 5 fights, 37% after 8, 41% after 12. The dots still jump, because every fight is a roll of the dice.
  3. The percentage is wins × 100 ÷ fights, with integer division. After 50 fights it is 40%, after 200 48%: the swings get smaller.
  4. After 1000 fights it is 48% and the green line shows where it settled. The same question with five different seeds: with 20 fights the answers lie between 30% and 55%, with 1000 between 48% and 51%.
  5. The table of 200 fights against the Warden: Barbarian 54%, Amazon 35%, Witch 7%. The band is 30 to 60: two are inside, the Witch is far below it.
  6. Tuning: only one number changes, the Witch’s life, and every row uses the same seed. At 24 life it is 5% (the table gave 7%: another generator, other luck).
  7. 28 life: 13%, 32: 21%. Her bar grows with her life, but it is still below 30.
  8. 36 life: 36%, the bar turns green: it is inside. The smallest change that does the job.
  9. 40 life: 51%: also inside, but now the Witch beats the Warden more often than the Amazon does (35%). The sweep shows the whole curve; the choice is yours.
  10. Checking the choice, 36 life, with 1000 fights: 38%. It is still inside, and the number is steadier than the one from 200 fights.

The code next to the scene is the fight, the loop over fights and the sweep from the programs of this lesson. At each step the lines that are working at that moment light up.

The idea

Noise: the same question, another seed

A percentage from a simulation is an estimate, not the truth. Every seed gives a different series of rolls, and with it a different number. How far apart are the answers? Here is one and the same question (Barbarian against the Warden) with five seeds and two sizes:

# The win percentage with a new generator from the given seed.
def win_rate(hero, enemy, fights, seed):
wins, _ = duels(Rng(seed), hero, enemy, fights)
return percent(wins, fights)
barbarian = Fighter("Barbarian", 40, 70, 4, 9)
warden = Fighter("Warden", 54, 70, 3, 7)
print(barbarian.name + " vs " + warden.name + ":")
for fights in [20, 1000]:
line = str(fights) + " fights:"
for seed in range(1, 6):
line += " " + str(win_rate(barbarian, warden, fights, seed)) + "%"
print(line)
Output
Barbarian vs Warden:
20 fights: 50% 45% 55% 50% 30%
1000 fights: 51% 51% 48% 50% 48%

With 5 seeds and 20 fights the answers lie between 30% and 55%: a spread of 25 points for one and the same question. With 1000 fights they lie between 48% and 51%, a spread of 3. The rule: the noise shrinks with the square root of the number of fights. To halve the error you need four times as many fights.

Two different things must not be mixed up. Repeatability: the same seed gives exactly the same number, so a test and a colleague see the same thing. Noise: two different seeds give different numbers, and the question is which to believe. Repeatability is a property of the code, noise is a property of the question.

The real game is no different. A run with 200 fights (as in the recorded output of the site check) gives 41%, 58% and 55% for the Warden, and with 1000 fights 44%, 58% and 54%. The Barbarian moves by 3 points without anything in the game being changed.

The idea

Tuning: one number at a time

Write the goal down before touching any numbers: the Warden is beaten in 30 to 60 of 100 fights. That band is the measure, not an opinion of the moment. The Witch is below it. What should change? There are many options (more life, a stronger spell, a weaker Warden), but the rule is one: change one number and compare at the same seed. Otherwise you do not know which change did the work.

The sweep runs the Witch with 24, 28, 32, 36 and 40 life. Every row uses the same seed, so the rows differ only in the life:

# The balance band: the Warden is beaten in 30 to 60 of 100 fights.
def in_band(rate):
return 30 <= rate <= 60
# The witch’s life from 24 to 40: every row uses the same seed, only the life differs.
def sweep(warden, fights, seed):
for life in range(24, 41, 4):
witch = Fighter("Witch", life, 85, 3, 8)
rate = win_rate(witch, warden, fights, seed)
print(witch.name + ", life " + str(life) + ": " + str(rate) + "%" + (" ✓" if in_band(rate) else ""))
warden = Fighter("Warden", 54, 70, 3, 7)
sweep(warden, 200, 7)
tuned = Fighter("Witch", 36, 85, 3, 8)
tuned_rate = win_rate(tuned, warden, 1000, 7)
print(tuned.name + ", life 36, 1000 fights: " + str(tuned_rate) + "%" + (" ✓" if in_band(tuned_rate) else ""))
Output
Witch, life 24: 5%
Witch, life 28: 13%
Witch, life 32: 21%
Witch, life 36: 36% ✓
Witch, life 40: 51% ✓
Witch, life 36, 1000 fights: 38% ✓

The curve grows with the life. At 36 life the Witch is at 36% and enters the band; at 40 she is at 51%, which is already more than the Amazon (35%). You choose the smallest change that does the job: 36. The last row checks the choice with 1000 fights: 38%, still inside. The first row (24 life) gives 5%, while the table gave 7% for the same hero: a different stream of numbers, different luck, not a different game.

After the tuning you record the playthrough of the game again (the next card), to see that only what you meant to change has changed.

The idea

The same on the real game

The toy has four numbers per fighter. The real game has hundreds, but the shape is the same and it is already built in. The region simulate is the same thing, on the real game:

# Balance: hundreds of battles without a screen, AI against AI. The hero's level
# matches the enemy's depth; the table shows the win rate and the average number of turns.
SIM_LEVELS = [1, 1, 3, 5, 7]
# The simulated hero learns the lowest step of the tree (on a tie, the first branch).
def sim_skill_choice(hero):
best = ''
for skill_id in learnable_skills(hero):
if best == '' or SKILLS[skill_id].tier < SKILLS[best].tier:
best = skill_id
return best
def make_sim_hero(game, hero_class, level):
hero = create_hero(game, cap(nm(hero_class)), hero_class.id)
for next_level in range(2, level + 1):
raise_level(hero)
skill = sim_skill_choice(hero)
if next_level % 2 == 0 and skill != '':
hero.skills.append(skill)
elif hero_class.id == 'barbarian':
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='strength', amount=1))
elif hero_class.id == 'amazon':
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='agility', amount=1))
else:
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='maxHp', amount=8))
if level >= 4:
hero.equipment['weapon'] = hero_class.better_weapon
hero.equipment['armor'] = hero_class.better_armor
hero.hp = hero.max_hp()
hero.mana = hero.max_mana()
hero.inventory.add('potion_heal', level // 2)
return hero
def simulate_fight(game, hero_class, kind):
game.hero = make_sim_hero(game, hero_class, SIM_LEVELS[kind.depth])
battle = Battle(game, [Enemy(game.new_id(), kind)], False)
battle.auto_hero = True
battle.advance()
return SimpleNamespace(win=battle.outcome == 'win', turns=battle.hero_turns)
def simulate(io, fights, seed):
game = Game(io, seed)
game.simulation = True
io.write(t('sim.title', {'fights': fights, 'seed': seed}) + '\n')
io.write(t('sim.levels') + '\n\n')
header = pad(t('sim.enemy'), 26) + pad_left(t('sim.level'), 5)
for hero_class in CLASSES.values():
header += pad_left(cap(nm(hero_class)), 14)
io.write(header + '\n')
totals = []
for i in range(len(CLASSES)):
totals.append(0)
rows = 0
for kind in ENEMY_KINDS.values():
if kind.depth == 0:
continue
rows += 1
line = pad(cap(nm(kind)), 26) + pad_left(str(SIM_LEVELS[kind.depth]), 5)
column = 0
for hero_class in CLASSES.values():
wins = 0
turns = 0
for fight in range(fights):
result = simulate_fight(game, hero_class, kind)
if result.win:
wins += 1
turns += result.turns
win_share = percent(wins, fights)
totals[column] += win_share
column += 1
tenths = (turns * 10) // fights
line += pad_left(str(win_share) + '% ' + str(tenths // 10) + '.' + str(tenths % 10), 14)
io.write(line + '\n')
footer = pad(t('sim.average'), 31)
for total in totals:
footer += pad_left(str(total // rows) + '%', 14)
io.write(footer + '\n\n' + t('sim.note') + '\n')

The differences from the toy are for realism, not for the idea. The hero has a level that depends on the depth of the enemy (SIM_LEVELS: 1, 3, 5, 7), learns skills in a simple order (simSkillChoice), wears better equipment from level 4 and drinks potions; the fight is the real Battle, run with autoHero, i.e. the hero on autopilot chooses by the utility score from the lesson on AI. Everything else is the game: nothing was copied for the simulation.

It is run with a flag: node arcanum.js --simulate=1000 --seed=7 --plain (the same on the other languages). This is the output I got for 1000 fights per pair:

Output
Enemy Level Barbarian Amazon Witch
Giant rat 1 100% 2.4 100% 1.3 100% 1.2
Goblin spearman 1 100% 2.4 100% 2.5 100% 2.0
Goblin shaman 1 100% 2.4 100% 2.3 100% 2.6
Blood wolf 1 99% 4.0 99% 3.5 92% 3.2
Wolf leader 3 78% 10.0 93% 7.2 77% 5.8
Skeleton 3 99% 11.3 100% 4.4 100% 3.0
Cultist 3 99% 5.9 100% 3.5 100% 3.3
Ore golem 5 100% 7.6 97% 14.2 99% 7.6
Warden of the Depths 7 44% 21.0 58% 19.4 54% 12.3
Average 91% 94% 91%

The Warden’s row is 44%, 58% and 54%: inside the band of 40 to 60 that we set for the real game. That is how it was tuned: the boss is hard but beatable for every class.

What happens if you start touching things? Enemies are data, not code (the lesson on items and data from tables). Here is the Warden’s row:

# id|en|de|es|glyph|color|life|strength|agility|defense|speed|min|max|kind|xp|gold|loot|resist|weak|immune|skill|mood|depth
warden|Warden of the Depths|Wächter der Tiefe|Guardián abisal|W|red|120|5|4|3|10|5|9|blunt|150|50|warden|-|-|bleed,poison|warden|guard|4

His life is the number 120. If you change it to 100 and run the same simulation, the Warden is beaten in 60%, 70% and 72% instead of 44%, 58% and 54%. Twenty life less makes the boss easy for everyone. The site check runs this variant on JavaScript too, so the numbers here were run, not made up.

More precisely, if you are curious

The simulation is part of the game itself, because it needs access to the insides: Battle and Enemy. It is not an outside script that reads the screen. That is also why the main site check (the next card) runs it on four languages with seed 7 and 200 fights and compares it with one file.

One more detail is deliberately simple: the simulated hero takes the lowest step of the skill tree. A real player will choose more cleverly, so the table says how hard the Warden is for a simple bot, not for the best player.

The idea

A recorded-playthrough test

How do you know that changing one number did not break something else? The game has a recorded-playthrough test (golden test, snapshot test). The file playthrough.txt has 230 commands, one per line, from creating the hero to beating the Warden. The first four:

playthrough.txt
1
Iva
1
(Enter)

They are fed to the game as input: --plain --seed=2026 --memory-saves (no colours, a fixed seed, saves in memory instead of files). The game prints every frame, and the output is 4395 lines. Here are the frames after the first two commands, with > command in front:

> 1
A new hero
What is your hero called?
Up to 12 characters: letters, digits, space, dash.
> Iva
A new hero
Iva, choose a class:
1) Barbarian: life 48, mana 4, strength 7, agility 4
An axe whose wounds bleed. Lots of life. Skill: Rage
(strength for defense).
2) Amazon: life 34, mana 10, strength 3, agility 8
A spear and a hunter's eye: accurate, often critical
hits. Skill: Double shot.
3) Witch: life 26, mana 20, strength 1, agility 5
Little life, much mana. Fireball, Poison cloud and Heal.

The output is stored in a file, playthrough.en.out. The site check runs the game in each of the four languages with each of the four text languages and compares byte for byte with the file: 20 comparisons together with the simulation. A change in the rules, in the order of the rolls or in a printed text changes the output, and the check fails.

Here is a small model of the same thing. A fight of the Barbarian against the wolf is recorded (seed 2027); then one number is changed (the wolf’s maximum damage, from 5 to 6) and the result is compared with the recording:

# One line of the fight log.
def describe(turn, who, target, damage, left):
if damage == 0:
return str(turn) + ". " + who + " misses"
return str(turn) + ". " + who + " hits " + str(damage) + " → " + target + " " + str(left)
def fight(rng, hero, enemy, log):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
damage = strike(rng, hero)
enemy_life -= damage
log.append(describe(turns, hero.name, enemy.name, damage, max(0, enemy_life)))
if enemy_life <= 0:
return True, turns
damage = strike(rng, enemy)
hero_life -= damage
log.append(describe(turns, enemy.name, hero.name, damage, max(0, hero_life)))
if hero_life <= 0:
return False, turns
# The first line where the two logs differ (0 if they are the same).
def first_difference(a, b):
for i in range(max(len(a), len(b))):
if i >= len(a) or i >= len(b) or a[i] != b[i]:
return i + 1
return 0
def line_at(lines, at):
return lines[at - 1] if at <= len(lines) else "-"
# The test: a fight with seed 2027 compared with the recording.
def check(label, recorded, hero, wolf):
log = []
fight(Rng(2027), hero, wolf, log)
at = first_difference(recorded, log)
if at == 0:
print(label + ": ok, " + str(len(log)) + " lines")
else:
print(label + ": line " + str(at))
print(" expected: " + line_at(recorded, at))
print(" got: " + line_at(log, at))
recorded = [
"1. Barbarian hits 6 → Wolf 12",
"1. Wolf misses",
"2. Barbarian misses",
"2. Wolf misses",
"3. Barbarian misses",
"3. Wolf hits 3 → Barbarian 37",
"4. Barbarian hits 4 → Wolf 8",
"4. Wolf hits 2 → Barbarian 35",
"5. Barbarian hits 5 → Wolf 3",
"5. Wolf misses",
"6. Barbarian hits 5 → Wolf 0",
]
hero = Fighter("Barbarian", 40, 70, 4, 9)
check("wolf, max damage 5", recorded, hero, Fighter("Wolf", 18, 70, 2, 5))
check("wolf, max damage 6", recorded, hero, Fighter("Wolf", 18, 70, 2, 6))
Output
wolf, max damage 5: ok, 11 lines
wolf, max damage 6: line 6
expected: 3. Wolf hits 3 → Barbarian 37
got: 3. Wolf hits 5 → Barbarian 35

The program does not say “there is a difference” but which line is the first to differ: line 6 of 11. Up to that line the behaviour is the same, so the difference is where the change is, and it is read in seconds. The first 5 lines are identical because the wolf rolls the same numbers: only the range the damage is taken from has changed.

When the test fails there are two cases:

  • The change was intended (you tuned a number). You read the difference, see that it is only what you expected, and record the playthrough again.
  • The change was unexpected. That is a bug that would otherwise have reached a player.

Never record again without reading the difference: then the test protects everything, even the bug. And the other way round: a green test does not mean the game is correct or fun, only that it is the same as when it was recorded.

The idea

The whole game

Here is what we built in the lessons and where it is in the game. The regions are from the JavaScript version, the lines are at top level:

LessonRegionsLines
game-loopmain game353
screen-stackscreen screen-stack92
screen-resultstarget-screen overlays465
console-renderbuffer terminal hud168
map-fovlevel fov map-load map-screen500
entitiesentity234
combatcombat-order attack battle-screen381
wounds-effectseffects bleeding198
actionsactions replay replay-screen234
aipathfind ai-map ai-battle216
items-lootitems-data inventory equip loot shop267
quests-eventsxp-levelup event-bus quests dialog dialogs-data skills-data296
save-loadsave load311
arcanum-finalsimulate node137

The whole thing is about 5500 lines in each language; the rest is data, texts, loading them and the screens of the title, the town and the hero. The table is not a complete breakdown, but a guide: where to look when you want to change something.

Play it to the end (it is the same game as in the first lesson) and download it:

Chronicles of Arcanum

The mine below Greyford has been silent for three days. There is blood on the path.

Click the screen to play with the keys (arrows or WASD, I, M, digits); Enter goes to the command line. Saves stay in this browser.

The files are: [Arcanum.java](/games/arcanum/en/Arcanum.java), [Arcanum.cs](/games/arcanum/en/Arcanum.cs), [arcanum.py](/games/arcanum/en/arcanum.py), [arcanum.js](/games/arcanum/en/arcanum.js). Put one in an empty folder and run it:

Terminal
python arcanum.py

With --simulate=1000 it prints the balance table instead of playing, with --seed=42 the world is always the same, with --lang=de the game is in German (there are also bg and es), with --plain there are no colours. Saves go in a folder saves next to the game.

The idea

How to continue it

The game is built so that most things can be added without touching the loop. A few ideas, from the smallest to the biggest, with the place in the code to start from:

  1. A new enemy. One row in the enemy table (enemies-data), a glyph and colour, a place on the map (maps-data). Then the simulation tells you if it is too easy. Nothing in the code is touched.
  2. A new class. A row in CLASSES_DATA, skills in skills-data and texts in four languages. The simulation shows the new class’s column next to the other three. Tune it with the sweep from this lesson until it enters the band.
  3. A new quest. A dialogue in dialogs-data and a subscription to the events (quests): the small “bring ore” errand is the shortest example in the game.
  4. A new effect. Freezing or slowing in effects: choose the stacking rule (does it extend, does it add up) the way bleeding and poison did.
  5. A smarter enemy. Replace the breadth-first search with A* or a distance field (see “More precisely” in the lesson on AI) or give the wolves a pack tactic.
  6. Another view. Terminal is an interface with two kinds; a third can draw on a canvas or in a window without the screens knowing. Input and output are separated (io) exactly for that.
  7. A language. The texts are tables by language: you add a fifth language with one more copy of the table of translations; if a key is missing, the game stops with missing text instead of skipping it silently.
  8. A new port. The game exists in four languages with one output. A fifth (Rust, Go, Kotlin) is checked against the same playthrough.out: until it is byte for byte the same, the port is not done.
  9. Daily mode. A seed from the date, and everyone plays the same world; the replay of a fight (the lesson on commands) already records the moves.

Whatever you do, run the recorded-playthrough test first, then change things. If it fails, read the first difference. That is the whole lesson on balance and on maintaining a game in one.

This is the end of the module. We started with the question “how is a game put together” and arrived at a whole game in four languages with blood, speed-ordered fights, enemies that think, a save that is refused when broken, and a tool that tells you whether it is any good. The rest is up to you.

Try it

Predict the output

The function cell writes one cell of the table: the win percentage and the average turns. What does the program print?

def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
print(cell(1, 3, 7))
print(cell(2, 3, 10))
print(cell(3, 3, 9))
Answer

Try it

Find the bug

A colleague wants to make the simulation “cleaner” and gives every fight a new generator with the same seed. The table no longer looks like anything:

heroes = [
Fighter("Barbarian", 40, 70, 4, 9),
Fighter("Amazon", 40, 80, 3, 7),
Fighter("Witch", 24, 85, 3, 8),
]
golem = Fighter("Golem", 40, 60, 3, 6)
for hero in heroes:
wins, turns = duels_bug(7, hero, golem, 200)
print(hero.name + ": " + cell(wins, 200, turns))
Output
Barbarian: 100% 10.0
Amazon: 100% 11.0
Witch: 0% 8.0

Against the golem the heroes win 100%, 100% and 0%, and the turns are whole numbers. Which line is to blame?

Pick the line with the bug.

More precisely

More precisely: noise, bots and recordings

How big is the error. For a percentage p out of n fights the standard error is √(p(1 − p) / n). At p = 50% it is 11.2 points with 20 fights, 3.5 with 200 and 1.6 with 1000. Twice the standard error (about 95% confidence) makes the interval ±7 points at 200 fights. So a difference of 3 points between two versions of a tuning means nothing at 200 fights, while a difference of 20 means a lot. A rule: run more fights where the decision is on the edge.

Do not tune to one seed. If you pick the number until it looks good at seed 7, you are tuning to the luck of seed 7. Check with a few seeds (say three) and take a sensible average.

A bot is not a player. The simulated hero makes the simplest decisions. The table says what the balance is for such a bot; a real player is smarter (chooses skills, runs away in time) or dumber (does not drink a potion). So a simulation catches the gross mistakes (a class is useless, the boss is unbeatable), and fine tuning is done with players.

Win percentage is not fun. Besides wins you measure the average turns (the column above), the life left over, how many potions were drunk. A fight won 100% of the time with 1 life left on the hero is not the same as one won 100% with 40.

What breaks repeatability. The iteration order of a hash table, fractions, the current date, the system’s language settings, an unstable sort without a second rule. The game avoids them (ordered collections, integers, a second rule when sorting), and the check on four languages catches any return of the problem.

Which commit changed the behaviour? The test is automatic and fast, so git bisect can run it and find the commit after which the recording no longer matches.

How many tests are needed. The recorded playthrough is an integration test: it catches everything, but says only “here is the first difference”, not “here is the bug”. Next to it stand the small tests of single rules (damage, stacking effects). We also ran 300 games with random commands while building the game: no crash and no number that was not a number. That is how layers are made.

Exercise

The hardest enemy

Solve the task in your editor. Start from the program for the table.

The hardest enemy. After the table, print for every hero the enemy against whom the win percentage is lowest, and that percentage. The order of the heroes is as in the table. On equal percentages the first enemy wins. The generator is the same (one shared by all pairs, seed 7, 200 fights) so the numbers match the table.

The output is:

Output
Hardest enemy for Barbarian: Warden (54%)
Hardest enemy for Amazon: Warden (35%)
Hardest enemy for Witch: Warden (7%)

More tasks

  • A new class. Add a fourth hero (say a hunter with 32 life, accuracy 90 and damage from 2 to 6) and look at its column. Tune it with a sweep until the Warden is in the band.
  • Confidence. Show the standard error (√(p(1 − p) / n)) next to every percentage, so you can see which differences are noise.
  • Automatic tuning. Write a function that raises the Witch’s life in steps of 1 until the percentage against the Warden enters the band, and returns it. Why must the percentage grow with the life for this search to work, and can you speed it up with a binary search?
  • Arguments. Let --fights= and --seed= be read from the program’s arguments, as the game does.

Test

Check yourself

1. The Barbarian beats the Warden in 50%, 30% or somewhere in between when only 20 fights are run with different seeds. With 1000 fights the answers lie between 48% and 51%. Why is a simulation better evidence than a few fights played by hand?
2. The sweep over the Witch’s life starts every row with a new generator from the same seed. Why?
3. After you change one number in an enemy’s data the recorded-playthrough test fails. What do you do?
4. The Witch with 36 life wins 36% with 200 fights and 38% with 1000 fights. Which number do you trust more?
5. The recorded-playthrough test is green. What does that prove?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·