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 { long state;
Rng(long seed) { state = seed; }
int next(int n) { state = state * 48271 % 2147483647; return (int) (state % n); }}state * 48271 is bigger than an int, so the state is a long. 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.static int strike(Rng rng, Fighter 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.static Result fight(Rng rng, Fighter hero, Fighter enemy) { int heroLife = hero.hp(); int enemyLife = enemy.hp(); int turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return new Result(true, turns); heroLife -= strike(rng, enemy); if (heroLife <= 0) return new Result(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.
static int strike(Rng rng, Fighter attacker) {
if (rng.next(100) attacker.hit()) {
return attacker.min() + rng.next(attacker.max() - attacker.min() + 1);
}
return 0;
}The roll is from 0 to 99, and accuracy 70 means 70 out of 100. The winning numbers are 0 to 69: less than 70. With <= there would be 71, and every strike would be 1% more accurate than written. No single fight shows such a mistake, but the table does.
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.
static int percent(int wins, int fights) { return wins * 100 / fights;}
static String cell(int wins, int fights, int turns) { int tenths = turns * 10 / fights; return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;}
// Many fights with one shared generator: the hero’s wins and turns.static Tally duels(Rng rng, Fighter hero, Fighter enemy, int fights) { int wins = 0; int turns = 0; for (int i = 0; i < fights; i++) { Result result = fight(rng, hero, enemy); if (result.win()) wins++; turns += result.turns(); } return new Tally(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.static void simulate(Fighter[] heroes, Fighter[] enemies, int fights, long seed) { Rng rng = new Rng(seed); String header = String.format("%-10s", "Enemy"); for (Fighter hero : heroes) header += String.format("%12s", hero.name()); System.out.println(header); int[] totals = new int[heroes.length]; for (Fighter enemy : enemies) { String line = String.format("%-10s", enemy.name()); for (int h = 0; h < heroes.length; h++) { Tally tally = duels(rng, heroes[h], enemy, fights); totals[h] += percent(tally.wins(), fights); line += String.format("%12s", cell(tally.wins(), fights, tally.turns())); } System.out.println(line); } String average = String.format("%-10s", "Average"); for (int total : totals) average += String.format("%12s", total / enemies.length + "%"); System.out.println(average);}Fighter[] heroes = { new Fighter("Barbarian", 40, 70, 4, 9), new Fighter("Amazon", 40, 80, 3, 7), new Fighter("Witch", 24, 85, 3, 8),};Fighter[] enemies = { new Fighter("Rat", 9, 60, 1, 3), new Fighter("Wolf", 18, 70, 2, 5), new Fighter("Golem", 40, 60, 3, 6), new Fighter("Warden", 54, 70, 3, 7),};
simulate(heroes, enemies, 200, 7);Enemy Barbarian Amazon WitchRat 100% 2.7 100% 2.7 100% 2.5Wolf 100% 4.6 100% 5.0 98% 4.4Golem 98% 9.5 93% 10.6 64% 8.0Warden 54% 11.0 35% 11.3 7% 7.2Average 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.static int strike(Rng rng, Fighter 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.static Result fight(Rng rng, Fighter hero, Fighter enemy) { int heroLife = hero.hp(); int enemyLife = enemy.hp(); int turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return new Result(true, turns); heroLife -= strike(rng, enemy); if (heroLife <= 0) return new Result(false, turns); }}
static int percent(int wins, int fights) { return wins * 100 / fights;}
static String cell(int wins, int fights, int turns) { int tenths = turns * 10 / fights; return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;}
// Many fights with one shared generator: the hero’s wins and turns.static Tally duels(Rng rng, Fighter hero, Fighter enemy, int fights) { int wins = 0; int turns = 0; for (int i = 0; i < fights; i++) { Result result = fight(rng, hero, enemy); if (result.win()) wins++; turns += result.turns(); } return new Tally(wins, turns);}
// The win percentage with a new generator from the given seed.static int winRate(Fighter hero, Fighter enemy, int fights, long seed) { Tally tally = duels(new Rng(seed), hero, enemy, fights); return percent(tally.wins(), fights);}
// The balance band: the Warden is beaten in 30 to 60 of 100 fights.static boolean inBand(int rate) { return rate >= 30 && rate <= 60;}
// The witch’s life from 24 to 40: every row uses the same seed, only the life differs.static void sweep(Fighter warden, int fights, long seed) { for (int life = 24; life <= 40; life += 4) { Fighter witch = new Fighter("Witch", life, 85, 3, 8); int rate = winRate(witch, warden, fights, seed); System.out.println(witch.name() + ", life " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : "")); }}Step by step, in words
- 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).
- 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.
- The percentage is wins × 100 ÷ fights, with integer division. After 50 fights it is 40%, after 200 48%: the swings get smaller.
- 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%.
- 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.
- 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).
- 28 life: 13%, 32: 21%. Her bar grows with her life, but it is still below 30.
- 36 life: 36%, the bar turns green: it is inside. The smallest change that does the job.
- 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.
- 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.static int winRate(Fighter hero, Fighter enemy, int fights, long seed) { Tally tally = duels(new Rng(seed), hero, enemy, fights); return percent(tally.wins(), fights);}Fighter barbarian = new Fighter("Barbarian", 40, 70, 4, 9);Fighter warden = new Fighter("Warden", 54, 70, 3, 7);System.out.println(barbarian.name() + " vs " + warden.name() + ":");for (int fights : new int[] { 20, 1000 }) { String line = fights + " fights:"; for (long seed = 1; seed <= 5; seed++) { line += " " + winRate(barbarian, warden, fights, seed) + "%"; } System.out.println(line);}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.static boolean inBand(int rate) { return rate >= 30 && rate <= 60;}
// The witch’s life from 24 to 40: every row uses the same seed, only the life differs.static void sweep(Fighter warden, int fights, long seed) { for (int life = 24; life <= 40; life += 4) { Fighter witch = new Fighter("Witch", life, 85, 3, 8); int rate = winRate(witch, warden, fights, seed); System.out.println(witch.name() + ", life " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : "")); }}Fighter warden = new Fighter("Warden", 54, 70, 3, 7);sweep(warden, 200, 7);Fighter tuned = new Fighter("Witch", 36, 85, 3, 8);int tunedRate = winRate(tuned, warden, 1000, 7);System.out.println(tuned.name() + ", life 36, 1000 fights: " + tunedRate + "%" + (inBand(tunedRate) ? " ✓" : ""));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.static final int[] SIM_LEVELS = {1, 1, 3, 5, 7};
// The simulated hero learns the lowest step of the tree (on a tie, the first branch).static String simSkillChoice(Hero hero) { String best = ""; for (String skillId : learnableSkills(hero)) { if (best.equals("") || SKILLS.get(skillId).tier < SKILLS.get(best).tier) best = skillId; } return best;}
static Hero makeSimHero(Game game, HeroClass heroClass, int level) { Hero hero = createHero(game, cap(nm(heroClass)), heroClass.id); for (int next = 2; next <= level; next++) { raiseLevel(hero); String skill = simSkillChoice(hero); if (next % 2 == 0 && !skill.equals("")) hero.skills.add(skill); else if (heroClass.id.equals("barbarian")) applyLevelChoice(hero, new LevelChoice("stat", "strength", 1)); else if (heroClass.id.equals("amazon")) applyLevelChoice(hero, new LevelChoice("stat", "agility", 1)); else applyLevelChoice(hero, new LevelChoice("stat", "maxHp", 8)); } if (level >= 4) { hero.equipment.put("weapon", heroClass.betterWeapon); hero.equipment.put("armor", heroClass.betterArmor); } hero.hp = hero.maxHp(); hero.mana = hero.maxMana(); hero.inventory.add("potion_heal", level / 2); return hero;}
// The result of one simulated battle: whether it is a win and how many turns the hero played.static class SimResult { boolean win; int turns;
SimResult(boolean win, int turns) { this.win = win; this.turns = turns; }}
static SimResult simulateFight(Game game, HeroClass heroClass, EnemyKind kind) { game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]); Battle battle = new Battle(game, new ArrayList<>(List.of(new Enemy(game.newId(), kind))), false); battle.autoHero = true; battle.advance(); return new SimResult(battle.outcome.equals("win"), battle.heroTurns);}
static void simulate(Io io, int fights, int seed) { Game game = new Game(io, seed); game.simulation = true; io.write(t("sim.title", "fights", fights, "seed", Integer.toUnsignedString(seed)) + "\n"); io.write(t("sim.levels") + "\n\n"); String header = pad(t("sim.enemy"), 26) + padLeft(t("sim.level"), 5); for (HeroClass heroClass : CLASSES.values()) header += padLeft(cap(nm(heroClass)), 14); io.write(header + "\n"); List<Integer> totals = new ArrayList<>(); for (int i = 0; i < CLASSES.size(); i++) totals.add(0); int rows = 0; for (EnemyKind kind : ENEMY_KINDS.values()) { if (kind.depth == 0) continue; rows++; String line = pad(cap(nm(kind)), 26) + padLeft(String.valueOf(SIM_LEVELS[kind.depth]), 5); int column = 0; for (HeroClass heroClass : CLASSES.values()) { int wins = 0; int turns = 0; for (int fight = 0; fight < fights; fight++) { SimResult result = simulateFight(game, heroClass, kind); if (result.win) wins++; turns += result.turns; } int winShare = percent(wins, fights); totals.set(column, totals.get(column) + winShare); column++; int tenths = (turns * 10) / fights; line += padLeft(winShare + "% " + tenths / 10 + "." + tenths % 10, 14); } io.write(line + "\n"); } String footer = pad(t("sim.average"), 31); for (int total : totals) footer += padLeft(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:
Enemy Level Barbarian Amazon WitchGiant rat 1 100% 2.4 100% 1.3 100% 1.2Goblin spearman 1 100% 2.4 100% 2.5 100% 2.0Goblin shaman 1 100% 2.4 100% 2.3 100% 2.6Blood wolf 1 99% 4.0 99% 3.5 92% 3.2Wolf leader 3 78% 10.0 93% 7.2 77% 5.8Skeleton 3 99% 11.3 100% 4.4 100% 3.0Cultist 3 99% 5.9 100% 3.5 100% 3.3Ore golem 5 100% 7.6 97% 14.2 99% 7.6Warden of the Depths 7 44% 21.0 58% 19.4 54% 12.3Average 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|depthwarden|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|4His 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:
1Iva1(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:
> 1A new hero
What is your hero called?Up to 12 characters: letters, digits, space, dash.> IvaA 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.static String describe(int turn, String who, String target, int damage, int left) { if (damage == 0) return turn + ". " + who + " misses"; return turn + ". " + who + " hits " + damage + " → " + target + " " + left;}
static Result fight(Rng rng, Fighter hero, Fighter enemy, List<String> log) { int heroLife = hero.hp(); int enemyLife = enemy.hp(); int turns = 0; while (true) { turns++; int damage = strike(rng, hero); enemyLife -= damage; log.add(describe(turns, hero.name(), enemy.name(), damage, Math.max(0, enemyLife))); if (enemyLife <= 0) return new Result(true, turns); damage = strike(rng, enemy); heroLife -= damage; log.add(describe(turns, enemy.name(), hero.name(), damage, Math.max(0, heroLife))); if (heroLife <= 0) return new Result(false, turns); }}
// The first line where the two logs differ (0 if they are the same).static int firstDifference(List<String> a, List<String> b) { for (int i = 0; i < Math.max(a.size(), b.size()); i++) { if (i >= a.size() || i >= b.size() || !a.get(i).equals(b.get(i))) return i + 1; } return 0;}
static String lineAt(List<String> lines, int at) { return at <= lines.size() ? lines.get(at - 1) : "-";}
// The test: a fight with seed 2027 compared with the recording.static void check(String label, List<String> recorded, Fighter hero, Fighter wolf) { List<String> log = new ArrayList<>(); fight(new Rng(2027), hero, wolf, log); int at = firstDifference(recorded, log); if (at == 0) { System.out.println(label + ": ok, " + log.size() + " lines"); } else { System.out.println(label + ": line " + at); System.out.println(" expected: " + lineAt(recorded, at)); System.out.println(" got: " + lineAt(log, at)); }}List<String> recorded = List.of( "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");Fighter hero = new Fighter("Barbarian", 40, 70, 4, 9);check("wolf, max damage 5", recorded, hero, new Fighter("Wolf", 18, 70, 2, 5));check("wolf, max damage 6", recorded, hero, new Fighter("Wolf", 18, 70, 2, 6));wolf, max damage 5: ok, 11 lineswolf, max damage 6: line 6 expected: 3. Wolf hits 3 → Barbarian 37 got: 3. Wolf hits 5 → Barbarian 35The 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:
| Lesson | Regions | Lines |
|---|---|---|
| game-loop | main game | 353 |
| screen-stack | screen screen-stack | 92 |
| screen-results | target-screen overlays | 465 |
| console-render | buffer terminal hud | 168 |
| map-fov | level fov map-load map-screen | 500 |
| entities | entity | 234 |
| combat | combat-order attack battle-screen | 381 |
| wounds-effects | effects bleeding | 198 |
| actions | actions replay replay-screen | 234 |
| ai | pathfind ai-map ai-battle | 216 |
| items-loot | items-data inventory equip loot shop | 267 |
| quests-events | xp-levelup event-bus quests dialog dialogs-data skills-data | 296 |
| save-load | save load | 311 |
| arcanum-final | simulate node | 137 |
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:
java Arcanum.javaWith --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:
- 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. - A new class. A row in
CLASSES_DATA, skills inskills-dataand 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. - A new quest. A dialogue in
dialogs-dataand a subscription to the events (quests): the small “bring ore” errand is the shortest example in the game. - 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. - 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.
- Another view.
Terminalis an interface with two kinds; a third can draw on acanvasor in a window without the screens knowing. Input and output are separated (io) exactly for that. - 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 textinstead of skipping it silently. - 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. - 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?
static int percent(int wins, int fights) { return wins * 100 / fights;}
static String cell(int wins, int fights, int turns) { int tenths = turns * 10 / fights; return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;}
System.out.println(cell(1, 3, 7));System.out.println(cell(2, 3, 10));System.out.println(cell(3, 3, 9));Both the percentage and the average turns are integer division, rounded down. 1 win out of 3 is 33% (not 33.3 and not 34), 2 of 3 is 66%, 3 of 3 is 100%. The turns are in tenths: 7 turns for 3 fights is 70 / 3 = 23 tenths, that is 2.3; 10 turns is 3.3; 9 turns is 90 / 3 = 30, that is 3.0. The second answer rounds (67%), the third gives the average turns as a whole number.
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:
Fighter[] heroes = { new Fighter("Barbarian", 40, 70, 4, 9), new Fighter("Amazon", 40, 80, 3, 7), new Fighter("Witch", 24, 85, 3, 8),};Fighter golem = new Fighter("Golem", 40, 60, 3, 6);for (Fighter hero : heroes) { Tally tally = duelsBug(7, hero, golem, 200); System.out.println(hero.name() + ": " + cell(tally.wins(), 200, tally.turns()));}Barbarian: 100% 10.0Amazon: 100% 11.0Witch: 0% 8.0Against 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.
new Rng(seed) inside the loop starts the series from the beginning in every fight. So all 200 fights are one and the same fight, repeated 200 times: the result is 0% or 100% and the average turns are whole numbers. The generator has to be created once, before the loop, and passed to every fight (rng in duels). The seed gives repeatability, but it must not be handed over again for every roll.
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:
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
Related lessons
Section titled "Related lessons"- Builds on: Instance methods · static: what all objects share · Lists · record, switch and patterns
- Tasks on this topic: Chronicles of Arcanum