Skip to content
wannacode

The rivals

In short: A rival gives every action it can pay for a number, takes the highest, pays and scores again. The empire’s personality is the weights in the same formulas.

Problem

A rival that does not think

In Epic Cosmos you play against three empires. While you type commands, each of them decides what to do this turn: place a mine, build a lab, settle a new planet, order ships, send a fleet. How do you make such a rival?

The first attempt is a script: “turn 1 mine, turn 2 mine, turn 3 colony…”. The player figures it out in three games and then beats it every time. The second attempt is a chain of “if … else”: if there is money for a colony, colonize; else if in danger, build ships; else… Every new action needs a new line and new exceptions to the others, and the rival is still predictable and does silly things, because nobody foresaw exactly this situation.

There is a better way. Give every possible action a number: how good it is for this empire right now. Do the action with the highest number. “Colony or ships?” becomes a comparison of two numbers, not a rule for this pair. A new kind of action is a new line in the list of scores, and nothing else is touched.

And one more thing that makes the game interesting: personality. The warlord, the builder and the scholar are not three programs, but the same code with different weights in the formulas.

The idea

A number for every action

Here is the situation the whole lesson works with. Turn 5, one empire with two planets (Kora I with a shipyard and Kora II with one mine), two more free planets in the same system (Vesta I and II), 100 metal and a fleet power of 10:

class Planet {
constructor(name, rich, mines, yard) {
this.name = name;
this.rich = rich;
this.mines = mines;
this.lab = false;
this.yard = yard;
}
}
class Empire {
constructor() {
this.metal = 100;
this.power = 10;
this.turn = 5;
this.owned = [];
this.free = [];
}
}
const MAX_MINE = 3;
const LAB_COST = 40;
const SHIP_COST = 8;
const SHIP_POWER = 5;
function newEmpire() {
const empire = new Empire();
empire.owned.push(new Planet("Kora I", 4, 0, true));
empire.owned.push(new Planet("Kora II", 3, 1, false));
empire.free.push(new Planet("Vesta I", 3, 0, false));
empire.free.push(new Planet("Vesta II", 1, 0, false));
return empire;
}

What can this empire do? Build a mine on a planet (it costs 15 × (mines + 1)), build a lab (40), settle a colony on a free planet (it costs more with every planet: now 52) or order ships. Each of them gets a score, and the scores are formulas. First the two buildings:

  • mine = build × richness / (4 × (mines + 1)): a rich planet is worth more, and every new mine on the same planet is worth less (the divisor grows);
  • lab = research × 6 / 5 − 40 × labs: the second lab is worth 40 less than the first.

Then the colonies and the ships:

  • colony = expand × (richness + 6) × 50 / (cost × 6): a free rich planet is tempting, and an expensive colony is less so;
  • ships = 60 + attack / 2 + (wanted power − power) / 2 (the last term is at most 100), but only while the power is below the wanted one. The wanted power grows with the turn: (20 + 3 × turn) × (140 + attack) / 240.
function options(empire, p) {
// Every action the empire can pay for gets a score.
const list = [];
let labs = 0;
for (const planet of empire.owned) {
if (planet.lab) labs++;
}
for (const planet of empire.owned) {
if (planet.mines < MAX_MINE && empire.metal >= mineCost(planet)) {
list.push({ kind: "mine", target: planet.name, utility: Math.floor((p.build * planet.rich) / (4 * (planet.mines + 1))) });
}
if (!planet.lab && empire.metal >= LAB_COST) {
list.push({ kind: "lab", target: planet.name, utility: Math.floor((p.research * 6) / 5) - 40 * labs });
}
}
const cost = colonyCost(empire);
if (empire.metal >= cost) {
for (const planet of empire.free) {
const value = planet.rich + 6;
list.push({ kind: "colony", target: planet.name, utility: Math.floor((p.expand * value * 50) / (cost * 6)) });
}
}
const want = wantedPower(empire, p);
for (const planet of empire.owned) {
if (planet.yard && empire.power < want && shipCount(empire) > 0) {
const urge = Math.min(100, Math.floor((want - empire.power) / 2));
list.push({ kind: "ships", target: planet.name, utility: 60 + Math.floor(p.attack / 2) + urge });
break;
}
}
return list;
}

That is the whole function. It does not choose: it only collects into a list everything the empire can pay for, with the scores. The condition “can pay” comes before the score: an action that cannot be paid for does not enter the list.

Option is a plain object with three fields. JavaScript has no integer division, so every division is wrapped in Math.floor(…): the numbers are positive and the result is the same as in the other languages.
More precisely, if you are curious

The formulas are tuned, not derived: the coefficients 4, 50, 6 and 60 are chosen so that the scores of a mine, a colony and ships are comparable (hundreds), and one is not a thousand times heavier than another. That is why the last lesson runs hundreds of AI-against-AI games and measures whether any personality is out of balance.

In the game there are four resources (metal, gas, crystal, uranium) and the richness of a planet is four numbers. Here there is metal and one richness, so that every number can be seen.

The idea

The highest number wins: strictly

Once the list is ready, the choice is a simple loop. A strictly higher score replaces the choice. So when the highest scores are equal, the one looked at first wins. The starting value is the floor: a score that does not beat it is not worth doing. For the economy it is 0 (an action with the score 0 is “pointless”), and for the fleet it will be 20 (it is better to stay).

function best(list, floor) {
// Only a strictly higher score wins, so on a tie the first one stays.
let pick = null;
let bestU = floor;
for (const option of list) {
if (option.utility > bestU) {
pick = option;
bestU = option.utility;
}
}
return pick;
}
function label(option) {
if (option.kind === "mine") return "mine " + option.target;
if (option.kind === "lab") return "lab " + option.target;
if (option.kind === "colony") return "colony " + option.target;
return "ships " + option.target;
}

The situation from above, run for the three personalities (each gets a new empire with the same planets and money). For each, the scores are listed in the order of the list, and at the end the choice:

for (const p of PERSONALITIES) {
const list = options(newEmpire(), p);
console.log(p.name);
for (const option of list) {
console.log(" " + pad(label(option), 21) + padLeft(String(option.utility), 4));
}
const pick = best(list, 0);
console.log(" → " + label(pick) + " (" + pick.utility + ")");
}
Output
Warlord
mine Kora I 90
lab Kora I 72
mine Kora II 33
lab Kora II 72
colony Vesta I 115
colony Vesta II 89
ships Kora I 162
→ ships Kora I (162)
Builder
mine Kora I 140
lab Kora I 96
mine Kora II 52
lab Kora II 96
colony Vesta I 230
colony Vesta II 179
ships Kora I 93
→ colony Vesta I (230)
Scholar
mine Kora I 100
lab Kora I 132
mine Kora II 37
lab Kora II 132
colony Vesta I 100
colony Vesta II 78
ships Kora I 99
→ lab Kora I (132)

One situation, three different choices. The warlord picks the ships (162): its wanted power is 45, and the fleet is only 10. The builder picks the colony on Vesta I (230), because its expansion weighs 160. The scholar picks the lab on Kora I (132): the lab on Kora II has the same score and stays second.

The idea

A personality is a table of weights

What differs between the three empires in the code? Only four numbers. Here they are from the game’s canon, PERSONALITIES_DATA:

Personality expand build attack research peace
Warlord 80 90 170 60 40
Builder 160 140 50 80 120
Scholar 70 100 60 110 110

The column “peace” is for diplomacy (lesson 5). The other four are multiplied into the scores: expand in colonies, build in mines, attack in ships and in the fleet’s attack, research in labs. The warlord is not programmed to attack: its attack simply weighs 170, and the builder’s 50. A new personality is a new row in the table, with no new code.

In the game the rivals are visible in the command emp (the personality is the second column):

Empire Character Pl Power Tec Score To you
A Terran Empire Builder 1 109 0 20
C Lyra Union Builder 1 109 0 20 peace
D Vega Academy Scholar 1 109 0 20 peace
B Orion Hegemony Warlord 1 109 0 20 peace

See it

Scoring in action

The scene goes through four stages. The first three are the same empire on turn 5, scored by the warlord, the builder and the scholar. The rows are the actions in the order of the list, the bars are the scores, and the orange line is the “best so far”: it moves only for a strictly higher number. In the fourth stage the builder has already paid for the colony and scores again.

function options(empire, p) {
// Every action the empire can pay for gets a score.
const list = [];
let labs = 0;
for (const planet of empire.owned) {
if (planet.lab) labs++;
}
for (const planet of empire.owned) {
if (planet.mines < MAX_MINE && empire.metal >= mineCost(planet)) {
list.push({ kind: "mine", target: planet.name, utility: Math.floor((p.build * planet.rich) / (4 * (planet.mines + 1))) });
}
if (!planet.lab && empire.metal >= LAB_COST) {
list.push({ kind: "lab", target: planet.name, utility: Math.floor((p.research * 6) / 5) - 40 * labs });
}
}
const cost = colonyCost(empire);
if (empire.metal >= cost) {
for (const planet of empire.free) {
const value = planet.rich + 6;
list.push({ kind: "colony", target: planet.name, utility: Math.floor((p.expand * value * 50) / (cost * 6)) });
}
}
const want = wantedPower(empire, p);
for (const planet of empire.owned) {
if (planet.yard && empire.power < want && shipCount(empire) > 0) {
const urge = Math.min(100, Math.floor((want - empire.power) / 2));
list.push({ kind: "ships", target: planet.name, utility: 60 + Math.floor(p.attack / 2) + urge });
break;
}
}
return list;
}
function best(list, floor) {
// Only a strictly higher score wins, so on a tie the first one stays.
let pick = null;
let bestU = floor;
for (const option of list) {
if (option.utility > bestU) {
pick = option;
bestU = option.utility;
}
}
return pick;
}
Step by step, in words
  1. Turn 5: 100 metal, power 10. The empire scores every action it can pay for. First comes the warlord with weights 80/90/170/60: build 90, attack 170.
  2. Warlord: a mine on Kora I = build × richness / (4 × (mines + 1)) = 90; on Kora II 33, because it already has one mine. A lab is research × 6 / 5 = 72, the same on both planets.
  3. A colony on Vesta I = expand × (richness + 6) × 50 / (cost × 6) = 115 at cost 52. Ships: the wanted power is 45 and it has 10, so 60 + attack / 2 + (45 − 10) / 2 = 162.
  4. The highest score is 162: the warlord builds ships. The “best so far” line moved three times (mine 90, colony 115, ships 162), each time for a strictly higher score.
  5. The same world, other weights. The builder (160/140/50/80) values building: a mine on Kora I 140, a lab 96. The planets, the money and the formulas are the same.
  6. The colonies score 230 and 179: expansion weighs 160. Ships are only 93, because the builder’s wanted power is 27 (its attack is 50).
  7. The builder picks the colony on Vesta I (230). The warlord, with exactly the same numbers, picked ships.
  8. The scholar (70/100/60/110) values research: a lab scores 132 on both planets, a mine on Kora I 100.
  9. The colonies score 100 and 78, ships 99: the wanted power is 29.
  10. Two labs with the same score 132. The second is not strictly higher, so the line stays on the first: the lab on Kora I wins.
  11. The builder paid for the colony: 48 metal left. The next colony now costs 64 (every planet makes it dearer), cannot be paid and gets no score: its row is empty. Everything is scored again.
  12. The mine on Kora I is again 140, ships 93. Now 48 metal is enough for a mine, a lab or ships, but not for a colony.
  13. The second step of the same turn: the builder builds a mine on Kora I (140). The loop is: score everything, do the best, pay, and score again.

The code next to the scene is options and best from the program. At each step the lines that are working at that moment light up.

The idea

Pay, then score again

A score is valid for the situation right now. As soon as the empire does the best action, the situation changes: the money goes down, the price of the next colony goes up, a new mine makes the next one less valuable, a new colony opens new actions. So the rival does not take the list once, but repeats: score everything, do the best, pay, score again. At most 6 times per turn (the canon does the same) or until there is nothing left to do.

function find(planets, name) {
return planets.find((planet) => planet.name === name);
}
function apply(empire, option) {
if (option.kind === "mine") {
const planet = find(empire.owned, option.target);
empire.metal -= mineCost(planet);
planet.mines++;
} else if (option.kind === "lab") {
empire.metal -= LAB_COST;
find(empire.owned, option.target).lab = true;
} else if (option.kind === "colony") {
const planet = find(empire.free, option.target);
empire.metal -= colonyCost(empire);
empire.free.splice(empire.free.indexOf(planet), 1);
empire.owned.push(planet);
} else {
const count = shipCount(empire);
empire.metal -= count * SHIP_COST;
empire.power += count * SHIP_POWER;
}
}
function endTurn(empire) {
let income = 4;
for (const planet of empire.owned) {
income += Math.floor((planet.rich * (2 + planet.mines)) / 2);
}
empire.metal += income;
empire.turn++;
}
function playTurn(empire, p, log) {
// Pay, score again, and repeat while there is something to do (at most 6 times).
for (let step = 1; step <= 6; step++) {
const option = best(options(empire, p), 0);
if (option === null) {
if (log) console.log(" nothing more to do: metal " + empire.metal);
return;
}
const before = empire.metal;
apply(empire, option);
if (log) {
console.log(" " + step + ". " + label(option) + " (" + option.utility + "): metal " + before + " → " + empire.metal);
}
}
}
function summarize(p) {
const empire = newEmpire();
for (let turn = 0; turn < 6; turn++) {
playTurn(empire, p, false);
endTurn(empire);
}
let mines = 0;
let labs = 0;
for (const planet of empire.owned) {
mines += planet.mines;
if (planet.lab) labs++;
}
console.log(p.name + ": planets " + empire.owned.length + ", mines " + mines + ", labs " + labs
+ ", power " + empire.power + ", metal " + empire.metal);
}

Here is one turn of the builder, action by action, and then what the three personalities have achieved in six turns:

const builder = PERSONALITIES[1];
const empire = newEmpire();
console.log(builder.name + ", turn " + empire.turn + ":");
playTurn(empire, builder, true);
console.log();
console.log("After 6 turns:");
for (const p of PERSONALITIES) summarize(p);
Output
Builder, turn 5:
1. colony Vesta I (230): metal 100 → 48
2. mine Kora I (140): metal 48 → 33
3. mine Vesta I (105): metal 33 → 18
4. ships Kora I (93): metal 18 → 2
nothing more to do: metal 2
After 6 turns:
Warlord: planets 2, mines 4, labs 0, power 75, metal 19
Builder: planets 3, mines 3, labs 1, power 40, metal 38
Scholar: planets 2, mines 4, labs 1, power 45, metal 21

The first action is the colony (230), which takes 52 metal. Then the scores are different: the next colony can no longer be paid for, and the best is the mine on Kora I (140). After 4 actions the money is not enough for anything and the turn ends. After six turns the differences are real but not dramatic: the warlord has the highest power (75) and no lab, the builder has the most planets (3), the scholar has a lab and 4 mines. The weights exclude nothing: the builder also builds a lab when that is the best thing left.

There is also a limit you should know about: the choice is greedy. The rival does not think “I will save up for the colony”, but takes the best thing now: the builder buys two small ships (score 93) instead of waiting.

The idea

The fleet: settle or attack

Building is half of it. The other half is the fleets: where should the fleet go? The same technique, another list. The candidates are the systems around, and there are two kinds of actions: settling (the system has free planets and no enemy) and attacking (it has enemy planets and we are strong enough). Before the score there are filters: an attack where the power ratio is below 130% or the target is farther than 6 is not considered at all.

const FLEET_POWER = 40;
const STAY = 20;
const SPOTS = [
{ name: "Brisa", distance: 1, freeValue: 12, enemyValue: 0, enemyPower: 0 },
{ name: "Altan", distance: 5, freeValue: 0, enemyValue: 12, enemyPower: 10 },
{ name: "Cygra", distance: 3, freeValue: 0, enemyValue: 18, enemyPower: 60 },
];
function targetOptions(power, p) {
const list = [];
for (const spot of SPOTS) {
const steps = spot.distance + 1;
if (spot.freeValue > 0 && spot.enemyPower === 0 && spot.distance <= 5) {
list.push({ kind: "settle", target: spot.name, utility: Math.floor((p.expand * spot.freeValue) / (6 * steps)) });
}
const ratio = Math.floor((power * 100 * 100) / (spot.enemyPower * 100 + 100));
if (spot.enemyValue > 0 && ratio >= 130 && spot.distance <= 6) {
const sure = Math.min(ratio, 300);
list.push({ kind: "attack", target: spot.name, utility: Math.floor((p.attack * spot.enemyValue * sure) / (4 * steps * 150)) });
}
}
return list;
}

The fleet has power 40. The system Brisa is free and near (distance 1), Altan is enemy and far (5, but the enemy is weak), and Cygra is enemy with power 60. The power ratio against Altan is 363%, but in the score it is raised to at most 300%. Against Cygra it is 65%, below 130%: an attack there is not scored. This time the floor is STAY, 20: if nothing beats it, the fleet stays.

for (const p of PERSONALITIES) {
const list = targetOptions(FLEET_POWER, p);
console.log(p.name);
for (const option of list) {
console.log(" " + pad(label(option), 18) + padLeft(String(option.utility), 4));
}
const pick = best(list, STAY);
console.log(" → " + (pick === null ? "stay" : label(pick) + " (" + pick.utility + ")"));
}
Output
Warlord
settle Brisa 80
attack Altan 170
→ attack Altan (170)
Builder
settle Brisa 160
attack Altan 50
→ settle Brisa (160)
Scholar
settle Brisa 70
attack Altan 60
→ settle Brisa (70)

The warlord attacks Altan (170), despite the distance. The builder settles Brisa (160). The scholar settles Brisa too, but only just: 70 against 60. The formulas are the same, only the weights differ.

The idea

In the game

What we built is the same as in the canon of Epic Cosmos, only smaller. Here is the real scoring of the economy. You can recognize the mine, the lab, the colony and the ships from our program. Besides them there are two more actions: a shipyard (yard, score 250 for the first one, because without it there is no fleet) and a fortress (fort, only when enemy power is nearby). The choice is the same idea, but with consider(score, action) instead of a list:

// One action in the economy: the best by utility, or null.
function aiBestAction(world, empire) {
const p = empire.personality;
let best = null;
let bestU = 0;
const consider = (u, action) => {
if (u > bestU) {
bestU = u;
best = action;
}
};
const mine = ownedPlanets(world, empire.id);
let labs = 0;
let yards = 0;
for (const planet of mine) {
labs += planet.lab;
yards += planet.yard;
}
const mods = techMods(empire);
for (const planet of mine) {
if (planet.mines < MAX_MINE && canPay(empire, buildCost('mine', planet))) {
consider(Math.floor((p.build * richSum(planet)) / (4 * (planet.mines + 1))), { kind: 'mine', planetId: planet.id });
}
if (planet.lab === 0 && canPay(empire, buildCost('lab', planet))) {
consider(Math.floor((p.research * 6) / 5) - 40 * labs, { kind: 'lab', planetId: planet.id });
}
if (planet.yard === 0 && canPay(empire, buildCost('yard', planet))) {
consider(yards === 0 ? 250 : mine.length >= 5 && yards < 2 ? 60 : 0, { kind: 'yard', planetId: planet.id });
}
if (mods.fort > 0 && planet.fort < MAX_FORT && canPay(empire, buildCost('fort', planet))) {
let threat = enemyPowerAt(world, empire, planet.systemId);
for (const next of world.systems[planet.systemId].links) threat += enemyPowerAt(world, empire, next);
if (threat > 0) consider(40 + minOf(160, threat) + (planet.id === empire.capital ? 30 : 0), { kind: 'fort', planetId: planet.id });
}
}
const cost = colonyCost(world, empire);
if (canPay(empire, cost)) {
for (const planet of world.planets) {
if (planet.owner >= 0 || !hasPresence(world, empire.id, planet.systemId) || hostileIn(world, empire.id, planet.systemId)) continue;
consider(Math.floor((p.expand * (richSum(planet) + 6) * 50) / (cost[0] * 6)), { kind: 'col', planetId: planet.id });
}
}
// Ships: while the strength is below the one wanted for this turn.
let want = Math.floor(((20 + 3 * world.turn) * (140 + p.attack)) / 240);
for (const other of world.empires) {
if (other.alive && other.id !== empire.id && empire.war[other.id] === 1) want = Math.floor((want * 3) / 2);
}
const have = empirePower(world, empire.id) + queuedPower(world, empire.id);
if (have < want) {
for (const planet of mine) {
if (planet.yard === 0) continue;
let kind = counterKind(world, empire);
if (shipAllowed(empire, 3) && canPay(empire, SHIPS[3].cost) && have >= 30) kind = 3;
let count = 4;
while (count > 0 && !canPay(empire, scaleCost(SHIPS[kind].cost, count))) count--;
if (count > 0) consider(60 + Math.floor(p.attack / 2) + minOf(100, Math.floor((want - have) / 2)), { kind: 'ship', planetId: planet.id, shipKind: kind, count: count });
break;
}
}
return best;
}

And here is the loop “score, do, repeat” (aiEconomy) and the whole turn of one rival (aiPlay): research, economy, fleets. Research is the same technique in small: aiResearch scores every available technology with research × (100 + personality bonus) × 10 / (cost + 5) and takes the highest (the tech tree is in lesson 2).

function aiEconomy(world, empire) {
for (let step = 0; step < 6; step++) {
const action = aiBestAction(world, empire);
if (action === null) return;
const planet = world.planets[action.planetId];
let error = null;
if (action.kind === 'col') error = colonize(world, empire, planet);
else if (action.kind === 'ship') error = queueShips(world, empire, planet, action.shipKind, action.count);
else error = buildStructure(world, empire, action.kind, planet);
if (error !== null) return;
}
}
function aiPlay(world, empire) {
// The artificial intelligence plans again every turn.
empire.orders = [];
aiResearch(world, empire);
aiEconomy(world, empire);
aiFleets(world, empire);
}

The differences from our program are few:

  • Four resources instead of one. canPay compares four numbers, and the richness of a planet is four numbers (richSum adds them up and weighs crystal and uranium double).
  • The same functions as the player. The rival calls colonize, queueShips and buildStructure, which the player calls with the commands col, ship and b. It pays the same prices under the same rules.
  • War changes the wanted power. For every empire we are at war with, the wanted power is multiplied by 3/2, and the kind of ships is chosen by the enemy (counterKind: interceptor beats bomber, bomber beats frigate, frigate beats interceptor; the roles are from lesson 3).
  • Defense is in the scores. A fortress is built if an enemy is near, and the fleet goes back to defend its own system (ownHere).
  • No randomness. Not a single die in these decisions: the same input gives the same move in Java, C#, Python and JavaScript.

And here is the choice of the fleet’s target:

function aiFleets(world, empire) {
const p = empire.personality;
const snapshot = [];
for (const fleet of world.fleets) {
if (fleet.owner === empire.id) snapshot.push(fleet);
}
for (const fleet of snapshot) {
const busy = orderedShips(empire, fleet.systemId);
const free = [0, 0, 0, 0];
for (let k = 0; k < SHIP_KIND_COUNT; k++) free[k] = fleet.ships[k] - busy[k];
const myPower = countsPower(free);
if (myPower === 0) continue;
const here = fleet.systemId;
const dist = bfs(world.systems, here).dist;
// Holding: if we are home under threat or besieging an enemy.
let bestU = 20;
let target = -1;
let split = false;
let siege = false;
for (const system of world.systems) {
const foe = enemyPowerAt(world, empire, system.id);
let freeValue = 0;
let enemyValue = 0;
let ownHere = false;
for (const pid of system.planets) {
const planet = world.planets[pid];
if (planet.owner < 0) freeValue += richSum(planet) + 6;
else if (planet.owner === empire.id) ownHere = true;
else if (empire.war[planet.owner] === 1) enemyValue += richSum(planet) + 10;
}
const d = dist[system.id];
if (d < 0) continue;
const ratio = percent(myPower * 100, foe * 100 + 100);
if (system.id === here) {
if (enemyValue > 0 && ratio >= 80) {
bestU = 250;
siege = true;
} else if (ownHere && foe > 0 && ratio >= 60) {
bestU = 200;
siege = true;
}
continue;
}
if (freeValue > 0 && foe === 0 && d <= 5) {
const u = Math.floor((p.expand * freeValue) / (6 * (d + 1)));
if (u > bestU) {
bestU = u;
target = system.id;
split = totalOf(free) >= 6;
siege = false;
}
}
if (enemyValue > 0 && ratio >= 130 && d <= 6) {
const u = Math.floor((p.attack * enemyValue * minOf(ratio, 300)) / (4 * (d + 1) * 150));
if (u > bestU) {
bestU = u;
target = system.id;
split = false;
siege = false;
}
}
if (ownHere && foe > 0 && d <= 4 && ratio >= 60) {
const u = 150 - 10 * d;
if (u > bestU) {
bestU = u;
target = system.id;
split = false;
siege = false;
}
}
}
if (target < 0 || siege) continue;
const step = nextStep(world.systems, here, target);
if (step < 0) continue;
orderMove(world, empire, here, target, split ? halfOf(free) : null);
}
}

It looks like our program: the same two formulas (settle and attack), the filter 130%, the floor 20. In addition there is “holding” (in a system with enemies and a good ratio the fleet stays and besieges: 250), defense of its own system (150 − 10 × distance) and splitting the fleet in two when it is big.

Try it

Predict the output

Four actions, two of them with the highest score 132. What does the program print? (The functions best and label are from the lesson.)

const list = [
{ kind: "mine", target: "Kora I", utility: 90 },
{ kind: "lab", target: "Kora I", utility: 132 },
{ kind: "lab", target: "Kora II", utility: 132 },
{ kind: "colony", target: "Vesta I", utility: 100 },
];
const pick = best(list, 0);
console.log(label(pick) + " (" + pick.utility + ")");
Answer

Try it

Fill in the blank

The starting value of the best is the parameter of the function: 0 for the economy, 20 for the fleet. What does bestU start with?

function best(list, floor) {
  // Only a strictly higher score wins, so on a tie the first one stays.
  let pick = null;
  let bestU = ;
  for (const option of list) {
    if (option.utility > bestU) {
      pick = option;
      bestU = option.utility;
    }
  }
  return pick;
}

Try it

Find the bug

A colleague computes the colonies without parentheses, and now every empire, even the warlord, only colonizes. Here are the builder’s scores:

for (const p of PERSONALITIES) {
const list = options(newEmpire(), p);
console.log(p.name);
for (const option of list) {
console.log(" " + pad(label(option), 21) + padLeft(String(option.utility), 4));
}
const pick = best(list, 0);
console.log(" → " + label(pick) + " (" + pick.utility + ")");
}
Output
Warlord
mine Kora I 90
lab Kora I 72
mine Kora II 33
lab Kora II 72
colony Vesta I 4152
colony Vesta II 3228
ships Kora I 162
→ colony Vesta I (4152)
Builder
mine Kora I 140
lab Kora I 96
mine Kora II 52
lab Kora II 96
colony Vesta I 8304
colony Vesta II 6456
ships Kora I 93
→ colony Vesta I (8304)
Scholar
mine Kora I 100
lab Kora I 132
mine Kora II 37
lab Kora II 132
colony Vesta I 3630
colony Vesta II 2826
ships Kora I 99
→ colony Vesta I (3630)

The colony on Vesta I is 8304 instead of 230. Which line computes it wrongly?

Pick the line with the bug.

More precisely

More precisely: what a score does not do

It is not a probability and has no unit. 162 does not mean “162 points of benefit”. The number only makes sense for comparison inside one list of one rival. That is why the formulas are tuned (4, 50, 6, 60), not derived. The personality weights are whole numbers “in percent”, as the canon’s table says, so that there are no fractions.

It is greedy. The rival looks only at the next action and does not plan: it does not save up for a colony and does not know that in three turns it will be weaker. That is the price of simplicity. More serious systems look ahead: goal planning (GOAP), search trees or Monte Carlo (MCTS, as in Go). But in a turn-based strategy a greedy choice with good formulas is usually clever enough to be fun. In the battles of Arcanum it is the same technique: Enemies that think.

Scores are tuned, not written. Where do the numbers 80, 90, 170 come from? A first guess, then balance by simulation: AI-against-AI games that count which personality wins how often. That tool is in the last lesson.

The rival does not cheat, quite the opposite. It pays the same prices and calls the same functions as you, but in a game with a human it produces only 80% of normal (RIVAL_PACE, the easy level); in the simulation it is 100%. The line is in empireIncome and it is out in the open.

Equal scores are not left to luck. The strict > and the fixed order of generation make the choice the same in the four languages and on every repetition: for each planet mine, lab, shipyard, fortress, then the colonies, and last the ships. That is why the order is part of the rules, not a detail.

Exercise

The fortress

Solve the task in your editor. Start from the program for the three personalities.

Fortress. Add a new action fort: an orbital fortress on a planet. It costs 25 metal (FORT_COST) and is scored by the danger: 40 + min(160, danger) + 30 if the planet is the capital (the first in the list), otherwise without the +30. The danger is a new value threat of the empire and is 120 in this task. The action is eligible only if the danger is positive and there are 25 metal. The order in the list is: for each planet mine, lab, fortress.

The expected output (add fort to label too):

Output
Warlord
mine Kora I 90
lab Kora I 72
fort Kora I 190
mine Kora II 33
lab Kora II 72
fort Kora II 160
colony Vesta I 115
colony Vesta II 89
ships Kora I 162
→ fort Kora I (190)
Builder
mine Kora I 140
lab Kora I 96
fort Kora I 190
mine Kora II 52
lab Kora II 96
fort Kora II 160
colony Vesta I 230
colony Vesta II 179
ships Kora I 93
→ colony Vesta I (230)
Scholar
mine Kora I 100
lab Kora I 132
fort Kora I 190
mine Kora II 37
lab Kora II 132
fort Kora II 160
colony Vesta I 100
colony Vesta II 78
ships Kora I 99
→ fort Kora I (190)

What changes in the choices? With danger 120 the capital’s fortress scores 190, and it beats the warlord’s ships (162) and the scholar’s lab (132). The builder still colonizes: 230 is more than 190. Danger is not a personality: the fortress has the same score for everyone, so it is not in the table of weights.

More tasks

  • Build the fortress. Add building a fortress to apply and run a whole turn of the warlord with danger 120: how many actions does it take before the money runs out?
  • A new personality. Add a fourth row (trader: expand 120, build 120, attack 60, research 70). What does it choose in the starting situation? Does anything change in the code besides the table?
  • The cap. The fleet’s attack uses min(ratio, 300). What happens without the cap if the enemy has power 0 (the ratio is 4000)? How does the choice of the warlord and the builder change?

Test

Check yourself

1. The warlord and the builder score the same world: the same planets, the same money, the same formulas. The warlord picks ships, the builder a colony. Where does the difference come from?
2. The scholar has two labs with the score 132, and that is the highest score. Which one gets built?
3. After the colony the builder has 48 metal and there is no colony in the list at all, although Vesta II is still free. Why?
4. A colleague writes “expand × value × 50 / cost × 6” instead of “… / (cost × 6)”. What is the score of the colony on Vesta I for the builder?
5. The fleet has power 40. In the system Cygra the enemy has power 60 and no personality attacks it, not even the warlord. Why?

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