Skip to content
wannacode

Fleets and space battle

In short: A fleet is an array of ship counts per role. The roles are rock-paper-scissors (a multiplier in percent), every group shoots at the best target, damage that does not add up to a whole ship stays as a wound, and the two volleys of a round are computed from the counts at the start of the round and hit at the same time. Moves work the same way: first everyone leaves, then everyone arrives.

Problem

Who shoots first?

Two fleets meet in a system. The first answer is in turns: the first side shoots, then the second. But then the first side wins only because of the order in the code: the ships it destroyed no longer answer. The game wants something else: in one round both sides shoot at the same time, and fleets also move between systems all at once.

Here is a frame from the game (the command battle, from the playthrough that the game is checked against): at Ismar, four interceptors against two bombers and two frigates. The empty lines of the screen are shortened.

Turn 28/150 Terran Empire (A) score 147
M3+27 G203+36 C11+24 U52+5 science +16
Researching: Torpedoes 28/31
Battle at Ismar: Terran Empire against Orion Hegemony
Round 1: 4I | 2B 2F lost 2 / 2
Round 2: 2I | 2F lost 2 / 0
Victory for Orion Hegemony. Left: 2F.
Enter or map: back to the map help: commands

The game folds the spaces in the lines (battle re-flows them to the width of the screen), so “Round 1: 4I | 2B 2F” has no columns. The programs in the lesson print the same words.

This frame needs five things: a fleet (ship counts per role), roles (rock-paper-scissors), a volley (every group shoots at the best target), a wound (damage that does not add up to a whole ship is not lost) and simultaneity (of volleys and of moves). The lesson builds them one by one with small programs and in the end puts them together in the same battle. In the lesson on the combat system the hits come one at a time and with a die. Here there is no die: the battle is a pure function, the same two fleets give the same result.

The idea

A fleet is a count

A fleet is not a list of ships but five numbers: how many interceptors (I), bombers (B), frigates (F), dreadnoughts (D) and fortresses (#) an empire has in one system. All ships of one role are the same, so there is no need for an object per ship: a battle with 500 ships is the same five numbers. The fortresses of the planets there fight as a fifth role.

The stats of the roles are two tables indexed by role: hull and attack. The third table is the multiplier, which is the next step:

static final int KINDS = 5;
static final int ROUNDS = 6;
static final String LETTERS = "IBFD#";
static final int[] HP = {10, 16, 30, 90, 25};
static final int[] ATK = {5, 7, 6, 18, 5};
static final int[][] MULT = {
{100, 175, 50, 50, 60},
{50, 100, 175, 80, 150},
{175, 50, 100, 80, 100},
{110, 110, 110, 100, 110},
{100, 100, 100, 100, 100},
};

Two small functions are used everywhere: the total of the ships and the text “3I 2B 2F” that the game writes for a fleet (an empty one is “-”). The power of a role is hull × attack / 10, and of a fleet the sum over roles. The game uses it to compare fleets (the artificial intelligence, the next lessons).

static int total(int[] counts) {
int sum = 0;
for (int n : counts) sum += n;
return sum;
}
static String countsText(int[] counts) {
String text = "";
for (int k = 0; k < KINDS; k++) {
if (counts[k] > 0) text += (text.isEmpty() ? "" : " ") + counts[k] + LETTERS.charAt(k);
}
return text.isEmpty() ? "-" : text;
}
static int unitPower(int kind) {
return HP[kind] * ATK[kind] / 10;
}
static int power(int[] counts) {
int sum = 0;
for (int k = 0; k < KINDS; k++) sum += counts[k] * unitPower(k);
return sum;
}

The program shows the fleet 3I 2B 2F: text, count, power and an empty fleet.

int[] fleet = {3, 2, 2, 0, 0};
System.out.println("Fleet: " + countsText(fleet));
System.out.println("Ships: " + total(fleet));
System.out.println("Power: " + power(fleet));
System.out.println("Empty fleet: " + countsText(new int[KINDS]));
Output
Fleet: 3I 2B 2F
Ships: 7
Power: 73
Empty fleet: -

The power is 3 × 5 + 2 × 11 + 2 × 18 = 73. The interceptor is 5, the bomber 11, the frigate 18 and the dreadnought 162.

In Java HP[kind] * ATK[kind] / 10 is a whole-number division because all three are int.

In the game the fleets are in a list world.fleets, ordered by empire and then by system. fleetAt looks for the fleet of an empire in a system, and addShips creates it (in its place in the order) or adds ships to the existing one:

static Fleet fleetAt(World world, int owner, int systemId) {
for (Fleet fleet : world.fleets) {
if (fleet.owner == owner && fleet.systemId == systemId) return fleet;
}
return null;
}
// Adds ships to the empire's fleet in the system; a new fleet takes its place in the order.
static void addShips(World world, int owner, int systemId, int[] counts) {
Fleet fleet = fleetAt(world, owner, systemId);
if (fleet == null) {
fleet = new Fleet(owner, systemId, new int[] {0, 0, 0, 0});
int at = world.fleets.size();
while (at > 0) {
Fleet before = world.fleets.get(at - 1);
if (before.owner < owner || (before.owner == owner && before.systemId < systemId)) break;
at--;
}
world.fleets.add(at, fleet);
}
for (int k = 0; k < SHIP_KIND_COUNT; k++) fleet.ships[k] += counts[k];
}

The idea

Rock, paper, scissors

If power were all that mattered, it would always pay to build the strongest ship. So the roles fight each other differently. The multiplier is a percentage of the damage: the row is the attacker, the column is the target. The interceptor beats the bomber (175%), the bomber beats the frigate (175%), the frigate beats the interceptor (175%), against the role that beats it everyone hits for half (50%), and against its own role for 100%. The dreadnought hits everyone for 100–110%, and everyone hits it weakly (50–80%): it is strong by itself, not by role.

// The damage multiplier in percent: the row is the attacker, the column is the target (interceptor, bomber,
// frigate, dreadnought, fortress).
static final int[][] MULT = {
{100, 175, 50, 50, 60},
{50, 100, 175, 80, 150},
{175, 50, 100, 80, 100},
{110, 110, 110, 100, 110},
{100, 100, 100, 100, 100},
};

Every group (all ships of one role) shoots at one target: the role among the living enemies against which the multiplier is the biggest. On a tie the first in the order wins: the comparison is strictly “greater”, so a later role with an equal multiplier does not replace the one already chosen.

static int chooseTarget(int kind, int[] enemy) {
int best = -1;
for (int k = 0; k < KINDS; k++) {
if (enemy[k] > 0 && (best < 0 || MULT[kind][k] > MULT[kind][best])) best = k;
}
return best;
}
static int chooseTarget(int kind, int[] enemy) {
    int best = -1;
    for (int k = 0; k < KINDS; k++) {
        if (enemy[k] > 0 && (best < 0 || MULT[kind][k]  MULT[kind][best])) best = k;
    }
    return best;
}

The program shows the targets of the five roles against a full fleet and one case where the most favourable role is missing:

int[] all = {1, 1, 1, 1, 1};
for (int kind = 0; kind < KINDS; kind++) {
int target = chooseTarget(kind, all);
System.out.println(NAMES[kind] + " -> " + NAMES[target] + " " + MULT[kind][target] + "%");
}
int[] noFrigates = {1, 1, 0, 1, 1};
int alt = chooseTarget(1, noFrigates);
System.out.println("Without frigates: " + NAMES[1] + " -> " + NAMES[alt] + " " + MULT[1][alt] + "%");
Output
interceptor -> bomber 175%
bomber -> frigate 175%
frigate -> interceptor 175%
dreadnought -> interceptor 110%
fortress -> interceptor 100%
Without frigates: bomber -> fortress 150%

The first three lines are the triangle. The dreadnought and the fortress have no role they are stronger against: their biggest multiplier (110% and 100%) is at the first place in the order, the interceptor. The last line is the most interesting: the bomber wants frigates, and there are none, so it picks the next best, the fortress (150%), not the interceptor (50%) and not the dreadnought (80%).

Here is the function in the game:

static int chooseTarget(int kind, int[] enemy) {
int best = -1;
for (int k = 0; k < KIND_COUNT; k++) {
if (enemy[k] > 0 && (best < 0 || MULT[kind][k] > MULT[kind][best])) best = k;
}
return best;
}

The idea

Volley and wound

The group that has chosen a target deals damage = count × attack × multiplier / 100. The division is a whole-number one and cuts off: 6 bombers with 7 each against frigates make 6 × 7 × 175 / 100 = 73 (not 73.5). The damage of all groups of one side is added up by the role of the target.

static int[] volley(int[] mine, int[] enemy) {
int[] damage = new int[KINDS];
for (int k = 0; k < KINDS; k++) {
if (mine[k] == 0) continue;
int target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += mine[k] * ATK[k] * MULT[k][target] / 100;
}
return damage;
}

Then the damage is applied. The damage on a role is divided by the hull of a ship: as many whole ships as fit fall. If all of them fall, the role disappears. Otherwise the rest (damage that does not add up to one more ship) is recorded as a wound and added to the next volley. Without wounds slow fire would never kill a ship: 9 damage on a ship with hull 10 is nothing, but a second time it is 18 and one ship falls.

static void applyDamage(int[] counts, int[] wound, int[] damage) {
for (int k = 0; k < KINDS; k++) {
if (damage[k] == 0 || counts[k] == 0) continue;
int sum = wound[k] + damage[k];
int killed = sum / HP[k];
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= killed;
wound[k] = sum - killed * HP[k];
}
}
}

The program does the first round of the battle at Ismar: the two volleys (the columns are I, B, F, D, #), then the applying. The small function numbers prints an array with spaces:

static String numbers(int[] values) {
String text = "";
for (int v : values) text += (text.isEmpty() ? "" : " ") + v;
return text;
}

The program:

int[] a = {4, 0, 0, 0, 0};
int[] b = {0, 2, 2, 0, 0};
int[] hitB = volley(a, b);
int[] hitA = volley(b, a);
System.out.println("a -> b: " + numbers(hitB));
System.out.println("b -> a: " + numbers(hitA));
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
System.out.println("a: " + countsText(a) + ", wounds " + numbers(woundA));
System.out.println("b: " + countsText(b) + ", wounds " + numbers(woundB));
Output
a -> b: 0 35 0 0 0
b -> a: 28 0 0 0 0
a: 2I, wounds 8 0 0 0 0
b: 2F, wounds 0 0 0 0 0

Side a hits only the bombers: 4 × 5 × 175 / 100 = 35, and two bombers are 32 hull, so both fall (35 / 16 = 2). Side b hits only the interceptors: the bombers at 50% (2 × 7 × 50 / 100 = 7), the frigates at 175% (2 × 6 × 175 / 100 = 21), 28 in total. 28 / 10 = 2 interceptors fall, and 8 stay as a wound on the third. The frigates are not hit in the first round: the interceptors shoot only at the bombers.

Here are the two functions in the game. Besides the ship counts and the damage they get stats (the bonuses from technologies); they are not here, so the numbers of the program are those of an empire without technologies. In Java the damage is a long, because a ship count from a save can be large and an int would overflow:

// The damage one side deals to the other, by target role. The calculation is in long: the number of
// ships from a save can be large, and JavaScript does not overflow.
static long[] volley(SideStats stats, int[] mine, int[] enemy) {
long[] damage = new long[KIND_COUNT];
for (int k = 0; k < KIND_COUNT; k++) {
if (mine[k] == 0) continue;
int target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += ((long) mine[k] * stats.atk[k] * MULT[k][target]) / 100;
}
return damage;
}
// Damage accumulates: the units lose hull, but the first survivor keeps its wound for the next round.
static void applyDamage(SideStats stats, int[] counts, int[] wound, long[] damage) {
for (int k = 0; k < KIND_COUNT; k++) {
if (damage[k] == 0 || counts[k] == 0) continue;
long total = wound[k] + damage[k];
long killed = total / stats.hp[k];
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= (int) killed;
wound[k] = (int) (total - killed * stats.hp[k]);
}
}
}

See it

The whole round, at once

Now everything comes together. For every round the loop of the battle does the following:

  1. If one side has no ships, it stops.
  2. It copies the counts of both sides: this is the “snapshot” of the start of the round.
  3. It computes both volleys from the snapshots: the volley of a on b and the volley of b on a.
  4. It applies the damage on b, then on a. The order does not matter, because the volleys are already computed.
  5. It records the line of the round: how many ships both sides had at the start and how many they lost.

After six rounds, if both sides still have ships, there is no winner. Here is the whole function and the program that fights the battle at Ismar:

static void runBattle(int[] a, int[] b, String nameA, String nameB, List<String> log) {
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
for (int round = 1; round <= ROUNDS; round++) {
if (total(a) == 0 || total(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
int[] hitB = volley(startA, startB);
int[] hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.add("Round " + round + ": " + countsText(startA) + " | " + countsText(startB) + " lost " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) == 0) log.add("Victory for " + nameA + ". Left: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) == 0) log.add("Victory for " + nameB + ". Left: " + countsText(b) + ".");
else if (total(a) == 0 && total(b) == 0) log.add("Both fleets are destroyed.");
else log.add("No winner after six rounds: " + countsText(a) + " | " + countsText(b) + ".");
}
int[] a = {4, 0, 0, 0, 0};
int[] b = {0, 2, 2, 0, 0};
String where = "Ismar";
String nameA = "Terran Empire";
String nameB = "Orion Hegemony";
List<String> log = new ArrayList<>();
runBattle(a, b, nameA, nameB, log);
System.out.println("Battle at " + where + ": " + nameA + " against " + nameB);
for (String line : log) System.out.println(line);
Output
Battle at Ismar: Terran Empire against Orion Hegemony
Round 1: 4I | 2B 2F lost 2 / 2
Round 2: 2I | 2F lost 2 / 0
Victory for Orion Hegemony. Left: 2F.

These are exactly the lines of the game. The second round is short: of the four interceptors two are left (with a wound of 8), and there is no trace of the bombers. The two interceptors hit the frigates at only 50% (2 × 5 × 50 / 100 = 5 damage, less than the hull of one ship), and the frigates hit the interceptors at 175%: 2 × 6 × 175 / 100 = 21, plus the wound 8 makes 29. Both interceptors fall. The frigates win without losing a ship. Follow the rounds in the scene: the arrows and the numbers are simultaneous.

static void runBattle(int[] a, int[] b, String nameA, String nameB, List<String> log) {
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
for (int round = 1; round <= ROUNDS; round++) {
if (total(a) == 0 || total(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
int[] hitB = volley(startA, startB);
int[] hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.add("Round " + round + ": " + countsText(startA) + " | " + countsText(startB) + " lost " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) == 0) log.add("Victory for " + nameA + ". Left: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) == 0) log.add("Victory for " + nameB + ". Left: " + countsText(b) + ".");
else if (total(a) == 0 && total(b) == 0) log.add("Both fleets are destroyed.");
else log.add("No winner after six rounds: " + countsText(a) + " | " + countsText(b) + ".");
}
Step by step, in words
  1. Two fleets at Ismar: a has 4I, b has 2B 2F. A fleet is just a count of ships per role, and the battle is a function of two such arrays.
  2. Round 1. Each side copies its counts at the start of the round. Both volleys will be computed from those copies.
  3. Each role picks the target with the biggest multiplier. a: 4I → B (175%). b: 2B → I (50%), 2F → I (175%). Rock-paper-scissors.
  4. The volleys: a: 4 × 5 × 175 / 100 = 35 on the bombers. b: 2 × 7 × 50 / 100 = 7; 2 × 6 × 175 / 100 = 21 on the interceptors, 28 in total. They hit at once.
  5. The damage is applied: 35 / 16 = 2 bombers fall. 28 / 10 = 2 interceptors fall, the rest, 8, is a wound. The log: lost 2 / 2.
  6. Round 2: 2I against 2F. The bombers are gone, so the targets are chosen again.
  7. The interceptors hit the frigates at only 50%: 2 × 5 × 50 / 100 = 5. The frigates hit the interceptors at 175%: 2 × 6 × 175 / 100 = 21.
  8. The damage on the interceptors is 21 plus the wound 8 = 29: both fall. The frigates get 5, less than hull 30: a wound of 5, none falls.
  9. a has no ships, b has 2F: Victory for Orion Hegemony. Left: 2F. This is the game's battle. The battle is a pure function.

In the game the battle is the same function, only with more parameters (stats for technologies), and it returns a list of rounds, while another function writes the text. Here it is:

static BattleResult runBattle(SideStats statsA, int[] countsA, SideStats statsB, int[] countsB) {
int[] a = countsA.clone();
int[] b = countsB.clone();
int[] woundA = emptyCounts();
int[] woundB = emptyCounts();
List<Round> rounds = new ArrayList<>();
for (int round = 1; round <= BATTLE_ROUNDS; round++) {
if (totalOf(a) == 0 || totalOf(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
long[] hitB = volley(statsA, startA, startB);
long[] hitA = volley(statsB, startB, startA);
applyDamage(statsB, b, woundB, hitB);
applyDamage(statsA, a, woundA, hitA);
int[] lostA = emptyCounts();
int[] lostB = emptyCounts();
for (int k = 0; k < KIND_COUNT; k++) {
lostA[k] = startA[k] - a[k];
lostB[k] = startB[k] - b[k];
}
rounds.add(new Round(startA, startB, lostA, lostB));
}
String winner = "draw";
if (totalOf(a) > 0 && totalOf(b) == 0) winner = "a";
else if (totalOf(b) > 0 && totalOf(a) == 0) winner = "b";
else if (totalOf(a) == 0 && totalOf(b) == 0) winner = "none";
return new BattleResult(rounds, a, b, winner);
}
More precisely, if you are curious

The battle is not random, so it is a pure function: the same two fleets give the same result, always and in all four languages. No die is rolled (unlike the lesson on the combat system, where every hit has a chance). That way the battle is tested with one call and a check of the result. The pirates from the events fight with the same function (against a fleet without technologies), and a decision such as “should I attack?” could play the battle out in its head on a copy of the fleets. The artificial intelligence of the game is simpler: it compares the powers of fleets (as above), and how it decides is the topic of the lesson on the rivals.

The rounds are at most six (BATTLE_ROUNDS): if the two fleets cannot destroy each other, for example a small fleet against a big fortress, the fight ends without a winner and the game writes “no winner after six rounds”. That way a battle never hangs forever.

The idea

Simultaneous moves too

The same principle applies to movement. In the game the player (and the artificial intelligence) gives orders, for example go 1 2 3 2 2 0 (“from system 1 to 2: three interceptors, two bombers, two frigates”), and the fleets leave only in the movement phase. They travel one lane per turn along the shortest path, and the order renews itself until they reach the target.

Movement has two phases: first everyone leaves their systems, only then everyone arrives. That is why, for example, two enemy fleets that swap systems do not meet: while one is leaving, the other has not arrived yet. The battles come after that, only where both fleets are in one system after the movement.

The program moves three fleets (X, Y and Z) along a chain of systems 0, 1, 2, 3 (orders to 3, 0 and 1) for two turns and prints where they are and who meets. Then it does the same wrongly: it moves fleet after fleet and checks for a meeting right after every single move.

static int stepToward(int pos, int dest) {
if (pos < dest) return pos + 1;
if (pos > dest) return pos - 1;
return pos;
}
static void report(int turn, String[] names, int[] pos) {
String line = "Turn " + turn + ": ";
for (int i = 0; i < pos.length; i++) line += (i > 0 ? ", " : "") + names[i] + " at " + pos[i];
System.out.println(line);
for (int s = 0; s < 4; s++) {
String here = "";
int count = 0;
for (int i = 0; i < pos.length; i++) {
if (pos[i] == s) here += (count++ > 0 ? ", " : "") + names[i];
}
if (count > 1) System.out.println("meeting at " + s + ": " + here);
}
}
String[] names = {"X", "Y", "Z"};
int[] dest = {3, 0, 1};
int[] pos = {0, 1, 2};
for (int turn = 1; turn <= 2; turn++) {
int[] next = new int[pos.length];
for (int i = 0; i < pos.length; i++) next[i] = stepToward(pos[i], dest[i]);
pos = next;
report(turn, names, pos);
}
int[] live = {0, 1, 2};
for (int i = 0; i < live.length; i++) {
live[i] = stepToward(live[i], dest[i]);
for (int j = 0; j < live.length; j++) {
if (j != i && live[j] == live[i]) System.out.println("One by one: " + names[i] + " meets " + names[j]);
}
}
Output
Turn 1: X at 1, Y at 0, Z at 1
meeting at 1: X, Z
Turn 2: X at 2, Y at 0, Z at 1
One by one: X meets Y
One by one: Z meets X

In the first turn X (from 0 to 3) and Y (from 1 to 0) swap systems and do not meet, but X and Z (from 2 to 1) are in system 1 at the same time: a meeting. The wrong way also sees a “meeting” between X and Y: X arrives in 1 while Y is still there and just leaving. The check inside the loop sees half a turn. The right way first works out where everyone will be (next) from the old state and only then puts them there (pos = next).

Here is the movement in the game. The list moving is “in the air”: the ships have already left their fleet but have not arrived yet:

static void resolveMovement(World world) {
List<Move> moving = new ArrayList<>();
for (Empire empire : world.empires) {
if (!empire.alive) continue;
for (Order order : empire.orders) {
Fleet fleet = fleetAt(world, empire.id, order.from);
if (fleet == null) continue;
int[] ships = {0, 0, 0, 0};
for (int k = 0; k < SHIP_KIND_COUNT; k++) {
ships[k] = minOf(order.ships[k], fleet.ships[k]);
fleet.ships[k] -= ships[k];
}
if (totalOf(ships) > 0) moving.add(new Move(empire.id, order.to, order.dest, ships));
}
empire.orders = new ArrayList<>();
}
for (Move move : moving) {
addShips(world, move.owner, move.to, move.ships);
// the long way continues on its own: a new order from the place where they arrived
if (move.to != move.dest) {
world.empires.get(move.owner).orders.add(new Order(move.to, nextStep(world.systems, move.to, move.dest), move.dest, move.ships.clone()));
}
}
removeEmptyFleets(world);
}

The idea

Simulating a battle

The battle is a pure function, so you do not have to watch it to understand it: you can run it on many fleets and read a table. This is how the balance of the game is tuned. The program compares equal fleets of 6 ships on several pairs of roles:

int[][] pairs = {{0, 1}, {1, 2}, {0, 2}, {0, 3}};
for (int[] pair : pairs) {
int[] a = new int[KINDS];
int[] b = new int[KINDS];
a[pair[0]] = 6;
b[pair[1]] = 6;
String head = countsText(a) + " vs " + countsText(b) + ": ";
runBattle(a, b, "a", "b", new ArrayList<>());
if (total(a) > 0 && total(b) == 0) System.out.println(head + "wins " + LETTERS.charAt(pair[0]) + ", left " + countsText(a));
else if (total(b) > 0 && total(a) == 0) System.out.println(head + "wins " + LETTERS.charAt(pair[1]) + ", left " + countsText(b));
else System.out.println(head + "no winner");
}
Output
6I vs 6B: wins I, left 3I
6B vs 6F: wins B, left 4B
6I vs 6F: wins F, left 6F
6I vs 6D: wins D, left 6D

The triangle shows in the numbers. Six interceptors beat six bombers and lose three. Six bombers beat six frigates and lose two. Six frigates destroy six interceptors without a single loss: the interceptors fall already in the first round. The dreadnought (hull 90, attack 18) is another case: six dreadnoughts destroy six interceptors, also without a loss, because each of them takes much more (in the game it needs a technology and more resources). “Which is stronger” has no single answer: it depends on the role of the enemy. That is why the power of a fleet (the sum of the powers of the roles) is only a rough estimate.

The game does the same with whole games: --simulate=200 runs two hundred games without a screen and prints who wins. That is the topic of the last lesson. The battles in them are this function, run thousands of times.

Try it

Predict the output

Three bombers against three interceptors. Who wins and how many rounds does the battle last? Do not run the program: work out the rounds by hand with the tables of the lesson (hull 10 and attack 5 for the interceptor, 16 and 7 for the bomber) and do not forget the wounds.

int[] a = {0, 3, 0, 0, 0};
int[] b = {3, 0, 0, 0, 0};
List<String> log = new ArrayList<>();
runBattle(a, b, "Terran Empire", "Orion Hegemony", log);
for (String line : log) System.out.println(line);
Answer

Try it

Find the bug

A colleague decided to “tidy up” the round: as soon as the volley of a is computed, it applies it at once, and only then computes the volley of b. That way the copies for the volleys are not needed. A duel of two interceptors against two bombers prints:

int[] a = {2, 0, 0, 0, 0};
int[] b = {0, 2, 0, 0, 0};
List<String> log = new ArrayList<>();
runBattle(a, b, "a", "b", log);
for (String line : log) System.out.println(line);
Output
Round 1: 2I | 2B lost 0 / 1
Round 2: 2I | 1B lost 0 / 1
Victory for a. Left: 2I.

The second round ends with “lost 0 / 1”, and the victory leaves two interceptors. Before the change the second round is “lost 1 / 1” and one interceptor is left (Round 2: 2I | 1B lost 1 / 1). Which line runs too early?

Pick the line with the bug.

More precisely

What is not in this system

The battle in the lesson is the core; the game has a few more things that do not change the principle.

Technologies give bonuses to attack and to hull (and extra ones for the bombers and for the frigates). They come in through stats of volley and applyDamage: the same array of ship counts fights differently for two empires. That is why the parameter exists in the canon and not here.

Fortresses are a fifth role (FORT_KIND): the planets of an empire in a system give it stations that shoot and fall like ships. After the battle the surviving stations are kept until the end of the turn, and at the start of the next one they are full again.

More than two empires in one system fight in pairs: for every pair at war with ships or stations there, one battle is called. The order of the pairs is fixed, so the result is determined.

There is no retreat in the middle of a battle and no choice of target. A retreat is an order to move, carried out before the battle. A group always shoots at the best role; a richer version would give the player a “focus on one role” command, but then the battle would no longer be a function of the two fleets alone.

Losses are whole ships. A wound is kept only within the battle: afterwards a wounded ship is healthy, because a fleet is only a count. A game with repairs would keep a wound on the fleet between turns.

Exercise

A battle from the input

Solve the task in your editor.

A battle from the input. The program reads two lines of five numbers from the input: the counts of I, B, F, D and # for fleet a, then the same for b. It prints the lines of the battle as in the lesson: one line per round and the last line with the victory (with the names “a” and “b”; no heading).

With the input 5 0 4 0 0 | 0 6 2 0 0 (one fleet per line) the output is:

Output
Round 1: 5I 4F | 6B 2F lost 4 / 2
Round 2: 3I 2F | 4B 2F lost 4 / 3
Round 3: 1I | 2B 1F lost 1 / 0
Victory for b. Left: 2B 1F.

Watch the third round: a is left with one interceptor against two bombers and one frigate, and the victory is b’s, not a’s, although a starts with more ships.

More tasks

  • How many interceptors are needed? For how many interceptors (from 1 to 30) do they beat 4 frigates? Print the first number for which a wins. Try bombers against 4 frigates too.
  • Swapping the order. Run the same battle as (a, b) and as (b, a). Is it the same? Why?
  • Dreadnought. Add a dreadnought to a. How many interceptors does b need to destroy it?
  • Empty fleet. What does the program do if one side has no ships? It must not crash.

Test

Check yourself

1. Why are both volleys of a round computed from the ship counts at the start of the round, and not one after the other?
2. 6 bombers (attack 7) shoot at frigates. The bomber’s multiplier against a frigate is 175%. How much damage do they deal?
3. Three interceptors (hull 10 each) take 25 damage in total and have no old wound. What is left?
4. The interceptors have neither bombers nor interceptors in front of them, only frigates and dreadnoughts. Their multipliers are 50 against a frigate and 50 against a dreadnought. Which do they pick?
5. Fleet x is in system 1 and has an order for system 2. An enemy fleet y is in system 2 and has an order for system 1. The two sides are at war. What happens in the turn?

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