The combat system
In short: A fight runs on ticks: everyone adds their speed to an energy bar, whoever reaches 100 acts and pays 100. A hit is accuracy against evasion, damage = weapon + strength − defense (at least 1), a critical hit doubles it, and the damage kind decides what follows.
Problem
The wolf is faster than the golem
In the mine the hero meets a blood wolf and a goblin at the same time. Who hits first, and how often? The first answer is in rounds: the hero, then each enemy in order, and start again. But the wolf is twice as fast as the ore golem (speed 14 against 7). If both hit once per round, speed means nothing, and a player who put points into it gets nothing.
So the order is different, and a hit has its own rules. Here is a battle screen from the game (from the playthrough that the game is checked against): the hero faces a giant rat, the log keeps the last lines, and the hero’s hit missed.
=== Battle ===Iva, barbarian lv1 L [###########-] 46/48 M [########] 4/41) Giant rat [########] 9/9 Battle! Against you: giant rat. Giant rat scratches Iva (1). Iva misses giant rat. Giant rat scratches Iva (1).1 attack 2 skill 3 item 4 fleeThis frame needs five things: turn order, accuracy against evasion, damage, damage kinds and words in the log. The lesson builds them one by one with small programs, then puts them together in a fight of the hero against a wolf and a goblin, and shows the same thing in the game. The fighters here come with ready-made stats: how they are put together from base, equipment and effects is in the lesson on creatures.
The idea
Fighters with ready-made stats
A fighter is a class with stats, life and one energy bar:
class Fighter{ public string Name; public int MaxHp; public int Strength; public int Agility; public int Defense; public int Speed; public int WeaponMin; public int WeaponMax; public string Kind; public string Resist; public string Weak; public int Hp; public int Energy;
public Fighter(string name, int maxHp, int strength, int agility, int defense, int speed, int weaponMin, int weaponMax, string kind, string resist, string weak) { Name = name; MaxHp = maxHp; Strength = strength; Agility = agility; Defense = defense; Speed = speed; WeaponMin = weaponMin; WeaponMax = weaponMax; Kind = kind; Resist = resist; Weak = weak; Hp = maxHp; }
public int Accuracy() { return 75 + Agility * 2; }
public int Evasion() { return Agility * 2; }
public int CritChance() { return 5 + Agility / 10; }
public bool IsAlive() { return Hp > 0; }}Three numbers for combat come from agility:
- accuracy = 75 + 2 × agility: how well you hit;
- evasion = 2 × agility: how hard you are to hit;
- critical chance = 5 + agility / 10 (integer division): every hit has at least a 5% chance.
The wolf has agility 8 and the hero 4: the hero’s accuracy is 75 + 8 = 83, the wolf’s evasion is 16. In the game the same three formulas live in Creature, but the stats come from the layers (base + equipment + effects):
// The raw value (it can be negative): base + gear + effects.public int RawStat(string stat){ int value = Base[stat] + GearBonus(stat); foreach (Effect effect in Effects) value += effect.Modifier(stat); return value;}
public int Stat(string stat){ int value = RawStat(stat); return value < 0 ? 0 : value;}
public int MaxHp(){ int value = Stat("maxHp"); return value < 1 ? 1 : value;}
public int MaxMana(){ return Stat("maxMana");}
// Below 25% life the wound is grave: hits become less accurate.public bool IsHeavilyWounded(){ return Hp * 4 < MaxHp();}
public int Accuracy(){ return 75 + Stat("agility") * 2 + RawStat("accuracy") - (IsHeavilyWounded() ? 15 : 0);}
public int Evasion(){ return Stat("agility") * 2 + RawStat("evasion");}
public int CritChance(){ return 5 + Stat("agility") / 10;}The idea
Energy instead of rounds
Instead of rounds, every fighter has an energy bar. The world runs ticks: on every tick everyone adds their speed to the bar. Whoever reaches 100 may act; when it acts, it pays 100. The faster one reaches 100 more often, so it acts more often, and nobody waits for the “round”.
public Fighter NextActor(){ while (true) { Fighter? best = null; foreach (Fighter f in Participants()) { if (f.Energy >= 100 && (best == null || f.Energy > best.Energy)) best = f; } if (best != null) return best; foreach (Fighter f in Participants()) f.Energy += Math.Max(1, f.Speed); Ticks++; }}Here are the rules in the order the code reads them:
- Among the living fighters, look for the one with the most energy among those at 100 or more. On a tie the earlier one in the list wins, and the hero is first. (The comparison is
>, not>=: a later fighter has to be strictly fuller.) - If there is none, a tick starts: everyone adds
max(1, speed). The lower bound of 1 protects the loop: a fighter with speed 0 or less (a slowing effect) would never reach 100, and the loop would never end. - The winner is not reset; it pays 100. The remainder keeps its advantage.
The program lines up the hero (speed 10), the wolf (14) and the goblin (10) and prints the first seven turns:
var hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");var wolf = new Fighter("Blood wolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");var goblin = new Fighter("Goblin spearman", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");var battle = new Battle(new List<Fighter> { hero, wolf, goblin });for (int i = 0; i < 7; i++){ Fighter actor = battle.NextActor(); Console.WriteLine("Tick " + battle.Ticks + ": " + actor.Name + " acts (energy " + actor.Energy + ")."); actor.Energy -= 100;}Tick 8: Blood wolf acts (energy 112).Tick 10: Iva acts (energy 100).Tick 10: Goblin spearman acts (energy 100).Tick 15: Blood wolf acts (energy 110).Tick 20: Iva acts (energy 100).Tick 20: Goblin spearman acts (energy 100).Tick 22: Blood wolf acts (energy 108).The wolf is first on tick 8 (14 × 8 = 112). On tick 10 the hero and the goblin are both at 100: the hero is earlier in the list, and the goblin acts right after it, without a new tick. The wolf acts again on tick 15, not 16: 12 is left over from its first turn.
See it
The queue in motion
The three bars grow tick by tick. Watch the line at 100 and the numbered tiles at the bottom:
public Fighter NextActor(){ while (true) { Fighter? best = null; foreach (Fighter f in Participants()) { if (f.Energy >= 100 && (best == null || f.Energy > best.Energy)) best = f; } if (best != null) return best; foreach (Fighter f in Participants()) f.Energy += Math.Max(1, f.Speed); Ticks++; }}
// in the battle loopFighter actor = battle.NextActor();actor.Energy -= 100;Step by step, in words
- Three fighters: Iva, Blood wolf and Goblin spearman. Each has an energy bar that starts at 0, and a speed (10, 14, 10). Whoever reaches 100 acts.
- Nobody can act yet, so the world makes a tick: everyone adds their speed to the bar (at least 1). The bars grow at different rates.
- Tick 8: Blood wolf is at 112. It reaches 100 first and acts. It pays 100, not everything: 12 is left.
- Tick 10: Iva and Goblin spearman are both at 100. A tie: the one earlier in the list acts, and the hero is first.
- The hero paid, and Goblin spearman is still at 100. No new tick: the next call to nextActor returns it at once.
- Tick 15: Blood wolf acts again, now at 110. The leftover from its first turn saves it a tick: without it, it would wait until tick 16.
- After 22 ticks: Blood wolf has acted 3 times, Iva and Goblin spearman 2 times each. Speed is the rate of the bar, not a number of turns: that is how the faster one acts more often, with no “rounds”.
In the game the same queue sits at the start of the Battle class, and a turn begins by paying 100:
public Creature NextActor(){ while (true) { Creature? best = null; foreach (Creature creature in Participants()) { if (creature.Energy >= 100 && (best == null || creature.Energy > best.Energy)) best = creature; } if (best != null) return best; foreach (Creature creature in Participants()) creature.Energy += Math.Max(1, creature.Stat("speed")); }}// The start of a turn: effects act, cooldowns go down. A stunned fighter skips the turn.public bool BeginTurn(Creature actor){ actor.Energy -= 100; bool stunned = actor.HasEffect("stun"); TickEffects(actor, this); actor.TickCooldowns(); HandleDeaths(); if (!actor.IsAlive()) return false; // After a stun the next turn is certain: otherwise three enemies with clubs would lock the hero down. actor.StunProof = stunned; if (stunned) { Say(T("combat.stunned", ("name", actor.Name))); return false; } return true;}The first line of beginTurn is actor.energy -= 100. Then come effects (bleeding, poison), cooldowns, and skipping the turn when stunned: those are the topics of the next lessons (blood, wounds and effects). What matters here is that the turn order is separate from the hit itself: nextActor says who acts, and what it does is another question.
More precisely, if you are curious
This queue is a priority queue: the next one is the one with the highest priority (the most energy). Here it is done by scanning all living fighters on every call. A fight has four at most, so that is a few comparisons. With hundreds of participants (a battle of armies) a binary heap is used: the next one is taken in O(log n), not O(n).
The ticks can also be skipped: instead of adding one speed at a time, you compute for each fighter ⌈(100 − energy) / speed⌉ ticks until ready, take the smallest and advance everyone by it at once. The result is the same, with no loop. The canon keeps the plain loop: it can be read at a glance and it is enough for four.
The game has a limit of 200 hero turns per fight (heroTurns): if something in the rules gets stuck, the fight ends as a loss instead of hanging forever.
The idea
Hit or miss
A hit does not always land. The chance is the attacker’s accuracy minus the target’s evasion, clamped between 10 and 95:
static int Clamp(int value, int low, int high){ if (value < low) return low; if (value > high) return high; return value;}
static int HitChance(int accuracy, int evasion){ return Clamp(accuracy - evasion, 10, 95);}The clamp (clamp) means every hit has at least a 10% chance to land and at least 5% to miss: an agile target is not untouchable, and the most accurate hit is not certain. Then the fight rolls a die from 1 to 100 and misses if the roll is above the chance.
The program shows four pairs (accuracy, evasion) and five rolls with seed 2026 against the hero’s chance against the wolf, 83 − 16 = 67%:
(int, int)[] pairs = { (83, 16), (91, 8), (83, 80), (120, 0) };foreach (var (accuracy, evasion) in pairs){ Console.WriteLine($"{accuracy} - {evasion} = {accuracy - evasion}, chance {HitChance(accuracy, evasion)}%");}
int chance = HitChance(83, 16);var dice = new Random(2026);for (int i = 0; i < 5; i++){ int roll = dice.Range(1, 100); Console.WriteLine("you roll " + roll + (roll > chance ? " — miss" : " — hit"));}83 - 16 = 67, chance 67%91 - 8 = 83, chance 83%83 - 80 = 3, chance 10%120 - 0 = 120, chance 95%you roll 76 — missyou roll 59 — hityou roll 23 — hityou roll 37 — hityou roll 58 — hitThe dice are the generator from the canon (Random, mulberry32): with the same seed the same numbers fall in all four languages. That is why a seeded fight is repeatable: it can be recorded, replayed and checked with a test.
static int HitChance(int accuracy, int evasion)
{
return Clamp(accuracy - evasion, , 95);
}The lower bound is 10: even against the most agile enemy (83 − 80 = 3) a hit lands in one case out of ten. The upper bound is 95.
The idea
Damage and the critical hit
If you hit, the damage is:
weapon + strength − defense, at least 1.
The weapon gives a number from a range (the hero’s hand axe is 4–7), the attacker’s strength is added, and the target’s defense is subtracted. The last limit matters: a weak hit against sturdy armor does not heal the target and is not zero; it stays 1.
A critical hit has a chance of 5% + agility / 10 (and another 10% for a piercing weapon) and doubles weapon and strength. The defense is subtracted after the doubling, so the armor takes the same off a critical hit:
static int DamageOf(int weapon, int strength, int defense, bool crit){ int damage = weapon + strength; if (crit) damage *= 2; damage -= defense; if (damage < 1) damage = 1; return damage;}
static void Show(string label, int weapon, int strength, int defense, bool crit){ string sum = weapon + " + " + strength; if (crit) sum = "(" + sum + ") * 2"; Console.WriteLine(label + ": " + sum + " - " + defense + " -> " + DamageOf(weapon, strength, defense, crit));}Show("plain hit", 6, 7, 1, false);Show("critical hit", 6, 7, 1, true);Show("weak hit against armor", 2, 1, 6, false);
foreach (int agility in new[] { 4, 8, 25, 40 }){ Console.WriteLine("critical hit: agility " + agility + " -> " + (5 + agility / 10) + "%");}plain hit: 6 + 7 - 1 -> 12critical hit: (6 + 7) * 2 - 1 -> 25weak hit against armor: 2 + 1 - 6 -> 1critical hit: agility 4 -> 5%critical hit: agility 8 -> 5%critical hit: agility 25 -> 7%critical hit: agility 40 -> 9%The first line is the hero’s hit against the wolf (defense 1). The second is the same hit, but critical: 25, more than double. The third is a weak hit against armor 6: the subtraction gives −3, and the game returns 1. The last four lines are the critical chance at different agility: it grows slowly, by 1% per ten points.
The idea
Damage kinds
A hit has a kind: slashing, piercing, blunt, fire, poison. The kind says two things. First, how the target reacts: some creatures are resistant (the damage is divided by 2), others are weak (it is multiplied by 3/2). Second, what follows the hit: slashing bleeds, blunt stuns, fire burns, poison poisons (the effects are the topic of the next lesson).
static int Resisted(Fighter target, string kind, int damage){ if (target.Resist == kind) return damage / 2; if (target.Weak == kind) return damage * 3 / 2; return damage;}A skeleton is resistant to slashing and weak to blunt. A hit of 12 against it gives:
var skeleton = new Fighter("Skeleton", 28, 4, 4, 2, 10, 3, 6, "slash", "slash", "blunt");string[] kinds = { "slash", "pierce", "blunt", "fire", "poison" };string[] names = { "slashing", "piercing", "blunt", "fire", "poison" };for (int i = 0; i < kinds.Length; i++){ Console.WriteLine(names[i] + ": 12 -> " + Resisted(skeleton, kinds[i], 12));}slashing: 12 -> 6piercing: 12 -> 12blunt: 12 -> 18fire: 12 -> 12poison: 12 -> 12Blunt weapons (the mace, the staff) shatter bones, while a sword slides off them. That is why not every weapon works against every enemy. In the table of enemies in the game’s data there are two columns for this: resist and weak. After the damage the game checks the kind of the hit and applies an effect:
public static void AfterHit(Battle battle, Creature target, string type, bool crit){ if (!target.IsAlive()) return; Random rng = battle.Game.Rng; if (type == "slash" && (crit || rng.Chance(35))) ApplyEffect(target, MakeEffect("bleed", 3, 1), battle); else if (type == "blunt" && rng.Chance(10)) ApplyEffect(target, MakeEffect("stun", 1, 0), battle); else if (type == "fire" && rng.Chance(30)) ApplyEffect(target, MakeEffect("burn", 3, 2), battle); else if (type == "poison") ApplyEffect(target, MakeEffect("poison", 3, 2), battle);}In the canon an enemy’s resist and weak are lists of kinds (the skeleton is resistant to slashing, the golem to piercing and fire), not a single kind. Here it is one string, to keep the code short.
The idea
The word instead of the number
The battle log does not write “damage 12”. It writes “Iva smashes the wolf (12)”. The word for the strength of a hit depends not on the number but on the share of the target’s life it takes:
static int Percent(int a, int b){ return a * 100 / b;}
static string DamageWord(int damage, int maxHp){ int share = Percent(damage, maxHp); if (share < 10) return "scratches"; if (share < 25) return "hits"; if (share < 45) return "slashes"; return "smashes";}foreach (int damage in new[] { 1, 2, 4, 5, 8, 9, 10, 12 }){ Console.WriteLine(damage + " of 20: " + Percent(damage, 20) + "%, " + DamageWord(damage, 20));}1 of 20: 5%, scratches2 of 20: 10%, hits4 of 20: 20%, hits5 of 20: 25%, slashes8 of 20: 40%, slashes9 of 20: 45%, smashes10 of 20: 50%, smashes12 of 20: 60%, smashesUnder 10% is a scratch, under 25% a hit, under 45% a slash, and above that a smash. So 12 damage is a smash for a wolf with 20 life, but only a slash for the hero with 48. The player understands the hit is heavy without comparing numbers. Here is the function in the game (the words are keys to the texts in the four languages):
// The word for the strength of a hit depends on how much of the target's life it takes.public static string DamageWord(int damage, int maxHp){ int share = Percent(damage, maxHp); if (share < 10) return T("verb.scratch"); if (share < 25) return T("verb.hit"); if (share < 45) return T("verb.cut"); return T("verb.crush");}See it
The whole fight
Now everything comes together. A hit checks the accuracy, rolls damage and a critical, subtracts the defense, applies resistance, does not allow less than 1, says it with a word, and lowers the life:
static bool Strike(Battle battle, Random dice, Fighter attacker, Fighter target){ int hitChance = Clamp(attacker.Accuracy() - target.Evasion(), 10, 95); if (dice.Range(1, 100) > hitChance) { battle.Say(attacker.Name + " misses " + target.Name + "."); return false; } int damage = dice.Range(attacker.WeaponMin, attacker.WeaponMax) + attacker.Strength; int critChance = attacker.CritChance(); if (attacker.Kind == "pierce") critChance += 10; bool crit = dice.Chance(critChance); if (crit) damage *= 2; damage -= target.Defense; damage = Resisted(target, attacker.Kind, damage); if (damage < 1) damage = 1; string line = attacker.Name + " " + DamageWord(damage, target.MaxHp) + " " + target.Name + " (" + damage + ")."; if (crit) line += " Blood splashes!"; battle.Say(line); target.Hp = Math.Max(0, target.Hp - damage); return true;}The fight is a loop: nextActor, pay 100, the hero’s hit at the first living enemy or an enemy’s hit at the hero, a word for a fall, and the end when one side is not alive. The hero (Iva) against a wolf and a goblin, with seed 16:
var dice = new Random(16);var hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");var wolf = new Fighter("Blood wolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");var goblin = new Fighter("Goblin spearman", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");var battle = new Battle(new List<Fighter> { hero, wolf, goblin });battle.Say("Battle! Against you: " + wolf.Name + ", " + goblin.Name + ".");while (true){ Fighter actor = battle.NextActor(); actor.Energy -= 100; Fighter target = actor == hero ? battle.Participants()[1] : hero; Strike(battle, dice, actor, target); if (!target.IsAlive()) battle.Say(target.Name + (target == hero ? " falls. The mine swallows the light." : " falls and does not rise.")); if (!hero.IsAlive() || battle.Participants().Count == 1) break;}battle.Say((hero.IsAlive() ? "Victory!" : "Defeat.") + " " + hero.Name + ": life " + hero.Hp + "/" + hero.MaxHp);foreach (string line in battle.Log) Console.WriteLine(line);Battle! Against you: Blood wolf, Goblin spearman.Blood wolf scratches Iva (4).Iva smashes Blood wolf (13).Goblin spearman misses Iva.Blood wolf hits Iva (7). Blood splashes!Iva smashes Blood wolf (13).Blood wolf falls and does not rise.Goblin spearman misses Iva.Iva smashes Goblin spearman (10).Goblin spearman scratches Iva (2).Iva smashes Goblin spearman (11).Goblin spearman falls and does not rise.Victory! Iva: life 35/48The log has 2 misses and 1 critical hit: the wolf hits the hero for 7, more than the usual 2–5, and at the end the log says that blood splashes. The log is a list of lines. The battle screen draws the last of them:
public override void Draw(CellBuffer buffer){ Battle battle = Battle; Hero hero = battle.Hero; buffer.Text(0, 0, "=== " + T("battle.title") + " ===", "yellow"); buffer.Text(0, 1, hero.Name + ", " + Nm(hero.HeroClass()) + " " + T("hud.level", ("level", hero.Level)), "white"); buffer.Text(2, 2, T("hud.hp"), "red"); buffer.Bar(4, 2, 12, hero.Hp, hero.MaxHp(), "red"); buffer.Text(19, 2, hero.Hp + "/" + hero.MaxHp(), "default"); buffer.Text(27, 2, T("hud.mana"), "blue"); buffer.Bar(29, 2, 8, hero.Mana, hero.MaxMana(), "blue"); buffer.Text(40, 2, hero.Mana + "/" + hero.MaxMana(), "default"); // The lines come right after each other: the hero, the enemies, the log, the commands. int row = 3; string heroEffects = EffectLabels(hero); if (heroEffects.Length > 0) { buffer.Text(2, row, heroEffects, "green"); row += 1; } List<Enemy> enemies = battle.AliveEnemies(); for (int i = 0; i < enemies.Count && i < 4; i++) { Enemy enemy = enemies[i]; buffer.Text(0, row, (i + 1) + ") ", "default"); buffer.Text(3, row, Pad(Cap(enemy.Name), 22), enemy.Kind.Color); buffer.Bar(25, row, 8, enemy.Hp, enemy.MaxHp(), "red"); buffer.Text(36, row, enemy.Hp + "/" + enemy.MaxHp(), "default"); buffer.Text(44, row, EffectLabels(enemy), "green"); row += 1; } int logRows = 7; int first = Math.Max(0, battle.Log.Count - logRows); for (int i = first; i < battle.Log.Count; i++) { buffer.Text(1, row, battle.Log[i], LogColor(battle.Log[i])); row += 1; } if (battle.Over) { buffer.Text(0, row, T(battle.Outcome == "win" ? "battle.won" : battle.Outcome == "fled" ? "battle.away" : "battle.lost"), "yellow"); } else { buffer.Text(0, row, Note.Length > 0 ? Note : T("battle.commands"), Note.Length > 0 ? "red" : "white"); }}And the hit itself in the game is the same, with two differences: a hit has a power (power), with which skills hit harder than a plain hit, and after the damage comes afterHit, which applies effects.
public static bool Strike(Battle battle, Creature attacker, Creature target, DamageProfile profile, int power){ Random rng = battle.Game.Rng; int hitChance = Clamp(attacker.Accuracy() - target.Evasion(), 10, 95); if (rng.Range(1, 100) > hitChance) { battle.Say(T("combat.miss", ("attacker", attacker.Name), ("target", target.Name))); return false; } int damage = rng.Range(profile.Min, profile.Max) + attacker.Stat("strength"); damage = (damage * power) / 100; int critChance = attacker.CritChance(); if (profile.Type == "pierce") critChance += 10; bool crit = rng.Chance(critChance); if (crit) damage *= 2; damage -= target.Stat("defense"); damage = Resisted(target, profile.Type, damage); if (damage < 1) damage = 1; string line = T("combat.hit", ("attacker", attacker.Name), ("verb", DamageWord(damage, target.MaxHp())), ("target", target.Name), ("damage", damage)); if (crit) line += T(!target.IsHero() && ((Enemy)target).IsBloodless() ? "combat.critBone" : "combat.critBlood"); battle.Say(line); DealDamage(battle, target, damage); AfterHit(battle, target, profile.Type, crit); return true;}More precisely, if you are curious
The critical hit is ×2, but a fighter with a spear (piercing) has another 10% chance of it: in the canon piercing is “the weapon of critical hits”. Here that is one line: critChance += 10.
Spells in the game always hit and the defense does not stop them (spellHit): they are computed only with the caster’s bonus and a damage kind. That is why a witch with a fireball and a barbarian with an axe are different choices, not the weaker and the stronger version of the same warrior.
Try it
Predict the output
The knight and the mage have speed 10, the archer 15. The list is in this order: knight, mage, archer. What does the program print?
var battle = new Battle(new List<Fighter>{ new Fighter("Knight", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""), new Fighter("Mage", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""), new Fighter("Archer", 30, 5, 5, 1, 15, 3, 6, "slash", "", "")});string order = "";for (int i = 0; i < 8; i++){ Fighter actor = battle.NextActor(); actor.Energy -= 100; order += (i > 0 ? ", " : "") + actor.Name;}Console.WriteLine(order);The archer (speed 15) reaches 100 first, on tick 7 (105), and acts. The knight and the mage (10) reach 100 together on tick 10: on a tie the earlier one in the list acts, so the knight goes before the mage. Then the archer on tick 14 (5 + 15 × 7 = 110), and on tick 20 all three are at 100 and act in list order. The second option reverses the tie (the mage before the knight: comparison >= instead of >). The third goes in rounds, as at the start of the lesson, ignoring speed.
Try it
Find the bug
A colleague decided that energy should be zero after a turn and touched one line. Here is what the program prints now:
var hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");var wolf = new Fighter("Blood wolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");var goblin = new Fighter("Goblin spearman", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");var battle = new Battle(new List<Fighter> { hero, wolf, goblin });for (int i = 0; i < 7; i++){ Fighter actor = battle.NextActor(); Console.WriteLine("Tick " + battle.Ticks + ": " + actor.Name + " acts (energy " + actor.Energy + ")."); actor.Energy = 0;}Tick 8: Blood wolf acts (energy 112).Tick 10: Iva acts (energy 100).Tick 10: Goblin spearman acts (energy 100).Tick 16: Blood wolf acts (energy 112).Tick 20: Iva acts (energy 100).Tick 20: Goblin spearman acts (energy 100).Tick 24: Blood wolf acts (energy 112).The wolf acts on tick 8 and then on tick 16, not 15 as in the lesson. Which line is to blame?
Pick the line with the bug.
actor.energy = 0 throws away everything above 100. The wolf is at 112 and the remainder 12 is lost, so every turn costs it a full 8 ticks and speed 14 is the same as 13. The right line is actor.energy -= 100: exactly 100 is paid and the rest is kept.
More precisely
What this system leaves out
The energy queue has few rules, but there are choices behind it.
Every turn costs 100. A quick action (a sip of a potion) could cost 50, and a heavy one (a strong skill) 150. Then the cost is a property of the action, and the turn pays energy -= cost. This tunes the balance more finely, but the player has to see the consequences, and for that the queue has to be on the screen.
The queue is not shown. The battle screen in the canon does not show who is next. Showing it follows from the same rule: you simulate nextActor on a copy of the fighters and write the names. We do not do it, because then the player plans deeper and the fight is slower to play.
Clamping the chance is a game decision. With 10–95 every fight has some chance in it. Narrower, say 30–70, is a noisier fight; wider, say 1–99, is more predictable. The numbers are not “right”: they are tuned with a simulation (the last lesson of the module).
The critical hit must not grow fast. Double damage is strong, so the chance is 5% + agility / 10, not agility. With growth of 1% per ten points, agility does not turn a fight into a lottery.
The dice are a separate input. strike does not look for a random number itself; it gets the generator. That is why a seeded fight is repeatable: you can write a test that checks a whole fight byte by byte, and make a record of a fight (the lesson on actions).
Exercise
A queue from a record
Solve this in your editor.
A queue from a record. The program reads the number of turns N on the first line of the input, then one fighter per line in the form name speed. The fighters are in the order they were written (that is the order on a tie). The program prints the first N turns as in the lesson: the tick, the name, and the fighter’s energy before it pays 100.
With the input 10 | Golem 7 | Mage 10 | Archer 15 (one per line) the output is:
Tick 7: Archer acts (energy 105).Tick 10: Mage acts (energy 100).Tick 14: Archer acts (energy 110).Tick 15: Golem acts (energy 105).Tick 20: Mage acts (energy 100).Tick 20: Archer acts (energy 100).Tick 27: Archer acts (energy 105).Tick 29: Golem acts (energy 103).Tick 30: Mage acts (energy 100).Tick 34: Archer acts (energy 110).Watch tick 20: the mage and the archer are at 100 together, and the earlier one in the list acts. Watch tick 14 as well: the archer acts again with 110, because the remainder of its first turn carries over.
More tasks
- Zero speed. Give a fighter speed 0 and −3. What happens without
max(1, speed)? And with the clamp? - The queue on the screen. Write
preview(battle, count), which returns the names of the nextcountparticipants without changing the fighters (it works on a copy). - How many fights does the hero win? Run the fight from the lesson with seeds from 1 to 1000 and count the wins. How many of them have a critical hit? This is the first step toward the simulation in the last lesson.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Constructors and this · Instance methods · static: what all objects share · Lists
- Tasks on this topic: Chronicles of Arcanum