Skip to content
wannacode

Skills and actions as commands

In short: A command is an object that knows how to execute itself and how to record itself as data. The battle executes commands through one method; who chooses them (a player, an artificial intelligence or a record) is a separate question. That is why recorded commands and the same seed repeat a battle, and without a screen the battle runs thousands of times.

Problem

Four choose, one executes

In the game’s battle there are four “choosers”. The player presses 1, 2, 3 or 4. The goblin simply strikes. The hero on autopilot, which drives the simulation, works out what is best. And the battle replay has to do what happened, with nobody choosing anything. There are four kinds of choices, and the battle that executes them is one.

If the choice is a chain if (choice == 1) … else if (choice == 2) … and it lives in four places, every new action is four changes and four chances to forget one. Add “drink a potion” and the replay will quietly not know it, and will show another battle.

Here is a battle in the game. After 2 a list of skills opens, and those that are still recharging are marked (Rage: in 4 turns):

=== Battle ===
Iva, barbarian lv6
L [##########--] 86/103 M [########] 9/9
ra+- Skills -----------------------------------------+
1) W| 1) Rage (0 mana) (in 4) |
Bat| 2) Whirlwind (3 mana) |
Iva| 3) War cry (2 mana) |
Iva| 4) Iron skin (3 mana) |
War| |
War| number: choose, x: back |
1 at+--------------------------------------------------+

The idea is to turn the action into an object: a command. Every command knows how to execute itself and how to record itself as data. The battle does not ask what it is, it only executes it. Who chose it (a player, an enemy, an artificial intelligence or a record) is a different question, and the battle does not deal with it. The lesson builds this on a small arena and then shows the same place in the game: the commands, skills from a table, mana and cooldowns, targets, the battle replay and the simulation without a screen.

The idea

A command is an object

The arena of the lesson is small: a warrior against a goblin and a rat. The arithmetic is integers, and the “dice” are a seeded generator (as in the intro lesson), so the battle is the same in every language. There are four kinds of commands, and from the battle’s point of view they all do the same thing:

class AttackAction {
constructor(actor, target) {
this.actor = actor;
this.target = target;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: "" };
}
execute(battle) {
battle.hit(this.actor, this.target, this.actor.power + battle.rng.roll(3));
}
}
class ItemAction {
constructor(actor, itemId) {
this.actor = actor;
this.itemId = itemId;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: "item", target: -1, id: this.itemId };
}
execute(battle) {
this.actor.potions -= 1;
const healed = Math.min(10, this.actor.maxHp - this.actor.hp);
this.actor.hp += healed;
battle.say(`${this.actor.name}: potion, +${healed} life`);
}
}
class FleeAction {
constructor(actor) {
this.actor = actor;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: "flee", target: -1, id: "" };
}
execute(battle) {
if (battle.rng.roll(100) < 50) {
battle.say(`${this.actor.name} flees.`);
battle.over = true;
battle.outcome = "fled";
} else {
battle.say(`${this.actor.name} cannot get away.`);
}
}
}

Every command keeps who acts (actor) and against whom (target) and has two methods:

  • execute(battle): carries it out. The command does not work out other people’s life itself: it calls battle.hit, which lowers the life and writes the line to the log;
  • toRecord(battle): describes it as data: the number of the fighter, a word for the kind, the number of the target and the skill or item. The record holds no life, no damage and no randomness.

That is why the battle can hold a list of commands of different kinds and call the same method on all of them: it is the polymorphism from inheritance, only here the commands are objects with a common interface. The program puts a hero strike, a goblin strike, a skill and a potion in a list and hands them to perform one by one. The lines of the battle come out at once (live), and the record is printed at the end.

const battle = newBattle(7);
battle.live = true;
const hero = battle.hero();
const goblin = battle.fighters[1];
const queue = [
new AttackAction(hero, goblin),
new AttackAction(goblin, hero),
new SkillAction(hero, skillOf(battle, "smash"), goblin),
new ItemAction(hero, "potion"),
];
for (const action of queue) {
battle.perform(action);
}
console.log("Record:");
for (const r of battle.record) {
console.log(describe(r));
}
Output
Warrior → Goblin: 8 damage
Goblin → Warrior: 8 damage
Warrior: Smash
Warrior → Goblin: 12 damage
Goblin falls.
Warrior: potion, +8 life
Record:
0 attack 1
1 attack 0
0 skill 1 smash
0 item -1 potion

The record is what is left of the battle: four lines of a few words. It is needed for the replay, which comes later.

In the game the classes have the same shape. This is how the strike and the escape look:

// Actions are commands: an object with execute(battle). The player and the AI choose a
// command, and the battle runs it. So the simulation runs the same battle with no screen.
class AttackAction {
constructor(actor, target) {
this.actor = actor;
this.target = target;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: 'attack', target: battle.indexOf(this.target), id: '' };
}
execute(battle) {
strike(battle, this.actor, this.target, this.actor.attackProfile(), 100);
}
}
class FleeAction {
constructor(actor) {
this.actor = actor;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: 'flee', target: -1, id: '' };
}
execute(battle) {
if (!battle.canFlee) {
battle.say(t('combat.noFlee'));
return;
}
let fastest = 0;
for (const enemy of battle.aliveEnemies()) fastest = Math.max(fastest, enemy.stat('agility'));
const chance = clamp(50 + (this.actor.stat('agility') - fastest) * 5, 15, 90);
if (battle.game.rng.chance(chance)) {
battle.say(t('combat.fled', { name: this.actor.name }));
battle.over = true;
battle.outcome = 'fled';
} else {
battle.say(t('combat.fleeFail', { name: this.actor.name }));
}
}
}

The escape has a chance (by the agility of the fastest enemy) but writes no damage. Enemies use commands too: skills such as the shaman’s spit or its healing are the class SpecialAction, one per kind of skill.

More precisely, if you are curious

This is the Command pattern (from the “Gang of Four” book): a request becomes an object. Things that are hard with if follow from it: recording (save the commands), replay (play them again), undo (every command also knows its opposite action and the commands are rolled back along a stack) and queueing (a command waits for its turn). This lesson uses the first two. Undo in a battle with dice, bleeding and death is hard, because the opposite of “damage 7” is not simply “life + 7”, so the game keeps a record, not an undo stack. In a text editor or a drawing program the pattern is used exactly for undo.

The idea

The battle executes

A battle is a class with fighters, a log and a record. What matters in it is one place that every command passes through:

class Battle {
constructor(seed, fighters, skills) {
this.rng = new Rng(seed);
this.fighters = fighters;
this.skills = skills;
this.log = [];
this.record = [];
this.over = false;
this.live = false;
this.outcome = "none";
this.rounds = 0;
}
hero() {
return this.fighters[0];
}
say(text) {
this.log.push(text);
if (this.live) console.log(text);
}
indexOf(fighter) {
return this.fighters.indexOf(fighter);
}
aliveEnemies() {
return this.fighters.slice(1).filter((f) => f.alive());
}
hit(actor, target, amount) {
target.hp = Math.max(0, target.hp - amount);
this.say(`${actor.name} → ${target.name}: ${amount} damage`);
if (!target.alive()) this.say(`${target.name} falls.`);
}
// The only place every command passes through: it is recorded first, then executed.
perform(action) {
this.record.push(action.toRecord(this));
action.execute(this);
this.checkEnd();
}
checkEnd() {
if (this.over) return;
if (!this.hero().alive()) {
this.over = true;
this.outcome = "lose";
this.say("Defeat.");
} else if (this.aliveEnemies().length === 0) {
this.over = true;
this.outcome = "win";
this.say("Victory!");
}
}
}

perform does three things in this order: it records the command, executes it, and checks whether the battle is over. There is no way around it: neither the hero, nor the goblin, nor the replay executes anything by another route. That is why the record is complete: if something changed the battle, it passed through here.

hit is the second place that deserves to be only one: it alone lowers the life and writes the line “who → whom: damage”. That makes the log exact enough to be compared line by line. In the game, the consequences from the previous lesson are applied after every strike too: bleeding, stun, burning, poison. They live in one place, so they are the same for a player, an enemy, a skill and a replay, instead of one copy in every command.

In the game perform is the same, except that after executing it handles deaths (loot, experience, events for the quests):

perform(action) {
this.record.push(action.toRecord(this));
action.execute(this);
this.handleDeaths();
}

The idea

Skills are data

SkillAction is one class, and there are many skills: Cleave strikes everyone, Smash strikes one enemy with 170% of the strength. The difference is in a table, not in code. Every line of the text is one skill: an identifier, mana, cooldown, strength in percent, target and a display name:

const SKILLS = `
cleave|3|3|80|all|Cleave
smash|4|4|170|one|Smash`.trim();
function loadSkills(text) {
return text.split("\n").map((line) => {
const p = line.split("|");
return { id: p[0], mana: Number(p[1]), cooldown: Number(p[2]), percent: Number(p[3]), target: p[4], name: p[5] };
});
}

The program reads the text once at start (loadSkills) and keeps Skill records. A new skill of a known kind is a new line, not a new class. This is the same idea as the table of tiles from the map: data is separated from code.

In the game the table is wider: besides mana and cooldown there is a class and a branch in the skill tree, a kind (a weapon strike, a spell, healing, an effect on yourself, an effect on the enemies), a target one, all or self, a number of hits, spell damage, and an effect with turns and strength. Here are its first lines, with the columns that hold the names in the other languages left out:

// Skills: class, branch (1 or 2), step in the branch (1-3), kind (weapon: with the weapon, spell: a spell,
// heal: healing, buff: an effect on yourself, debuff: an effect on enemies), target (one, all, self),
// mana, cooldown in turns, hits, power as a percent of a weapon hit, spell damage
// (min-max), damage type ("-" is the weapon's type), effect, effect turns, effect power.
const SKILLS_DATA = `
# id|class|branch|step|kind|target|mana|cooldown|hits|power|min|max|damage|effect|turns|effect power|name
rage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Rage
whirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Whirlwind
bloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Bloodlust

One SkillAction in the game reads a line of this table and, depending on the kind, makes a weapon strike, casts a spell, heals, or applies an effect:

class SkillAction {
constructor(actor, skill, target) {
this.actor = actor;
this.skill = skill;
this.target = target;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: 'skill', target: battle.indexOf(this.target), id: this.skill.id };
}
execute(battle) {
const hero = this.actor;
const skill = this.skill;
hero.mana -= skill.mana;
hero.cooldowns.set(skill.id, skill.cooldown);
battle.say(t('combat.uses', { name: hero.name, skill: nm(skill) }));
let targets = [this.target];
if (skill.target === 'all') targets = battle.aliveEnemies();
if (skill.target === 'self') targets = [hero];
for (const target of targets) {
if (skill.kind === 'weapon') {
const profile = hero.attackProfile();
if (skill.type !== '-') profile.type = skill.type;
for (let hit = 0; hit < skill.hits; hit++) {
if (!target.isAlive()) break;
const landed = strike(battle, hero, target, profile, skill.power);
if (landed && skill.effect !== '-') applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle);
}
} else if (skill.kind === 'spell') {
spellHit(battle, hero, target, skill.min, skill.max, skill.type);
if (skill.effect !== '-') applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle);
} else if (skill.kind === 'heal') {
const healed = hero.heal(skill.min + hero.level * 2);
battle.say(t('combat.healed', { name: hero.name, amount: healed }));
if (skill.effect !== '-') applyEffect(hero, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle);
} else {
applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle);
}
}
}
}

The idea

Mana and cooldowns

A skill with no cost is the best choice every time. So there are two limiters: mana (a stock that is spent) and cooldown (for how many turns a skill is unavailable after use). In the code they live in three places and each does one thing:

  • SkillAction.execute pays: it lowers mana and sets the cooldown;
  • Fighter.tick counts: at the start of a fighter’s turn every cooldown goes down by one;
  • problem asks: it returns the reason why a skill is not available, or an empty string.
// Why a skill is not available now: an empty string if it is.
function problem(battle, hero, skill) {
if (hero.cooldownOf(skill.id) > 0) return `recharging: ${hero.cooldownOf(skill.id)}`;
if (hero.mana < skill.mana) return "not enough mana";
return "";
}
function usableSkills(battle, hero) {
return battle.skills.filter((skill) => problem(battle, hero, skill) === "");
}
class Fighter {
constructor(name, hp, power, mana, potions) {
this.name = name;
this.hp = hp;
this.maxHp = hp;
this.power = power;
this.mana = mana;
this.maxMana = mana;
this.potions = potions;
this.cooldowns = new Map();
}
alive() {
return this.hp > 0;
}
cooldownOf(skillId) {
return this.cooldowns.get(skillId) ?? 0;
}
// A turn passes: every cooldown gets shorter by one.
tick() {
for (const [skillId, left] of this.cooldowns) {
this.cooldowns.set(skillId, Math.max(0, left - 1));
}
}
}

The program gives the hero six turns and shows for every skill whether it is ready, and if not, why. On every turn the hero uses the first available one:

const battle = newBattle(7);
const hero = battle.hero();
for (let turn = 1; turn <= 6; turn++) {
hero.tick();
console.log(`Turn ${turn}, mana ${hero.mana}`);
let chosen = null;
for (const skill of battle.skills) {
const why = problem(battle, hero, skill);
console.log(" " + skill.name + ": " + (why === "" ? "ready" : why));
if (why === "" && chosen === null) chosen = skill;
}
if (chosen === null) {
console.log(" nothing usable");
continue;
}
console.log(" -> uses " + chosen.name);
battle.perform(new SkillAction(hero, chosen, battle.aliveEnemies()[0]));
}
Output
Turn 1, mana 8
Cleave: ready
Smash: ready
-> uses Cleave
Turn 2, mana 5
Cleave: recharging: 2
Smash: ready
-> uses Smash
Turn 3, mana 1
Cleave: recharging: 1
Smash: recharging: 3
nothing usable
Turn 4, mana 1
Cleave: not enough mana
Smash: recharging: 2
nothing usable
Turn 5, mana 1
Cleave: not enough mana
Smash: recharging: 1
nothing usable
Turn 6, mana 1
Cleave: not enough mana
Smash: not enough mana
nothing usable

Look at turn 2: Cleave is still recharging (2 turns), but Smash is ready, because the mana is enough. On turn 4 Cleave has recharged, but the mana (1) is not enough. On turn 6 both have recharged and the mana is still not enough for either. That is why problem checks the cooldown first: for Smash the reason changes from “recharging” to “not enough mana” only when the cooldown is over.

this.actor.cooldowns.set(this.skill.id, );

In the game the list of available skills is the same check, only on the hero of the game:

function usableSkills(hero) {
const result = [];
for (const skillId of hero.skills) {
const skill = SKILLS.get(skillId);
if (hero.mana >= skill.mana && hero.cooldownOf(skill.id) === 0) result.push(skill);
}
return result;
}
More precisely, if you are curious

In the game mana returns by one on every step on the map, not on a turn in battle, and also with a potion and on a new level. So in battle it is a stock that has to last: if you spend it on Cleave on the first turn, you do not have it when the boss asks for it. The cooldown is counted on the fighter’s own turns (tick is at the start of its turn), not on rounds. So a faster fighter, who acts more often, recharges more often.

The idea

Targets

A skill has a target: one (one enemy), all (all living ones) or self (the hero). SkillAction collects the targets in a list and applies the effect to each: with all that is all the living enemies, otherwise it is one target. That is why Cleave strikes both the goblin and the rat with one command, and Smash only one.

But who chooses the target? The player types a number: the enemy is 1 or 2 among the living ones, and without a number it is the first. The program turns the player’s line into a command in parse:

// The target is a number from 1 among the living enemies; without a number it is the first.
function aim(battle, parts, index) {
const alive = battle.aliveEnemies();
if (parts.length <= index) return alive[0];
const text = parts[index];
if (!/^\d$/.test(text)) return null;
const number = Number(text);
return number >= 1 && number <= alive.length ? alive[number - 1] : null;
}
// An invalid command costs no turn: it says why and asks again.
function refuse(battle, line, reason) {
console.log(`? ${line.trim()}: ${reason}`);
return null;
}
function parse(battle, hero, line) {
const parts = line.trim().split(/\s+/);
const word = parts[0];
if (word === "attack") {
const target = aim(battle, parts, 1);
return target === null ? refuse(battle, line, "no such target") : new AttackAction(hero, target);
}
if (word === "skill") {
const skill = parts.length > 1 ? skillOf(battle, parts[1]) : null;
if (skill === null) return refuse(battle, line, "unknown skill");
const why = problem(battle, hero, skill);
if (why !== "") return refuse(battle, line, why);
const target = aim(battle, parts, 2);
return target === null ? refuse(battle, line, "no such target") : new SkillAction(hero, skill, target);
}
if (word === "item") {
return hero.potions > 0 ? new ItemAction(hero, "potion") : refuse(battle, line, "no potions");
}
if (word === "flee") return new FleeAction(hero);
return refuse(battle, line, "unknown command");
}

Notice that a wrong command costs no turn. parse says the reason (an unknown skill, no such target, the skill is recharging) and returns nothing, and Player reads the next line. A command that was not made is not recorded and not executed, so the battle never learns about it. It is the same rule as bumping into a wall on the map.

The program reads the commands from standard input, one per line. Some of them are wrong on purpose:

Input
skill fireball
attack 5
skill cleave
skill cleave
dance
skill smash 2
item
attack 1
attack
const battle = newBattle(7);
battle.live = true;
const lines = readFileSync(0, "utf8").split("\n").filter((line) => line.trim() !== "");
run(battle, new Player(lines));
Output
== Round 1 ==
? skill fireball: unknown skill
? attack 5: no such target
Warrior: Cleave
Warrior → Goblin: 6 damage
Warrior → Rat: 6 damage
Goblin → Warrior: 8 damage
Rat → Warrior: 4 damage
== Round 2 ==
? skill cleave: recharging: 2
? dance: unknown command
Warrior: Smash
Warrior → Rat: 12 damage
Rat falls.
Goblin → Warrior: 7 damage
== Round 3 ==
Warrior: potion, +10 life
Goblin → Warrior: 7 damage
== Round 4 ==
Warrior → Goblin: 6 damage
Goblin → Warrior: 6 damage
== Round 5 ==
Warrior → Goblin: 7 damage
Goblin falls.
Victory!

The lines with ? are the refusals: there is no skill “fireball”, there is no fifth enemy, Cleave is still recharging, “dance” is not a command. None of them passed through perform, so none is in the log or in the record.

In the game, choosing a target is a separate screen over the battle, transparent, which does not strike by itself: it returns the number of the target with pop(result), and the battle decides what to do with it (the lesson on screens that return an answer). It looks like this:

=== Battle ===
Iva, barbarian lv1
L [############] 48/48 M [########] 4/4
1) Goblin spearman [########] 16/16
2) Goblin shaman [########] 13/13
Battle! Against you: goblin spearman, goblin shaman.
1 attack 2 skill 3 item 4 flee
+- Target -------------------------+
| 1) goblin spearman 16/16 |
| 2) goblin shaman 13/13 |
| |
| number: target, x: back |
+----------------------------------+
// Choosing a target is a transparent screen over the battle. It does not attack: pop(result) returns the target number,
// and the battle decides what to do with it.
class TargetScreen extends Screen {
constructor(game, battle) {
super(game);
this.battle = battle;
this.note = '';
}
isTransparent() {
return true;
}
draw(buffer) {
const enemies = this.battle.aliveEnemies();
buffer.box(24, 9, 36, enemies.length + 4, t('target.title'));
for (let i = 0; i < enemies.length; i++) {
buffer.text(26, 10 + i, (i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + enemies[i].hp + '/' + enemies[i].maxHp(), enemies[i].kind.color);
}
buffer.text(26, 11 + enemies.length, this.note.length > 0 ? this.note : t('target.help'), 'gray');
}
handle(command) {
if (command === 'x') {
this.game.stack.pop(null);
return;
}
const choice = parseIntStrict(command);
if (choice === null || choice < 1 || choice > this.battle.aliveEnemies().length) {
this.note = t('ui.unknown', { command: command });
return;
}
this.game.stack.pop(choice - 1);
}
}

The battle screen keeps what was started (pending), and onResult finishes the command when the answer comes: it checks mana and cooldown, opens a second screen for the target if needed, and finally calls heroAct with the ready command:

onResult(result) {
const battle = this.battle;
const hero = battle.hero;
const pending = this.pending;
this.pending = null;
if (result === null || pending === null) return;
if (pending.kind === 'attack') {
battle.heroAct(new AttackAction(hero, battle.aliveEnemies()[result]));
} else if (pending.kind === 'skill') {
const skill = SKILLS.get(hero.skills[result]);
if (hero.mana < skill.mana) {
this.note = t('battle.noMana', { skill: nm(skill) });
} else if (hero.cooldownOf(skill.id) > 0) {
this.note = t('battle.cooldown', { skill: nm(skill), turns: hero.cooldownOf(skill.id) });
} else if (skill.target === 'one' && battle.aliveEnemies().length > 1) {
this.pending = { kind: 'skillTarget', skill: skill };
this.game.stack.push(new TargetScreen(this.game, battle));
} else {
battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies()[0]));
}
} else if (pending.kind === 'skillTarget') {
battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies()[result]));
} else {
battle.heroAct(new ItemAction(hero, this.potionIds()[result]));
}
}

The idea

Who chooses: the chooser

The commands exist now. Who chooses them? Every chooser is an object with the same method choose(battle, actor), which returns a command or nothing (the end of the input):

class Auto {
choose(battle, actor) {
return actor === battle.hero() ? chooseHero(battle) : chooseEnemy(battle, actor);
}
}
class Player {
constructor(lines) {
this.lines = lines;
this.next = 0;
}
choose(battle, actor) {
if (actor !== battle.hero()) return chooseEnemy(battle, actor);
while (this.next < this.lines.length) {
const action = parse(battle, actor, this.lines[this.next++]);
if (action !== null) return action;
}
return null;
}
}
class Replay {
constructor(record) {
this.record = record;
this.next = 0;
}
choose(battle, actor) {
if (this.next >= this.record.length) return null;
return actionFromRecord(battle, this.record[this.next++]);
}
}
function weakest(battle) {
let best = null;
for (const enemy of battle.aliveEnemies()) {
if (best === null || enemy.hp < best.hp) best = enemy;
}
return best;
}
function chooseEnemy(battle, enemy) {
return new AttackAction(enemy, battle.hero());
}
function chooseHero(battle) {
const hero = battle.hero();
const target = weakest(battle);
if (Math.floor((hero.hp * 100) / hero.maxHp) < 35 && hero.potions > 0) return new ItemAction(hero, "potion");
const usable = usableSkills(battle, hero);
for (const skill of usable) {
if (skill.target === "all" && battle.aliveEnemies().length >= 2) return new SkillAction(hero, skill, target);
}
for (const skill of usable) {
if (skill.target === "one") return new SkillAction(hero, skill, target);
}
return new AttackAction(hero, target);
}
  • Auto chooses for everyone: the enemies strike the hero, and the hero decides by priority: a potion when life is low, a skill for many targets, a strong strike, a plain strike. This is how the simulation runs. It is the simplest “utility scoring”; the smarter decisions of the enemies are the topic of the next lesson (Enemies that think);
  • Player gives the hero’s commands from the input, and the enemies stay with the artificial intelligence;
  • Replay we will see in a moment.

The battle loop run does not know who is who. It runs rounds, tells every fighter “it is your turn” and takes a command from the chooser. So who chooses and what is executed are two independent things: the player, the autopilot and the record are swapped without touching the battle.

// One battle: rounds, and in each round everybody takes a turn in order. The command comes from the chooser.
function run(battle, chooser) {
while (!battle.over && battle.rounds < 12) {
battle.rounds++;
battle.say(`== Round ${battle.rounds} ==`);
for (const actor of battle.fighters) {
if (battle.over) break;
if (!actor.alive()) continue;
actor.tick();
const action = chooser.choose(battle, actor);
if (action === null) {
battle.over = true;
break;
}
battle.perform(action);
}
}
}
function actionFromRecord(battle, r) {
const actor = battle.fighters[r.actor];
const target = r.target >= 0 ? battle.fighters[r.target] : null;
if (r.action === "attack") return new AttackAction(actor, target);
if (r.action === "skill") return new SkillAction(actor, skillOf(battle, r.id), target);
if (r.action === "item") return new ItemAction(actor, r.id);
return new FleeAction(actor);
}
function describe(r) {
return `${r.actor} ${r.action} ${r.target}` + (r.id ? ` ${r.id}` : "");
}
// The replay: a new start from the same seed, and the record instead of a choice.
function replayBattle(seed, record) {
const battle = newBattle(seed);
run(battle, new Replay(record));
return battle;
}
function sameLines(first, second) {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++) {
if (first[i] !== second[i]) return false;
}
return true;
}

See it

Record and replay

The commands now pass through perform, and that is where they are recorded. What is left is to play them again. A replay is a new battle from the same start (the same seed, the same fighters) in which the chooser is Replay: it hands out the recorded commands one by one. On the scene above is the live battle, in the middle is the tape with the record, below is the replay.

// The only place every command passes through: it is recorded first, then executed.
perform(action) {
this.record.push(action.toRecord(this));
action.execute(this);
this.checkEnd();
}
// One battle: rounds, and in each round everybody takes a turn in order. The command comes from the chooser.
function run(battle, chooser) {
while (!battle.over && battle.rounds < 12) {
battle.rounds++;
battle.say(`== Round ${battle.rounds} ==`);
for (const actor of battle.fighters) {
if (battle.over) break;
if (!actor.alive()) continue;
actor.tick();
const action = chooser.choose(battle, actor);
if (action === null) {
battle.over = true;
break;
}
battle.perform(action);
}
}
}
// The replay: a new start from the same seed, and the record instead of a choice.
function replayBattle(seed, record) {
const battle = newBattle(seed);
run(battle, new Replay(record));
return battle;
}
function sameLines(first, second) {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++) {
if (first[i] !== second[i]) return false;
}
return true;
}
Step by step, in words
  1. The battle runs rounds. One fighter is up and the chooser gives a command: the artificial intelligence (or the player) for the hero, a strike for the enemies. The battle does not ask what the command is: it just takes it.
  2. perform records the command and executes it. The first is the skill from the table (0 skill 2): mana drops to 5; the goblin’s life goes 18 → 12, the rat’s 10 → 4. The chip on the tape is its record.
  3. Every command, the hero’s and the enemies’, goes through the same perform and drops a chip on the tape. The record is only data: who (a number), what, against whom. It holds no life, no damage and no randomness.
  4. The battle is over: 10 commands in the record (the tape shows the first 8). The replay starts again from the same seed with the same fighters at full life. Only the choice is different: the commands come from the record, not from a player and an artificial intelligence.
  5. Replay.choose hands out the next chip as a command, and run and perform are the same as live. The generator has the same seed, so every strike comes out the same and the numbers in the lower battle repeat the upper one turn by turn.
  6. sameLines compares the two logs line by line: 21 lines and not a single difference. So a replay can be checked, not only watched.
  7. The seed and the record are the whole battle. The same loop, run without a screen thousands of times, is also the simulation for balance.

The program plays a battle with the autopilot, prints the record and replays it twice: with the same seed and with another.

const battle = newBattle(7);
run(battle, new Auto());
console.log("Record:");
for (const r of battle.record) {
console.log(describe(r));
}
const again = replayBattle(7, battle.record);
console.log("Same battle from the record: " + (sameLines(battle.log, again.log) ? "yes" : "no"));
const other = replayBattle(8, battle.record);
console.log("Same battle from another seed: " + (sameLines(battle.log, other.log) ? "yes" : "no"));
Output
Record:
0 skill 2 cleave
1 attack 0
2 attack 0
0 skill 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Same battle from the record: yes
Same battle from another seed: no

The first replay matches the battle line by line, the second does not. The record keeps the choices (who, what, against whom), not the dice. Damage and hits come from the seeded generator, so the same record on another seed gives the same choices on other numbers. That is also why the record is small: 10 commands, not 21 log lines.

In the game the replay is a screen after a victory (the key r). It shows the same log page by page and an answer whether it matches the battle:

=== Battle replay (1/1) ===
Battle! Against you: giant rat.
Giant rat scratches Iva (1).
Iva misses giant rat.
Giant rat scratches Iva (1).
Iva smashes giant rat (12).
Giant rat falls and does not rise.
Gold: +1.
Giant rat leaves behind: antidote.
The replay matches the battle: yes
(Enter: back)
function actionFromRecord(battle, record) {
const actor = battle.creatureAt(record.actor);
const target = record.target >= 0 ? battle.creatureAt(record.target) : null;
if (record.action === 'attack') return new AttackAction(actor, target);
if (record.action === 'skill') return new SkillAction(actor, SKILLS.get(record.id), target);
if (record.action === 'item') return new ItemAction(actor, record.id);
if (record.action === 'flee') return new FleeAction(actor);
return new SpecialAction(actor, record.id, target);
}
// Returns the log of the replayed battle.
function replayBattle(snapshot, record) {
const world = new ReplayWorld(snapshot);
const enemies = [];
for (const enemy of snapshot.enemies) enemies.push(enemy.copy());
const battle = new Battle(world, enemies, snapshot.canFlee);
battle.script = record;
battle.start();
battle.advance();
return battle.log;
}
function sameLines(first, second) {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++) {
if (first[i] !== second[i]) return false;
}
return true;
}
More precisely, if you are curious

In the game the replay does not start from the seed of the world, but from a snapshot of the start of the battle: the state of the generator and copies of the hero and the enemies (with the hero’s skills, mana and items at that moment). The hero has an inventory and a level that are not built from a seed, so the snapshot carries them. The replay is a Battle with a script instead of a player and an artificial intelligence: advance takes the commands from the record. The world of the replay (ReplayWorld) does not change: no corpses, gold or quests, only the log remains.

A record depends on the version of the rules: if you change the damage of Cleave, old records no longer match. So records are good for checking and for hunting bugs in the same version, not for permanent storage.

The idea

A thousand battles without a screen

If you run run with Auto for both sides, you need no screen, no player and no record. The program plays battles with seeds from 1 to 1000 and counts the wins:

console.log("Simulation without a screen");
let wins = 0;
let losses = 0;
let commands = 0;
const total = 1000;
for (let seed = 1; seed <= total; seed++) {
const battle = newBattle(seed);
run(battle, new Auto());
if (battle.outcome === "win") wins++;
else losses++;
commands += battle.record.length;
}
const average = Math.floor((commands * 10) / total);
console.log(`Battles: ${total}, wins: ${wins}, losses: ${losses}, average commands: ${Math.floor(average / 10)}.${average % 10}`);
Output
Simulation without a screen
Battles: 1000, wins: 875, losses: 125, average commands: 9.9

The result is 875 wins and 125 defeats. This is how balance is measured: if the hero wins every battle, the enemies are weak; if the hero loses, they are too strong; the numbers are nudged until the outcome is what you want. Everything is deterministic: the same code gives the same numbers every time and in every language.

In the game this is --simulate=1000: battles for every class against every enemy, as you will see in the last lesson. The base is this: the hero on autopilot (autoHero) chooses commands with the same scoring, and the battle runs with the same advance, without a screen:

function simulateFight(game, heroClass, kind) {
game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]);
const battle = new Battle(game, [new Enemy(game.newId(), kind)], false);
battle.autoHero = true;
battle.advance();
return { win: battle.outcome === 'win', turns: battle.heroTurns };
}

The simulation is possible because the battle writes to a log, not to the screen (in the lesson live only copies it there), and the choice of command is replaceable.

Try it

Predict the output

On every turn the hero uses the last available skill (Smash if possible, otherwise Cleave). Cleave costs 3 mana and recharges for 3 turns, Smash costs 4 and recharges for 4, and the hero starts with 8 mana. What does the program print?

const battle = newBattle(7);
const hero = battle.hero();
for (let turn = 1; turn <= 5; turn++) {
hero.tick();
let used = "-";
for (const skill of usableSkills(battle, hero)) used = skill.id;
if (used !== "-") {
battle.perform(new SkillAction(hero, skillOf(battle, used), battle.fighters[1]));
}
console.log(turn, used);
}
Answer

Try it

Find the bug

A colleague changed SkillAction and now the replay does not match the battle. The record looks strange:

class SkillAction {
constructor(actor, skill, target) {
this.actor = actor;
this.skill = skill;
this.target = target;
}
toRecord(battle) {
return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: this.skill.id };
}
execute(battle) {
this.actor.mana -= this.skill.mana;
this.actor.cooldowns.set(this.skill.id, this.skill.cooldown);
battle.say(`${this.actor.name}: ${this.skill.name}`);
const targets = this.skill.target === "all" ? battle.aliveEnemies() : [this.target];
for (const victim of targets) {
battle.hit(this.actor, victim, Math.floor((this.actor.power * this.skill.percent) / 100) + battle.rng.roll(3));
}
}
}
const battle = newBattle(7);
run(battle, new Auto());
console.log("Record:");
for (const r of battle.record) {
console.log(describe(r));
}
const again = replayBattle(7, battle.record);
console.log("Same battle from the record: " + (sameLines(battle.log, again.log) ? "yes" : "no"));
Output
Record:
0 attack 2 cleave
1 attack 0
2 attack 0
0 attack 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Same battle from the record: no

In the record Cleave is attack. Which line is to blame?

Pick the line with the bug.

More precisely

What a command cannot do

A command as an object is handy, but it has limits worth knowing.

The class does not protect itself from an illegal command. SkillAction does not check mana, because parse (for the player) and usableSkills (for the artificial intelligence) check it before they create it. If you bypass those checks somewhere, mana goes negative. The other choice is to put the check inside the command, which returns “I cannot”. The game chooses the first: an illegal command is never created.

A record depends on the rules. It keeps the choices, not the consequences. If you change the damage formula or add one more dice roll, old records give another battle. The order of the calls to the generator is part of the contract.

Randomness has to be in the seed. If part of the battle uses the language’s Random or the clock, the replay diverges. That is why the game has its own generator, and damage and hits are rolled in only a few places.

A command is not an event. An event says “something happened” (an enemy died), a command says “do”. The event bus is another lesson (the quests), and the two often work together: a command changes the battle, and the battle fires an event.

One class per kind does not grow forever. For four kinds of commands it is fine, for eighteen skills it is too many. That is why skills are data, and SkillAction is one class for all of them.

Exercise

Rest

Solve the task in your editor. Start from the program with the player from this lesson.

Rest. Add a fourth kind of command for the hero: rest gives back 2 mana (not more than the maximum) and costs the turn. The player types it as rest. Three places have to be touched: the class RestAction itself (with a toRecord that returns rest), parse (the player’s word) and actionFromRecord (the record). At the end the program plays the replay and prints whether it matches. The input (one per line) is `skill smash 2`, `dance`, `rest`, `item`, `skill smash 1`, `attack 1`, `skill smash 1`, and the output is:

Input
skill smash 2
dance
rest
item
skill smash 1
attack 1
skill smash 1
Output
== Round 1 ==
Warrior: Smash
Warrior → Rat: 12 damage
Rat falls.
Goblin → Warrior: 8 damage
== Round 2 ==
? dance: unknown command
Warrior: rest +2 mana
Goblin → Warrior: 8 damage
== Round 3 ==
Warrior: potion, +10 life
Goblin → Warrior: 6 damage
== Round 4 ==
? skill smash 1: recharging: 1
Warrior → Goblin: 8 damage
Goblin → Warrior: 7 damage
== Round 5 ==
Warrior: Smash
Warrior → Goblin: 11 damage
Goblin falls.
Victory!
Same battle from the record: yes

More tasks

  • A healing skill. Add to the table a skill that heals the hero (target self). How many lines of code have to change, and why so few?
  • Escape. FleeAction rolls dice (50%). Give flee in the player’s input and look at the replay: does it match? Why are the dice not in the record?
  • Undo. Try to add an undo() method to AttackAction and ItemAction. Which of the commands cannot be undone without a snapshot of the state?
  • Balance. Raise the goblin’s strength by 1 and run the simulation. How much do the wins change, and why are the numbers from a thousand battles and not from one?

Test

Check yourself

1. You add a “rest” command to the game. Besides the class itself, which two other places must learn about it for both the player and the replay to work?
2. The record keeps the number of the fighter (0, 1, 2), not the object itself. Why?
3. The hero has 8 mana and uses Cleave (3 mana, cooldown 3). On the next turn the player types the same skill. What happens?
4. The replay with another seed (“Same battle from another seed: no”) uses the same record. Why is the battle different?
5. What makes a simulation without a screen possible?

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